diff --git a/.agents/README.md b/.agents/README.md index 9da4aee7..4d299d39 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -41,15 +41,32 @@ Specs live in the vault, not this repo. The maintainer keeps a document-driven d - **Endpoint**: `POST /api/mcp` — a stateless Model Context Protocol server built on `@modelcontextprotocol/server` v2. Docs: `src/pages/api/mcp/README.md`. -- **Surface**: read-only plus governance ballot drafts. It cannot sign, spend or - broadcast, and that boundary is enforced by a test (`src/__tests__/mcpTools.test.ts`) — - adding a write tool must be a deliberate decision, not a registry addition. +- **Surface**: read-only, governance ballot drafts, and — under the opt-in + `transactions:write` scope — unsigned transaction drafts in two steps + (`transaction_preview` returns a review-card PNG plus a signed draft token; + `transaction_propose` takes only that token and creates the pending transaction with + zero signatures). It cannot sign, spend or broadcast, and that boundary is enforced by + a test (`src/__tests__/mcpTools.test.ts`) — adding a write tool must be a deliberate + decision, not a registry addition. - **Tools wrap the existing v1 handlers in-process** via `src/lib/mcp/invokeV1.ts`, so - authorization and validation stay defined once. Handler imports in - `src/lib/mcp/tools.ts` must stay **lazy** or the Mesh/whisky WASM lands in the route's - cold path. + authorization and validation stay defined once — including the dual identity (human + signer JWT vs. bot key with a WalletBotAccess grant), which a tool body calling a + tRPC router directly would not reproduce. A tool that needs an operation with no REST + route gets a new v1 handler first (`tasks.ts` / `taskUpsert.ts` are the model). The + transaction review tools are the exception: they live in `src/lib/tx-review/` and + reuse the canvas builder's `src/lib/tx-draft/` pipeline server-side, and even they + take their UTxOs from `freeUtxos.ts` through `freeUtxosFetcher` in `tools.ts`. Handler + imports in `src/lib/mcp/tools.ts` must stay **lazy** or the Mesh/whisky WASM (and the + `next/og` renderer) lands in the route's cold path. - **Auth**: an OAuth 2.1 access token, or an existing v1 bearer token. The authorization server lives under `src/pages/api/oauth/` — see `src/pages/api/oauth/README.md`. +- **Inline card (MCP App)**: the review tools point at `ui://mesh-multisig/review-card` + (`src/lib/mcp/apps/review-card.ts`), a self-contained HTML view the Claude app renders + inline with a Confirm button. Keep it dependency-free (default CSP: no fetch, no external + assets) and keep the `ui/initialize` handshake — the host hides the frame until it completes. +- **Server instructions** (`MCP_SERVER_INSTRUCTIONS`, `src/lib/mcp/server.ts`) are sent + at initialize and land in the model's context for every conversation: keep them short + and behavioural (what to do with tool results), never a feature list. ## Docs to keep in sync diff --git a/.env.example b/.env.example index 182d3c80..8584593a 100644 --- a/.env.example +++ b/.env.example @@ -40,9 +40,9 @@ NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD="your-blockfrost-preprod-api-key" # SNAPSHOT_AUTH_TOKEN="your-snapshot-auth-token" # Canonical public URL of the deployment. -# Used for SEO: canonical tags, Open Graph / Twitter URLs, robots.txt and the -# sitemap. Defaults to the production domain when unset; set this on preview or -# self-hosted environments so links point at the right origin. +# Used for bot setup guide URLs and SEO: canonical tags, Open Graph / Twitter +# URLs, robots.txt and the sitemap. Defaults to the production domain when unset; +# set this on local, preview or self-hosted environments so links point at the right origin. # NEXT_PUBLIC_SITE_URL="https://multisig.meshjs.dev" # Optional: Skip environment validation during builds diff --git a/.github/workflows/ballot-deadline-reminders.yml b/.github/workflows/ballot-deadline-reminders.yml new file mode 100644 index 00000000..50969b94 --- /dev/null +++ b/.github/workflows/ballot-deadline-reminders.yml @@ -0,0 +1,50 @@ +name: Ballot Deadline Reminders + +# Enqueues 48h/24h ballot-deadline reminder emails (derived from on-chain +# proposal expiration epochs) and drains the outbox, by calling the +# authenticated reminder endpoint. Shares NOTIFICATION_DRAIN_SECRET with the +# outbox drain workflow; until it is set both here and on the deployment, runs +# are graceful no-ops. + +on: + schedule: + # Hourly; reminder windows are 24h wide so this is ample resolution. + - cron: '17 * * * *' + # Allow manual triggering for testing + workflow_dispatch: + +jobs: + remind: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Scan ballots and enqueue deadline reminders + env: + API_BASE_URL: 'https://multisig.meshjs.dev' + DRAIN_SECRET: ${{ secrets.NOTIFICATION_DRAIN_SECRET }} + run: | + if [ -z "$DRAIN_SECRET" ]; then + echo "NOTIFICATION_DRAIN_SECRET repo secret is not set; skipping." + exit 0 + fi + + status=$(curl -s -o response.json -w "%{http_code}" -X POST \ + "$API_BASE_URL/api/notifications/ballot-deadlines" \ + -H "Authorization: Bearer $DRAIN_SECRET") + + echo "HTTP $status" + cat response.json || true + echo + + case "$status" in + 200) + ;; + 503) + echo "Endpoint reports NOTIFICATION_DRAIN_SECRET is not configured on the deployment; skipping." + ;; + *) + echo "Ballot deadline reminder request failed." + exit 1 + ;; + esac diff --git a/README.md b/README.md index bf2eb9da..f1b48161 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,8 @@ The application provides comprehensive API documentation through Swagger UI: - `GET /api/v1/walletIds` - Get user's wallet IDs - `POST /api/v1/addTransaction` - Create new transaction - `POST /api/v1/authSigner` - Authenticate signer -- `GET /api/v1/lookupMultisigWallet` - Lookup multisig wallet +- `GET /api/v1/lookupMultisigWallet` - Lookup multisig wallet registrations by signer key hash +- `GET /api/v1/resolveScript` - Resolve a native script (policy id or wallet address) to its signer key hashes - `POST /api/discord/send-message` - Send Discord notifications > 💡 **Tip**: The Swagger UI provides interactive API testing. Start the dev server and visit `/api-docs` to explore all available endpoints. diff --git a/ROADMAP.md b/ROADMAP.md index 85730706..4af37159 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,11 +1,51 @@ # 12-Month Roadmap: Mesh Multi-Sig Wallet -**Timeline:** May 2026 - April 2027 -**Team:** Quirin + Andre, part-time (~25 hrs/week combined), feature-based ownership +**Timeline:** April 2026 – March 2027 **Approach:** Month-by-month cadence combining baseline maintenance with feature delivery. No hard requirements for feature delivery or releases — tasks scale up/down based on project activity. --- +## MRP task mapping + +The authoritative mapping between MRP reward tasks and the months below. **Month N of this roadmap = MRP Month N = the calendar month in the same row.** Use this table whenever an MRP task and a roadmap section appear to disagree. + +| MRP task | Calendar month | Roadmap section | On-chain task hash | +|---|---|---|---| +| MRP Month 1 | April 2026 | [Month 1](#month-1--april-2026) | — | +| MRP Month 2 | May 2026 | [Month 2](#month-2--may-2026) | `02e1e7c8…65256f` | +| MRP Month 3 | June 2026 | [Month 3](#month-3--june-2026) | `a833f41c…91cef8` | +| MRP Month 4 | July 2026 | [Month 4](#month-4--july-2026) | `27034bf3…dd219a` | +| MRP Month 5 | August 2026 | [Month 5](#month-5--august-2026) | — | +| MRP Month 6 | September 2026 | [Month 6](#month-6--september-2026) | — | +| MRP Month 7 | October 2026 | [Month 7](#month-7--october-2026) | — | +| MRP Month 8 | November 2026 | [Month 8](#month-8--november-2026) | — | +| MRP Month 9 | December 2026 | [Month 9](#month-9--december-2026) | — | +| MRP Month 10 | January 2027 | [Month 10](#month-10--january-2027) | — | +| MRP Month 11 | February 2027 | [Month 11](#month-11--february-2027) | — | +| MRP Month 12 | March 2027 | [Month 12](#month-12--march-2027) | — | + +> **Why this table exists.** The month headings were renumbered on 2026-08-03 (`984aa46`) to match actual delivery: the original numbering started at "Month 1 — May 2026" while Month 1's own proof-of-completion table documented April work. MRP task cards created before that date therefore carry bullet text describing the **following** month — e.g. the card headed *MRP Month 2* lists the June workstreams. The table above is what governs; a card's bullet text does not. + +### Underlying PRs per MRP month + +Each MRP month resolves to a concrete, reproducible set of merged pull requests. The "all merged PRs" link runs the exact GitHub search; the counts are Quirin's authored merges in that window. + +| MRP month | Merged PRs (Quirin) | The actual PRs | +|---|---|---| +| **M1 — April 2026** | [10](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-04-01..2026-04-30) | [#215](https://github.com/MeshJS/multisig/pull/215) drep prerender fix · [#216](https://github.com/MeshJS/multisig/pull/216) missing User table on startup · [#217](https://github.com/MeshJS/multisig/pull/217) VKey witness filter + CI smoke system · [#218](https://github.com/MeshJS/multisig/pull/218) preprod environment · [#219](https://github.com/MeshJS/multisig/pull/219)/[#222](https://github.com/MeshJS/multisig/pull/222)/[#224](https://github.com/MeshJS/multisig/pull/224)/[#226](https://github.com/MeshJS/multisig/pull/226) 12-month roadmap + contributing guide · [#227](https://github.com/MeshJS/multisig/pull/227) invalid-CBOR guard in `addTransaction` · [#228](https://github.com/MeshJS/multisig/pull/228) M1 proof of completion | +| **M2 — May 2026** | [3](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-05-01..2026-05-31) | [#257](https://github.com/MeshJS/multisig/pull/257) pin Mesh SDK + reject witnesses that don't verify against the tx body · [#259](https://github.com/MeshJS/multisig/pull/259) Import Wallet wizard · [#260](https://github.com/MeshJS/multisig/pull/260) `main`→`preprod` merge clearing #229 + CodeQL fixes | +| **M3 — June 2026** | [51](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-06-01..2026-06-30) | Governance [#271](https://github.com/MeshJS/multisig/pull/271)–[#272](https://github.com/MeshJS/multisig/pull/272), [#279](https://github.com/MeshJS/multisig/pull/279), [#286](https://github.com/MeshJS/multisig/pull/286), [#296](https://github.com/MeshJS/multisig/pull/296)–[#297](https://github.com/MeshJS/multisig/pull/297), [#300](https://github.com/MeshJS/multisig/pull/300), [#302](https://github.com/MeshJS/multisig/pull/302), [#315](https://github.com/MeshJS/multisig/pull/315) · Signing & auth [#273](https://github.com/MeshJS/multisig/pull/273)–[#277](https://github.com/MeshJS/multisig/pull/277), [#281](https://github.com/MeshJS/multisig/pull/281)–[#282](https://github.com/MeshJS/multisig/pull/282), [#324](https://github.com/MeshJS/multisig/pull/324) · Mesh 2.0 groundwork [#229](https://github.com/MeshJS/multisig/pull/229), [#269](https://github.com/MeshJS/multisig/pull/269), [#278](https://github.com/MeshJS/multisig/pull/278) · Mobile & UX [#287](https://github.com/MeshJS/multisig/pull/287)–[#295](https://github.com/MeshJS/multisig/pull/295) · Landing/theme/SEO [#298](https://github.com/MeshJS/multisig/pull/298)–[#299](https://github.com/MeshJS/multisig/pull/299), [#308](https://github.com/MeshJS/multisig/pull/308)–[#318](https://github.com/MeshJS/multisig/pull/318), [#328](https://github.com/MeshJS/multisig/pull/328) · Infra & security [#284](https://github.com/MeshJS/multisig/pull/284), [#301](https://github.com/MeshJS/multisig/pull/301), [#319](https://github.com/MeshJS/multisig/pull/319) · Docs & releases [#280](https://github.com/MeshJS/multisig/pull/280), [#283](https://github.com/MeshJS/multisig/pull/283), [#285](https://github.com/MeshJS/multisig/pull/285), [#303](https://github.com/MeshJS/multisig/pull/303), [#309](https://github.com/MeshJS/multisig/pull/309), [#320](https://github.com/MeshJS/multisig/pull/320)–[#321](https://github.com/MeshJS/multisig/pull/321) | +| **M4 — July 2026** | [16](https://github.com/MeshJS/multisig/pulls?q=is%3Apr+is%3Amerged+author%3AQSchlegel+merged%3A2026-07-01..2026-07-31) | Bot platform & API [#341](https://github.com/MeshJS/multisig/pull/341)–[#345](https://github.com/MeshJS/multisig/pull/345) · Agent/crawler surface [#346](https://github.com/MeshJS/multisig/pull/346) · DRep vote-history explorer [#337](https://github.com/MeshJS/multisig/pull/337)–[#339](https://github.com/MeshJS/multisig/pull/339) · Roadmap & delivery audit [#347](https://github.com/MeshJS/multisig/pull/347), [#350](https://github.com/MeshJS/multisig/pull/350)–[#352](https://github.com/MeshJS/multisig/pull/352) · Production hardening [#332](https://github.com/MeshJS/multisig/pull/332)–[#334](https://github.com/MeshJS/multisig/pull/334) | + +Reproduce any row: + +```bash +gh pr list --repo MeshJS/multisig --state merged --limit 100 \ + --search "merged:2026-06-01..2026-06-30 author:QSchlegel" --json number,title,mergedAt +``` + +--- + ## Baseline (applies every month) - Issues and PRs do not stall @@ -14,10 +54,12 @@ --- -## Delivered to date (May – July 2026) +## Delivered to date (April – July 2026) What the product can actually do today, as verified in the codebase on 2026-07-26. The per-month **Progress** tables below track plan-vs-actual; this section is the cumulative capability inventory, and it is the input that reshaped M4–M6. +Coverage starts at **April**, the programme's first month — April's output is infrastructure rather than user-facing features (the preprod environment, the real-chain smoke system, transaction-integrity guards), so it shows up inside the sections below rather than as a headline capability of its own. + > **Caveat — delivered ≠ live.** Everything below is merged on `preprod`. `main` is 75 commits behind and the production database is four migrations behind, so a good share of this is not yet reachable on the production deployment. Closing that gap is the first item in August. ### Governance @@ -41,23 +83,28 @@ What the product can actually do today, as verified in the codebase on 2026-07-2 ### Notifications -Resend-backed email channel with a real outbox: `NotificationDelivery` carries an idempotency key, attempt counter, `nextAttemptAt` backoff and nine statuses (including four distinct skip reasons), drained by `drainNotificationOutbox` via a token-authenticated `POST /api/notifications/drain`. Event types are `email.verify`, `signature.required`, `signature.reminder`. Per-wallet × per-signer settings UI on the wallet Info page, plus hashed-token email verification ([#322](https://github.com/MeshJS/multisig/pull/322), [#326](https://github.com/MeshJS/multisig/pull/326)). **Gap:** no scheduled workflow drains the outbox — `daily-balance-snapshots.yml` is the only cron in the repo. +Resend-backed email channel with a real outbox: `NotificationDelivery` carries an idempotency key, attempt counter, `nextAttemptAt` backoff and nine statuses (including four distinct skip reasons), drained by `drainNotificationOutbox` via a token-authenticated `POST /api/notifications/drain`. Event types are `email.verify`, `signature.required`, `signature.reminder`, `threshold.reached` (a transaction/payload collected enough signatures) and `ballot.deadline` (48h/24h reminders for ballots and pending vote transactions, derived from on-chain proposal expiration epochs, scanned hourly by `ballot-deadline-reminders.yml` via `POST /api/notifications/ballot-deadlines`). Per-wallet × per-signer settings UI on the wallet Info page, plus hashed-token email verification ([#322](https://github.com/MeshJS/multisig/pull/322), [#326](https://github.com/MeshJS/multisig/pull/326)). **Gap:** no scheduled workflow drains the outbox — `daily-balance-snapshots.yml` is the only cron in the repo. ### Testing & CI - **Playwright E2E**: 11 spec files, ~54 tests, in `e2e/tests/` — wallet creation (legacy + SDK), ring transfers on real preprod, staking, proxy, DRep/ballot UI, bot management, notification settings, wallet access control, signing rejection, responsive smoke. Runs in Docker via `pr-playwright-browser.yml`, serialized against the v1 smoke job through a shared `ci-preprod-wallets` concurrency group ([#323](https://github.com/MeshJS/multisig/pull/323), [#335](https://github.com/MeshJS/multisig/pull/335), [#336](https://github.com/MeshJS/multisig/pull/336)). -- Real-chain smoke system closed ([#213](https://github.com/MeshJS/multisig/issues/213)); deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)). +- **Preprod environment + real-chain smoke CI** — built in April: the `preprod` branch and environment ([#218](https://github.com/MeshJS/multisig/pull/218)) and the CI smoke-test system that exercises the route chain against real preprod ([#217](https://github.com/MeshJS/multisig/pull/217)), which skips gracefully when `SMOKE_*` secrets are absent. [#213](https://github.com/MeshJS/multisig/issues/213) closed once the first real run was linked. Everything since — the Playwright suite above included — runs on this foundation. +- deploy-migrations on Node 22 + manual dispatch ([#319](https://github.com/MeshJS/multisig/pull/319)); RLS follow-up migration authored ([#332](https://github.com/MeshJS/multisig/pull/332)); worktree gitlink fix ([#333](https://github.com/MeshJS/multisig/pull/333)). ### Platform -Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul; on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)). +**Transaction & signing integrity** — the through-line from April onward: extraneous VKey witnesses filtered out of submitted transactions ([#217](https://github.com/MeshJS/multisig/pull/217)); an invalid-CBOR guard on `POST /api/v1/addTransaction` plus a degraded "unreadable transaction" card with Reject & Delete, so an API-poisoned row can no longer lock a wallet's UTxOs ([#227](https://github.com/MeshJS/multisig/pull/227), [#211](https://github.com/MeshJS/multisig/issues/211)); Mesh SDK pinned to exact versions after a lockfile patch drift changed Conway CBOR encoding and made multisig DRep votes fail on chain, with a client-side guard that now rejects witnesses which don't verify against the body they're attached to ([#257](https://github.com/MeshJS/multisig/pull/257)). + +**Wallet lifecycle** — Import Wallet wizard covering four sources (another multisig instance, Summon, native-script CBOR, JSON backup) with `importWallet`/`exportWallet` procedures, cross-instance endpoints reusing the CIP-8 `checkSignature` path, a downloadable JSON backup and a `lockedSigners` gate so imported wallets can't silently diverge from their origin ([#259](https://github.com/MeshJS/multisig/pull/259)); on-chain wallet registration and discovery ([#340](https://github.com/MeshJS/multisig/pull/340)). + +**Everything else** — Mesh 2.0 groundwork (Prisma 7.8 + Next 16, tx-builder hardfork upgrade, wallet ops consolidated behind one bridge with an ESLint guardrail); signing & auth reliability (bech32 normalization, `signData` arg order, core-cst witness/body-hash merge, stuck-"Loading…" recovery, cross-instance import, non-opaque wallet-session status codes); mobile foundations, skeleton/empty states, error toasts, pagination; landing + SEO + glass theme overhaul. ### Landed ahead of schedule | Capability | Planned | Actually delivered | Effect on the plan | |------------|---------|--------------------|--------------------| | Governance metadata fix (#122) | M7 (Nov) | June | Closed | -| Wallet V2 — registration & discovery (#33) | M3 (Jul) | July ([#340](https://github.com/MeshJS/multisig/pull/340)) | On time; feeds the Discover page (#52), which moves up from M10 | +| Wallet V2 — registration & discovery (#33) | M3 (Jul) | July ([#340](https://github.com/MeshJS/multisig/pull/340)) | On time; feeds the M5 Discover lookup by signer/policy, which moves up from M10 (#52 was closed as unspecified; scope now tracked under #33) | | Bot platform (SDK/reference client, scoped auth, ballot API) | M7 (Nov) | July | M7 reduces to **webhooks only** — no webhook code exists yet | | API documentation & developer portal | M8 (Dec) | June–July | Done; M8 slot freed | | Pending transactions on user's homepage (#125) | M7 (Nov) | Shipped — surfaced on the wallets dashboard | Issue still open; verify and close | @@ -163,7 +210,7 @@ End-of-month snapshot. Last updated 2026-07-26. | FROST research kickoff (#220) | Not started | Carried to August. Needs to start there to leave runway before the October go/no-go | | CI/maintenance baseline | Watch item — unchanged | `pr-multisig-v1-smoke.yml` still `exit 1`s in its "Validate required CI secrets" step when secrets are absent, and dependabot-triggered runs never receive repo Actions secrets. Every dependabot PR is therefore red for systemic reasons, not because of the version bump — 7 are open, the oldest since 2026-06-15. The sibling `ci-smoke-preprod.yml` already has the skip-when-unconfigured guard to copy | | Wallet V2 (#33) | Delivered | On-chain wallet registration + discovery shipped in [#340](https://github.com/MeshJS/multisig/pull/340) | -| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month — see [Delivered to date](#delivered-to-date-may--july-2026) | +| Unplanned July delivery | Delivered | Bot platform, DRep vote-history explorer, Playwright E2E, and agent/API documentation all landed this month — see [Delivered to date](#delivered-to-date-april--july-2026) | --- @@ -171,7 +218,7 @@ End-of-month snapshot. Last updated 2026-07-26. **Focus:** Close the production release gap, then start Document Sign-Off (see [Flagship feature](#flagship-feature--document-sign-off)). -Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-may--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work. +Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date-april--july-2026)) freed the M7/M8 documentation and bot slots, and surfaced a release gap that outranks all feature work. **Quirin** @@ -208,7 +255,7 @@ Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date | Task | Issues | |------|--------| | Transaction visualization MVP (ship) — extend the tx visualizer to work with bot and display/build all tx types multisig is capable of doing | | -| Discover page — fold into the delivered Wallet V2 registration/discovery rather than building it standalone; add lookup by signer/policy *(moved up from M10)* | #52, #33 | +| Discover — lookup by signer/policy on the import wizard's Discover tab (search by signer address/key hash or wallet address/script hash; view-only for non-participants), `resolveScript` route, MCP `multisig_lookup_wallet` by script hash/address. No standalone page — rides the delivered Wallet V2 discovery *(moved up from M10)* | #33 | | Notification digests & deadline reminders — ballot-deadline and threshold-reached emails on the existing outbox (product work, infrastructure already exists) | | | Monthly report | | @@ -216,7 +263,7 @@ Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date ## Month 6 — September 2026 -**Focus:** Document Sign-Off provenance, FROST findings, hardware wallets. +**Focus:** Document Sign-Off provenance, FROST findings, and MCP transaction review. **Quirin** @@ -229,14 +276,25 @@ Revised 2026-07-26. July's actual output ([Delivered to date](#delivered-to-date | Task | Issues | |------|--------| -| Hardware wallet support — Ledger/Trezor. **Scope the CIP-8 `signData` constraint during the M4–M5 Sign-Off build, not after** — Ledger/Trezor support for `signData` is limited, and Document Sign-Off approvals depend on it | #44 | -| UX papercut batch — full-address verification (#196), transaction pagination (#30), better 404 page (#22) | #196, #30, #22 | +| MCP unsigned transaction creation — create unsigned multisig transactions through MCP and prepare them for signer review | | +| Transaction review PNG & in-chat review — generate a clear PNG summary containing the key transaction details and display it in the user's chat for human review; MCP must not sign or broadcast on the user's behalf | | +| Project task board with multisig payouts — let users and agents create and manage project tasks, optionally define one or more payment recipients and amounts, and prepare payouts for multisig review and approval | | + +### Proof of completion + +Status of M6 tasks (Andre). Last updated 2026-09-07. + +| Task | Status | Evidence | +|------|--------|----------| +| MCP unsigned transaction creation | In review | Branch `feat/mcp-unsigned-tx-creation`: new `transactions:write` scope; `transaction_preview` builds payments, staking certificates and DRep votes through the canvas builder's `src/lib/tx-draft/` pipeline server-side and returns a signed draft token; `transaction_propose` accepts only that token and creates the pending transaction with zero signatures (never broadcasts). Design and boundary in `src/pages/api/mcp/README.md`; guarded by `src/__tests__/mcpTools.test.ts` and `txReview*.test.ts` | +| Transaction review PNG & in-chat review | In review | Same branch: `src/lib/tx-review/summary.ts` (one summary model behind text, JSON and image), `card.ts` + `render-png.ts` (rasterized with the `ImageResponse` bundled in Next, no new dependency); the card is returned as an MCP `image` content block by preview, propose and `multisig_review_pending_transaction` (any pending transaction). Render proven by `src/__tests__/txReviewRenderPng.test.ts` | +| Project task board with multisig payouts | In review | Same branch: `Task` / `TaskRecipient` / `TaskPayout` models (`prisma/migrations/20260910090000_add_task_board`), kanban board at `/wallets/[wallet]/tasks` (`src/components/pages/wallet/tasks/`, dnd-kit), `task` tRPC router with `preparePayout` → `confirmPayout` sharing the `src/lib/tx-review/` pipeline server-side through `src/lib/task-payout/` (draft token bound to the task ids and a hash of their recipient rows; `TaskPayout` links created inside the pending row's insert transaction; links flip to Paid on submit and Cancelled on delete/replace); MCP tools `task_list`, `task_upsert` (new `tasks:write` scope) and `task_prepare_payout` (confirmed through the existing `transaction_propose`). Guarded by `src/__tests__/taskPayout*.test.ts`, `taskBoardModel.test.ts`, `trpc/taskRouter.test.ts`, extended `txReviewPropose`/`txReviewDraftToken`/`createPendingMultisigTransaction`/`mcpTools` tests, and `e2e/tests/task-board.spec.ts` | --- ## Month 7 — October 2026 -**Focus:** Governance polish, dApp connector, bot platform. +**Focus:** Governance polish, dApp connector, and advanced transaction building. Revised 2026-07-26: the governance metadata fix closed in June, and the bot platform and developer portal shipped in July, so this month absorbs the work those slots were holding. @@ -251,9 +309,8 @@ Revised 2026-07-26: the governance metadata fix closed in June, and the bot plat | Task | Issues | |------|--------| -| Bot platform — webhooks. The rest of "v2" (scoped auth, reference client, example bots, OpenAPI) shipped in July; webhooks are the only unbuilt piece — no webhook code exists in `src/` today | | -| Multisig MCP server — expose the existing bot API as an MCP server so an agent can act as a wallet observer or ballot drafter. Small step from `/llms.txt` + `/api/skill` + the scoped bot JWT, and a genuine differentiator | | -| Verify and close pending-transactions-on-homepage (#125), already surfaced on the wallets dashboard | #125 | +| Output datum controls — let users attach and edit validated inline datum values on individual transaction outputs under Advanced, preserve each output-to-datum association through draft editing, and encode the datums in the generated unsigned transaction | | +| Plutus script-spend and redeemer controls — let users configure Plutus-controlled inputs with their script, datum source, and validated redeemer under Advanced; use an ADA-only collateral UTxO supplied by the connected signer, require that collateral owner's signature, show the amount at risk, and evaluate and preserve the script data in the generated unsigned transaction. Automatic collateral creation, reservation, and collateral-return management remain in #221 | | --- @@ -442,13 +499,13 @@ Aggregated view of the 12-month roadmap split by contributor. Each task has a si - [M4] Unblock dependabot CI — skip-when-unconfigured guard in `pr-multisig-v1-smoke.yml`, then clear the 7 open dependency PRs - [M4] Notification center follow-ups — gov-proposal improvements, Playwright coverage, scheduled outbox drain (#327) - [M4–5] Document Sign-Off MVP — Documents UI, six-state lifecycle, signer review, diffs -- [M5] Discover page + lookup by signer/policy (#52, #33) — moved up from M10 +- [M5] Discover — lookup by signer/policy on the Discover tab, `resolveScript` route, MCP policy lookup (#33) — moved up from M10 - [M5] Notification digests & deadline reminders -- [M6] Hardware wallet support — Ledger/Trezor (#44); CIP-8 `signData` constraint scoped during M4–M5 -- [M6] UX papercut batch — full-address verification (#196), tx pagination (#30), 404 page (#22) -- [M7] Bot platform — webhooks (the rest of "v2" shipped in July) -- [M7] Multisig MCP server — agent access over the existing bot API -- [M7] Verify and close pending transactions on homepage (#125) +- [M6] MCP unsigned transaction creation — create unsigned multisig transactions through MCP and prepare them for signer review +- [M6] Transaction review PNG & in-chat review — generate and display a visual transaction summary in chat without signing or broadcasting +- [M6] Project task board with multisig payouts — let users and agents create and manage project tasks, optionally define one or more payment recipients and amounts, and prepare payouts for multisig review and approval +- [M7] Output datum controls — attach and edit validated inline datum values on individual transaction outputs under Advanced, preserve each output-to-datum association through draft editing, and encode the datums in the generated unsigned transaction +- [M7] Plutus script-spend and redeemer controls — configure Plutus-controlled inputs with their script, datum source, and validated redeemer under Advanced; use an ADA-only collateral UTxO supplied by the connected signer, require that collateral owner's signature, show the amount at risk, and evaluate and preserve the script data in the generated unsigned transaction. Automatic collateral creation, reservation, and collateral-return management remain in #221 - [M8] Backlog cleanup, dependency/security updates - [M9] User profiles and contacts - [M11] Document Sign-Off v3 — Collaboration & standards (research) diff --git a/docs/notification-center-plan.md b/docs/notification-center-plan.md index 1ad88681..78fc0896 100644 --- a/docs/notification-center-plan.md +++ b/docs/notification-center-plan.md @@ -432,6 +432,15 @@ Manual QA: 7. Enable production for verified internal/test signers. 8. Remove or migrate client-side Discord reminder calls after email path is stable. +## Phase 12: Threshold-Reached and Ballot-Deadline Events (shipped 2026-08-27) + +Two more toggles on `WalletSignerNotificationSetting` (`notifyThresholdReached`, `notifyBallotDeadlines`) and two event types on the same outbox: + +- `threshold.reached` — enqueued by `enqueueThresholdReachedNotifications` (`src/lib/notifications/center.ts`) from `transaction.updateTransaction`, `signable.updateSignable` and `POST /api/v1/signTransaction` whenever a signature update moves a resource from below to at-or-above `getRequiredSignerCount`. Audience is every wallet signer except the actor (`resolveWalletSignerRecipients`), so unlike `signature.required` the creator and earlier signers are included. One row per resource × recipient. +- `ballot.deadline` — `enqueueBallotDeadlineReminders` (`src/lib/notifications/ballotDeadlines.ts`) has two sources: saved ballots, and pending (`state: 0`) transactions that vote (proposal ids read from `txJson.votes[].vote.govActionId` and `txJson.proxyBot.votes[].proposalId` via `extractVoteProposalIds`), so a direct vote cast without a ballot is covered too. The deadline is the earliest active proposal's Blockfrost `expiration` epoch (end of that epoch = `end_time(latest) + Δepochs × 432000s`); exactly one window per run (`48h` = 24–48h out, `24h` = 0–24h out); rows are keyed on `` × id × signer × window × expiration epoch. Transaction reminders stop by themselves once the tx is submitted. A ballot whose expiring proposals are all covered by a pending vote tx of the same wallet defers to that transaction's reminder (one email, not two); ballots with a submitted `Ballot Vote:`/`Proxy Ballot Vote:` transaction created after the ballot are skipped. Client-side proxy votes (the vote lives in a Plutus redeemer) are covered because `useTransaction.newTransaction` accepts `txJsonExtras`, and the proxy-vote call sites (`proposal/voteButtton.tsx`, `ballot/ballot.tsx`) annotate the stored txJson with the same `proxyBot: { kind: "proxyVote", votes }` block the bot API writes — a client-written annotation, only present on transactions created after 2026-08-27. Driven hourly by `.github/workflows/ballot-deadline-reminders.yml` → `POST /api/notifications/ballot-deadlines` (same `NOTIFICATION_DRAIN_SECRET`). + +The worker's send-time preference re-check is keyed by `getNotificationPreferenceField(eventType, resourceType)` (`events.ts`), so both new events honour toggles flipped after enqueue. + ## Open Questions - Should wallet creators be allowed to enter another signer's email, or should emails only be entered and verified by the signer themselves? diff --git a/e2e/tests/discover-wallet-ui.spec.ts b/e2e/tests/discover-wallet-ui.spec.ts new file mode 100644 index 00000000..39dd1d81 --- /dev/null +++ b/e2e/tests/discover-wallet-ui.spec.ts @@ -0,0 +1,276 @@ +// Discover tab (import wizard, "Discover on-chain"): lookup by signer / policy. +// +// The chain reads behind the tab are intercepted in the browser so the spec is +// deterministic and needs no on-chain registration: +// - /api/v1/lookupMultisigWallet -> one registration whose participants ECHO +// the requested pubKeyHashes. The default +// view therefore lists a wallet the signer +// belongs to (Import enabled), while a +// search for someone else's keys yields a +// wallet they are not part of (view-only). +// - /api/v1/resolveScript -> a fixed pair of fake signer hashes, so a +// policy search resolves to a wallet the +// signer is not part of; returns no signers +// for the bare-hash fallback case. +// +// Import itself (resolveRegistrationScript + script reconstruction) is covered +// by unit tests; nothing here is ever persisted. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import type { Page } from "@playwright/test"; + +const REGISTRATION_TX = "4".repeat(64); +const FOREIGN_SIG_HASHES = ["5".repeat(56), "6".repeat(56)]; + +type DiscoveryMocks = { + /** every intercepted discovery request, path + query, in order */ + requests: string[]; +}; + +async function mockDiscoveryRoutes( + page: Page, + options: { resolveToSigners: boolean }, +): Promise { + const requests: string[] = []; + + await page.route("**/api/v1/lookupMultisigWallet**", async (route) => { + const url = new URL(route.request().url()); + requests.push(`${url.pathname}${url.search}`); + const hashes = (url.searchParams.get("pubKeyHashes") ?? "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); + const participants = Object.fromEntries( + hashes.map((hash, i) => [hash, { name: `Signer ${i + 1}` }]), + ); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + tx_hash: REGISTRATION_TX, + json_metadata: { + types: [0], + name: "E2E Registered Wallet", + description: "Mocked CIP-0146 registration", + participants, + }, + }, + ]), + }); + }); + + await page.route("**/api/v1/resolveScript**", async (route) => { + const url = new URL(route.request().url()); + requests.push(`${url.pathname}${url.search}`); + const scriptHash = url.searchParams.get("scriptHash") ?? ""; + const sigHashes = options.resolveToSigners ? FOREIGN_SIG_HASHES : []; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + scriptHash, + stakeCredentialHash: null, + scriptJson: options.resolveToSigners + ? { + type: "atLeast", + required: 2, + scripts: sigHashes.map((keyHash) => ({ type: "sig", keyHash })), + } + : null, + sigHashes, + }), + }); + }); + + return { requests }; +} + +async function openDiscoverTab(page: Page): Promise { + await page.goto("/wallets/import-wallet?tab=discover"); + await expect(page.getByText("Discover registered wallets")).toBeVisible({ + timeout: 60_000, + }); +} + +function searchBox(page: Page) { + return page.getByRole("textbox", { + name: "Search by signer or wallet address", + }); +} + +async function search(page: Page, value: string): Promise { + await searchBox(page).fill(value); + await page.getByRole("button", { name: "Search", exact: true }).click(); +} + +test.describe("discover tab lookup by signer / policy", () => { + test("lists the connected signer's registered wallet as importable", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + + await openDiscoverTab(page); + + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("you", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Import", exact: true })).toBeEnabled(); + await expect(page.getByText("View only")).toHaveCount(0); + expect( + mocks.requests.some((r) => r.startsWith("/api/v1/lookupMultisigWallet")), + ).toBe(true); + }); + + test("searching another signer's address shows their wallet view-only", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const otherSigner = ctx.signerAddresses[1]; + if (!otherSigner) throw new Error("Bootstrap context needs two signers"); + + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + + await search(page, otherSigner); + + await expect(page.getByText(/found for this signer/)).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("match", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "View only" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Import", exact: true })).toHaveCount(0); + await expect( + page.getByText(/isn't a participant of this wallet/), + ).toBeVisible(); + + // The lookup was made with the searched signer's keys, not the user's. + const { resolvePaymentKeyHash } = await import("@meshsdk/core"); + const otherHash = resolvePaymentKeyHash(otherSigner).toLowerCase(); + expect( + mocks.requests.some( + (r) => + r.startsWith("/api/v1/lookupMultisigWallet") && r.includes(otherHash), + ), + ).toBe(true); + }); + + test("searching a multisig address resolves the script and matches by policy", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const walletAddress = ctx.wallets[0]?.walletAddress; + if (!walletAddress) throw new Error("Bootstrap context has no wallet address"); + + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + + await search(page, walletAddress); + + await expect(page.getByText(/has 2 signers; showing registrations/)).toBeVisible( + { timeout: 30_000 }, + ); + await expect(page.getByText(/found for this wallet/)).toBeVisible(); + await expect(page.getByRole("button", { name: "View only" })).toBeDisabled(); + + // Policy path: resolveScript by hash first, then lookup by its signers. + const resolveIdx = mocks.requests.findIndex((r) => + r.startsWith("/api/v1/resolveScript?scriptHash="), + ); + expect(resolveIdx).toBeGreaterThanOrEqual(0); + const followUp = mocks.requests + .slice(resolveIdx + 1) + .find((r) => r.startsWith("/api/v1/lookupMultisigWallet")); + expect(followUp).toBeDefined(); + for (const hash of FOREIGN_SIG_HASHES) { + expect(followUp).toContain(hash); + } + }); + + test("a bare hash that is not a script falls back to a signer lookup", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + const ctx = loadContext(); + const self = ctx.signerAddresses[0]; + if (!self) throw new Error("Bootstrap context has no signer address"); + const { resolvePaymentKeyHash } = await import("@meshsdk/core"); + const selfHash = resolvePaymentKeyHash(self).toLowerCase(); + + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: false }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + + await search(page, selfHash.toUpperCase()); + + await expect(page.getByText(/found for this hash/)).toBeVisible({ + timeout: 30_000, + }); + // The echoed registration lists the user's own hash, so it is importable. + await expect(page.getByRole("button", { name: "Import", exact: true })).toBeEnabled(); + expect( + mocks.requests.some((r) => + r.startsWith(`/api/v1/resolveScript?scriptHash=${selfHash}`), + ), + ).toBe(true); + expect( + mocks.requests.some( + (r) => + r.startsWith("/api/v1/lookupMultisigWallet") && r.includes(selfHash), + ), + ).toBe(true); + }); + + test("malformed input shows an inline error and makes no request", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(120_000); + await authenticateAs(page, 0); + const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true }); + await openDiscoverTab(page); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + const before = mocks.requests.length; + + await search(page, "not-an-address"); + + // Next's route announcer is also role="alert", so match on the copy. + const inlineError = page.getByText(/Enter a signer address/); + await expect(inlineError).toBeVisible(); + await expect(inlineError).toHaveAttribute("role", "alert"); + await expect( + page.getByText("Fix the search to look up registrations."), + ).toBeVisible(); + expect(mocks.requests.length).toBe(before); + + // Clear restores the default (own keys) listing. + await page.getByRole("button", { name: "Clear", exact: true }).click(); + await expect(inlineError).toHaveCount(0); + await expect(page.getByText("E2E Registered Wallet")).toBeVisible({ + timeout: 30_000, + }); + }); +}); diff --git a/e2e/tests/notification-settings-ui.spec.ts b/e2e/tests/notification-settings-ui.spec.ts index f9cb20aa..471f4eee 100644 --- a/e2e/tests/notification-settings-ui.spec.ts +++ b/e2e/tests/notification-settings-ui.spec.ts @@ -15,11 +15,25 @@ // The email delivery pipeline itself (outbox rows, worker, verify link) is // server-side and covered outside the browser suite. +import type { Page } from "@playwright/test"; import { test, expect } from "../fixtures/authFixture"; import { loadContext } from "../helpers/contextLoader"; import { createThrowawayWallet } from "../helpers/apiHelpers"; import { mockWalletUtxos } from "../helpers/phase3Mocks"; +// The "Notification settings saved" toast lingers for 4s, so it cannot be +// used to sequence back-to-back saves: a stale toast from the previous save +// satisfies the assertion before the next mutation's request has landed. +// Sync on the tRPC response instead. +function waitForSettingsSave(page: Page) { + return page.waitForResponse( + (response) => + response.url().includes("notification.upsertWalletSignerSetting") && + response.request().method() === "POST", + { timeout: 60_000 }, + ); +} + test.describe("notification settings UI", () => { test("signer saves an email, sees verification state, and toggles persist", async ({ page, @@ -51,7 +65,13 @@ test.describe("notification settings UI", () => { const emailInput = page.getByLabel("Email address"); await expect(emailInput).toBeEnabled({ timeout: 30_000 }); await emailInput.fill(email); + const emailSavePromise = waitForSettingsSave(page); await page.getByRole("button", { name: "Save", exact: true }).click(); + const emailSaveResponse = await emailSavePromise; + expect( + emailSaveResponse.ok(), + `email save failed ${emailSaveResponse.status()}`, + ).toBe(true); await expect(page.getByText("Notification settings saved").first()).toBeVisible({ timeout: 30_000, }); @@ -76,12 +96,41 @@ test.describe("notification settings UI", () => { }); await expect(transactionsToggle).toBeEnabled({ timeout: 30_000 }); await expect(transactionsToggle).toHaveAttribute("aria-checked", "true"); + const transactionsSavePromise = waitForSettingsSave(page); await transactionsToggle.click(); - await expect(page.getByText("Notification settings saved").first()).toBeVisible({ - timeout: 30_000, + const transactionsSaveResponse = await transactionsSavePromise; + expect( + transactionsSaveResponse.ok(), + `transactions toggle save failed ${transactionsSaveResponse.status()}`, + ).toBe(true); + + // The threshold-reached and ballot-deadline toggles persist the same way. + const thresholdToggle = page.getByRole("switch", { + name: "Toggle threshold reached notifications", + }); + await expect(thresholdToggle).toBeEnabled({ timeout: 30_000 }); + await expect(thresholdToggle).toHaveAttribute("aria-checked", "true"); + const thresholdSavePromise = waitForSettingsSave(page); + await thresholdToggle.click(); + const thresholdSaveResponse = await thresholdSavePromise; + expect( + thresholdSaveResponse.ok(), + `threshold toggle save failed ${thresholdSaveResponse.status()}`, + ).toBe(true); + const ballotToggle = page.getByRole("switch", { + name: "Toggle ballot deadline notifications", }); + await expect(ballotToggle).toBeEnabled({ timeout: 30_000 }); + await expect(ballotToggle).toHaveAttribute("aria-checked", "true"); + const ballotSavePromise = waitForSettingsSave(page); + await ballotToggle.click(); + const ballotSaveResponse = await ballotSavePromise; + expect( + ballotSaveResponse.ok(), + `ballot toggle save failed ${ballotSaveResponse.status()}`, + ).toBe(true); - // Everything survives a full reload: email, badge, and the toggle. + // Everything survives a full reload: email, badge, and the toggles. await page.reload(); await expect( page.getByRole("heading", { name: "Email Notifications" }), @@ -97,5 +146,11 @@ test.describe("notification settings UI", () => { name: "Toggle transaction signature notifications", }), ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); + await expect( + page.getByRole("switch", { name: "Toggle threshold reached notifications" }), + ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); + await expect( + page.getByRole("switch", { name: "Toggle ballot deadline notifications" }), + ).toHaveAttribute("aria-checked", "false", { timeout: 30_000 }); }); }); diff --git a/e2e/tests/responsive-smoke.spec.ts b/e2e/tests/responsive-smoke.spec.ts index ff87a8bd..131a1f18 100644 --- a/e2e/tests/responsive-smoke.spec.ts +++ b/e2e/tests/responsive-smoke.spec.ts @@ -132,6 +132,40 @@ for (const viewport of VIEWPORTS) { await expectNoHorizontalOverflow(page, "new transaction form"); }); + test("transaction builder keeps its actions on screen", async ({ + page, + authenticateAs, + }) => { + const ctx = loadContext(); + const wallet = getWallet(ctx, "legacy"); + await authenticateAs(page, 0); + await mockUtxos(page); + await page.goto(`/wallets/${wallet.walletId}/build`); + await expect(page.getByTestId("tx-builder-canvas")).toBeVisible({ + timeout: 60_000, + }); + + // The primary action must sit inside the viewport, not merely exist: + // the shell clips horizontal overflow, so a button pushed past the + // right edge would still count as "visible" to Playwright. + const buildButton = page.getByTestId("tx-builder-build"); + await buildButton.scrollIntoViewIfNeeded(); + await expect(buildButton).toBeVisible(); + const box = await buildButton.boundingBox(); + expect(box, "build button has no bounding box").not.toBeNull(); + expect( + box!.x + box!.width, + `build button overflows the ${viewport.width}px viewport`, + ).toBeLessThanOrEqual(viewport.width); + expect(box!.x).toBeGreaterThanOrEqual(0); + + // Phones get the palette folded into a single "Add" menu. + await expect(page.getByTestId("tx-builder-add-menu")).toBeVisible(); + await expect(page.getByTestId("tx-builder-add-recipient")).toBeHidden(); + + await expectNoHorizontalOverflow(page, "transaction builder"); + }); + test("wallet connect entry point works without overflow", async ({ page, injectWallet, diff --git a/e2e/tests/task-board.spec.ts b/e2e/tests/task-board.spec.ts new file mode 100644 index 00000000..921ce748 --- /dev/null +++ b/e2e/tests/task-board.spec.ts @@ -0,0 +1,181 @@ +// Project task board with multisig payouts. +// +// Browser coverage for the board itself on an isolated throwaway wallet: +// - create a task with a payment recipient → it lands in Backlog with a +// "Payment configured" badge and the recipient total +// - move it between columns through the card menu (the accessible +// fallback for drag-and-drop) and by dragging +// - edit the title; the change persists across a reload +// - move accepted work to Done: "Prepare payout" is enabled without any tick, +// ticking the card narrows the payout to it, and the dialog lists it checked; +// the preview is requested from the server, which builds against the +// wallet's spendable UTxOs. A throwaway wallet has none and the preview +// runs server-side (browser Blockfrost mocks do not reach it), so the spec +// asserts the graceful error path rather than a created transaction. The +// preview → confirm pipeline is covered by unit and tRPC tests. +// - delete the task +// +// The lock a pending or paid payout puts on a task (read-only dialog, no +// moves, unlock on cancellation) is not covered here: seeding that state +// needs direct database rows, and the runner has no database access. It is +// covered against a real Postgres by src/__tests__/trpc/taskRouter.test.ts. +// +// Cleanup: the task is deleted through the UI; the wallet row is a +// throwaway like the other Phase 3 specs use. + +import { test, expect } from "../fixtures/authFixture"; +import { loadContext } from "../helpers/contextLoader"; +import { createThrowawayWallet, trpcMutate } from "../helpers/apiHelpers"; +import { mockWalletUtxos } from "../helpers/phase3Mocks"; + +function waitForTrpc(page: import("@playwright/test").Page, procedure: string) { + return page.waitForResponse( + (response) => + response.url().includes(procedure) && response.request().method() === "POST", + { timeout: 60_000 }, + ); +} + +test.describe("task board", () => { + test("signer creates, moves, edits, prepares a payout for, and deletes a task", async ({ + page, + authenticateAs, + }) => { + test.setTimeout(240_000); + const ctx = loadContext(); + const recipient = ctx.signerAddresses[1]!; + const additionalRecipient = ctx.signerAddresses[2]!; + + await authenticateAs(page, 0); + const wallet = await createThrowawayWallet( + page, + ctx, + `E2E tasks ${Date.now()}-${test.info().workerIndex}`, + ); + await mockWalletUtxos(page); + + await page.goto(`/wallets/${wallet.walletId}/tasks`); + await expect(page.getByRole("heading", { name: "Tasks" })).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText("No tasks yet")).toBeVisible({ timeout: 30_000 }); + + // Create a task and add one of the wallet's signers as its ADA recipient. + await page.getByTestId("new-task-button").first().click(); + const dialog = page.getByTestId("task-dialog"); + await expect(dialog).toBeVisible(); + const title = `Write the release notes ${Date.now()}`; + await dialog.getByTestId("task-title-input").fill(title); + await dialog.getByTestId("task-assignee-signer-select").click(); + await page.getByRole("option", { name: "Signer 2" }).click(); + const addAssignee = dialog.getByTestId("task-add-assignee-recipient"); + await addAssignee.click(); + await expect(addAssignee).toBeDisabled(); + await expect(addAssignee).toContainText("Assignee already added"); + await expect(dialog.getByTestId("recipient-address-input-0")).toHaveValue(recipient); + await dialog.getByTestId("amount-input-0").fill("2.5"); + await dialog.getByTestId("task-recipient-signer-select").click(); + await page.getByRole("option", { name: "Signer 3" }).click(); + await dialog.getByTestId("task-add-signer-recipient").click(); + await expect(dialog.getByTestId("recipient-address-input-1")).toHaveValue(additionalRecipient); + await dialog.getByTestId("amount-input-1").fill("1"); + const createPromise = waitForTrpc(page, "task.create"); + await dialog.getByTestId("task-save").click(); + expect((await createPromise).ok()).toBe(true); + await expect(dialog).toBeHidden({ timeout: 30_000 }); + + const card = page.locator('[data-testid^="task-card-"]').filter({ hasText: title }); + await expect(card).toBeVisible({ timeout: 30_000 }); + const taskId = (await card.getAttribute("data-testid"))!.replace("task-card-", ""); + await expect(page.getByTestId("task-column-Backlog")).toContainText(title); + await expect(card.getByTestId("payout-badge-configured")).toHaveText("Payment configured"); + await expect(card.getByTestId(`task-select-${taskId}`)).toBeHidden(); + await expect(card.getByTestId(`task-totals-${taskId}`)).toHaveText("3.5 ADA"); + + // Move through the card menu (keyboard/touch-safe path). + await card.getByTestId(`task-menu-${taskId}`).click(); + const movePromise = waitForTrpc(page, "task.move"); + await page.getByTestId(`task-move-${taskId}-InProgress`).click(); + expect((await movePromise).ok()).toBe(true); + await expect(page.getByTestId("task-column-InProgress")).toContainText(title, { timeout: 30_000 }); + await expect(page.getByTestId("task-column-count-InProgress")).toHaveText("1"); + await expect(page.getByTestId("task-column-count-Backlog")).toHaveText("0"); + + // Drag it to In review. + const target = page.getByTestId("task-column-InReview"); + const cardBox = (await card.boundingBox())!; + const targetBox = (await target.boundingBox())!; + const dragPromise = waitForTrpc(page, "task.move"); + await page.mouse.move(cardBox.x + cardBox.width / 2, cardBox.y + 12); + await page.mouse.down(); + // Past the 6px activation distance, then in steps so dnd-kit sees the move. + await page.mouse.move(cardBox.x + cardBox.width / 2 + 12, cardBox.y + 12, { steps: 4 }); + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 60, { steps: 12 }); + await page.mouse.up(); + expect((await dragPromise).ok()).toBe(true); + await expect(page.getByTestId("task-column-InReview")).toContainText(title, { timeout: 30_000 }); + + // Edit the title; it survives a reload. + await card.click(); + await expect(dialog).toBeVisible(); + const renamed = `${title} (edited)`; + await dialog.getByTestId("task-title-input").fill(renamed); + const updatePromise = waitForTrpc(page, "task.update"); + await dialog.getByTestId("task-save").click(); + expect((await updatePromise).ok()).toBe(true); + await page.reload(); + await expect(page.getByTestId("task-column-InReview")).toContainText(renamed, { timeout: 60_000 }); + + // Reviewed work only becomes payable once it reaches Done. + const renamedCard = page.getByTestId(`task-card-${taskId}`); + await expect(renamedCard.getByTestId("payout-badge-configured")).toBeVisible(); + await renamedCard.getByTestId(`task-menu-${taskId}`).click(); + const donePromise = waitForTrpc(page, "task.move"); + await page.getByTestId(`task-move-${taskId}-Done`).click(); + expect((await donePromise).ok()).toBe(true); + await expect(page.getByTestId("task-column-Done")).toContainText(renamed, { timeout: 30_000 }); + await expect(renamedCard.getByTestId("payout-badge-ready")).toBeVisible(); + + // A payable task is enough: nothing has to be ticked for "Prepare payout" + // to work (it defaults to every payable task). + const prepareButton = page.getByTestId("prepare-payout-button").first(); + await expect(prepareButton).toBeEnabled(); + await expect(prepareButton).not.toContainText("("); + + // Ticking the card narrows the payout to it and shows the count on the button. + await renamedCard.getByTestId(`task-select-${taskId}`).click(); + await expect(prepareButton).toContainText("(1)"); + await prepareButton.click(); + const payoutDialog = page.getByTestId("payout-dialog"); + await expect(payoutDialog).toBeVisible(); + await expect(payoutDialog.getByTestId("payout-task-list")).toContainText(renamed); + await expect(payoutDialog.getByTestId(`payout-task-toggle-${taskId}`)).toHaveAttribute("data-state", "checked"); + + // Ask for the preview. The server builds against real spendable UTxOs, + // which this unfunded wallet lacks, so the dialog must show the + // pipeline's error rather than a created transaction. + const previewPromise = waitForTrpc(page, "task.preparePayout"); + await payoutDialog.getByTestId("payout-preview-button").click(); + await previewPromise; + await expect( + payoutDialog.getByTestId("payout-summary").or(payoutDialog.getByTestId("payout-error")), + ).toBeVisible({ timeout: 60_000 }); + // Whatever the chain said, nothing was created without a confirmation. + await expect(payoutDialog.getByTestId("payout-created")).toBeHidden(); + await page.keyboard.press("Escape"); + await expect(payoutDialog).toBeHidden(); + + // Delete through the confirm dialog (it portals outside the task dialog). + await renamedCard.click(); + await expect(dialog).toBeVisible(); + await dialog.getByTestId("task-delete").click(); + const deletePromise = waitForTrpc(page, "task.delete"); + await page.getByTestId("task-delete-confirm").click(); + expect((await deletePromise).ok()).toBe(true); + await expect(page.getByText("No tasks yet")).toBeVisible({ timeout: 30_000 }); + + // Belt and braces: nothing pending was left on the throwaway wallet. + const pending = await trpcMutate(page, "transaction.getPendingTransactions", { + walletId: wallet.walletId, + }).catch(() => [] as unknown[]); + expect(Array.isArray(pending) ? pending.length : 0).toBe(0); + }); +}); diff --git a/jest.shared.mjs b/jest.shared.mjs index a046e627..04046b5c 100644 --- a/jest.shared.mjs +++ b/jest.shared.mjs @@ -33,6 +33,7 @@ export const ESM_TESTS = [ 'apiSecurity', 'botBallotsUpsert', + 'botSetupGuide', 'governanceActiveProposals', 'mcpConnections', 'og', @@ -40,6 +41,9 @@ export const ESM_TESTS = [ 'reviewSignersCardKey', 'signing', 'signTransaction', + // next/og is an ESM bundle (import.meta.url for its WASM and font); the + // CJS project cannot load it, so the render test lives here. + 'txReviewRenderPng', ]; // trpc/* are database integration tests; they run in their own workflow diff --git a/next.config.js b/next.config.js index fab7794d..6013a6c6 100644 --- a/next.config.js +++ b/next.config.js @@ -92,6 +92,15 @@ const config = { "@sidan-lab/whisky-js-nodejs", ], + // The MCP transaction review card is rendered with `next/og` in the Node + // runtime (src/lib/tx-review/render-png.ts). Its bundle loads resvg.wasm, + // yoga.wasm and the Geist face through `import.meta.url`, which output file + // tracing cannot see — without this the deployed function has the JS but + // not the assets. + outputFileTracingIncludes: { + "/api/mcp": ["./node_modules/next/dist/compiled/@vercel/og/**/*"], + }, + // OAuth discovery documents must live under /.well-known/, but Next ignores // dot-directories inside pages/, so they cannot be files. Rewrites map the // well-known paths onto real API routes. diff --git a/package-lock.json b/package-lock.json index d7e13efa..6878e799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "hasInstallScript": true, "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@meshsdk/core": "^1.9.0", "@meshsdk/core-csl": "^1.9.0", "@meshsdk/core-cst": "^1.9.0", @@ -49,6 +52,7 @@ "clsx": "^2.1.1", "cors": "^2.8.5", "dexie": "^4.0.11", + "dotenv": "^17.4.2", "formidable": "^3.5.4", "framer-motion": "^11.11.9", "geist": "^1.3.0", @@ -98,6 +102,7 @@ "@types/react-dom": "^19.2.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-react": "^5.18.0", + "@types/three": "^0.168.0", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", "eslint": "^9.39.1", @@ -113,9 +118,9 @@ } }, "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", "license": "MIT", "engines": { "node": ">=10" @@ -208,22 +213,105 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@auth/core": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", + "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", + "extraneous": true, + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "extraneous": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "extraneous": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/core/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@auth/core/node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "extraneous": true, + "license": "MIT" + }, "node_modules/@auth/prisma-adapter": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.2.tgz", - "integrity": "sha512-GyNEUNtrPgDPs0M4xX6F5i7jTsCKwU6BXV9zutctcoo6K1Ud+juckrmQS11uyNgeWsw6sliextHbU/e+8lsizQ==", + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.3.tgz", + "integrity": "sha512-jZbpVAO6PTc9zNtdTWc0RLWG8qap4iMc54/3oWaWbuKdj92wHxzPrs3HivWB2mB9975GPW0l3YM/wFUWiUlTlg==", "license": "ISC", "dependencies": { - "@auth/core": "0.41.2" + "@auth/core": "0.41.3" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", - "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", @@ -235,7 +323,7 @@ "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7.0.7" + "nodemailer": "^7.0.7 || ^8.0.5" }, "peerDependenciesMeta": { "@simplewebauthn/browser": { @@ -250,18 +338,18 @@ } }, "node_modules/@auth/prisma-adapter/node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/@auth/prisma-adapter/node_modules/oauth4webapi": { - "version": "3.8.6", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", - "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -277,13 +365,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -292,9 +380,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -302,21 +390,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -343,14 +431,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -360,14 +448,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -387,9 +475,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -397,29 +485,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -429,9 +517,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -439,9 +527,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -449,9 +537,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -459,9 +547,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -469,27 +557,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -554,13 +642,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -596,13 +684,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -722,13 +810,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -738,18 +826,18 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", - "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.7.tgz", + "integrity": "sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==", "license": "MIT", "dependencies": { "core-js-pure": "^3.48.0" @@ -759,33 +847,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -793,14 +881,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -879,16 +967,16 @@ } }, "node_modules/@cardano-sdk/core": { - "version": "0.45.10", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.45.10.tgz", - "integrity": "sha512-PU/onQuPgsy0CtFKDlHcozGHMTHrigWztTmKq54tL0TdWRcClXbMh5Q63ALcP388ZouPC1nKomOAooVgyrrEfw==", + "version": "0.46.12", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.46.12.tgz", + "integrity": "sha512-yUA/xBUQMiMqIWiZPvIhM911pL3jNKg4PkZQ8qP9R7yU3NQ5x4RQkZ+zFDlVLxUt+gJiwIW2es0iPd8ObIKCxA==", "license": "Apache-2.0", "dependencies": { "@biglup/is-cid": "^1.0.3", "@cardano-ogmios/client": "6.9.0", "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.2.3", - "@cardano-sdk/util": "~0.16.0", + "@cardano-sdk/crypto": "~0.4.5", + "@cardano-sdk/util": "~0.17.1", "@foxglove/crc": "^0.0.3", "@scure/base": "^1.1.1", "fraction.js": "4.0.1", @@ -910,36 +998,18 @@ } } }, - "node_modules/@cardano-sdk/core/node_modules/@cardano-sdk/util": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.16.0.tgz", - "integrity": "sha512-f0tfX8oiauqAFCyyc/o2Ouezyk83QD4zqLl4DUjZNyCtITL8gBHh25Bkw7RUCGEZ+hf6Qms1n0ui0j3wVY7zRg==", - "license": "Apache-2.0", - "dependencies": { - "bech32": "^2.0.0", - "lodash": "^4.17.21", - "serialize-error": "^8", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4", - "type-fest": "^2.19.0" - }, - "engines": { - "node": ">=16.20.2" - } - }, "node_modules/@cardano-sdk/crypto": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.2.3.tgz", - "integrity": "sha512-jTl8rbocV1XO5DBR6+lGY6Owc/bP+wBg5eO3PttTeKhx/J7o99pyuTa5H36a/XTJwqDwKIXV922QxZR+rfjVbA==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.5.tgz", + "integrity": "sha512-ymliqxdmen5dGVaiMVQ0VnhrwaYUjbPD3sHoMj8NI6MTuxrREp3pLJASREtWhwmv9k+QzDT6CoyuIXnlEQiWZQ==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/util": "~0.16.0", + "@cardano-sdk/util": "~0.17.1", "blake2b": "^2.1.4", "i": "^0.3.7", - "libsodium-wrappers-sumo": "^0.7.5", + "libsodium-wrappers-sumo": "0.7.10", "lodash": "^4.17.21", - "npm": "^9.3.0", - "pbkdf2": "^3.1.2", + "pbkdf2": "^3.1.3", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4" }, @@ -963,89 +1033,62 @@ } } }, - "node_modules/@cardano-sdk/crypto/node_modules/@cardano-sdk/util": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.16.0.tgz", - "integrity": "sha512-f0tfX8oiauqAFCyyc/o2Ouezyk83QD4zqLl4DUjZNyCtITL8gBHh25Bkw7RUCGEZ+hf6Qms1n0ui0j3wVY7zRg==", - "license": "Apache-2.0", - "dependencies": { - "bech32": "^2.0.0", - "lodash": "^4.17.21", - "serialize-error": "^8", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4", - "type-fest": "^2.19.0" - }, - "engines": { - "node": ">=16.20.2" - } - }, "node_modules/@cardano-sdk/dapp-connector": { - "version": "0.13.26", - "resolved": "https://registry.npmjs.org/@cardano-sdk/dapp-connector/-/dapp-connector-0.13.26.tgz", - "integrity": "sha512-4GptUVsGmgZhzKs+yp3360dA+HNdMi8IW1r2n1H63PYOJDPj2bjopBeyOGFn8Dmkzt+64rm2KyVyZM2SlcUq9Q==", + "version": "0.13.31", + "resolved": "https://registry.npmjs.org/@cardano-sdk/dapp-connector/-/dapp-connector-0.13.31.tgz", + "integrity": "sha512-WbfZkA9Ps1MuQ3WDLjh+1KDy1ssAvXT50hyZyqwoapnv8tYHJGjEVnH1NGtAg7FrHgErjF6EeuusWnjRgi87yg==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "~0.46.12", - "@cardano-sdk/crypto": "~0.4.5", - "@cardano-sdk/util": "~0.17.1", + "@cardano-sdk/core": "~0.47.1", + "@cardano-sdk/crypto": "~0.4.7", + "@cardano-sdk/util": "~0.17.2", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4", "webextension-polyfill": "^0.8.0" }, "engines": { - "node": ">=16.20.2" + "node": ">=22" } }, "node_modules/@cardano-sdk/dapp-connector/node_modules/@cardano-sdk/core": { - "version": "0.46.12", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.46.12.tgz", - "integrity": "sha512-yUA/xBUQMiMqIWiZPvIhM911pL3jNKg4PkZQ8qP9R7yU3NQ5x4RQkZ+zFDlVLxUt+gJiwIW2es0iPd8ObIKCxA==", + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.47.1.tgz", + "integrity": "sha512-72G11AWm7dKOY1h+WR/nEbvmZXHjaBq79NSO/mDktDnxhVCIMviKSsw3cC9LoddfIpAg8KRE7obtYRMq0e8WmQ==", "license": "Apache-2.0", "dependencies": { "@biglup/is-cid": "^1.0.3", - "@cardano-ogmios/client": "6.9.0", - "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.4.5", - "@cardano-sdk/util": "~0.17.1", + "@cardano-sdk/crypto": "~0.4.7", + "@cardano-sdk/util": "~0.17.2", "@foxglove/crc": "^0.0.3", "@scure/base": "^1.1.1", "fraction.js": "4.0.1", - "ip-address": "^9.0.5", + "ip-address": "^10.2.0", "lodash": "^4.17.21", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4", "web-encoding": "^1.1.5" }, "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "rxjs": "^7.4.0" - }, - "peerDependenciesMeta": { - "rxjs": { - "optional": true - } + "node": ">=22" } }, "node_modules/@cardano-sdk/dapp-connector/node_modules/@cardano-sdk/crypto": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.5.tgz", - "integrity": "sha512-ymliqxdmen5dGVaiMVQ0VnhrwaYUjbPD3sHoMj8NI6MTuxrREp3pLJASREtWhwmv9k+QzDT6CoyuIXnlEQiWZQ==", + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.7.tgz", + "integrity": "sha512-4aCsLOkqaIS/gSGw4SBqNGcCIp6hZ8alQWJx58U/OiOxC43t7i2JpKagdyWfCNekPM14hO7BWER7y31wf4BFxw==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/util": "~0.17.1", + "@cardano-sdk/util": "~0.17.2", "blake2b": "^2.1.4", "i": "^0.3.7", - "libsodium-wrappers-sumo": "0.7.10", + "libsodium-wrappers-sumo": "0.8.4", "lodash": "^4.17.21", "pbkdf2": "^3.1.3", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4" }, "engines": { - "node": ">=16.20.2" + "node": ">=22" }, "peerDependencies": { "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", @@ -1065,9 +1108,9 @@ } }, "node_modules/@cardano-sdk/dapp-connector/node_modules/@cardano-sdk/util": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.1.tgz", - "integrity": "sha512-TCYe+wRguW1WgRlbWqhGPhcSBkzVzdIcCVgDDN7wiQk2dew0EWVqjsKeqDZdfwzy/s2kr/ZOgXIGywBn/Bzu/Q==", + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.2.tgz", + "integrity": "sha512-LdY33OQG5H4p0rppHtjnM+akc1poKhz+02T9zsXsbShfTbeU9HXlA0nJ8xj9FNZMs+ozDR0HWrUjDc7fkNZI7w==", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", @@ -1077,18 +1120,42 @@ "ts-log": "^2.2.4" }, "engines": { - "node": ">=16.20.2" + "node": ">=22" + } + }, + "node_modules/@cardano-sdk/dapp-connector/node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@cardano-sdk/dapp-connector/node_modules/libsodium-sumo": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz", + "integrity": "sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==", + "license": "ISC" + }, + "node_modules/@cardano-sdk/dapp-connector/node_modules/libsodium-wrappers-sumo": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz", + "integrity": "sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.8.0" } }, "node_modules/@cardano-sdk/input-selection": { - "version": "0.13.34", - "resolved": "https://registry.npmjs.org/@cardano-sdk/input-selection/-/input-selection-0.13.34.tgz", - "integrity": "sha512-/AidYTF9WesLoMc4PHoETxXgrfYEq8GECcikjvLwx1mygmKpok4Lp41Aio7sBasUCLvZ82/yTd3uXIAvec1aCA==", + "version": "0.14.28", + "resolved": "https://registry.npmjs.org/@cardano-sdk/input-selection/-/input-selection-0.14.28.tgz", + "integrity": "sha512-pbysJUaIbbpesbv/f0XfFPKBb+bLjCmPcMfNJzpePSZBvr8bUcFpnfKtq28KthVdpe2mgL3k9ebTTcBSk7aERw==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "~0.43.0", - "@cardano-sdk/key-management": "~0.25.1", - "@cardano-sdk/util": "~0.15.5", + "@cardano-sdk/core": "~0.46.12", + "@cardano-sdk/key-management": "~0.29.12", + "@cardano-sdk/util": "~0.17.1", "bignumber.js": "^9.1.1", "lodash": "^4.17.21", "ts-custom-error": "^3.2.0" @@ -1097,56 +1164,69 @@ "node": ">=16.20.2" } }, - "node_modules/@cardano-sdk/input-selection/node_modules/@cardano-sdk/core": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.43.0.tgz", - "integrity": "sha512-hPnZXjObJub0eXV2dDAG2/gEg/vw092RZ1VGMZfBSqavz18Tg/K6jGQ3cOpWZ9d+MqFzZTCB+s5ctdRkYt3idA==", + "node_modules/@cardano-sdk/key-management": { + "version": "0.29.19", + "resolved": "https://registry.npmjs.org/@cardano-sdk/key-management/-/key-management-0.29.19.tgz", + "integrity": "sha512-acuKWBWDRmgpaU4rKOo78XzFSVESblx8gL8DCUgKMs3Kfh62U3Clujh8OV5X7R/znmBMxUjkNV15XDHJz8h/RA==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/core": "~0.47.1", + "@cardano-sdk/crypto": "~0.4.7", + "@cardano-sdk/dapp-connector": "~0.13.31", + "@cardano-sdk/util": "~0.17.2", + "@emurgo/cardano-message-signing-nodejs": "^1.0.1", + "bip39": "^3.0.4", + "chacha": "^2.1.0", + "get-random-values": "^2.0.0", + "lodash": "^4.17.21", + "pbkdf2": "^3.1.3", + "rxjs": "^7.4.0", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/core": { + "version": "0.47.1", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.47.1.tgz", + "integrity": "sha512-72G11AWm7dKOY1h+WR/nEbvmZXHjaBq79NSO/mDktDnxhVCIMviKSsw3cC9LoddfIpAg8KRE7obtYRMq0e8WmQ==", "license": "Apache-2.0", "dependencies": { "@biglup/is-cid": "^1.0.3", - "@cardano-ogmios/client": "6.9.0", - "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.1.32", - "@cardano-sdk/util": "~0.15.5", + "@cardano-sdk/crypto": "~0.4.7", + "@cardano-sdk/util": "~0.17.2", "@foxglove/crc": "^0.0.3", "@scure/base": "^1.1.1", "fraction.js": "4.0.1", - "ip-address": "^9.0.5", + "ip-address": "^10.2.0", "lodash": "^4.17.21", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4", "web-encoding": "^1.1.5" }, "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "rxjs": "^7.4.0" - }, - "peerDependenciesMeta": { - "rxjs": { - "optional": true - } + "node": ">=22" } }, - "node_modules/@cardano-sdk/input-selection/node_modules/@cardano-sdk/crypto": { - "version": "0.1.32", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.1.32.tgz", - "integrity": "sha512-RCKFvkzD32QpKQ0jULADVRNmfBNkCwiZl2nlFbkZ3aCrfIex+6/2CizoagJ161fA7lL5/HGuzWfjOg3GX2ax6w==", + "node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/crypto": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.7.tgz", + "integrity": "sha512-4aCsLOkqaIS/gSGw4SBqNGcCIp6hZ8alQWJx58U/OiOxC43t7i2JpKagdyWfCNekPM14hO7BWER7y31wf4BFxw==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/util": "~0.15.5", + "@cardano-sdk/util": "~0.17.2", "blake2b": "^2.1.4", "i": "^0.3.7", - "libsodium-wrappers-sumo": "^0.7.5", + "libsodium-wrappers-sumo": "0.8.4", "lodash": "^4.17.21", - "npm": "^9.3.0", - "pbkdf2": "^3.1.2", + "pbkdf2": "^3.1.3", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4" }, "engines": { - "node": ">=16.20.2" + "node": ">=22" }, "peerDependencies": { "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", @@ -1165,110 +1245,57 @@ } } }, - "node_modules/@cardano-sdk/key-management": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@cardano-sdk/key-management/-/key-management-0.25.1.tgz", - "integrity": "sha512-D99XTIplI2aQnCZtVUKZdmH9wZJQC2WuZL6hTqGZHHFBAeju2zBzGWT21LlcPRlT0/2DP2/OdfIHoHCr2ORp4g==", + "node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/util": { + "version": "0.17.2", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.2.tgz", + "integrity": "sha512-LdY33OQG5H4p0rppHtjnM+akc1poKhz+02T9zsXsbShfTbeU9HXlA0nJ8xj9FNZMs+ozDR0HWrUjDc7fkNZI7w==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "~0.43.0", - "@cardano-sdk/crypto": "~0.1.32", - "@cardano-sdk/dapp-connector": "~0.13.1", - "@cardano-sdk/util": "~0.15.5", - "@emurgo/cardano-message-signing-nodejs": "^1.0.1", - "bip39": "^3.0.4", - "chacha": "^2.1.0", - "get-random-values": "^2.0.0", + "bech32": "^2.0.0", "lodash": "^4.17.21", - "pbkdf2": "^3.1.2", - "rxjs": "^7.4.0", + "serialize-error": "^8", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4" }, "engines": { - "node": ">=16.20.2" + "node": ">=22" } }, - "node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/core": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.43.0.tgz", - "integrity": "sha512-hPnZXjObJub0eXV2dDAG2/gEg/vw092RZ1VGMZfBSqavz18Tg/K6jGQ3cOpWZ9d+MqFzZTCB+s5ctdRkYt3idA==", - "license": "Apache-2.0", - "dependencies": { - "@biglup/is-cid": "^1.0.3", - "@cardano-ogmios/client": "6.9.0", - "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.1.32", - "@cardano-sdk/util": "~0.15.5", - "@foxglove/crc": "^0.0.3", - "@scure/base": "^1.1.1", - "fraction.js": "4.0.1", - "ip-address": "^9.0.5", - "lodash": "^4.17.21", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4", - "web-encoding": "^1.1.5" - }, - "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "rxjs": "^7.4.0" - }, - "peerDependenciesMeta": { - "rxjs": { - "optional": true - } - } - }, - "node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/crypto": { - "version": "0.1.32", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.1.32.tgz", - "integrity": "sha512-RCKFvkzD32QpKQ0jULADVRNmfBNkCwiZl2nlFbkZ3aCrfIex+6/2CizoagJ161fA7lL5/HGuzWfjOg3GX2ax6w==", - "license": "Apache-2.0", - "dependencies": { - "@cardano-sdk/util": "~0.15.5", - "blake2b": "^2.1.4", - "i": "^0.3.7", - "libsodium-wrappers-sumo": "^0.7.5", - "lodash": "^4.17.21", - "npm": "^9.3.0", - "pbkdf2": "^3.1.2", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" - }, - "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", - "@dcspark/cardano-multiplatform-lib-browser": "^3.1.1", - "@dcspark/cardano-multiplatform-lib-nodejs": "^3.1.1" - }, - "peerDependenciesMeta": { - "@dcspark/cardano-multiplatform-lib-asmjs": { - "optional": true - }, - "@dcspark/cardano-multiplatform-lib-browser": { - "optional": true - }, - "@dcspark/cardano-multiplatform-lib-nodejs": { - "optional": true - } - } - }, - "node_modules/@cardano-sdk/util": { - "version": "0.15.7", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.15.7.tgz", - "integrity": "sha512-L0f3gXFujRwSSpjzq2W/OwW23fg0gw5S+9R+91He3LgmyfjNygd939eFPCLhwOscsHcJ4AN27UJSYnx3JMKZ0w==", + "node_modules/@cardano-sdk/key-management/node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@cardano-sdk/key-management/node_modules/libsodium-sumo": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz", + "integrity": "sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==", + "license": "ISC" + }, + "node_modules/@cardano-sdk/key-management/node_modules/libsodium-wrappers-sumo": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz", + "integrity": "sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.8.0" + } + }, + "node_modules/@cardano-sdk/util": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.1.tgz", + "integrity": "sha512-TCYe+wRguW1WgRlbWqhGPhcSBkzVzdIcCVgDDN7wiQk2dew0EWVqjsKeqDZdfwzy/s2kr/ZOgXIGywBn/Bzu/Q==", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", "lodash": "^4.17.21", "serialize-error": "^8", "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4", - "type-fest": "^2.19.0" + "ts-log": "^2.2.4" }, "engines": { "node": ">=16.20.2" @@ -1331,25 +1358,18 @@ } }, "node_modules/@digitalbazaar/http-client": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.3.0.tgz", - "integrity": "sha512-6lMpxpt9BOmqHKGs9Xm6DP4LlZTBFer/ZjHvP3FcW3IaUWYIWC7dw5RFZnvw4fP57kAVcm1dp3IF+Y50qhBvAw==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.4.0.tgz", + "integrity": "sha512-ODhCGmElUPmR3IR+KZmBNkRAFyjJ01rxvk2E+/qQ2h2EGPJH5k6bz3N24ympGc5+i4YCGk/ipIpmkwc0+iSmRg==", "license": "BSD-3-Clause", "dependencies": { - "ky": "^1.14.2", - "undici": "^6.23.0" + "ky": "^1.14.3", + "undici": "^6.28.0" }, "engines": { "node": ">=18.0" } }, - "node_modules/@dimforge/rapier3d-compat": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", - "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -1360,6 +1380,59 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dnsquery/dns-packet": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@dnsquery/dns-packet/-/dns-packet-6.1.1.tgz", @@ -1374,33 +1447,33 @@ } }, "node_modules/@electric-sql/pglite": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", - "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", + "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", - "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", + "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" }, "peerDependencies": { - "@electric-sql/pglite": "0.4.1" + "@electric-sql/pglite": "0.4.3" } }, "node_modules/@electric-sql/pglite-tools": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", - "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", + "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", "devOptional": true, "license": "Apache-2.0", "peerDependencies": { - "@electric-sql/pglite": "0.4.1" + "@electric-sql/pglite": "0.4.3" } }, "node_modules/@emnapi/core": { @@ -1416,9 +1489,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", - "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -1443,9 +1516,9 @@ "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -1472,9 +1545,9 @@ } }, "node_modules/@eslint/compat": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.0.tgz", - "integrity": "sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.1.tgz", + "integrity": "sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1547,9 +1620,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { @@ -1559,7 +1632,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1571,9 +1644,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1621,31 +1694,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1653,9 +1726,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@foxglove/crc": { @@ -1665,9 +1738,9 @@ "license": "MIT" }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -1743,10 +1816,11 @@ } }, "node_modules/@harmoniclabs/crypto": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.2.5.tgz", - "integrity": "sha512-t2saWMFWBx8tOHotiYTTfQKhPGpWT4AMLXxq3u0apShVXNV0vgL0gEgSMudBjES/wrKByCqa2xmU70gadz26hA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.3.1.tgz", + "integrity": "sha512-KA9KgI+2c+9KddAU0A5mTAC/O94J25IwvU6z6wzvgIZMYY5f4VxWLvPWA6aRpAe9pO7hOC9s5hu1FzZWxJkZ5A==", "license": "MIT", + "peer": true, "dependencies": { "@harmoniclabs/bitstream": "^1.0.0", "@harmoniclabs/uint8array-utils": "^1.0.3" @@ -1765,9 +1839,9 @@ "license": "MIT" }, "node_modules/@harmoniclabs/plutus-data": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@harmoniclabs/plutus-data/-/plutus-data-1.2.4.tgz", - "integrity": "sha512-cpr6AnJRultH6PJRDriewHEgNLQs2IGLampZrLjmK5shzTsHICD0yD0Zig9eKdcS7dmY6mlzvSpAJWPGeTxbCA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@harmoniclabs/plutus-data/-/plutus-data-1.2.6.tgz", + "integrity": "sha512-rF046GZ07XDpjZBNybALKYSycjxCLzXKbhLylu9pRuZiii5fVXReEfgtLB29TsPBvGY6ZBeiyHgJnLgm+huZBw==", "license": "Apache-2.0", "dependencies": { "@harmoniclabs/biguint": "^1.0.0", @@ -1779,6 +1853,16 @@ "@harmoniclabs/cbor": "^1.3.0" } }, + "node_modules/@harmoniclabs/plutus-data/node_modules/@harmoniclabs/crypto": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.2.5.tgz", + "integrity": "sha512-t2saWMFWBx8tOHotiYTTfQKhPGpWT4AMLXxq3u0apShVXNV0vgL0gEgSMudBjES/wrKByCqa2xmU70gadz26hA==", + "license": "MIT", + "dependencies": { + "@harmoniclabs/bitstream": "^1.0.0", + "@harmoniclabs/uint8array-utils": "^1.0.3" + } + }, "node_modules/@harmoniclabs/uint8array-utils": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@harmoniclabs/uint8array-utils/-/uint8array-utils-1.0.4.tgz", @@ -1786,14 +1870,13 @@ "license": "Apache-2.0" }, "node_modules/@harmoniclabs/uplc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@harmoniclabs/uplc/-/uplc-1.2.4.tgz", - "integrity": "sha512-Px6utj94cO/hQd9NJgVQI8zycsbgh3rAzDeLdZ1m52bo++EuU1GL+arWX3JYso3/3uNrnUFuizjrAIISwQe3Fg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@harmoniclabs/uplc/-/uplc-1.4.1.tgz", + "integrity": "sha512-sELKStjxPBPBxBMylU4oBSUe0/8eJe2HqRblNSwrMu8Fso4YpSPDqHZ33iDZ8QAadVUsT5r2EQKX0TLrj7qXvQ==", "license": "Apache-2.0", "dependencies": { "@harmoniclabs/bigint-utils": "^1.0.0", - "@harmoniclabs/bitstream": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.0" + "@harmoniclabs/uint8array-utils": "^1.0.3" }, "funding": { "url": "https://github.com/sponsors/HarmonicLabs" @@ -1801,24 +1884,11 @@ "peerDependencies": { "@harmoniclabs/bytestring": "^1.0.0", "@harmoniclabs/cbor": "^1.3.0", - "@harmoniclabs/crypto": "^0.2.4", + "@harmoniclabs/crypto": "^0.3.0-dev0", "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "^1.2.4" } }, - "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1978,6 +2048,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1994,6 +2067,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2010,6 +2086,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2026,6 +2105,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2042,6 +2124,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2058,6 +2143,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2074,6 +2162,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2090,6 +2181,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2106,6 +2200,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2128,6 +2225,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2150,6 +2250,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2172,6 +2275,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2194,6 +2300,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2216,6 +2325,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2238,6 +2350,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2260,6 +2375,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2402,9 +2520,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -2485,17 +2603,17 @@ } }, "node_modules/@jest/console": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", - "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.2.tgz", + "integrity": "sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -2503,18 +2621,18 @@ } }, "node_modules/@jest/core": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", - "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.2.tgz", + "integrity": "sha512-/jqvFWoJF7LV1a6AUpYMbMm+2yIYsHJfR474H0SCzr9k8tnO/jl9wRg6zSG6lxgf6EozNlCG4amFzWsbZSvvZA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/pattern": "30.4.0", - "@jest/reporters": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -2522,20 +2640,20 @@ "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.4.1", - "jest-config": "30.4.2", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-resolve-dependencies": "30.4.2", - "jest-runner": "30.4.2", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "jest-watcher": "30.4.1", - "pretty-format": "30.4.1", + "jest-changed-files": "30.5.1", + "jest-config": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-resolve-dependencies": "30.5.2", + "jest-runner": "30.5.2", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "jest-watcher": "30.5.2", + "pretty-format": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -2551,9 +2669,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", "dev": true, "license": "MIT", "engines": { @@ -2561,70 +2679,70 @@ } }, "node_modules/@jest/environment": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", - "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", + "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "30.4.1" + "jest-mock": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.4.1", - "jest-snapshot": "30.4.1" + "expect": "30.5.2", + "jest-snapshot": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", - "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", + "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0" + "@jest/get-type": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", - "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", + "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", "dev": true, "license": "MIT", "engines": { @@ -2632,62 +2750,78 @@ } }, "node_modules/@jest/globals": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", - "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.2.tgz", + "integrity": "sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/types": "30.4.1", - "jest-mock": "30.4.1" + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/types": "30.5.1", + "jest-mock": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", - "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.4.0" + "jest-regex-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", - "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.2.tgz", + "integrity": "sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", + "@jest/console": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -2705,9 +2839,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { @@ -2718,13 +2852,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", - "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", + "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -2734,14 +2868,15 @@ } }, "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.2.tgz", + "integrity": "sha512-c5CehvkbfHX6yyNg7bMn2ylqlp9AHXuZ/3hPH/Lh5bxD/vefd/lV9HBjMqXef9yQNqkdwyZvkLSVSwB38hy8Qw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", + "@jridgewell/trace-mapping": "^0.3.31", "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.11" }, "engines": { @@ -2749,14 +2884,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", - "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.2.tgz", + "integrity": "sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/types": "30.5.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -2765,15 +2900,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", - "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.2.tgz", + "integrity": "sha512-bmij8jZyYAC47ExT2GeP7PcrlwSNS+Bp3KeRuBH88gpcxqDv6fljN8PemS4J/lLZ79xb6mpaennQTTq0zUAVVg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", + "@jest/test-result": "30.5.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", + "jest-haste-map": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -2781,23 +2916,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", - "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.2.tgz", + "integrity": "sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -2807,14 +2942,14 @@ } }, "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -2856,9 +2991,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2881,13 +3016,6 @@ "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "devOptional": true, - "license": "MIT" - }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -2895,17 +3023,17 @@ "license": "MIT" }, "node_modules/@libp2p/interface": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@libp2p/interface/-/interface-3.2.2.tgz", - "integrity": "sha512-IU78g6uF8Ls0//4v9VE1rL5Jvy+i6I8LI/DssojFICbaDJSkL59Sn5XRfHrY5OCxTnUnUxnWK7pHz/3+UZcRNQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@libp2p/interface/-/interface-3.3.0.tgz", + "integrity": "sha512-SXahM/4IgpiFKTtocbXYSTA1wEUVjktmT1yBCzBbc2Vgsu1VBCg6SvsFmXo2Hbeyev3D8EU+y3uaCKDB43C/Vg==", "license": "Apache-2.0 OR MIT", "dependencies": { "@multiformats/dns": "^1.0.6", - "@multiformats/multiaddr": "^13.0.1", + "@multiformats/multiaddr": "^13.0.3", "main-event": "^1.0.1", - "multiformats": "^13.4.0", + "multiformats": "^14.0.0", "progress-events": "^1.1.0", - "uint8arraylist": "^2.4.8" + "uint8arraylist": "^3.0.2" } }, "node_modules/@libp2p/interface/node_modules/@multiformats/multiaddr": { @@ -2920,10 +3048,10 @@ "uint8arrays": "^6.1.1" } }, - "node_modules/@libp2p/interface/node_modules/@multiformats/multiaddr/node_modules/multiformats": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.0.tgz", - "integrity": "sha512-iWK1RrAS58p2NDfeZFuSUSv3ZPewTIhsGbh/5NgeGGJwJmRljLxGtjRR3nkn+loG3zl+IrfR/W1590QnrSK+Gg==", + "node_modules/@libp2p/interface/node_modules/multiformats": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.5.tgz", + "integrity": "sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==", "license": "Apache-2.0 OR MIT" }, "node_modules/@libp2p/interface/node_modules/uint8-varint": { @@ -2936,15 +3064,6 @@ "uint8arrays": "^6.1.0" } }, - "node_modules/@libp2p/interface/node_modules/uint8-varint/node_modules/uint8arraylist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-3.0.2.tgz", - "integrity": "sha512-LDVoq9BQaGJzGDUovEnoX6rpKCvnY/Jbtws4ikwnBzjRbq5qBAFpBZevUEbSmMM87aO0Sp+wOZy2ZXf5yODmXQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "uint8arrays": "^6.0.0" - } - }, "node_modules/@libp2p/interface/node_modules/uint8arrays": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-6.1.1.tgz", @@ -2954,16 +3073,10 @@ "multiformats": "^14.0.0" } }, - "node_modules/@libp2p/interface/node_modules/uint8arrays/node_modules/multiformats": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.0.tgz", - "integrity": "sha512-iWK1RrAS58p2NDfeZFuSUSv3ZPewTIhsGbh/5NgeGGJwJmRljLxGtjRR3nkn+loG3zl+IrfR/W1590QnrSK+Gg==", - "license": "Apache-2.0 OR MIT" - }, "node_modules/@lightsparkdev/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@lightsparkdev/core/-/core-1.5.2.tgz", - "integrity": "sha512-7EiV/Ld+IqAQJYvSLN2gS6E/UrcCCQ/H4voUz+nAPUDSk8U1P06afTKALh1FeizAXIzqxt1jFQWmQlXPSXmxIA==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lightsparkdev/core/-/core-1.5.3.tgz", + "integrity": "sha512-4bh42oEeJrLYUAl19p5Adxqmn3VvV7Z5Vpqn7ZF9XZwi5nPKeXXc3DMo0+GabJhlUvrk8AtNaXD1nVCMTFLTzA==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", @@ -2978,9 +3091,9 @@ } }, "node_modules/@lightsparkdev/core/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3019,9 +3132,9 @@ } }, "node_modules/@meshsdk/common": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.0.tgz", - "integrity": "sha512-FBS1j5eALPZ4WXjzH23do3GQr6VgzcFJ7vUzemDYGjeUtf+E6a/J14Vf1YFFIqVcjINLjeU/xl3Pjm46j4DgUg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.1.tgz", + "integrity": "sha512-66l7vcY+h9w71auVaE7RLJYs87DmO+JTEmQ2xknd6F8fAcR73nMxLZunMRbygPoV9gHDBTMYfHoMBZqWt7N6EA==", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", @@ -3031,26 +3144,26 @@ } }, "node_modules/@meshsdk/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@meshsdk/core/-/core-1.9.0.tgz", - "integrity": "sha512-6YTH9EhWOYjEgkUfEzKqOiHjZrtOzbB/tx15lDPx/bBITAuxKUe2LByHZjmzUYbMhd1LnkoJ9v6WrCqiWS7juA==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@meshsdk/core/-/core-1.9.1.tgz", + "integrity": "sha512-khMrEkMU71t3kn6nHM8/5rjVry055jORdln+xegP0KpVzCjWxjOe+bzyCMnQehwNT7R8OEe7wx8zYUL4qTijkA==", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0", - "@meshsdk/core-cst": "1.9.0", + "@meshsdk/common": "1.9.1", + "@meshsdk/core-cst": "1.9.1", "@meshsdk/provider": "1.9.0-beta.101", - "@meshsdk/transaction": "1.9.0", - "@meshsdk/wallet": "1.9.0", + "@meshsdk/transaction": "1.9.1", + "@meshsdk/wallet": "1.9.1", "scalus": "^0.17.0" } }, "node_modules/@meshsdk/core-csl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@meshsdk/core-csl/-/core-csl-1.9.0.tgz", - "integrity": "sha512-CdEEVanE1H7texWTNlriXYzF1Wxxl0yMRpISqx9UIqb9n9l6bioV+L387XzohZvxdmFhmoIy4sMhk/tVd2FSVQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@meshsdk/core-csl/-/core-csl-1.9.1.tgz", + "integrity": "sha512-4NPxkpAsGTs0pBTZM67uqxYrlU0Aorp2HHqljhzV/mm6+Q/swb8IxYVMN88VHS5TFI5zF0Ye+3+b1fYczYtRJg==", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0", + "@meshsdk/common": "1.9.1", "@sidan-lab/whisky-js-browser": "^1.0.11", "@sidan-lab/whisky-js-nodejs": "^1.0.11", "@types/base32-encoding": "^1.0.2", @@ -3061,9 +3174,9 @@ } }, "node_modules/@meshsdk/core-cst": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@meshsdk/core-cst/-/core-cst-1.9.0.tgz", - "integrity": "sha512-2M0oZ5lRTnNfgTy65SEdp/i16CMqm31UNHKq7W7FvTIbIg2k8L8BNB2tq55ARBtXPqO6dYICzXEvHI4pfM6NAQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@meshsdk/core-cst/-/core-cst-1.9.1.tgz", + "integrity": "sha512-1e72mtTehjV/XQWpxtnejhEXyhU1f8CWqoTaQfq2rfBydLJfFs/NDEYfBKBOx697MOWM7VPAHjLvVAENqS57EQ==", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.12", @@ -3074,7 +3187,7 @@ "@harmoniclabs/pair": "^1.0.0", "@harmoniclabs/plutus-data": "1.2.6", "@harmoniclabs/uplc": "1.4.1", - "@meshsdk/common": "1.9.0", + "@meshsdk/common": "1.9.1", "@types/base32-encoding": "^1.0.2", "base32-encoding": "^1.0.0", "bech32": "^2.0.0", @@ -3083,210 +3196,157 @@ "hash.js": "^1.1.7" } }, - "node_modules/@meshsdk/core-cst/node_modules/@cardano-sdk/core": { - "version": "0.46.12", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.46.12.tgz", - "integrity": "sha512-yUA/xBUQMiMqIWiZPvIhM911pL3jNKg4PkZQ8qP9R7yU3NQ5x4RQkZ+zFDlVLxUt+gJiwIW2es0iPd8ObIKCxA==", + "node_modules/@meshsdk/core/node_modules/@meshsdk/provider": { + "version": "1.9.0-beta.101", + "resolved": "https://registry.npmjs.org/@meshsdk/provider/-/provider-1.9.0-beta.101.tgz", + "integrity": "sha512-xbb0pCDpeHk8WEzgZCUlwy1CLpOPSPNn3TPtuLuMNnebCO8lZhvP3NYZqX/CUnTkOjXP4dxu5Zbf7ipZ43V4RQ==", "license": "Apache-2.0", "dependencies": { - "@biglup/is-cid": "^1.0.3", - "@cardano-ogmios/client": "6.9.0", - "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.4.5", - "@cardano-sdk/util": "~0.17.1", - "@foxglove/crc": "^0.0.3", - "@scure/base": "^1.1.1", - "fraction.js": "4.0.1", - "ip-address": "^9.0.5", - "lodash": "^4.17.21", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4", - "web-encoding": "^1.1.5" - }, - "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "rxjs": "^7.4.0" - }, - "peerDependenciesMeta": { - "rxjs": { - "optional": true - } + "@meshsdk/common": "1.9.0-beta.100", + "@meshsdk/core-cst": "1.9.0-beta.100", + "@utxorpc/sdk": "^0.6.7", + "@utxorpc/spec": "^0.16.0", + "axios": "^1.7.2", + "cbor": "^10.0.9" } }, - "node_modules/@meshsdk/core-cst/node_modules/@cardano-sdk/crypto": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.5.tgz", - "integrity": "sha512-ymliqxdmen5dGVaiMVQ0VnhrwaYUjbPD3sHoMj8NI6MTuxrREp3pLJASREtWhwmv9k+QzDT6CoyuIXnlEQiWZQ==", + "node_modules/@meshsdk/core/node_modules/@meshsdk/provider/node_modules/@meshsdk/common": { + "version": "1.9.0-beta.100", + "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.0-beta.100.tgz", + "integrity": "sha512-H3ktKR9eheRKZupg7DLdUr8A9dsefJbu7Wc+I1suwrv+oAZWiJ2wCuF3bX2QQo3LyWrSkVCE7WEiKFfQmukIww==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/util": "~0.17.1", + "bech32": "^2.0.0", + "bip39": "3.1.0", "blake2b": "^2.1.4", - "i": "^0.3.7", - "libsodium-wrappers-sumo": "0.7.10", - "lodash": "^4.17.21", - "pbkdf2": "^3.1.3", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" - }, - "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", - "@dcspark/cardano-multiplatform-lib-browser": "^3.1.1", - "@dcspark/cardano-multiplatform-lib-nodejs": "^3.1.1" - }, - "peerDependenciesMeta": { - "@dcspark/cardano-multiplatform-lib-asmjs": { - "optional": true - }, - "@dcspark/cardano-multiplatform-lib-browser": { - "optional": true - }, - "@dcspark/cardano-multiplatform-lib-nodejs": { - "optional": true - } + "blakejs": "^1.2.1" } }, - "node_modules/@meshsdk/core-cst/node_modules/@cardano-sdk/input-selection": { - "version": "0.14.28", - "resolved": "https://registry.npmjs.org/@cardano-sdk/input-selection/-/input-selection-0.14.28.tgz", - "integrity": "sha512-pbysJUaIbbpesbv/f0XfFPKBb+bLjCmPcMfNJzpePSZBvr8bUcFpnfKtq28KthVdpe2mgL3k9ebTTcBSk7aERw==", + "node_modules/@meshsdk/core/node_modules/@meshsdk/provider/node_modules/@meshsdk/core-cst": { + "version": "1.9.0-beta.100", + "resolved": "https://registry.npmjs.org/@meshsdk/core-cst/-/core-cst-1.9.0-beta.100.tgz", + "integrity": "sha512-gXC7c81puzv12C3xJ6vhH/KIEc/P6ScuXsgmLlqFMpDv0SuoMg+42HgdyWi0WrccVwi8cdepsn5YhtCaYVn0nw==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "~0.46.12", - "@cardano-sdk/key-management": "~0.29.12", - "@cardano-sdk/util": "~0.17.1", - "bignumber.js": "^9.1.1", - "lodash": "^4.17.21", - "ts-custom-error": "^3.2.0" - }, - "engines": { - "node": ">=16.20.2" + "@cardano-sdk/core": "0.46.12", + "@cardano-sdk/crypto": "0.4.5", + "@cardano-sdk/input-selection": "0.14.28", + "@cardano-sdk/util": "0.17.1", + "@harmoniclabs/cbor": "1.6.0", + "@harmoniclabs/pair": "^1.0.0", + "@harmoniclabs/plutus-data": "1.2.6", + "@harmoniclabs/uplc": "1.4.1", + "@meshsdk/common": "1.9.0-beta.100", + "@types/base32-encoding": "^1.0.2", + "base32-encoding": "^1.0.0", + "bech32": "^2.0.0", + "blakejs": "^1.2.1", + "bn.js": "^5.2.0", + "hash.js": "^1.1.7", + "scalus": "^0.14.2" } }, - "node_modules/@meshsdk/core-cst/node_modules/@cardano-sdk/key-management": { - "version": "0.29.13", - "resolved": "https://registry.npmjs.org/@cardano-sdk/key-management/-/key-management-0.29.13.tgz", - "integrity": "sha512-yvdWrZcvrH9s5mBwnKpdT6xGF7vMZ9DSrrEENnhA9NG9jXDLpuYX/KI2TGczUdRPezBFm2y9kO7oHNf8GNYvAA==", + "node_modules/@meshsdk/core/node_modules/@meshsdk/provider/node_modules/scalus": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.14.2.tgz", + "integrity": "sha512-dobDMIUDUVhtxoX3ceGlaykKQGkph4HOE9hjkLsmwVgYf24fIik6YrZzVFrZSNCTvI2WN7hjEknehIrEJo1CMQ==", + "license": "Apache-2.0" + }, + "node_modules/@meshsdk/provider": { + "version": "1.9.0-beta.105", + "resolved": "https://registry.npmjs.org/@meshsdk/provider/-/provider-1.9.0-beta.105.tgz", + "integrity": "sha512-Nh82wwc4zdhBBp0bksi1ek9wLhb4SgPrvnpkpaI0swsO8H0uIyfkZ26XUK+jaXLvKZSbhDQDwpfO7RjCCnzbag==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "~0.46.12", - "@cardano-sdk/crypto": "~0.4.5", - "@cardano-sdk/dapp-connector": "~0.13.26", - "@cardano-sdk/util": "~0.17.1", - "@emurgo/cardano-message-signing-nodejs": "^1.0.1", - "bip39": "^3.0.4", - "chacha": "^2.1.0", - "get-random-values": "^2.0.0", - "lodash": "^4.17.21", - "pbkdf2": "^3.1.3", - "rxjs": "^7.4.0", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" - }, - "engines": { - "node": ">=16.20.2" + "@meshsdk/common": "1.9.0-beta.100", + "@meshsdk/core-cst": "1.9.0-beta.100", + "@utxorpc/sdk": "^0.6.7", + "@utxorpc/spec": "^0.16.0", + "axios": "^1.7.2", + "cbor": "^10.0.9" } }, - "node_modules/@meshsdk/core-cst/node_modules/@cardano-sdk/util": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.1.tgz", - "integrity": "sha512-TCYe+wRguW1WgRlbWqhGPhcSBkzVzdIcCVgDDN7wiQk2dew0EWVqjsKeqDZdfwzy/s2kr/ZOgXIGywBn/Bzu/Q==", + "node_modules/@meshsdk/provider/node_modules/@meshsdk/common": { + "version": "1.9.0-beta.100", + "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.0-beta.100.tgz", + "integrity": "sha512-H3ktKR9eheRKZupg7DLdUr8A9dsefJbu7Wc+I1suwrv+oAZWiJ2wCuF3bX2QQo3LyWrSkVCE7WEiKFfQmukIww==", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", - "lodash": "^4.17.21", - "serialize-error": "^8", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" - }, - "engines": { - "node": ">=16.20.2" - } - }, - "node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/crypto": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.3.0.tgz", - "integrity": "sha512-UvmGQOLFVFhRIDYLpcWbPQLXl9advCt0h02Z/BtBuXtHiy35WRxKQ3njcUKI0v6zGITuvqQhsf6VOPMeekLdeA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@harmoniclabs/bitstream": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.3" + "bip39": "3.1.0", + "blake2b": "^2.1.4", + "blakejs": "^1.2.1" } }, - "node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/plutus-data": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@harmoniclabs/plutus-data/-/plutus-data-1.2.6.tgz", - "integrity": "sha512-rF046GZ07XDpjZBNybALKYSycjxCLzXKbhLylu9pRuZiii5fVXReEfgtLB29TsPBvGY6ZBeiyHgJnLgm+huZBw==", + "node_modules/@meshsdk/provider/node_modules/@meshsdk/core-cst": { + "version": "1.9.0-beta.100", + "resolved": "https://registry.npmjs.org/@meshsdk/core-cst/-/core-cst-1.9.0-beta.100.tgz", + "integrity": "sha512-gXC7c81puzv12C3xJ6vhH/KIEc/P6ScuXsgmLlqFMpDv0SuoMg+42HgdyWi0WrccVwi8cdepsn5YhtCaYVn0nw==", "license": "Apache-2.0", "dependencies": { - "@harmoniclabs/biguint": "^1.0.0", - "@harmoniclabs/crypto": "^0.2.4", - "@harmoniclabs/uint8array-utils": "^1.0.0" - }, - "peerDependencies": { - "@harmoniclabs/bytestring": "^1.0.0", - "@harmoniclabs/cbor": "^1.3.0" + "@cardano-sdk/core": "0.46.12", + "@cardano-sdk/crypto": "0.4.5", + "@cardano-sdk/input-selection": "0.14.28", + "@cardano-sdk/util": "0.17.1", + "@harmoniclabs/cbor": "1.6.0", + "@harmoniclabs/pair": "^1.0.0", + "@harmoniclabs/plutus-data": "1.2.6", + "@harmoniclabs/uplc": "1.4.1", + "@meshsdk/common": "1.9.0-beta.100", + "@types/base32-encoding": "^1.0.2", + "base32-encoding": "^1.0.0", + "bech32": "^2.0.0", + "blakejs": "^1.2.1", + "bn.js": "^5.2.0", + "hash.js": "^1.1.7", + "scalus": "^0.14.2" } }, - "node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/plutus-data/node_modules/@harmoniclabs/crypto": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.2.5.tgz", - "integrity": "sha512-t2saWMFWBx8tOHotiYTTfQKhPGpWT4AMLXxq3u0apShVXNV0vgL0gEgSMudBjES/wrKByCqa2xmU70gadz26hA==", - "license": "MIT", - "dependencies": { - "@harmoniclabs/bitstream": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.3" - } + "node_modules/@meshsdk/provider/node_modules/scalus": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.14.2.tgz", + "integrity": "sha512-dobDMIUDUVhtxoX3ceGlaykKQGkph4HOE9hjkLsmwVgYf24fIik6YrZzVFrZSNCTvI2WN7hjEknehIrEJo1CMQ==", + "license": "Apache-2.0" }, - "node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/uplc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@harmoniclabs/uplc/-/uplc-1.4.1.tgz", - "integrity": "sha512-sELKStjxPBPBxBMylU4oBSUe0/8eJe2HqRblNSwrMu8Fso4YpSPDqHZ33iDZ8QAadVUsT5r2EQKX0TLrj7qXvQ==", + "node_modules/@meshsdk/react": { + "version": "2.0.0-beta.2", + "resolved": "https://registry.npmjs.org/@meshsdk/react/-/react-2.0.0-beta.2.tgz", + "integrity": "sha512-FF0sb2NZCMn+fFsmyEst7nEauW4f/I58cH2Zb08zM1C37lGynySNmt7A0Rj2TCvUeT6tIybLAZI3HtIaNcRUfw==", "license": "Apache-2.0", "dependencies": { - "@harmoniclabs/bigint-utils": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/HarmonicLabs" + "@meshsdk/common": "1.9.0-beta.98", + "@meshsdk/wallet": "2.0.0-beta.5", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-tooltip": "^1.1.4", + "@utxos/sdk": "0.1.5", + "class-variance-authority": "^0.7.1", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7" }, "peerDependencies": { - "@harmoniclabs/bytestring": "^1.0.0", - "@harmoniclabs/cbor": "^1.3.0", - "@harmoniclabs/crypto": "^0.3.0-dev0", - "@harmoniclabs/pair": "^1.0.0", - "@harmoniclabs/plutus-data": "^1.2.4" + "react": ">=16.0.0 <20.0.0 || >=16.0.0-rc <20.0.0-rc || >=19.0.0-rc", + "react-dom": ">=16.0.0 <20.0.0 || >=16.0.0-rc <20.0.0-rc || >=19.0.0-rc" } }, - "node_modules/@meshsdk/provider": { - "version": "1.9.0-beta.101", - "resolved": "https://registry.npmjs.org/@meshsdk/provider/-/provider-1.9.0-beta.101.tgz", - "integrity": "sha512-xbb0pCDpeHk8WEzgZCUlwy1CLpOPSPNn3TPtuLuMNnebCO8lZhvP3NYZqX/CUnTkOjXP4dxu5Zbf7ipZ43V4RQ==", - "license": "Apache-2.0", - "dependencies": { - "@meshsdk/common": "1.9.0-beta.100", - "@meshsdk/core-cst": "1.9.0-beta.100", - "@utxorpc/sdk": "^0.6.7", - "@utxorpc/spec": "^0.16.0", - "axios": "^1.7.2", - "cbor": "^10.0.9" - } + "node_modules/@meshsdk/react/node_modules/@bufbuild/protobuf": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", + "license": "(Apache-2.0 AND BSD-3-Clause)" }, - "node_modules/@meshsdk/provider/node_modules/@cardano-sdk/core": { - "version": "0.46.12", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.46.12.tgz", - "integrity": "sha512-yUA/xBUQMiMqIWiZPvIhM911pL3jNKg4PkZQ8qP9R7yU3NQ5x4RQkZ+zFDlVLxUt+gJiwIW2es0iPd8ObIKCxA==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/core": { + "version": "0.46.11", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.46.11.tgz", + "integrity": "sha512-N/f0Gna41Jsw/KFdulqgpTks4VoeNG1rhTYmGkgtUkMqBTYK+IdaOwMH4QrNxz08VpbOGv76Km3phqGuvTUinQ==", "license": "Apache-2.0", "dependencies": { "@biglup/is-cid": "^1.0.3", "@cardano-ogmios/client": "6.9.0", "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.4.5", + "@cardano-sdk/crypto": "~0.4.4", "@cardano-sdk/util": "~0.17.1", "@foxglove/crc": "^0.0.3", "@scure/base": "^1.1.1", @@ -3309,16 +3369,16 @@ } } }, - "node_modules/@meshsdk/provider/node_modules/@cardano-sdk/crypto": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.5.tgz", - "integrity": "sha512-ymliqxdmen5dGVaiMVQ0VnhrwaYUjbPD3sHoMj8NI6MTuxrREp3pLJASREtWhwmv9k+QzDT6CoyuIXnlEQiWZQ==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/crypto": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.4.tgz", + "integrity": "sha512-jvElFox4TPlTZRtjfw0HlkucRD90EeijfhMT0uD0N6ptkn8sRQXUFO+z+1Zcp9v9L2V324N7+2ThpjjBEoUdXQ==", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/util": "~0.17.1", "blake2b": "^2.1.4", "i": "^0.3.7", - "libsodium-wrappers-sumo": "0.7.10", + "libsodium-wrappers-sumo": "^0.7.5", "lodash": "^4.17.21", "pbkdf2": "^3.1.3", "ts-custom-error": "^3.2.0", @@ -3344,15 +3404,15 @@ } } }, - "node_modules/@meshsdk/provider/node_modules/@cardano-sdk/input-selection": { - "version": "0.14.28", - "resolved": "https://registry.npmjs.org/@cardano-sdk/input-selection/-/input-selection-0.14.28.tgz", - "integrity": "sha512-pbysJUaIbbpesbv/f0XfFPKBb+bLjCmPcMfNJzpePSZBvr8bUcFpnfKtq28KthVdpe2mgL3k9ebTTcBSk7aERw==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/input-selection": { + "version": "0.13.34", + "resolved": "https://registry.npmjs.org/@cardano-sdk/input-selection/-/input-selection-0.13.34.tgz", + "integrity": "sha512-/AidYTF9WesLoMc4PHoETxXgrfYEq8GECcikjvLwx1mygmKpok4Lp41Aio7sBasUCLvZ82/yTd3uXIAvec1aCA==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "~0.46.12", - "@cardano-sdk/key-management": "~0.29.12", - "@cardano-sdk/util": "~0.17.1", + "@cardano-sdk/core": "~0.43.0", + "@cardano-sdk/key-management": "~0.25.1", + "@cardano-sdk/util": "~0.15.5", "bignumber.js": "^9.1.1", "lodash": "^4.17.21", "ts-custom-error": "^3.2.0" @@ -3361,174 +3421,126 @@ "node": ">=16.20.2" } }, - "node_modules/@meshsdk/provider/node_modules/@cardano-sdk/key-management": { - "version": "0.29.13", - "resolved": "https://registry.npmjs.org/@cardano-sdk/key-management/-/key-management-0.29.13.tgz", - "integrity": "sha512-yvdWrZcvrH9s5mBwnKpdT6xGF7vMZ9DSrrEENnhA9NG9jXDLpuYX/KI2TGczUdRPezBFm2y9kO7oHNf8GNYvAA==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/input-selection/node_modules/@cardano-sdk/core": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.43.0.tgz", + "integrity": "sha512-hPnZXjObJub0eXV2dDAG2/gEg/vw092RZ1VGMZfBSqavz18Tg/K6jGQ3cOpWZ9d+MqFzZTCB+s5ctdRkYt3idA==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "~0.46.12", - "@cardano-sdk/crypto": "~0.4.5", - "@cardano-sdk/dapp-connector": "~0.13.26", - "@cardano-sdk/util": "~0.17.1", - "@emurgo/cardano-message-signing-nodejs": "^1.0.1", - "bip39": "^3.0.4", - "chacha": "^2.1.0", - "get-random-values": "^2.0.0", + "@biglup/is-cid": "^1.0.3", + "@cardano-ogmios/client": "6.9.0", + "@cardano-ogmios/schema": "6.9.0", + "@cardano-sdk/crypto": "~0.1.32", + "@cardano-sdk/util": "~0.15.5", + "@foxglove/crc": "^0.0.3", + "@scure/base": "^1.1.1", + "fraction.js": "4.0.1", + "ip-address": "^9.0.5", "lodash": "^4.17.21", - "pbkdf2": "^3.1.3", - "rxjs": "^7.4.0", "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" + "ts-log": "^2.2.4", + "web-encoding": "^1.1.5" }, "engines": { "node": ">=16.20.2" + }, + "peerDependencies": { + "rxjs": "^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } } }, - "node_modules/@meshsdk/provider/node_modules/@cardano-sdk/util": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.1.tgz", - "integrity": "sha512-TCYe+wRguW1WgRlbWqhGPhcSBkzVzdIcCVgDDN7wiQk2dew0EWVqjsKeqDZdfwzy/s2kr/ZOgXIGywBn/Bzu/Q==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/input-selection/node_modules/@cardano-sdk/crypto": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.1.32.tgz", + "integrity": "sha512-RCKFvkzD32QpKQ0jULADVRNmfBNkCwiZl2nlFbkZ3aCrfIex+6/2CizoagJ161fA7lL5/HGuzWfjOg3GX2ax6w==", "license": "Apache-2.0", "dependencies": { - "bech32": "^2.0.0", + "@cardano-sdk/util": "~0.15.5", + "blake2b": "^2.1.4", + "i": "^0.3.7", + "libsodium-wrappers-sumo": "^0.7.5", "lodash": "^4.17.21", - "serialize-error": "^8", + "npm": "^9.3.0", + "pbkdf2": "^3.1.2", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4" }, "engines": { "node": ">=16.20.2" - } - }, - "node_modules/@meshsdk/provider/node_modules/@harmoniclabs/plutus-data": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@harmoniclabs/plutus-data/-/plutus-data-1.2.6.tgz", - "integrity": "sha512-rF046GZ07XDpjZBNybALKYSycjxCLzXKbhLylu9pRuZiii5fVXReEfgtLB29TsPBvGY6ZBeiyHgJnLgm+huZBw==", - "license": "Apache-2.0", - "dependencies": { - "@harmoniclabs/biguint": "^1.0.0", - "@harmoniclabs/crypto": "^0.2.4", - "@harmoniclabs/uint8array-utils": "^1.0.0" }, "peerDependencies": { - "@harmoniclabs/bytestring": "^1.0.0", - "@harmoniclabs/cbor": "^1.3.0" - } - }, - "node_modules/@meshsdk/provider/node_modules/@meshsdk/common": { - "version": "1.9.0-beta.100", - "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.0-beta.100.tgz", - "integrity": "sha512-H3ktKR9eheRKZupg7DLdUr8A9dsefJbu7Wc+I1suwrv+oAZWiJ2wCuF3bX2QQo3LyWrSkVCE7WEiKFfQmukIww==", - "license": "Apache-2.0", - "dependencies": { - "bech32": "^2.0.0", - "bip39": "3.1.0", - "blake2b": "^2.1.4", - "blakejs": "^1.2.1" + "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-browser": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-nodejs": "^3.1.1" + }, + "peerDependenciesMeta": { + "@dcspark/cardano-multiplatform-lib-asmjs": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-browser": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-nodejs": { + "optional": true + } } }, - "node_modules/@meshsdk/provider/node_modules/@meshsdk/core-cst": { - "version": "1.9.0-beta.100", - "resolved": "https://registry.npmjs.org/@meshsdk/core-cst/-/core-cst-1.9.0-beta.100.tgz", - "integrity": "sha512-gXC7c81puzv12C3xJ6vhH/KIEc/P6ScuXsgmLlqFMpDv0SuoMg+42HgdyWi0WrccVwi8cdepsn5YhtCaYVn0nw==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/input-selection/node_modules/@cardano-sdk/util": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.15.7.tgz", + "integrity": "sha512-L0f3gXFujRwSSpjzq2W/OwW23fg0gw5S+9R+91He3LgmyfjNygd939eFPCLhwOscsHcJ4AN27UJSYnx3JMKZ0w==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/core": "0.46.12", - "@cardano-sdk/crypto": "0.4.5", - "@cardano-sdk/input-selection": "0.14.28", - "@cardano-sdk/util": "0.17.1", - "@harmoniclabs/cbor": "1.6.0", - "@harmoniclabs/pair": "^1.0.0", - "@harmoniclabs/plutus-data": "1.2.6", - "@harmoniclabs/uplc": "1.4.1", - "@meshsdk/common": "1.9.0-beta.100", - "@types/base32-encoding": "^1.0.2", - "base32-encoding": "^1.0.0", "bech32": "^2.0.0", - "blakejs": "^1.2.1", - "bn.js": "^5.2.0", - "hash.js": "^1.1.7", - "scalus": "^0.14.2" - } - }, - "node_modules/@meshsdk/provider/node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/crypto": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.3.0.tgz", - "integrity": "sha512-UvmGQOLFVFhRIDYLpcWbPQLXl9advCt0h02Z/BtBuXtHiy35WRxKQ3njcUKI0v6zGITuvqQhsf6VOPMeekLdeA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@harmoniclabs/bitstream": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.3" - } - }, - "node_modules/@meshsdk/provider/node_modules/@meshsdk/core-cst/node_modules/@harmoniclabs/uplc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@harmoniclabs/uplc/-/uplc-1.4.1.tgz", - "integrity": "sha512-sELKStjxPBPBxBMylU4oBSUe0/8eJe2HqRblNSwrMu8Fso4YpSPDqHZ33iDZ8QAadVUsT5r2EQKX0TLrj7qXvQ==", - "license": "Apache-2.0", - "dependencies": { - "@harmoniclabs/bigint-utils": "^1.0.0", - "@harmoniclabs/uint8array-utils": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/HarmonicLabs" + "lodash": "^4.17.21", + "serialize-error": "^8", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4", + "type-fest": "^2.19.0" }, - "peerDependencies": { - "@harmoniclabs/bytestring": "^1.0.0", - "@harmoniclabs/cbor": "^1.3.0", - "@harmoniclabs/crypto": "^0.3.0-dev0", - "@harmoniclabs/pair": "^1.0.0", - "@harmoniclabs/plutus-data": "^1.2.4" + "engines": { + "node": ">=16.20.2" } }, - "node_modules/@meshsdk/provider/node_modules/scalus": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/scalus/-/scalus-0.14.2.tgz", - "integrity": "sha512-dobDMIUDUVhtxoX3ceGlaykKQGkph4HOE9hjkLsmwVgYf24fIik6YrZzVFrZSNCTvI2WN7hjEknehIrEJo1CMQ==", - "license": "Apache-2.0" - }, - "node_modules/@meshsdk/react": { - "version": "2.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@meshsdk/react/-/react-2.0.0-beta.2.tgz", - "integrity": "sha512-FF0sb2NZCMn+fFsmyEst7nEauW4f/I58cH2Zb08zM1C37lGynySNmt7A0Rj2TCvUeT6tIybLAZI3HtIaNcRUfw==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/key-management": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@cardano-sdk/key-management/-/key-management-0.25.1.tgz", + "integrity": "sha512-D99XTIplI2aQnCZtVUKZdmH9wZJQC2WuZL6hTqGZHHFBAeju2zBzGWT21LlcPRlT0/2DP2/OdfIHoHCr2ORp4g==", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0-beta.98", - "@meshsdk/wallet": "2.0.0-beta.5", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-tooltip": "^1.1.4", - "@utxos/sdk": "0.1.5", - "class-variance-authority": "^0.7.1", - "tailwind-merge": "^2.6.0", - "tailwindcss-animate": "^1.0.7" + "@cardano-sdk/core": "~0.43.0", + "@cardano-sdk/crypto": "~0.1.32", + "@cardano-sdk/dapp-connector": "~0.13.1", + "@cardano-sdk/util": "~0.15.5", + "@emurgo/cardano-message-signing-nodejs": "^1.0.1", + "bip39": "^3.0.4", + "chacha": "^2.1.0", + "get-random-values": "^2.0.0", + "lodash": "^4.17.21", + "pbkdf2": "^3.1.2", + "rxjs": "^7.4.0", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4" }, - "peerDependencies": { - "react": ">=16.0.0 <20.0.0 || >=16.0.0-rc <20.0.0-rc || >=19.0.0-rc", - "react-dom": ">=16.0.0 <20.0.0 || >=16.0.0-rc <20.0.0-rc || >=19.0.0-rc" + "engines": { + "node": ">=16.20.2" } }, - "node_modules/@meshsdk/react/node_modules/@bufbuild/protobuf": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", - "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", - "license": "(Apache-2.0 AND BSD-3-Clause)" - }, - "node_modules/@meshsdk/react/node_modules/@cardano-sdk/core": { - "version": "0.46.11", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.46.11.tgz", - "integrity": "sha512-N/f0Gna41Jsw/KFdulqgpTks4VoeNG1rhTYmGkgtUkMqBTYK+IdaOwMH4QrNxz08VpbOGv76Km3phqGuvTUinQ==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/core": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.43.0.tgz", + "integrity": "sha512-hPnZXjObJub0eXV2dDAG2/gEg/vw092RZ1VGMZfBSqavz18Tg/K6jGQ3cOpWZ9d+MqFzZTCB+s5ctdRkYt3idA==", "license": "Apache-2.0", "dependencies": { "@biglup/is-cid": "^1.0.3", "@cardano-ogmios/client": "6.9.0", "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.4.4", - "@cardano-sdk/util": "~0.17.1", + "@cardano-sdk/crypto": "~0.1.32", + "@cardano-sdk/util": "~0.15.5", "@foxglove/crc": "^0.0.3", "@scure/base": "^1.1.1", "fraction.js": "4.0.1", @@ -3550,18 +3562,19 @@ } } }, - "node_modules/@meshsdk/react/node_modules/@cardano-sdk/crypto": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.4.tgz", - "integrity": "sha512-jvElFox4TPlTZRtjfw0HlkucRD90EeijfhMT0uD0N6ptkn8sRQXUFO+z+1Zcp9v9L2V324N7+2ThpjjBEoUdXQ==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/crypto": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.1.32.tgz", + "integrity": "sha512-RCKFvkzD32QpKQ0jULADVRNmfBNkCwiZl2nlFbkZ3aCrfIex+6/2CizoagJ161fA7lL5/HGuzWfjOg3GX2ax6w==", "license": "Apache-2.0", "dependencies": { - "@cardano-sdk/util": "~0.17.1", + "@cardano-sdk/util": "~0.15.5", "blake2b": "^2.1.4", "i": "^0.3.7", "libsodium-wrappers-sumo": "^0.7.5", "lodash": "^4.17.21", - "pbkdf2": "^3.1.3", + "npm": "^9.3.0", + "pbkdf2": "^3.1.2", "ts-custom-error": "^3.2.0", "ts-log": "^2.2.4" }, @@ -3585,17 +3598,18 @@ } } }, - "node_modules/@meshsdk/react/node_modules/@cardano-sdk/util": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.1.tgz", - "integrity": "sha512-TCYe+wRguW1WgRlbWqhGPhcSBkzVzdIcCVgDDN7wiQk2dew0EWVqjsKeqDZdfwzy/s2kr/ZOgXIGywBn/Bzu/Q==", + "node_modules/@meshsdk/react/node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/util": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.15.7.tgz", + "integrity": "sha512-L0f3gXFujRwSSpjzq2W/OwW23fg0gw5S+9R+91He3LgmyfjNygd939eFPCLhwOscsHcJ4AN27UJSYnx3JMKZ0w==", "license": "Apache-2.0", "dependencies": { "bech32": "^2.0.0", "lodash": "^4.17.21", "serialize-error": "^8", "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" + "ts-log": "^2.2.4", + "type-fest": "^2.19.0" }, "engines": { "node": ">=16.20.2" @@ -3615,6 +3629,52 @@ "url": "https://github.com/sponsors/HarmonicLabs" } }, + "node_modules/@meshsdk/react/node_modules/@harmoniclabs/crypto": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.2.5.tgz", + "integrity": "sha512-t2saWMFWBx8tOHotiYTTfQKhPGpWT4AMLXxq3u0apShVXNV0vgL0gEgSMudBjES/wrKByCqa2xmU70gadz26hA==", + "license": "MIT", + "dependencies": { + "@harmoniclabs/bitstream": "^1.0.0", + "@harmoniclabs/uint8array-utils": "^1.0.3" + } + }, + "node_modules/@meshsdk/react/node_modules/@harmoniclabs/plutus-data": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@harmoniclabs/plutus-data/-/plutus-data-1.2.4.tgz", + "integrity": "sha512-cpr6AnJRultH6PJRDriewHEgNLQs2IGLampZrLjmK5shzTsHICD0yD0Zig9eKdcS7dmY6mlzvSpAJWPGeTxbCA==", + "license": "Apache-2.0", + "dependencies": { + "@harmoniclabs/biguint": "^1.0.0", + "@harmoniclabs/crypto": "^0.2.4", + "@harmoniclabs/uint8array-utils": "^1.0.0" + }, + "peerDependencies": { + "@harmoniclabs/bytestring": "^1.0.0", + "@harmoniclabs/cbor": "^1.3.0" + } + }, + "node_modules/@meshsdk/react/node_modules/@harmoniclabs/uplc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@harmoniclabs/uplc/-/uplc-1.2.4.tgz", + "integrity": "sha512-Px6utj94cO/hQd9NJgVQI8zycsbgh3rAzDeLdZ1m52bo++EuU1GL+arWX3JYso3/3uNrnUFuizjrAIISwQe3Fg==", + "license": "Apache-2.0", + "dependencies": { + "@harmoniclabs/bigint-utils": "^1.0.0", + "@harmoniclabs/bitstream": "^1.0.0", + "@harmoniclabs/uint8array-utils": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/HarmonicLabs" + }, + "peerDependencies": { + "@harmoniclabs/bytestring": "^1.0.0", + "@harmoniclabs/cbor": "^1.3.0", + "@harmoniclabs/crypto": "^0.2.4", + "@harmoniclabs/pair": "^1.0.0", + "@harmoniclabs/plutus-data": "^1.2.4" + } + }, "node_modules/@meshsdk/react/node_modules/@meshsdk/common": { "version": "1.9.0-beta.98", "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.0-beta.98.tgz", @@ -3956,6 +4016,12 @@ "json-bigint": "^1.0.0" } }, + "node_modules/@meshsdk/react/node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@meshsdk/react/node_modules/@utxos/sdk": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@utxos/sdk/-/sdk-0.1.5.tgz", @@ -3993,12 +4059,12 @@ } }, "node_modules/@meshsdk/react/node_modules/@utxos/sdk/node_modules/@buildonspark/issuer-sdk": { - "version": "0.1.41", - "resolved": "https://registry.npmjs.org/@buildonspark/issuer-sdk/-/issuer-sdk-0.1.41.tgz", - "integrity": "sha512-KhRK5LoXXa+MzOsgaU2BZXBNXTycsH4ztihk2IIdD+Oiad7aENysp5+D+nCkG1d2b6OPCCAdJOBgd8UZC0xCqQ==", + "version": "0.1.49", + "resolved": "https://registry.npmjs.org/@buildonspark/issuer-sdk/-/issuer-sdk-0.1.49.tgz", + "integrity": "sha512-GFwYaabEeJlABOmSCJWxz9Zq3OR/A85zOxdhPZOIdDyEflKFIGKs4a9B7v4NVJjuzhZhy48QXZxBWiIKLM3ABA==", "license": "Apache-2.0", "dependencies": { - "@buildonspark/spark-sdk": "0.8.5", + "@buildonspark/spark-sdk": "0.12.1", "@noble/curves": "^1.9.7", "@scure/btc-signer": "^1.5.0", "buffer": "^6.0.3" @@ -4024,9 +4090,9 @@ } }, "node_modules/@meshsdk/react/node_modules/@utxos/sdk/node_modules/@buildonspark/issuer-sdk/node_modules/@buildonspark/spark-sdk": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@buildonspark/spark-sdk/-/spark-sdk-0.8.5.tgz", - "integrity": "sha512-EbaA20iu2EJLvvkqPZxeNf21/Ahd9Rc/5GHoe8eKk4jPMGT4UtCEzCyHImiVcfBQ1qTiRWxptAzj2rOwAXdJ+g==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@buildonspark/spark-sdk/-/spark-sdk-0.12.1.tgz", + "integrity": "sha512-xCPLdNcaIl/uEoIg0YjTS/KTfFuDdsFO/NuybSXItBrzY/+ZAwcYSCHSEjo02H2N73xS0PteyongBtg1WhzH7A==", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.2.5", @@ -4050,7 +4116,6 @@ "nice-grpc-client-middleware-retry": "^3.1.10", "nice-grpc-common": "^2.0.2", "nice-grpc-web": "^3.3.7", - "ts-proto": "2.8.3", "ua-parser-js": "^2.0.6", "uuidv7": "^1.0.2" }, @@ -4075,15 +4140,16 @@ } }, "node_modules/@meshsdk/react/node_modules/@utxos/sdk/node_modules/@buildonspark/issuer-sdk/node_modules/bare-fetch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bare-fetch/-/bare-fetch-3.0.1.tgz", - "integrity": "sha512-OWC8Z62E8JmomltTkXt9cCPMPj2DNi2vp66FOj3BkglNKNshZuk8n98Ba3afUxrrM4kv9/eMzh9+U9dXZSQyOg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/bare-fetch/-/bare-fetch-3.4.0.tgz", + "integrity": "sha512-NtISrwMKKnz+ITDb0+PSitFAtKTp1u8MvpU/TVoEE97uAHjNEr+MJhYiss2SjNzPRvrj1nWoJvYtG+dQiQxgJQ==", "license": "Apache-2.0", "dependencies": { "bare-form-data": "^1.2.0", "bare-http1": "^4.5.2", "bare-https": "^3.0.0", "bare-mime": "^1.0.0", + "bare-performance": "^2.1.1", "bare-stream": "^2.9.1", "bare-url": "^2.4.0", "bare-zlib": "^1.3.0" @@ -4185,185 +4251,55 @@ "@simplewebauthn/browser": "^13.0.0" } }, - "node_modules/@meshsdk/react/node_modules/ts-proto": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.8.3.tgz", - "integrity": "sha512-TdXInqG+61pj/TvORqITWjvjTTsL1EZxwX49iEj89+xFAcqPT8tjChpAGQXzfcF4MJwvNiuoCEbBOKqVf3ds3g==", - "license": "ISC", - "dependencies": { - "@bufbuild/protobuf": "^2.0.0", - "case-anything": "^2.1.13", - "ts-poet": "^6.12.0", - "ts-proto-descriptors": "2.0.0" + "node_modules/@meshsdk/react/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" }, - "bin": { - "protoc-gen-ts_proto": "protoc-gen-ts_proto" - } - }, - "node_modules/@meshsdk/react/node_modules/ts-proto-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.0.0.tgz", - "integrity": "sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==", - "license": "ISC", - "dependencies": { - "@bufbuild/protobuf": "^2.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@meshsdk/transaction": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@meshsdk/transaction/-/transaction-1.9.0.tgz", - "integrity": "sha512-1PxLGoH88A5jUQDV/cUaBh1KvT3sjWwp0DFRP6ASBzwYmhDWr8SNuYQtDB0MLDM7sGXY4VVYq5N46r15TIcB1Q==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@meshsdk/transaction/-/transaction-1.9.1.tgz", + "integrity": "sha512-FjKSoUi2xFaKJPI1ObQ13UkrCJu+iPW6THP//ZD/f5I1NciaUz3bcoQ7xWcc7El4TCcDunBPp9di6w9JitBR6Q==", "license": "Apache-2.0", "dependencies": { "@cardano-sdk/core": "0.46.12", "@cardano-sdk/input-selection": "0.14.28", "@cardano-sdk/util": "0.17.1", - "@meshsdk/common": "1.9.0", - "@meshsdk/core-cst": "1.9.0", + "@meshsdk/common": "1.9.1", + "@meshsdk/core-cst": "1.9.1", "json-bigint": "^1.0.0" } }, - "node_modules/@meshsdk/transaction/node_modules/@cardano-sdk/core": { - "version": "0.46.12", - "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.46.12.tgz", - "integrity": "sha512-yUA/xBUQMiMqIWiZPvIhM911pL3jNKg4PkZQ8qP9R7yU3NQ5x4RQkZ+zFDlVLxUt+gJiwIW2es0iPd8ObIKCxA==", - "license": "Apache-2.0", - "dependencies": { - "@biglup/is-cid": "^1.0.3", - "@cardano-ogmios/client": "6.9.0", - "@cardano-ogmios/schema": "6.9.0", - "@cardano-sdk/crypto": "~0.4.5", - "@cardano-sdk/util": "~0.17.1", - "@foxglove/crc": "^0.0.3", - "@scure/base": "^1.1.1", - "fraction.js": "4.0.1", - "ip-address": "^9.0.5", - "lodash": "^4.17.21", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4", - "web-encoding": "^1.1.5" - }, - "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "rxjs": "^7.4.0" - }, - "peerDependenciesMeta": { - "rxjs": { - "optional": true - } - } - }, - "node_modules/@meshsdk/transaction/node_modules/@cardano-sdk/crypto": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.4.5.tgz", - "integrity": "sha512-ymliqxdmen5dGVaiMVQ0VnhrwaYUjbPD3sHoMj8NI6MTuxrREp3pLJASREtWhwmv9k+QzDT6CoyuIXnlEQiWZQ==", - "license": "Apache-2.0", - "dependencies": { - "@cardano-sdk/util": "~0.17.1", - "blake2b": "^2.1.4", - "i": "^0.3.7", - "libsodium-wrappers-sumo": "0.7.10", - "lodash": "^4.17.21", - "pbkdf2": "^3.1.3", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" - }, - "engines": { - "node": ">=16.20.2" - }, - "peerDependencies": { - "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", - "@dcspark/cardano-multiplatform-lib-browser": "^3.1.1", - "@dcspark/cardano-multiplatform-lib-nodejs": "^3.1.1" - }, - "peerDependenciesMeta": { - "@dcspark/cardano-multiplatform-lib-asmjs": { - "optional": true - }, - "@dcspark/cardano-multiplatform-lib-browser": { - "optional": true - }, - "@dcspark/cardano-multiplatform-lib-nodejs": { - "optional": true - } - } - }, - "node_modules/@meshsdk/transaction/node_modules/@cardano-sdk/input-selection": { - "version": "0.14.28", - "resolved": "https://registry.npmjs.org/@cardano-sdk/input-selection/-/input-selection-0.14.28.tgz", - "integrity": "sha512-pbysJUaIbbpesbv/f0XfFPKBb+bLjCmPcMfNJzpePSZBvr8bUcFpnfKtq28KthVdpe2mgL3k9ebTTcBSk7aERw==", - "license": "Apache-2.0", - "dependencies": { - "@cardano-sdk/core": "~0.46.12", - "@cardano-sdk/key-management": "~0.29.12", - "@cardano-sdk/util": "~0.17.1", - "bignumber.js": "^9.1.1", - "lodash": "^4.17.21", - "ts-custom-error": "^3.2.0" - }, - "engines": { - "node": ">=16.20.2" - } - }, - "node_modules/@meshsdk/transaction/node_modules/@cardano-sdk/key-management": { - "version": "0.29.13", - "resolved": "https://registry.npmjs.org/@cardano-sdk/key-management/-/key-management-0.29.13.tgz", - "integrity": "sha512-yvdWrZcvrH9s5mBwnKpdT6xGF7vMZ9DSrrEENnhA9NG9jXDLpuYX/KI2TGczUdRPezBFm2y9kO7oHNf8GNYvAA==", - "license": "Apache-2.0", - "dependencies": { - "@cardano-sdk/core": "~0.46.12", - "@cardano-sdk/crypto": "~0.4.5", - "@cardano-sdk/dapp-connector": "~0.13.26", - "@cardano-sdk/util": "~0.17.1", - "@emurgo/cardano-message-signing-nodejs": "^1.0.1", - "bip39": "^3.0.4", - "chacha": "^2.1.0", - "get-random-values": "^2.0.0", - "lodash": "^4.17.21", - "pbkdf2": "^3.1.3", - "rxjs": "^7.4.0", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" - }, - "engines": { - "node": ">=16.20.2" - } - }, - "node_modules/@meshsdk/transaction/node_modules/@cardano-sdk/util": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.17.1.tgz", - "integrity": "sha512-TCYe+wRguW1WgRlbWqhGPhcSBkzVzdIcCVgDDN7wiQk2dew0EWVqjsKeqDZdfwzy/s2kr/ZOgXIGywBn/Bzu/Q==", - "license": "Apache-2.0", - "dependencies": { - "bech32": "^2.0.0", - "lodash": "^4.17.21", - "serialize-error": "^8", - "ts-custom-error": "^3.2.0", - "ts-log": "^2.2.4" - }, - "engines": { - "node": ">=16.20.2" - } - }, "node_modules/@meshsdk/wallet": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@meshsdk/wallet/-/wallet-1.9.0.tgz", - "integrity": "sha512-yu84c5Ao6/8AI76HKq1TqxNZFVwqO/yahufgI8o8IAikG3zaqQpdIl3UwFLFcUeStlbFi1zpYQNNybjmffHvqw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@meshsdk/wallet/-/wallet-1.9.1.tgz", + "integrity": "sha512-lPovMk86Ex6t1s12CiSJFfxnTM31vXxwB7h0BOnznPgueBts2z0cgvQVtBMlhIKHTmPWW1/gkuEH/5dGMfZ3jA==", "license": "Apache-2.0", "dependencies": { - "@meshsdk/common": "1.9.0", - "@meshsdk/core-cst": "1.9.0", - "@meshsdk/transaction": "1.9.0", + "@meshsdk/common": "1.9.1", + "@meshsdk/core-cst": "1.9.1", + "@meshsdk/transaction": "1.9.1", "@simplewebauthn/browser": "^13.0.0" } }, + "node_modules/@meshsdk/wallet/node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", - "license": "MIT", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.0.tgz", + "integrity": "sha512-09BHFaNVBe5pB2AhzCfLj0cqmsKLERvZQ4FblzP90UMGLaXVqdZPByFjURwsUYCmxonUW2yAwgTTvle+8O2/8w==", + "license": "Apache-2.0", "dependencies": { "zod": "^4.2.0" }, @@ -4372,21 +4308,21 @@ } }, "node_modules/@modelcontextprotocol/core/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", - "license": "MIT", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.0.tgz", + "integrity": "sha512-+6b0LdsQLmHsvS7J6sQJ4weTpzUn61fU1JKS+321/+IyDYoHW5CR2Tgqcs6GgAAQvlesCo6yX3xttgmx+XGtJw==", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.0", "zod": "^4.2.0" }, "engines": { @@ -4394,9 +4330,9 @@ } }, "node_modules/@modelcontextprotocol/server/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -4416,9 +4352,9 @@ } }, "node_modules/@multiformats/dns": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.13.tgz", - "integrity": "sha512-yr4bxtA3MbvJ+2461kYIYMsiiZj/FIqKI64hE4SdvWJUdWF9EtZLar38juf20Sf5tguXKFUruluswAO6JsjS2w==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@multiformats/dns/-/dns-1.0.15.tgz", + "integrity": "sha512-W0zAMABtAn+3chgFcPGvllKND7M6GblMAAFcJQTy+iMmGiyFErZYPzAh2b50Y3SFf340iFV7+ckVgZkGfUyxzA==", "license": "Apache-2.0 OR MIT", "dependencies": { "@dnsquery/dns-packet": "^6.1.1", @@ -4426,7 +4362,22 @@ "hashlru": "^2.3.0", "p-queue": "^9.0.0", "progress-events": "^1.0.0", - "uint8arrays": "^5.0.2" + "uint8arrays": "^6.1.1" + } + }, + "node_modules/@multiformats/dns/node_modules/multiformats": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.5.tgz", + "integrity": "sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/@multiformats/dns/node_modules/uint8arrays": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-6.1.1.tgz", + "integrity": "sha512-iz7JN0XCSZYA111lhFG2Ui9EhFvTNekqSRHw3lvMHq+dzwWy1OQftxFQREEh4rffU0oSoXdQHsk2TiHKVm4fsA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^14.0.0" } }, "node_modules/@multiformats/mafmt": { @@ -4454,28 +4405,31 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@next/bundle-analyzer": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.6.tgz", - "integrity": "sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.3.8.tgz", + "integrity": "sha512-8VBk5/pjANz7fxbQZrDwUMTAs598Vckb4PE7BSYrDIPthJskKCXEHOK+WEMgDD6X2xWlKjrXPo2r41N/G1azSw==", "dev": true, "license": "MIT", "dependencies": { @@ -4489,15 +4443,35 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", - "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz", + "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==", "dev": true, "license": "MIT", "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, + "node_modules/@next/eslint-plugin-next/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, "node_modules/@next/swc-darwin-arm64": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", @@ -4537,6 +4511,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4553,6 +4530,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4569,6 +4549,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4585,6 +4568,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4817,9 +4803,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", - "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.11.0.tgz", + "integrity": "sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4829,9 +4815,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4877,12 +4863,29 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4893,13 +4896,14 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", + "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4910,14 +4914,14 @@ } }, "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", - "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.11.0.tgz", + "integrity": "sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/context-async-hooks": "2.7.1", - "@opentelemetry/core": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@opentelemetry/context-async-hooks": "2.11.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/sdk-trace-base": "2.11.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4927,13 +4931,13 @@ } }, "node_modules/@opentelemetry/sdk-trace-web": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.7.1.tgz", - "integrity": "sha512-K806OouCSOjMd8Nr7+ZCq3QT22tdAzzS/7h8vprfiKjkgFQ99/dvwU8d12WJANA6D5Qtme65hyBAqAu9CkQuxQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.11.0.tgz", + "integrity": "sha512-8hgLI437x8VRKzgUr+WRcHFp9AeaHmveKRsPIMNele9tethgCKprakRRqZCWyt41ilALHATfspUMl1An0OTMUQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/sdk-trace-base": "2.7.1" + "@opentelemetry/core": "2.11.0", + "@opentelemetry/sdk-trace-base": "2.11.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4943,9 +4947,9 @@ } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -4969,65 +4973,373 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", - "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", - "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "tslib": "^2.0.0" + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/webcrypto": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", - "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/json-schema": "^1.1.12", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2", - "webcrypto-core": "^1.8.0" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10.12.0" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": ">= 10.0.0" }, "funding": { - "url": "https://opencollective.com/pkgr" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz", + "integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2", + "webcrypto-core": "^1.8.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" } }, "node_modules/@playwright/test": { @@ -5054,24 +5366,24 @@ "license": "MIT" }, "node_modules/@prisma/adapter-pg": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", - "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", + "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.8.0", + "@prisma/driver-adapter-utils": "7.10.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "node_modules/@prisma/client": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", - "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", + "integrity": "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q==", "license": "Apache-2.0", "dependencies": { - "@prisma/client-runtime-utils": "7.8.0" + "@prisma/client-runtime-utils": "7.10.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0" @@ -5090,15 +5402,15 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", - "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.0.tgz", + "integrity": "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", - "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.0.tgz", + "integrity": "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -5109,97 +5421,95 @@ } }, "node_modules/@prisma/debug": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", - "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", + "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", - "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "version": "0.24.17", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", + "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", "devOptional": true, "license": "ISC", "dependencies": { - "@electric-sql/pglite": "0.4.1", - "@electric-sql/pglite-socket": "0.1.1", - "@electric-sql/pglite-tools": "0.3.1", - "@hono/node-server": "1.19.11", + "@electric-sql/pglite": "0.4.3", + "@electric-sql/pglite-socket": "0.1.3", + "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", - "@prisma/streams-local": "0.1.2", + "@prisma/streams-local": "0.1.11", + "find-my-way": "9.7.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", - "hono": "^4.12.8", - "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", - "valibot": "1.2.0", + "valibot": "1.4.2", "zeptomatch": "2.1.0" } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", - "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", + "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.10.0" } }, "node_modules/@prisma/engines": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", - "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", + "integrity": "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0", - "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "@prisma/fetch-engine": "7.8.0", - "@prisma/get-platform": "7.8.0" + "@prisma/debug": "7.10.0", + "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", + "@prisma/fetch-engine": "7.10.0", + "@prisma/get-platform": "7.10.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", - "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3.tgz", + "integrity": "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", - "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz", + "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.10.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", - "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.0.tgz", + "integrity": "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0", - "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "@prisma/get-platform": "7.8.0" + "@prisma/debug": "7.10.0", + "@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3", + "@prisma/get-platform": "7.10.0" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", - "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz", + "integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.10.0" } }, "node_modules/@prisma/get-platform": { @@ -5227,9 +5537,9 @@ "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", - "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", + "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -5239,7 +5549,7 @@ "proper-lockfile": "^4.1.2" }, "engines": { - "bun": ">=1.3.6", + "bun": ">=1.2.0", "node": ">=22.0.0" } }, @@ -5268,14 +5578,23 @@ "license": "MIT" }, "node_modules/@prisma/studio-core": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", - "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", + "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", - "chart.js": "4.5.1" + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", + "elkjs": "0.11.1" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0", @@ -5287,31 +5606,178 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" + "node_modules/@prisma/studio-core/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" }, - "node_modules/@protobufjs/base64": { + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@protobufjs/fetch": { + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", @@ -5326,12 +5792,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -5345,38 +5805,38 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -5394,12 +5854,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -5417,19 +5877,18 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5447,19 +5906,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5477,15 +5936,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -5502,28 +5961,10 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -5536,9 +5977,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -5551,25 +5992,26 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -5586,28 +6028,10 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -5620,16 +6044,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", @@ -5647,18 +6071,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -5676,9 +6100,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -5691,14 +6115,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5716,20 +6140,20 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", - "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -5756,12 +6180,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5774,12 +6198,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", - "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -5796,13 +6220,30 @@ } } }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -5819,30 +6260,22 @@ } } }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -5859,70 +6292,20 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -5934,13 +6317,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -5958,12 +6340,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -5980,39 +6362,23 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -6030,32 +6396,33 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -6072,54 +6439,13 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -6137,12 +6463,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -6155,19 +6481,19 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -6185,23 +6511,23 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", - "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -6219,14 +6545,14 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -6244,18 +6570,18 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", - "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -6273,23 +6599,24 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -6306,28 +6633,10 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -6340,13 +6649,14 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -6359,12 +6669,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -6376,14 +6686,11 @@ } } }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -6395,9 +6702,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -6410,9 +6717,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -6425,12 +6732,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -6443,12 +6750,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -6461,12 +6768,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -6484,9 +6791,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, "node_modules/@react-spring/animated": { @@ -6611,9 +6918,9 @@ } }, "node_modules/@react-three/drei/node_modules/zustand": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", - "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", "dev": true, "license": "MIT", "engines": { @@ -6786,15 +7093,27 @@ "license": "Apache-2.0" }, "node_modules/@simplewebauthn/browser": { - "version": "13.3.0", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", - "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-9.0.1.tgz", + "integrity": "sha512-wD2WpbkaEP4170s13/HUxPcAV5y4ZXaKo1TfNklS5zDefPinIgXOpgz1kpEvobAsaLPa2KeH7AKKX/od1mrBJw==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@simplewebauthn/types": "^9.0.1" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-9.0.1.tgz", + "integrity": "sha512-tGSRP1QvsAvsJmnOlRQyw/mvK9gnPtjEc5fg2+m8n+QUa+D7rvrKkOYyfpy42GTs90X3RDOnqJgfHt+qO67/+w==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "extraneous": true, "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, @@ -6832,13 +7151,13 @@ "license": "MIT" }, "node_modules/@swagger-api/apidom-ast": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.11.1.tgz", - "integrity": "sha512-5vcFzXltmIpCsjQouVKzjj7pPPUxYmwIARHuenim96GDnmqqVTtAoBXpIX++cD5RcJA72EBEqepQ+VSAA12RPA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.11.6.tgz", + "integrity": "sha512-HzudlOOzs17o2lDnDfcOFT5STKq4ckOYAK6jOaN5udDRA9luDm0axRuc3sHlImHDaOHFN+Yf+rp0zUpT+2Dsiw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6846,14 +7165,14 @@ } }, "node_modules/@swagger-api/apidom-core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.11.1.tgz", - "integrity": "sha512-KsN0dZBsutUGWtbsqBMvQ+3pJUjq/wRRABCNIG2Ys/1Ctq8FaQaA0MoICPuYgDZCUNsZuJYbw6Swm6e0GaHWtA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.11.6.tgz", + "integrity": "sha512-OdHzm974FpfYuO0L+W5B5x+byBMjs9gWuQy37LZYjIe4K8X8IHsN00KAgODLSKgccGlnWi4QBBs8WfHNgRWqGA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", "minim": "~0.23.8", "ramda": "~0.30.0", @@ -6863,37 +7182,37 @@ } }, "node_modules/@swagger-api/apidom-error": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.11.1.tgz", - "integrity": "sha512-7KV2Ac4BOcrv4yJz7T5DbZiTdqbnVUT+g68Hjhabl5zhD28mfEEn9V8Zq2D6rtjlCYkqWAMFb8Y6Y+9ssH5wgA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.11.6.tgz", + "integrity": "sha512-q44Zh9Ro0qDH/GLiFvGfe9tISSOSmq6ktrwmjuADVHwpAB//SfvhihIE9LwqNXYEZJTA4ExnFuxy01jiql++HQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.20.7" } }, "node_modules/@swagger-api/apidom-json-pointer": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.11.1.tgz", - "integrity": "sha512-c8QSUgQxDolTO+rP2bvX4CrZOrnTMTAMh0xGq8LaYvzVzs0bQT7ZApsbcA/4bzWlwcg6wy2Uuw+qMadl1FNR3w==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.11.6.tgz", + "integrity": "sha512-sqRsfWsTEy+NQp5Ea3xwsGlPRYnXVLcf2WXCCrexcD2OrTOxhlZ6rCZQyc2trrUwAptna/Px/czVO+TjofXf4g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@swaggerexpert/json-pointer": "^2.10.1" } }, "node_modules/@swagger-api/apidom-ns-api-design-systems": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.11.1.tgz", - "integrity": "sha512-2K3Ix+nRHDkuixkZ4FAMWY5MAJHipzpFvZrRtneZ7hsx7nObw9HYEXZw/yXuYrvnhC8jsE4z91Gwuvvz7ZjfPw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.11.6.tgz", + "integrity": "sha512-lp7YvMSXoW/UQVxOzJYCLVFIIPU2NYsT7mljIiPQTjUZAFuQ20Vi6v93YHfgVIXUj0yQYSraAnvXuO4GciWlPQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6901,15 +7220,15 @@ } }, "node_modules/@swagger-api/apidom-ns-arazzo-1": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.11.1.tgz", - "integrity": "sha512-rnICw0uXnKeNHUaS+Ip7lxtVXqH1iA3zFlX446e4XAamJd6yU28sujIsGiZ71qPQ217teidkfK7Bx7MktHdiEw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-arazzo-1/-/apidom-ns-arazzo-1-1.11.6.tgz", + "integrity": "sha512-GiAuYiAMwfX2AhUq4ANpmNmjJxSWiTV4QJYJUX3wHZpyU4EMXEM/BTaExGQpbmu7ewRPF4Nve/d9PKNouNXhJQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2020-12": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6917,15 +7236,15 @@ } }, "node_modules/@swagger-api/apidom-ns-asyncapi-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.11.1.tgz", - "integrity": "sha512-syABiWLeWRfKoonUhPriPVwDDeEOlN5RD20Dj/MS9DT5r1BJUrAB1BfRRRHsVhzaXVdUcKKH99iC9C842J9kvA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.11.6.tgz", + "integrity": "sha512-RHTv4qINvCKe40bDyoWkhve/2/qv6GObFY0umALw2Lv1KiW2hjHrBO63Y+kU8vYu34bnhFCUDIqyCyEmJt6zkQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-draft-7": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-7": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6933,15 +7252,15 @@ } }, "node_modules/@swagger-api/apidom-ns-asyncapi-3": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.11.1.tgz", - "integrity": "sha512-y4syE8jOEGuSirc3YaeI0dh3rEvHfc/pERQOTj3KofS2IABpBXTmtg+oDfG2zte1/Cyc/eJ6qecVAns5mhBpow==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-3/-/apidom-ns-asyncapi-3-1.11.6.tgz", + "integrity": "sha512-bGUXNP+vS9bjNl8ehjmoyUck1liWjxOUQReLZG4yqIix7dHWvLSeDw1hi0pq1A+VWjixPazAcb9Y6f/t5Bqvyg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6949,15 +7268,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-2019-09": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.11.1.tgz", - "integrity": "sha512-1SNXikZN2uQ1YZ3A4dzWBoMN6wTkba1qZdy/NOkweFtoLuBb63KKN/gD1e6chQV8+ikqGn8TTUZnYvX6SVBZ6g==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.11.6.tgz", + "integrity": "sha512-XZmo85MS8Y5bYzrxmYbbqvXOghWZt4tInhE26c5nGbKyCx5CbsLlhzcp0wMzkb5k22BnUmlwmniVFrnveYnOGQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-draft-7": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-7": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6965,15 +7284,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-2020-12": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.11.1.tgz", - "integrity": "sha512-oyvTkjDXI9k3G8oVHOvpL/t1MfZmx8d7rgeNqsm6j/vK6WlOXIOHdN9LTYRo8YdACaWq/JV5B30grkio/HRMKQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.11.6.tgz", + "integrity": "sha512-WU6Vw8aa0TLs1COGHjCFS2gz6MkQzSNZYFejs7EtZOnlAkgG3TN8H55BHVoif1QGBlrhTQ6o6RxCdk5bI1WneQ==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-2019-09": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2019-09": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6981,14 +7300,14 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.11.1.tgz", - "integrity": "sha512-Ha23zkVSItmFZbAoSKMI7hwYJT7yTMWO+EcNzDBEClsqRrkcCtvF2YsiQZcyUt5SrEwV8rW0TWE0CVG+WEs2zg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.11.6.tgz", + "integrity": "sha512-kiYyTkUCsHBKS2EFTQgI0bF0yKjOlt+QMiPzsXKzHZ7+XqMmEeRBFn5wSfTF3h9aJ5JMgtmku4j3TtdlxEUvUA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.11.1", - "@swagger-api/apidom-core": "^1.11.1", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -6996,15 +7315,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.11.1.tgz", - "integrity": "sha512-Gm4ULCg4yulfjZiMIbH1XiiKHI/BqK0zc1GexViiLShXS35/2dc27GmpI0YgV7S+DqvivNrwAkqojeN7ho9/NA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.11.6.tgz", + "integrity": "sha512-vTlvnkmQ0UP0NeiPD8h6Kls2So8tNVruANah1n9BskJLUpvTVJV820HFDw88/+ocXUglAEg8aKENcPo0AWsrkw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-4": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -7012,15 +7331,15 @@ } }, "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.11.1.tgz", - "integrity": "sha512-OHW4Qb0BqbHJ3QoQcGREE5bobMeBkZzSQe/0RFGayhI1HJZqrmwtot2nLAuie9sQJoj/xeUprOsA/he06NVFEw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.11.6.tgz", + "integrity": "sha512-kddXboLPDceL0302icmJvltcg/LzwGPaK1W/35PiHbs0lCtgRg5bz8F0tzGbPa1zk6n2o5BSS8+YU0J6vGKf3g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-draft-6": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-6": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -7028,16 +7347,16 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.11.1.tgz", - "integrity": "sha512-yXHJmyN+NyF2xBD6KlFmGuMrf1hKqK9pm/FwStepIUqvn6bfTGgEdUi5BivQuErRrN6NtQczFF21Jlu6jjg86Q==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.11.6.tgz", + "integrity": "sha512-eMlB/P7apYuiwiBPsCW5zc5cyb6T/ZY7VeMSLBBN/JDy8I4rtTpFQq3ltQKXJlFhU321CuCibRiZ6p7z45wWKw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-4": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -7045,15 +7364,15 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-0": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.11.1.tgz", - "integrity": "sha512-R2zHd33OiVT5eTlYKS1FyVDP0G76ymdP2EIrBPbM1FDKam1kRIRdgZA2StCd9PY4oNp/LqQKMnfe9wdLWZS3AA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.11.6.tgz", + "integrity": "sha512-RGngR9Daer9PtkPevSKgBqWZdXxCbDyjudadq0CImqFfjYd8OWhya20haKbPGpJuHDz7Rwmu2m92+0wZFjCcew==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-draft-4": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-ns-json-schema-draft-4": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -7061,17 +7380,17 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-1": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.11.1.tgz", - "integrity": "sha512-FtoW4wkFO1VSHu6G+wUZ71hQhIOuastJPyWEePbfySE4Uiz+01t/X/ODnl2OHRGVUYFoJa7kJi5/xqcsprdxtA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.11.6.tgz", + "integrity": "sha512-mZe+3NgDxTjUmitxxgvlmLyK9mWSNkQajqA26j9JxcFdEvN/lcp/EsjxMX5kST4E0Qm4tQKD0i1QnJmjBSaHWw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.11.1", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-json-pointer": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2020-12": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -7079,18 +7398,18 @@ } }, "node_modules/@swagger-api/apidom-ns-openapi-3-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-2/-/apidom-ns-openapi-3-2-1.11.1.tgz", - "integrity": "sha512-ILJAgp6mHwoV8rRuKYD3QuvPdcRcmK9YmAfrsjgC7fJM7irqzC+nBOKhrWVpTAee7r3b+B3HpV5MG8aKGd9qNQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-2/-/apidom-ns-openapi-3-2-1.11.6.tgz", + "integrity": "sha512-TBDgIDAZodphp/1DS+mTilPLfTS84e/IXGEoRCE/MFrLKa4GZFY7bx8hXQmgGJfDWwxsNcQk5Q/X+SCi+WQg0g==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.11.1", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-json-pointer": "^1.11.1", - "@swagger-api/apidom-ns-json-schema-2020-12": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-json-schema-2020-12": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -7098,144 +7417,144 @@ } }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.11.1.tgz", - "integrity": "sha512-bCt1/7NPfCznhq2D3Y1UcZowdxMtr6wGCISMSPf3ziaCcOQhy7sG/nWEzS/rwcKCVNefVft833Ab3jaCWGivJw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.11.6.tgz", + "integrity": "sha512-vFwzMj6z5HBf8d8DM0eNjVkaI2hiewE4+RDkTYhpfydAUq8m8jTuzJCf7DDdQ0YdvHH7/Zwdj4EshYnzEAp8Aw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-api-design-systems": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-api-design-systems": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.11.1.tgz", - "integrity": "sha512-hUcshr5ydn/L4VsgP5nyrFDp4QqIADrx5nQnFddw/OWCNi1Al19ccPxuBh1Qlf421AAmk1oUiybeGyduvRsVPQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.11.6.tgz", + "integrity": "sha512-bwRh9F9phloYhpQJImsFuY3e8M5uf9lf436zqU413UCNnbU0F1p4HzIxKjFeIwXxiTzHfN89tCpsrPRsmLySaA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-api-design-systems": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-api-design-systems": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-arazzo-json-1": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.11.1.tgz", - "integrity": "sha512-8ydiEnlSJ7DPhFqg9Z11u4Vda16yaOuIGLablI0mOnYoAMTlqnteGk5CDPlVb970VBTYvsNlgW+164XfHAU/6w==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-json-1/-/apidom-parser-adapter-arazzo-json-1-1.11.6.tgz", + "integrity": "sha512-opQc80CzT9buTKr3Q4k8R1DxVFJpHuJ4ej1Z6V4glGd7d2gEoabMkLKT9v9nKy50XLhJUTLkA86MLEyZPHxsfQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-arazzo-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-arazzo-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-arazzo-yaml-1": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.11.1.tgz", - "integrity": "sha512-G4++rZDMKokEfq78EJ2aE7pgd1Xo70XIn1/ikSiT5awfuhPJzNcV99ZdzQI2xVVU/pbKIL2Vc/b5SP1IRlfCwA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-arazzo-yaml-1/-/apidom-parser-adapter-arazzo-yaml-1-1.11.6.tgz", + "integrity": "sha512-7wMLd6yGGNW0UI1ourkMfGJaR79fav1HntVCC0IvmcaO5u8X6gUIwDpqMcIuX71Ipw1FHX2x9PKSakMiWy5LJQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-arazzo-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-arazzo-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.11.1.tgz", - "integrity": "sha512-7Npn4LkG4q95b2VimG3SV0lqgG3xPeF5Srq+sVbG7iFd4yDubvEVy5zzqx5QH4tOtATdarhv6glA9j3hTfWBdQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.11.6.tgz", + "integrity": "sha512-jsx299Uconwq9oPHDNK51v3VPqm7JqkvovM2w/azW4aXQNv9u3ZcSqHMAbfC8KpYCbnx0LIuCU9OgRyH8dj8Nw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-3": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.11.1.tgz", - "integrity": "sha512-/C1CzsnUW2ZMBg4kWYrhrfqmyjb4aGo9+YaySQwdArLfM8l2HCOQqDEteGIivedVEsmTpVdhC60gdb6N2VzSaQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-3/-/apidom-parser-adapter-asyncapi-json-3-1.11.6.tgz", + "integrity": "sha512-JePNK/AwnJk+U0yxUKDQynq6ER8Snx/zcZKxar+cBy/COMj1EV0Bif0vUdmF3RCEoPjoXNRq2HfBIbZ6yT+9AQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-asyncapi-3": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.11.1.tgz", - "integrity": "sha512-0Xfu8PLM787el0R7lwjFfQYe0Bpv3Jz0YlkEiQqAVvftVb0oNi8tg9FhDTR8ju/N94gpNXIfaH/5Ahgz5G+NKg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.11.6.tgz", + "integrity": "sha512-SJqn9y4tPSCq6dfE5hHYGpl45oLwogbXtrsbd4IWmAFrxT9VnbSUSyGmLm+bBYycQiXsvg0SJ7QRuecCeEJvfw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.11.1.tgz", - "integrity": "sha512-DqoR43NsFBmiJW1h2Xg3n2V6NQx+95qJ3ziA9rIbKJHGCidHtjNJgi4I7sWGnaIApIHijYY2bW22MKXaT0a0cQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-3/-/apidom-parser-adapter-asyncapi-yaml-3-1.11.6.tgz", + "integrity": "sha512-gTDh14ge0xBHu+44I/oBsKFC24sFK18yaL3paEdi0LGdSVKYmAbPyOaQbX3JEJ5BkOAwLvXAEQBm2snZ75JcXw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-asyncapi-3": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-json": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.11.1.tgz", - "integrity": "sha512-L8XFzTbEknHDhD40M/pSoDlimjlYaXXWZS4AmyD3i+XRfiDWWVhEWHPE9OTNk6UL8R6DOBm3RSDxAd5xpLoPjg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.11.6.tgz", + "integrity": "sha512-LIbKkaydhviIJJ9VdMMmDrx96B2H21l1deH+osQt9QyrBr6Cdpo0NCv1xMr8TZi7CRjm6YD7+ec8mNmUNu54Bw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.11.1", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0", @@ -7245,144 +7564,144 @@ } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.11.1.tgz", - "integrity": "sha512-s9xZa/h4Yiz+Qc304s+9JSTPFsToYtSWQCeyA9jkHOWy/Oq8ZjD9wg34IjENS3yBqM1YLz6Dk+PX06DcyAOnnw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.11.6.tgz", + "integrity": "sha512-AeaRUdwEtQ1ThWQ3d47ZEH3WGLIHFR9Z5+uBhuYeT0jke0fHlLW6tnBWseZZ91Fk7Xo0sJR0fMnXRuCeBs4qTQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.11.1.tgz", - "integrity": "sha512-dLGaVn24N+YZRB0vzQMC4R+aiSNfD81Xcp5TwdEbE+jOeOnoOe5NqzqCWjaDpSMChDsK/NdaSDjQj4uiYfWpug==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.11.6.tgz", + "integrity": "sha512-KJ9NyuwhRc17GKMMF9IS/wFbzJ2hRNssoza16v2mVhL+xs+1qYrUi5sOwb2vCWWfNtso8Wst/ILQWxYbhFfEHw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.11.1.tgz", - "integrity": "sha512-EnYF3rzPZoiCYDnp4ChB6K15RUV4rE6QfEh7fTEwIlkWMUKv4oVwZd8aqz2i9laRZiBH6S2uUoED8YNtCNbeIg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.11.6.tgz", + "integrity": "sha512-9CG6vfA5usCEYf18g7+SYVRhD/zds8vvwV8gnF6gyrJOK+RbaAeVp1/Oeg499uRXy8D1LvypY07eB8aeVsaw3w==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-2/-/apidom-parser-adapter-openapi-json-3-2-1.11.1.tgz", - "integrity": "sha512-digw37g+k/rg87HHMUHuSZVWH1Kh8OjC8SmQflIh1Oot9fGhmnZWddsws+sKWSVy6/HveuZPykL8bxtSV3Nc/A==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-2/-/apidom-parser-adapter-openapi-json-3-2-1.11.6.tgz", + "integrity": "sha512-2EGjNq/tSFDJLVw46ApAaG2cRTxHF0Hhba5jEe5JiHTf52qZi0aPoi+YDxNnMvDKIzTrcEw34ZQddACu/JvOhQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.11.1.tgz", - "integrity": "sha512-b38GFur/NjjLFBCVR/wo7DRF6EW5h8B5jBe7C17EVaJvg9eRzknnr9/KMnxYeTtjQVO8W/JeY7LlLad1/j0pcA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.11.6.tgz", + "integrity": "sha512-ohy7FFWM0DN0bhIqHUZJrx8qCN0qGnvjf8xqJGx/cRfxmqToEeukAXvrBBhBhtz4eqLKiSeE9TkdgHPYRRDZWg==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.11.1.tgz", - "integrity": "sha512-dza6Bwe5kLL+4jANuaScxvYh3o7RxESp6Riz6M09cXRysyRrHFQ7UYuUhxepSD4jSiSxJQS8nu0i547i6Z7W7Q==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.11.6.tgz", + "integrity": "sha512-6U+yTLDTdlMs68YLVKsOOpN3eFlFWeo0wbkkSSDQw7ugfpUi2PEEsjrQ+C6cviPYH3ZfZNchlj4EZaNFtMZ1/Q==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.11.1.tgz", - "integrity": "sha512-PgmolQN1PYdROSo/cHNyXINVD+aLmW6VqfwT7potNo08c4aWj+QQ/a0Az+mldfJ+G98WjNRvEKr8dhEw8zfqmw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.11.6.tgz", + "integrity": "sha512-P3t3Q4uUt92ZUrm0zPlWacPRWDHfMFqlFXdzH5dJN5phyQMMu08vdPFMaWFPv+w48wcZFXbrT/GeilorAoPlDw==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2/-/apidom-parser-adapter-openapi-yaml-3-2-1.11.1.tgz", - "integrity": "sha512-+nmtJ3/wPLBBN6d8xI8rD0mOz80V4iSRe6rYYOQ/skel673N1SY4B58Ufnc7KnMNV4cOce/a52ASQ1Qd1csLvQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-2/-/apidom-parser-adapter-openapi-yaml-3-2-1.11.6.tgz", + "integrity": "sha512-bty6P85OHEV4YCdbVlNTsJRXZPs4ii3ynzTISURoIuyn08Y28C/Pm9H484Pl4iC9t16d1TKGngTAWc5RX0ve1Q==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" } }, "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.11.1.tgz", - "integrity": "sha512-KEgk5PoSmmLC7ZvH0+RF4FPyWAj0NyrPFbTr04DmNPznfr2qpGqvt3ZBmAJm82jrWoI1dc8EH1ugT1YX69N8ww==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.11.6.tgz", + "integrity": "sha512-voxU8HMUypS36DYmZX8OsZ9pHz0hoT66GiBXjGKb1ETkXQR/8rVIYJnbxvVqtyXUI82aZy3dRTcXd6C2BHqH/A==", "license": "Apache-2.0", "optional": true, "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-ast": "^1.11.1", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-ast": "1.11.6", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@tree-sitter-grammars/tree-sitter-yaml": "=0.7.1", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", @@ -7411,6 +7730,16 @@ } } }, + "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { "version": "0.22.4", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", @@ -7424,46 +7753,46 @@ } }, "node_modules/@swagger-api/apidom-reference": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.11.1.tgz", - "integrity": "sha512-wxsRo12YVc2Q4o81K9EGzX5oM1htNDkeCIRkLyg1wPvzFQUH4khd6aOWYaX/0V0L+7yqwwmeW/t80xV8qLEGAQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.11.6.tgz", + "integrity": "sha512-wAU/b6fBw0zS2KLvbiwY13apXbXO43N+CnlnHvGuOf4WjV+msdcb31OPUAehMENsC1BUcrf/vroQj+lJqz77dA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.26.10", - "@swagger-api/apidom-core": "^1.11.1", - "@swagger-api/apidom-error": "^1.11.1", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", "@types/ramda": "~0.30.0", - "axios": "^1.16.0", - "minimatch": "^10.2.1", + "axios": "^1.18.0", + "minimatch": "^10.2.6", "ramda": "~0.30.0", "ramda-adjunct": "^5.0.0" }, "optionalDependencies": { - "@swagger-api/apidom-json-pointer": "^1.11.1", - "@swagger-api/apidom-ns-arazzo-1": "^1.11.1", - "@swagger-api/apidom-ns-asyncapi-2": "^1.11.1", - "@swagger-api/apidom-ns-openapi-2": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-0": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-1": "^1.11.1", - "@swagger-api/apidom-ns-openapi-3-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^1.11.1", - "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^1.11.1", - "@swagger-api/apidom-parser-adapter-arazzo-json-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "^1.11.1", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "^1.11.1", - "@swagger-api/apidom-parser-adapter-json": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-json-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^1.11.1", - "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "^1.11.1", - "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.11.1" + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-arazzo-1": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-0": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-json-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6" } }, "node_modules/@swagger-api/apidom-reference/node_modules/balanced-match": { @@ -7476,24 +7805,24 @@ } }, "node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@swagger-api/apidom-reference/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -7569,9 +7898,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.100.14", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", - "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", "license": "MIT", "funding": { "type": "github", @@ -7579,12 +7908,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.14", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", - "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.14" + "@tanstack/query-core": "5.104.1" }, "funding": { "type": "github", @@ -7594,10 +7923,37 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.10.tgz", + "integrity": "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz", + "integrity": "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@trpc/client": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.17.0.tgz", - "integrity": "sha512-KpJBFrbKTDeVCFv/3ckL1XBBH5Yssn8hethI/rUy7GIpTj+VzjtPjykDqJpzobuVOz+d26cXCSu1t4I6MYI5Zg==", + "version": "11.19.0", + "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.19.0.tgz", + "integrity": "sha512-0MKkPq6MejsKB5Opca1Rl22KVIaqo2+A1EiXyZ8EGUsl9TLrPdpGn+Usux20TZKFwnSC2+wI/WilqNJaP4ODfw==", "funding": [ "https://trpc.io/sponsor" ], @@ -7606,14 +7962,14 @@ "intent": "bin/intent.js" }, "peerDependencies": { - "@trpc/server": "11.17.0", + "@trpc/server": "11.19.0", "typescript": ">=5.7.2" } }, "node_modules/@trpc/next": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@trpc/next/-/next-11.17.0.tgz", - "integrity": "sha512-EjwMkyamGjf5aslgfYB70hMKs4cgmsjSJbMinmuXktkGvbmdW54VbV04Sk4o4QTY5b//Rns9dz+ku8DN6lVhvw==", + "version": "11.19.0", + "resolved": "https://registry.npmjs.org/@trpc/next/-/next-11.19.0.tgz", + "integrity": "sha512-6rSzxEm87zZP3XM4KZakhUTMFrrqs+eVV9zqyX5Xpu03s7VhXfvDYQ/ampnZ5NxL5wrvcl14jV2QYeJ6FSzBJw==", "funding": [ "https://trpc.io/sponsor" ], @@ -7623,9 +7979,9 @@ }, "peerDependencies": { "@tanstack/react-query": "^5.59.15", - "@trpc/client": "11.17.0", - "@trpc/react-query": "11.17.0", - "@trpc/server": "11.17.0", + "@trpc/client": "11.19.0", + "@trpc/react-query": "11.19.0", + "@trpc/server": "11.19.0", "next": "*", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -7641,25 +7997,25 @@ } }, "node_modules/@trpc/react-query": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.17.0.tgz", - "integrity": "sha512-AGcl5YAF8NnhBmyJ6PqJqKb1M5VTGSoNRNqJ3orct4o4epdcg0GWhW+qT9q6gPzs/2ImIwYCdfFpgNGdZ9yLHA==", + "version": "11.19.0", + "resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-11.19.0.tgz", + "integrity": "sha512-TrqZBa/6weXX27mf0P2dMCI65duqO3B/wf9BkP1MezUWSUg7SRGJrIv6K8vjgjeHdafFBI1mmcqPEYXHF4AlVQ==", "funding": [ "https://trpc.io/sponsor" ], "license": "MIT", "peerDependencies": { "@tanstack/react-query": "^5.80.3", - "@trpc/client": "11.17.0", - "@trpc/server": "11.17.0", + "@trpc/client": "11.19.0", + "@trpc/server": "11.19.0", "react": ">=18.2.0", "typescript": ">=5.7.2" } }, "node_modules/@trpc/server": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.17.0.tgz", - "integrity": "sha512-jbAOUe0PpUTCYqziyu+8vYXZdDXPudZgnEhWCQ2NjKnVEjfE93RqHTt1oycZJv/HNf51YlRXfEEwSIAbb161rw==", + "version": "11.19.0", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.19.0.tgz", + "integrity": "sha512-7C1goiDurz82yFKtZUbEzjXd0Rg/jyisaIZQhdvus+1l7E6mwcEYZXjZ1US/HF8kOyz1LERL8UN3bcboJtjcyQ==", "funding": [ "https://trpc.io/sponsor" ], @@ -7672,13 +8028,13 @@ } }, "node_modules/@turf/boolean-point-in-polygon": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz", - "integrity": "sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.4.0.tgz", + "integrity": "sha512-GQrHc6WSDHhf32GB/b9AGyOeC05AQ3nGN7ooWTrlCYefKn7X9/2wG9BNexF47AuyDq5jPdOvITVR26BLFWV+mA==", "license": "MIT", "dependencies": { - "@turf/helpers": "7.3.5", - "@turf/invariant": "7.3.5", + "@turf/helpers": "7.4.0", + "@turf/invariant": "7.4.0", "@types/geojson": "^7946.0.10", "point-in-polygon-hao": "^1.1.0", "tslib": "^2.8.1" @@ -7688,9 +8044,9 @@ } }, "node_modules/@turf/helpers": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", - "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.4.0.tgz", + "integrity": "sha512-7PAwLZqOdRzTI5g9bHvUwlloAXPDH/mlajtryk0tw4ZwGMtmXsAyF4QundsAMfy4u48Fyd3AUqMXY0SMxqJGWg==", "license": "MIT", "dependencies": { "@types/geojson": "^7946.0.10", @@ -7701,12 +8057,12 @@ } }, "node_modules/@turf/invariant": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.5.tgz", - "integrity": "sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.4.0.tgz", + "integrity": "sha512-OAsc3qdNx+tRqzWmMNFMnVlWWACIqnYqIejZKvb2oKkKPYJJrURPXZ6OdrGD58ljJMolLoqwIZGSx3VndaEjxg==", "license": "MIT", "dependencies": { - "@turf/helpers": "7.3.5", + "@turf/helpers": "7.4.0", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -7721,9 +8077,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "dev": true, "license": "MIT", "optional": true, @@ -7794,6 +8150,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "extraneous": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -7804,12 +8167,26 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", @@ -7819,6 +8196,23 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -7828,28 +8222,69 @@ "@types/d3-color": "*" } }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "devOptional": true, "license": "MIT" }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "devOptional": true, "license": "MIT", "dependencies": { - "@types/d3-selection": "*" + "@types/d3-time": "*" } }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, @@ -7912,9 +8347,9 @@ "license": "MIT" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -7995,6 +8430,13 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -8011,9 +8453,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -8037,9 +8479,9 @@ } }, "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -8063,22 +8505,22 @@ } }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-reconciler": { @@ -8123,18 +8565,18 @@ } }, "node_modules/@types/three": { - "version": "0.184.1", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", - "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "version": "0.168.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.168.0.tgz", + "integrity": "sha512-qAGLGzbaYgkkonOBfwOr+TZpOskPfFjrDAj801WQSVkUz0/D9zwir4vhruJ/CC/GteywzR9pqeVVfs5th/2oKw==", "dev": true, "license": "MIT", "dependencies": { - "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", - "@types/webxr": ">=0.5.17", + "@types/webxr": "*", + "@webgpu/types": "*", "fflate": "~0.8.2", - "meshoptimizer": "~1.1.1" + "meshoptimizer": "~0.18.1" } }, "node_modules/@types/trusted-types": { @@ -8187,17 +8629,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -8210,15 +8652,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -8226,16 +8668,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "engines": { @@ -8251,14 +8693,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "engines": { @@ -8273,14 +8715,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8291,9 +8733,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { @@ -8308,15 +8750,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -8333,9 +8775,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { @@ -8347,16 +8789,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -8385,26 +8827,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -8414,16 +8856,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8438,13 +8880,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8469,9 +8911,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { @@ -8580,6 +9022,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8594,6 +9039,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -8608,6 +9056,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8622,6 +9073,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -8636,6 +9090,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8650,6 +9107,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8664,6 +9124,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -8678,6 +9141,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8692,6 +9158,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -8706,6 +9175,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -8870,9 +9342,9 @@ } }, "node_modules/@utxos/sdk/node_modules/@bufbuild/protobuf": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", - "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@utxos/sdk/node_modules/@buildonspark/spark-sdk": { @@ -8935,6 +9407,348 @@ } } }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/core": { + "version": "0.45.10", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.45.10.tgz", + "integrity": "sha512-PU/onQuPgsy0CtFKDlHcozGHMTHrigWztTmKq54tL0TdWRcClXbMh5Q63ALcP388ZouPC1nKomOAooVgyrrEfw==", + "license": "Apache-2.0", + "dependencies": { + "@biglup/is-cid": "^1.0.3", + "@cardano-ogmios/client": "6.9.0", + "@cardano-ogmios/schema": "6.9.0", + "@cardano-sdk/crypto": "~0.2.3", + "@cardano-sdk/util": "~0.16.0", + "@foxglove/crc": "^0.0.3", + "@scure/base": "^1.1.1", + "fraction.js": "4.0.1", + "ip-address": "^9.0.5", + "lodash": "^4.17.21", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4", + "web-encoding": "^1.1.5" + }, + "engines": { + "node": ">=16.20.2" + }, + "peerDependencies": { + "rxjs": "^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/core/node_modules/@cardano-sdk/util": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.16.0.tgz", + "integrity": "sha512-f0tfX8oiauqAFCyyc/o2Ouezyk83QD4zqLl4DUjZNyCtITL8gBHh25Bkw7RUCGEZ+hf6Qms1n0ui0j3wVY7zRg==", + "license": "Apache-2.0", + "dependencies": { + "bech32": "^2.0.0", + "lodash": "^4.17.21", + "serialize-error": "^8", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4", + "type-fest": "^2.19.0" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/crypto": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.2.3.tgz", + "integrity": "sha512-jTl8rbocV1XO5DBR6+lGY6Owc/bP+wBg5eO3PttTeKhx/J7o99pyuTa5H36a/XTJwqDwKIXV922QxZR+rfjVbA==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/util": "~0.16.0", + "blake2b": "^2.1.4", + "i": "^0.3.7", + "libsodium-wrappers-sumo": "^0.7.5", + "lodash": "^4.17.21", + "npm": "^9.3.0", + "pbkdf2": "^3.1.2", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4" + }, + "engines": { + "node": ">=16.20.2" + }, + "peerDependencies": { + "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-browser": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-nodejs": "^3.1.1" + }, + "peerDependenciesMeta": { + "@dcspark/cardano-multiplatform-lib-asmjs": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-browser": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-nodejs": { + "optional": true + } + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/crypto/node_modules/@cardano-sdk/util": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.16.0.tgz", + "integrity": "sha512-f0tfX8oiauqAFCyyc/o2Ouezyk83QD4zqLl4DUjZNyCtITL8gBHh25Bkw7RUCGEZ+hf6Qms1n0ui0j3wVY7zRg==", + "license": "Apache-2.0", + "dependencies": { + "bech32": "^2.0.0", + "lodash": "^4.17.21", + "serialize-error": "^8", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4", + "type-fest": "^2.19.0" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/input-selection": { + "version": "0.13.34", + "resolved": "https://registry.npmjs.org/@cardano-sdk/input-selection/-/input-selection-0.13.34.tgz", + "integrity": "sha512-/AidYTF9WesLoMc4PHoETxXgrfYEq8GECcikjvLwx1mygmKpok4Lp41Aio7sBasUCLvZ82/yTd3uXIAvec1aCA==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/core": "~0.43.0", + "@cardano-sdk/key-management": "~0.25.1", + "@cardano-sdk/util": "~0.15.5", + "bignumber.js": "^9.1.1", + "lodash": "^4.17.21", + "ts-custom-error": "^3.2.0" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/input-selection/node_modules/@cardano-sdk/core": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.43.0.tgz", + "integrity": "sha512-hPnZXjObJub0eXV2dDAG2/gEg/vw092RZ1VGMZfBSqavz18Tg/K6jGQ3cOpWZ9d+MqFzZTCB+s5ctdRkYt3idA==", + "license": "Apache-2.0", + "dependencies": { + "@biglup/is-cid": "^1.0.3", + "@cardano-ogmios/client": "6.9.0", + "@cardano-ogmios/schema": "6.9.0", + "@cardano-sdk/crypto": "~0.1.32", + "@cardano-sdk/util": "~0.15.5", + "@foxglove/crc": "^0.0.3", + "@scure/base": "^1.1.1", + "fraction.js": "4.0.1", + "ip-address": "^9.0.5", + "lodash": "^4.17.21", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4", + "web-encoding": "^1.1.5" + }, + "engines": { + "node": ">=16.20.2" + }, + "peerDependencies": { + "rxjs": "^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/input-selection/node_modules/@cardano-sdk/crypto": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.1.32.tgz", + "integrity": "sha512-RCKFvkzD32QpKQ0jULADVRNmfBNkCwiZl2nlFbkZ3aCrfIex+6/2CizoagJ161fA7lL5/HGuzWfjOg3GX2ax6w==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/util": "~0.15.5", + "blake2b": "^2.1.4", + "i": "^0.3.7", + "libsodium-wrappers-sumo": "^0.7.5", + "lodash": "^4.17.21", + "npm": "^9.3.0", + "pbkdf2": "^3.1.2", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4" + }, + "engines": { + "node": ">=16.20.2" + }, + "peerDependencies": { + "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-browser": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-nodejs": "^3.1.1" + }, + "peerDependenciesMeta": { + "@dcspark/cardano-multiplatform-lib-asmjs": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-browser": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-nodejs": { + "optional": true + } + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/key-management": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@cardano-sdk/key-management/-/key-management-0.25.1.tgz", + "integrity": "sha512-D99XTIplI2aQnCZtVUKZdmH9wZJQC2WuZL6hTqGZHHFBAeju2zBzGWT21LlcPRlT0/2DP2/OdfIHoHCr2ORp4g==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/core": "~0.43.0", + "@cardano-sdk/crypto": "~0.1.32", + "@cardano-sdk/dapp-connector": "~0.13.1", + "@cardano-sdk/util": "~0.15.5", + "@emurgo/cardano-message-signing-nodejs": "^1.0.1", + "bip39": "^3.0.4", + "chacha": "^2.1.0", + "get-random-values": "^2.0.0", + "lodash": "^4.17.21", + "pbkdf2": "^3.1.2", + "rxjs": "^7.4.0", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/core": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@cardano-sdk/core/-/core-0.43.0.tgz", + "integrity": "sha512-hPnZXjObJub0eXV2dDAG2/gEg/vw092RZ1VGMZfBSqavz18Tg/K6jGQ3cOpWZ9d+MqFzZTCB+s5ctdRkYt3idA==", + "license": "Apache-2.0", + "dependencies": { + "@biglup/is-cid": "^1.0.3", + "@cardano-ogmios/client": "6.9.0", + "@cardano-ogmios/schema": "6.9.0", + "@cardano-sdk/crypto": "~0.1.32", + "@cardano-sdk/util": "~0.15.5", + "@foxglove/crc": "^0.0.3", + "@scure/base": "^1.1.1", + "fraction.js": "4.0.1", + "ip-address": "^9.0.5", + "lodash": "^4.17.21", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4", + "web-encoding": "^1.1.5" + }, + "engines": { + "node": ">=16.20.2" + }, + "peerDependencies": { + "rxjs": "^7.4.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/key-management/node_modules/@cardano-sdk/crypto": { + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@cardano-sdk/crypto/-/crypto-0.1.32.tgz", + "integrity": "sha512-RCKFvkzD32QpKQ0jULADVRNmfBNkCwiZl2nlFbkZ3aCrfIex+6/2CizoagJ161fA7lL5/HGuzWfjOg3GX2ax6w==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/util": "~0.15.5", + "blake2b": "^2.1.4", + "i": "^0.3.7", + "libsodium-wrappers-sumo": "^0.7.5", + "lodash": "^4.17.21", + "npm": "^9.3.0", + "pbkdf2": "^3.1.2", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4" + }, + "engines": { + "node": ">=16.20.2" + }, + "peerDependencies": { + "@dcspark/cardano-multiplatform-lib-asmjs": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-browser": "^3.1.1", + "@dcspark/cardano-multiplatform-lib-nodejs": "^3.1.1" + }, + "peerDependenciesMeta": { + "@dcspark/cardano-multiplatform-lib-asmjs": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-browser": { + "optional": true + }, + "@dcspark/cardano-multiplatform-lib-nodejs": { + "optional": true + } + } + }, + "node_modules/@utxos/sdk/node_modules/@cardano-sdk/util": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/@cardano-sdk/util/-/util-0.15.7.tgz", + "integrity": "sha512-L0f3gXFujRwSSpjzq2W/OwW23fg0gw5S+9R+91He3LgmyfjNygd939eFPCLhwOscsHcJ4AN27UJSYnx3JMKZ0w==", + "license": "Apache-2.0", + "dependencies": { + "bech32": "^2.0.0", + "lodash": "^4.17.21", + "serialize-error": "^8", + "ts-custom-error": "^3.2.0", + "ts-log": "^2.2.4", + "type-fest": "^2.19.0" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/@utxos/sdk/node_modules/@harmoniclabs/crypto": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@harmoniclabs/crypto/-/crypto-0.2.5.tgz", + "integrity": "sha512-t2saWMFWBx8tOHotiYTTfQKhPGpWT4AMLXxq3u0apShVXNV0vgL0gEgSMudBjES/wrKByCqa2xmU70gadz26hA==", + "license": "MIT", + "dependencies": { + "@harmoniclabs/bitstream": "^1.0.0", + "@harmoniclabs/uint8array-utils": "^1.0.3" + } + }, + "node_modules/@utxos/sdk/node_modules/@harmoniclabs/plutus-data": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@harmoniclabs/plutus-data/-/plutus-data-1.2.4.tgz", + "integrity": "sha512-cpr6AnJRultH6PJRDriewHEgNLQs2IGLampZrLjmK5shzTsHICD0yD0Zig9eKdcS7dmY6mlzvSpAJWPGeTxbCA==", + "license": "Apache-2.0", + "dependencies": { + "@harmoniclabs/biguint": "^1.0.0", + "@harmoniclabs/crypto": "^0.2.4", + "@harmoniclabs/uint8array-utils": "^1.0.0" + }, + "peerDependencies": { + "@harmoniclabs/bytestring": "^1.0.0", + "@harmoniclabs/cbor": "^1.3.0" + } + }, + "node_modules/@utxos/sdk/node_modules/@harmoniclabs/uplc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@harmoniclabs/uplc/-/uplc-1.2.4.tgz", + "integrity": "sha512-Px6utj94cO/hQd9NJgVQI8zycsbgh3rAzDeLdZ1m52bo++EuU1GL+arWX3JYso3/3uNrnUFuizjrAIISwQe3Fg==", + "license": "Apache-2.0", + "dependencies": { + "@harmoniclabs/bigint-utils": "^1.0.0", + "@harmoniclabs/bitstream": "^1.0.0", + "@harmoniclabs/uint8array-utils": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/HarmonicLabs" + }, + "peerDependencies": { + "@harmoniclabs/bytestring": "^1.0.0", + "@harmoniclabs/cbor": "^1.3.0", + "@harmoniclabs/crypto": "^0.2.4", + "@harmoniclabs/pair": "^1.0.0", + "@harmoniclabs/plutus-data": "^1.2.4" + } + }, "node_modules/@utxos/sdk/node_modules/@meshsdk/common": { "version": "1.9.0-beta.89", "resolved": "https://registry.npmjs.org/@meshsdk/common/-/common-1.9.0-beta.89.tgz", @@ -8969,39 +9783,232 @@ "bn.js": "^5.2.0" } }, - "node_modules/@utxos/sdk/node_modules/@meshsdk/transaction": { - "version": "1.9.0-beta.89", - "resolved": "https://registry.npmjs.org/@meshsdk/transaction/-/transaction-1.9.0-beta.89.tgz", - "integrity": "sha512-p3ePCJWcLosswb4F5OiCHYszTci6EG5UR7JnONTJmjkE83blg92iNSkWYQbHclXXM5Ldf/CuHL9BDhPQcIfErw==", - "license": "Apache-2.0", + "node_modules/@utxos/sdk/node_modules/@meshsdk/transaction": { + "version": "1.9.0-beta.89", + "resolved": "https://registry.npmjs.org/@meshsdk/transaction/-/transaction-1.9.0-beta.89.tgz", + "integrity": "sha512-p3ePCJWcLosswb4F5OiCHYszTci6EG5UR7JnONTJmjkE83blg92iNSkWYQbHclXXM5Ldf/CuHL9BDhPQcIfErw==", + "license": "Apache-2.0", + "dependencies": { + "@cardano-sdk/core": "^0.45.5", + "@cardano-sdk/input-selection": "^0.13.33", + "@cardano-sdk/util": "^0.15.5", + "@meshsdk/common": "1.9.0-beta.89", + "@meshsdk/core-cst": "1.9.0-beta.89", + "json-bigint": "^1.0.0" + } + }, + "node_modules/@utxos/sdk/node_modules/@meshsdk/wallet": { + "version": "1.9.0-beta.89", + "resolved": "https://registry.npmjs.org/@meshsdk/wallet/-/wallet-1.9.0-beta.89.tgz", + "integrity": "sha512-HcirkitcpSflgCjZdKu2hKy6ico36L54cgI+mJXIqhYk8evAZiUyw8bLhfqQhjjTCKCTfmGDtu+xuBs8eYmVWw==", + "license": "Apache-2.0", + "dependencies": { + "@meshsdk/common": "1.9.0-beta.89", + "@meshsdk/core-cst": "1.9.0-beta.89", + "@meshsdk/transaction": "1.9.0-beta.89", + "@simplewebauthn/browser": "^13.0.0" + } + }, + "node_modules/@utxos/sdk/node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@utxos/sdk/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", + "devOptional": true, + "license": "MIT", "dependencies": { - "@cardano-sdk/core": "^0.45.5", - "@cardano-sdk/input-selection": "^0.13.33", - "@cardano-sdk/util": "^0.15.5", - "@meshsdk/common": "1.9.0-beta.89", - "@meshsdk/core-cst": "1.9.0-beta.89", - "json-bigint": "^1.0.0" + "@visx/vendor": "4.0.0-alpha.0" } }, - "node_modules/@utxos/sdk/node_modules/@meshsdk/wallet": { - "version": "1.9.0-beta.89", - "resolved": "https://registry.npmjs.org/@meshsdk/wallet/-/wallet-1.9.0-beta.89.tgz", - "integrity": "sha512-HcirkitcpSflgCjZdKu2hKy6ico36L54cgI+mJXIqhYk8evAZiUyw8bLhfqQhjjTCKCTfmGDtu+xuBs8eYmVWw==", - "license": "Apache-2.0", + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "devOptional": true, + "license": "MIT", "dependencies": { - "@meshsdk/common": "1.9.0-beta.89", - "@meshsdk/core-cst": "1.9.0-beta.89", - "@meshsdk/transaction": "1.9.0-beta.89", - "@simplewebauthn/browser": "^13.0.0" + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "devOptional": true, + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@visx/vendor/node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" } }, + "node_modules/@webgpu/types": { + "version": "0.1.74", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", + "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@xyflow/react": { - "version": "12.11.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", - "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.79", + "@xyflow/system": "0.0.83", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -9021,9 +10028,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.79", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", - "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -9072,9 +10079,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -9160,19 +10167,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -9286,18 +10280,18 @@ } }, "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" }, @@ -9524,9 +10518,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -9534,13 +10528,13 @@ } }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -9556,16 +10550,16 @@ } }, "node_modules/babel-jest": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", - "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.2.tgz", + "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.4.1", + "@jest/transform": "30.5.2", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.4.0", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -9578,9 +10572,9 @@ } }, "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "dev": true, "license": "BSD-3-Clause", "workspaces": [ @@ -9591,16 +10585,16 @@ "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" + "test-exclude": "^7.0.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", - "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", "dev": true, "license": "MIT", "dependencies": { @@ -9638,20 +10632,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", - "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.4.0", + "babel-plugin-jest-hoist": "30.5.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, "node_modules/bail": { @@ -9668,7 +10662,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/bare-ansi-escapes": { @@ -9689,27 +10682,28 @@ } }, "node_modules/bare-assert": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz", - "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.3.0.tgz", + "integrity": "sha512-tF3A1xa5WHzOwn2Y6Pm+mV1NGOztWUKyUQycmtMCnIcEx/0xv5MMgv2qerXQVvhkv6IZJNaEbiGBJNFD6RmunA==", "license": "Apache-2.0", "dependencies": { - "bare-inspect": "^3.1.2" + "bare-inspect": "^3.1.5", + "bare-type": "^1.3.0" } }, "node_modules/bare-buffer": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/bare-buffer/-/bare-buffer-3.6.0.tgz", - "integrity": "sha512-/maRWEQ2eBkVNMbNFVsq1pHXJYVj4Y3AixwruB24eKZDs5Gtu0fixzvjYmBIuTsBMtVH5Yb27pQO9BhFa+IlIQ==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/bare-buffer/-/bare-buffer-3.7.1.tgz", + "integrity": "sha512-cIjZnSO+y89ykVX9x96OVnA6tGORBn7vGJ/e3PCbBHMEyfMNG/ixzOS0lLa6saMlWcs69bSKjxFyDwjK152OMg==", "license": "Apache-2.0", "engines": { "bare": ">=1.20.0" } }, "node_modules/bare-crypto": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.7.tgz", - "integrity": "sha512-gvYgECS1X1wDXFuipwgrN1kwOovMP+BNpaSnRcKRZeT0wYrZIDf9H48BpdWd0t81nmCWtfbpKZhWBziUwLY05Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.15.4.tgz", + "integrity": "sha512-4luuiLnxIJvM7ZyyW6pXbaaGoQDZcIkfcVZrPzJ3oIpdi2UIP37dROmzBWqI2lnrzfKoQlKWM8ryHmyG2svViw==", "license": "Apache-2.0", "dependencies": { "bare-assert": "^1.2.0", @@ -9725,18 +10719,26 @@ } }, "node_modules/bare-dns": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.1.4.tgz", - "integrity": "sha512-abwjHmpWqSRNB7V5615QxPH92L71AVzFm/kKTs8VYiNTAi2xVdonpv0BjJ0hwXLwomoW+xsSOPjW6PZPO14asg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.2.1.tgz", + "integrity": "sha512-76kta7f4YMiXwhdlK1q8AFnVvSlZ5yv4FL4AH83KoTRErgj0Ttm6Jo9993+gWEWQfl7VZTHtihPozstlknXszw==", "license": "Apache-2.0", "engines": { "bare": ">=1.7.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, "node_modules/bare-events": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.3.tgz", - "integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" @@ -9784,20 +10786,27 @@ "bare-stream": "^2.6.5" } }, + "node_modules/bare-hrtime": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bare-hrtime/-/bare-hrtime-2.1.2.tgz", + "integrity": "sha512-ePu0axonbewvuUv9TFqjfvnZTaw+mL4JNx1q79dw8T3DIQEjqHs+pXyj92ghepa8K14KLtX3GdUuAVB+faaGDw==", + "license": "Apache-2.0" + }, "node_modules/bare-http-parser": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.4.tgz", - "integrity": "sha512-DL+7fTEUWzAEj/Baw9e/BwNAidARbxuUf5bonQ/Wt3VPUdJNyf562ydaono9ZkQBAUw0NydzYEI97rSs/93ruA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-2.1.4.tgz", + "integrity": "sha512-6CRjxqMdrEoKKBCS6FbdZUuVsRtyqq18BUae9l9N5okSV75S9lUAF9f5aFSKTNybBS/hcnlxFNocdpXbd2Ohaw==", "license": "Apache-2.0" }, "node_modules/bare-http1": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.5.6.tgz", - "integrity": "sha512-31OAwMkSU+z1VuUOCk65hx3aWQgzCfH/zQ6LGxbJtmiy2Czsw0+uvOBM9YkqaL6zUSTSYG2pLbL0v/TjME3Buw==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.6.2.tgz", + "integrity": "sha512-fnvBPNUH6KfdcEG7sKKKtvc3/9BEn3vkmJPgdtSLqGl6i2J9Csnl14mVv78ODPn8p3Zu2ZfmlG5XQzQQ45fwLw==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.6.0", - "bare-http-parser": "^1.1.1", + "bare-hrtime": "^2.1.1", + "bare-http-parser": "^2.1.0", "bare-stream": "^2.10.0", "bare-tcp": "^2.2.0" }, @@ -9815,9 +10824,9 @@ } }, "node_modules/bare-https": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-3.0.0.tgz", - "integrity": "sha512-W1GRSCzn+xXKf5bMcPs/hg6Ga1bxPqb7owGfS+tvlBQfPe5Q2STcanRuKZrgU60v5uKrhXH5cgWwM+DLqvXZgQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-3.1.0.tgz", + "integrity": "sha512-HNAS0q7CBAXGDFRy/xaYJEsAaVcvyDRXKU1b/KxS/zdcfBWsQVB0TntSzz3IlqZ5ZWHiaOOrMYAO/oIDgpTLkQ==", "license": "Apache-2.0", "dependencies": { "bare-http1": "^4.4.0", @@ -9826,9 +10835,9 @@ } }, "node_modules/bare-inspect": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz", - "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.10.tgz", + "integrity": "sha512-Qu01XZNTIT2/bS9k7c6A3OXuAzBnWwDCejGri76BUltdFqVe6Et/IxVOSSbcDpreSSWwqQ5pAu/bxFE6hPDWRQ==", "license": "Apache-2.0", "dependencies": { "bare-ansi-escapes": "^2.1.0", @@ -9845,9 +10854,9 @@ "license": "Apache-2.0" }, "node_modules/bare-net": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.1.tgz", - "integrity": "sha512-MypSqDKpDU2Xt7FIfazn5yGvRnV09gFcIPHGWstW0gxuzA4tucTcwJSZeos97C4F89vtU5oGwXDN/HrGN6Y4Jw==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.3.tgz", + "integrity": "sha512-q1noXFJKn+eNi6vXYv2Y+5FTnn+o7qpli4m2lUZTdK8cBjVs2zGaykuzTUIa29v70eOsQI9OAIyYwN3ODHtFaQ==", "license": "Apache-2.0", "dependencies": { "bare-events": "^2.2.2", @@ -9856,43 +10865,52 @@ "bare-tcp": "^2.0.0" } }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-performance": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bare-performance/-/bare-performance-2.1.1.tgz", + "integrity": "sha512-nVlulswnYgXS2Fkbk4ZIKgfIWY/rmeG8ljM9aryPYClgPNzpOgOSLQzVSgU/K+ReLJHq7fEUQ9SBdjEs1QTIfw==", "license": "Apache-2.0", "dependencies": { - "bare-os": "^3.0.1" + "bare-events": "^2.9.1" + }, + "engines": { + "bare": ">=1.27.0" } }, "node_modules/bare-pipe": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.5.tgz", - "integrity": "sha512-6OfxaG8JSkRh3Gc4hzHRsxNt+yu2PpN7lrv1V+T78GdknWQkVGwiEvu4m+1nbfk8cMVQ0TGxRvQ90XA4rhnTuw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.3.1.tgz", + "integrity": "sha512-3P4MYTgOys1Bbz5dgWzwfroNOOl8x1zWJHTHVlwsojThhV/uJPRf+qFrYYBOnI0vQiJJLJtHlw8a4Osj/kcsJQ==", "license": "Apache-2.0", "dependencies": { - "bare-events": "^2.0.0", - "bare-stream": "^2.0.0" + "bare-events": "^2.5.4", + "bare-stream": "^2.6.4" }, "engines": { "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, "node_modules/bare-stream": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", - "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", "license": "Apache-2.0", "dependencies": { + "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, @@ -9913,10 +10931,24 @@ } } }, + "node_modules/bare-stream/node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/bare-tcp": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.13.tgz", - "integrity": "sha512-4KQPgqYugvK6QxcSnVGbl87XslBebxmXlv7Glf4M9iwwoSCDKtYmC1t6zsMctTNhzKXbWCId7mB4R9qLWj3JMw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.6.1.tgz", + "integrity": "sha512-8f8UFP7O27gIGaVWJPysQ2am8LSD6YCuVSbnTbOrQjUD0RkWEZexvi2Q66P99MLxSephlyjoU0mLxWmozRJMnA==", "license": "Apache-2.0", "dependencies": { "bare-dns": "^2.0.4", @@ -9925,12 +10957,20 @@ }, "engines": { "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-pipe": "*" + }, + "peerDependenciesMeta": { + "bare-pipe": { + "optional": true + } } }, "node_modules/bare-tls": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.5.tgz", - "integrity": "sha512-yoOtW3MyJF1mMwavLeuqOE7+qTKZ9cl1GRPxCUOXMUvYCfGltvXyRH48R4EKrRIfgUG6vil6n4Ea1i81fwmgZA==", + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-3.1.11.tgz", + "integrity": "sha512-lCQMepLYPPmdunO4MtkVe6l619Kdrt47/0HDGOgJY6zVvEeFACZD1hkh1kRw6zJk2NQHLcnebggYQZ/z6J/cPg==", "license": "Apache-2.0", "dependencies": { "bare-net": "^2.0.1", @@ -9941,30 +10981,38 @@ } }, "node_modules/bare-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz", - "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.4.0.tgz", + "integrity": "sha512-6anDbVGruQLK8m6zxlCPNkUuxXnzChT1aOmJbFRjEGn8MWZqviFP6vGi9NAnG8aRXyRAfXIGLlBJwYGa4Ylcug==", "license": "Apache-2.0", "engines": { - "bare": ">=1.2.0" + "bare": ">=1.32.0" } }, "node_modules/bare-url": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.3.tgz", - "integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==", + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz", + "integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==", "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" } }, "node_modules/bare-zlib": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/bare-zlib/-/bare-zlib-1.3.3.tgz", - "integrity": "sha512-rXNczo+SQg6cn20olmh/mUiGeJK9maipFH/zI/QwYgwhEmOns1R7fl1GV5apNO+aAp4x2d4uUa7HLhO4mhOnBQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/bare-zlib/-/bare-zlib-1.4.1.tgz", + "integrity": "sha512-CsnQl+XyLaUecB9/OUpjqmemung10M7J2UNXz+6NAVrZAI3HC9c5Kxw34aI0jaU9+gb2yUCD31hOrtPZlnE3bA==", "license": "Apache-2.0", "dependencies": { "bare-stream": "^2.0.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } } }, "node_modules/base-x": { @@ -10000,9 +11048,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -10024,16 +11072,16 @@ "license": "Apache-2.0" }, "node_modules/better-result": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", - "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", "devOptional": true, "license": "MIT" }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "dev": true, "license": "MIT", "dependencies": { @@ -10072,22 +11120,22 @@ } }, "node_modules/bip174": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz", - "integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.1.tgz", + "integrity": "sha512-avOnOoLOwdjw1MebWKh3bHqWOH558Q9VpqmRKUv2S63vjw0pnX7Y+yRihxIibgP55twhc7+YfC0IAH43IF5FqQ==", "license": "MIT", "dependencies": { - "uint8array-tools": "^0.0.9", - "varuint-bitcoin": "^2.0.0" + "uint8array-tools": "^0.0.10", + "varuint-bitcoin": "^2.0.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/bip174/node_modules/uint8array-tools": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz", - "integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.10.tgz", + "integrity": "sha512-sSbrZqlr04w4p8KKpHw0ME/R4JzQaadum+BWhMlk2M7LrDyXWTfJOco3d1jNMdQNxgk5DDm88R3IWYH0nR+tzQ==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -10118,9 +11166,9 @@ } }, "node_modules/bitcoinjs-lib": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", - "integrity": "sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==", + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.8.tgz", + "integrity": "sha512-A6o+yq7BvwlmLj4F0NC5f3XX109rRe5evxi368S9zil5voOtUfiqfiMCzZYKHk4k7iIJ3+NmcQW05Ar4CBlKyg==", "license": "MIT", "dependencies": { "@noble/hashes": "^1.2.0", @@ -10173,9 +11221,9 @@ } }, "node_modules/blake2b-wasm/node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -10193,16 +11241,15 @@ "license": "MIT" }, "node_modules/bn.js": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", - "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -10222,9 +11269,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -10242,11 +11289,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -10327,13 +11374,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/c12": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", @@ -10456,9 +11496,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "funding": [ { "type": "opencollective", @@ -10548,7 +11588,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -10611,19 +11650,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -10644,7 +11670,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, "funding": [ { "type": "github", @@ -10671,9 +11696,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", "dev": true, "license": "MIT" }, @@ -10801,7 +11826,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/confbox": { @@ -10828,13 +11852,10 @@ } }, "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.1.5.tgz", + "integrity": "sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==", "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, "engines": { "node": ">=18" }, @@ -10852,11 +11873,14 @@ } }, "node_modules/core-js-pure": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", - "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz", + "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==", "hasInstallScript": true, "license": "MIT", + "engines": { + "node": "*" + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -11000,9 +12024,9 @@ } }, "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", "license": "ISC", "dependencies": { "delaunator": "5" @@ -11043,18 +12067,18 @@ } }, "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", "license": "ISC", "engines": { "node": ">=12" } }, "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" @@ -11096,6 +12120,16 @@ "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", "license": "MIT" }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -11134,6 +12168,19 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -11289,9 +12336,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debounce": { @@ -11501,8 +12548,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -11537,9 +12584,9 @@ } }, "node_modules/dexie": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz", - "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.6.tgz", + "integrity": "sha512-hJP/BO6mjB+tX6hToIO1kmxYLNmun90wYbfcoAoLpKEyDYal/k33dg0TAfoUe2TDsbbLoVIzljJ3BN2UbR5EOg==", "license": "Apache-2.0" }, "node_modules/dezalgo": { @@ -11583,9 +12630,9 @@ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, "node_modules/dompurify": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", - "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -11595,7 +12642,6 @@ "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -11663,9 +12709,9 @@ "license": "MIT" }, "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz", + "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==", "license": "ISC" }, "node_modules/ecdsa-sig-formatter": { @@ -11703,12 +12749,19 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "devOptional": true, + "license": "EPL-2.0" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -11831,6 +12884,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -11850,9 +12922,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -11877,6 +12949,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -11918,15 +12997,18 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -11958,9 +13040,10 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { @@ -11969,8 +13052,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -12018,13 +13101,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", - "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz", + "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.6", + "@next/eslint-plugin-next": "16.3.8", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -12115,9 +13198,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", "dependencies": { @@ -12502,27 +13585,27 @@ } }, "node_modules/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "devOptional": true, "license": "MIT" }, @@ -12588,6 +13671,13 @@ ], "license": "MIT" }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12650,6 +13740,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", @@ -12657,9 +13757,9 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -12673,9 +13773,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -12772,6 +13872,21 @@ "node": ">=8" } }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -12804,16 +13919,16 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", @@ -12862,16 +13977,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -12948,9 +14063,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12971,18 +14086,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -13002,9 +14120,9 @@ } }, "node_modules/geist": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.1.tgz", - "integrity": "sha512-XF8DM30ODhDu0Ng5S2kOS8j4ZkG/6z2fKWAiFJA7wG0Hc92ZXgjYLrwbD9bVU4nGVzwboPtG+QtaPGsfgnXLaA==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.2.tgz", + "integrity": "sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==", "license": "SIL OPEN FONT LICENSE", "peerDependencies": { "next": ">=13.2.0" @@ -13155,9 +14273,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -13168,9 +14286,9 @@ } }, "node_modules/giget": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", - "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", "devOptional": true, "license": "MIT", "bin": { @@ -13223,24 +14341,24 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -13312,13 +14430,12 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/grammex": { - "version": "3.1.12", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", - "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", + "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", "devOptional": true, "license": "MIT" }, @@ -13330,9 +14447,9 @@ "license": "MIT" }, "node_modules/graphql": { - "version": "16.14.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.0.tgz", - "integrity": "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==", + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -13367,9 +14484,9 @@ } }, "node_modules/h3-js": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", - "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.5.0.tgz", + "integrity": "sha512-uKmdPc+DuarnR+XqZxEuWOMw7KzzKROrx3MLeJpFnMOs78S9M5eZ+X5RieS9UcSFQqbeXzbfWuX/W9Pyajinqw==", "license": "Apache-2.0", "engines": { "node": ">=4", @@ -13416,7 +14533,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13551,9 +14667,9 @@ "license": "MIT" }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -13665,22 +14781,12 @@ "license": "CC0-1.0" }, "node_modules/hls.js": { - "version": "1.6.16", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", - "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", "dev": true, "license": "Apache-2.0" }, - "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -13698,13 +14804,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http-status-codes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "devOptional": true, - "license": "MIT" - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -13737,9 +14836,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -13754,9 +14853,9 @@ } }, "node_modules/idb-keyval": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz", - "integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", "license": "Apache-2.0" }, "node_modules/ieee754": { @@ -13797,13 +14896,10 @@ "license": "MIT" }, "node_modules/immutable": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", - "integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.1", @@ -13938,9 +15034,9 @@ } }, "node_modules/ipaddr.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", "license": "MIT", "engines": { "node": ">= 10" @@ -14099,12 +15195,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -14158,6 +15254,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -14308,9 +15420,9 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", "dev": true, "license": "MIT", "engines": { @@ -14507,18 +15619,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -14677,16 +15777,16 @@ } }, "node_modules/jest": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", - "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.2.tgz", + "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/types": "30.4.1", + "@jest/core": "30.5.2", + "@jest/types": "30.5.1", "import-local": "^3.2.0", - "jest-cli": "30.4.2" + "jest-cli": "30.5.2" }, "bin": { "jest": "bin/jest.js" @@ -14704,14 +15804,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", - "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", + "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "p-limit": "^3.1.0" }, "engines": { @@ -14719,29 +15819,29 @@ } }, "node_modules/jest-circus": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", - "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.2.tgz", + "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-each": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "p-limit": "^3.1.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -14751,21 +15851,21 @@ } }, "node_modules/jest-cli": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", - "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.2.tgz", + "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/core": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-config": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "yargs": "^17.7.2" }, "bin": { @@ -14784,33 +15884,33 @@ } }, "node_modules/jest-config": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", - "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.2.tgz", + "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.4.0", - "@jest/test-sequencer": "30.4.1", - "@jest/types": "30.4.1", - "babel-jest": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.2", + "@jest/types": "30.5.1", + "babel-jest": "30.5.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-circus": "30.4.2", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-runner": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-circus": "30.5.2", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-runner": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "parse-json": "^5.2.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -14835,25 +15935,25 @@ } }, "node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "pretty-format": "30.4.1" + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", - "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", "dev": true, "license": "MIT", "dependencies": { @@ -14864,111 +15964,110 @@ } }, "node_modules/jest-each": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", - "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.2.tgz", + "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", "chalk": "^4.1.2", - "jest-util": "30.4.1", - "pretty-format": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", - "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.2.tgz", + "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1" + "jest-mock": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", - "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", + "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", + "@parcel/watcher": "^2.6.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "picomatch": "^4.0.3", - "walker": "^1.0.8" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", - "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", + "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.4.1" + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", + "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" + "jest-diff": "30.5.2", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", - "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "picomatch": "^4.0.3", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -14977,42 +16076,25 @@ } }, "node_modules/jest-mock": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", - "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-util": "30.4.1" + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, "node_modules/jest-regex-util": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", - "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", "dev": true, "license": "MIT", "engines": { @@ -15020,100 +16102,100 @@ } }, "node_modules/jest-resolve": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", - "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", + "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "unrs-resolver": "^1.12.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", - "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.2.tgz", + "integrity": "sha512-GMI0DP5enX9Z2qW2zFy1bOkrCfWAOPRJQ7qHt0pdfrxBPsgWxglMPrRSK2TX/wRWYxcPRSLFg3Z88qPOzHx7FQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.4.0", - "jest-snapshot": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", - "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.2.tgz", + "integrity": "sha512-/B6px7hiiNhVSRwuv9AGn0nawYvwsHeQZ3ItTd5Gp2diFd0EOKaWVeWqcbw3L88vx1GfpCKjQkxWItZCTl11Rw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/environment": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/environment": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-haste-map": "30.4.1", - "jest-leak-detector": "30.4.1", - "jest-message-util": "30.4.1", - "jest-resolve": "30.4.1", - "jest-runtime": "30.4.2", - "jest-util": "30.4.1", - "jest-watcher": "30.4.1", - "jest-worker": "30.4.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-leak-detector": "30.5.1", + "jest-message-util": "30.5.1", + "jest-resolve": "30.5.1", + "jest-runtime": "30.5.2", + "jest-util": "30.5.1", + "jest-watcher": "30.5.2", + "jest-worker": "30.5.1", + "p-limit": "^3.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", - "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.2.tgz", + "integrity": "sha512-0paUAHBlheai10A3XSy9Xh+YBG0IROy+/qzcDPzQ/nbbXrznQzeWy5qniICWK6d3W3scaRQCVAbXdcK0ibBq4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/globals": "30.4.1", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/globals": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", + "cjs-module-lexer": "^2.2.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.5.0", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -15122,9 +16204,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", - "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.2.tgz", + "integrity": "sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==", "dev": true, "license": "MIT", "dependencies": { @@ -15133,20 +16215,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.1", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.4.1", + "expect": "30.5.2", "graceful-fs": "^4.2.11", - "jest-diff": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "pretty-format": "30.4.1", + "jest-diff": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "pretty-format": "30.5.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -15155,13 +16237,13 @@ } }, "node_modules/jest-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -15173,18 +16255,18 @@ } }, "node_modules/jest-validate": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", - "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", + "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.4.1" + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -15204,19 +16286,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", - "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.2.tgz", + "integrity": "sha512-rqRgg4R11GA9m1UsEZwQeixHsgZReCn6TOHAHtIH33yXrIvoX0OGPA20Yzzjkz7X40fZvcRSxEpQppYPcWzmcQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "string-length": "^4.0.2" }, "engines": { @@ -15224,15 +16306,15 @@ } }, "node_modules/jest-worker": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", - "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", + "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -15276,9 +16358,9 @@ } }, "node_modules/js-base64": { - "version": "3.7.8", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", - "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz", + "integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==", "license": "BSD-3-Clause" }, "node_modules/js-file-download": { @@ -15294,9 +16376,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -15344,7 +16436,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -15763,9 +16854,9 @@ } }, "node_modules/lru.min": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", - "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", "devOptional": true, "license": "MIT", "engines": { @@ -15799,9 +16890,9 @@ } }, "node_modules/main-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/main-event/-/main-event-1.0.4.tgz", - "integrity": "sha512-sKazUjIy2Jalv5lkQ446iOcrx8Q7TkaCuk6xfnzg5uUqMusMLDMPmRDmSNE2kjSVpSTJo4j1bQZusS+Ib7Bvrg==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/main-event/-/main-event-1.0.5.tgz", + "integrity": "sha512-4l9z8r7Q446mhhVTwdHmfPksOYBwN2xa7ewEW2yxPVNQapIaAf57ORFLMB91SpMEMa4wXowect7fq3uMLjDnGA==", "license": "Apache-2.0 OR MIT" }, "node_modules/make-dir": { @@ -15827,16 +16918,6 @@ "dev": true, "license": "ISC" }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -15868,9 +16949,9 @@ } }, "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -15896,9 +16977,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -15973,9 +17054,9 @@ } }, "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -16116,9 +17197,9 @@ } }, "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -16126,8 +17207,10 @@ "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" }, @@ -16176,9 +17259,9 @@ } }, "node_modules/meshoptimizer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", - "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", "dev": true, "license": "MIT" }, @@ -16195,9 +17278,9 @@ } }, "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", "funding": [ { "type": "GitHub Sponsors", @@ -16220,6 +17303,7 @@ "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", @@ -16230,9 +17314,9 @@ } }, "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", "funding": [ { "type": "GitHub Sponsors", @@ -16249,18 +17333,19 @@ "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", + "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" + "micromark-util-types": "^2.0.3" } }, "node_modules/micromark-extension-gfm": { @@ -16338,9 +17423,9 @@ } }, "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -16428,9 +17513,9 @@ } }, "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", "funding": [ { "type": "GitHub Sponsors", @@ -16612,6 +17697,25 @@ "micromark-util-symbol": "^2.0.0" } }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, "node_modules/micromark-util-encode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", @@ -16742,9 +17846,9 @@ "license": "MIT" }, "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", "funding": [ { "type": "GitHub Sponsors", @@ -16844,7 +17948,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -16961,9 +18064,9 @@ } }, "node_modules/nan": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", "license": "MIT", "optional": true }, @@ -16974,9 +18077,9 @@ "license": "ISC" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -17022,9 +18125,9 @@ "license": "MIT" }, "node_modules/neotraverse": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", - "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", "license": "MIT", "engines": { "node": ">= 10" @@ -17084,9 +18187,9 @@ } }, "node_modules/next-auth": { - "version": "4.24.14", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz", - "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==", + "version": "4.24.15", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.15.tgz", + "integrity": "sha512-NnjYtjrSOAx/TIVFGTX4IfI/9yHnNpi4B7FuLUwuV20v2Zxgr2OGP/YN0ynJuI7y8QOnTBPitfOdEXZrVvhIuA==", "license": "ISC", "dependencies": { "@babel/runtime": "^7.20.13", @@ -17097,7 +18200,7 @@ "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", - "uuid": "^8.3.2" + "uuid": "^11.1.1" }, "peerDependencies": { "@auth/core": "0.34.3", @@ -17115,16 +18218,6 @@ } } }, - "node_modules/next-auth/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -17154,24 +18247,24 @@ } }, "node_modules/nice-grpc": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-2.1.16.tgz", - "integrity": "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ==", + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/nice-grpc/-/nice-grpc-2.1.17.tgz", + "integrity": "sha512-pu9xYPlWSeqoYQOCqb2ftQqZi/P2DaI70PSuwnxvoyIRiilaOVtktyPe6LmuXegWB4Ic1sPuDGCnCCASbwzK0w==", "license": "MIT", "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", - "nice-grpc-common": "^2.0.3" + "nice-grpc-common": "^2.0.4" } }, "node_modules/nice-grpc-client-middleware-retry": { - "version": "3.1.15", - "resolved": "https://registry.npmjs.org/nice-grpc-client-middleware-retry/-/nice-grpc-client-middleware-retry-3.1.15.tgz", - "integrity": "sha512-fXfNNdtjCQzc3O/w3WsK1AOU+xdE1V7FCm4FEQWS/UUVsV1616S2oryvWqsZAF8aOvuMir8lFtNmgH3DeP+PBg==", + "version": "3.1.16", + "resolved": "https://registry.npmjs.org/nice-grpc-client-middleware-retry/-/nice-grpc-client-middleware-retry-3.1.16.tgz", + "integrity": "sha512-8EbOCUZZ1Uq1pCfTD8dB2zMU1AYrilRvm9al5VSUi444eqhPhxElnI7iqP6ZY/ixb1NGsvMpfbU5FW/3L7DdFw==", "license": "MIT", "dependencies": { "abort-controller-x": "^0.5.0", - "nice-grpc-common": "^2.0.3" + "nice-grpc-common": "^2.0.4" } }, "node_modules/nice-grpc-client-middleware-retry/node_modules/abort-controller-x": { @@ -17181,25 +18274,25 @@ "license": "MIT" }, "node_modules/nice-grpc-common": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/nice-grpc-common/-/nice-grpc-common-2.0.3.tgz", - "integrity": "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/nice-grpc-common/-/nice-grpc-common-2.0.4.tgz", + "integrity": "sha512-gOEXlD6ShXZMZ8k+49wm/bA2j1+3IKbEFV9WYREJcvZV4kM5L1KkILYTX9VYBzZF2OuYCe1wp8c82bQkvR0fHw==", "license": "MIT", "dependencies": { "ts-error": "^1.0.6" } }, "node_modules/nice-grpc-opentelemetry": { - "version": "0.1.22", - "resolved": "https://registry.npmjs.org/nice-grpc-opentelemetry/-/nice-grpc-opentelemetry-0.1.22.tgz", - "integrity": "sha512-Azmn6JhkDeenXk6jag5tq7cRrp7UqIOsNVjtchmXjZp3BaSMI3E8FVk1DSaT+QjO59ftU0tqOofeE/5MixiZKw==", + "version": "0.1.23", + "resolved": "https://registry.npmjs.org/nice-grpc-opentelemetry/-/nice-grpc-opentelemetry-0.1.23.tgz", + "integrity": "sha512-cRc/CXdZDs4Wzv2mwpGA+MUGz8o/4B07DNPPn0qqUSdNgFjSbM0Vb3F7WFBz3XU4+zqQ6ROe+/5Lq6Z2/bjikw==", "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.8.0", "@opentelemetry/semantic-conventions": "^1.22.0", "abort-controller-x": "^0.5.0", "ipaddr.js": "^2.0.1", - "nice-grpc-common": "^2.0.3" + "nice-grpc-common": "^2.0.4" } }, "node_modules/nice-grpc-opentelemetry/node_modules/abort-controller-x": { @@ -17209,15 +18302,15 @@ "license": "MIT" }, "node_modules/nice-grpc-web": { - "version": "3.3.10", - "resolved": "https://registry.npmjs.org/nice-grpc-web/-/nice-grpc-web-3.3.10.tgz", - "integrity": "sha512-8XyCtbs7uL0mWQEjpkjZy57bnLbtheRbIWgj8p98XPbjFqXOBkTLu+ebj5H4fUu/yxqt+6ULPPDHT/icUsyieA==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nice-grpc-web/-/nice-grpc-web-3.3.11.tgz", + "integrity": "sha512-PANW77TBmfT3K4YmN8cMqou/ua2ofWQ8s3N8JKlRRHsQ3lnpLS5krJrNEwvHx1p29FmpqKuX9LNTIakNbI2Xhg==", "license": "MIT", "dependencies": { "abort-controller-x": "^0.5.0", "isomorphic-ws": "^5.0.0", "js-base64": "^3.7.2", - "nice-grpc-common": "^2.0.3" + "nice-grpc-common": "^2.0.4" } }, "node_modules/nice-grpc-web/node_modules/abort-controller-x": { @@ -17248,19 +18341,16 @@ "license": "MIT" }, "node_modules/node-addon-api": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", - "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" }, "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", "dev": true, "license": "MIT", "dependencies": { @@ -17326,9 +18416,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -17354,9 +18444,9 @@ } }, "node_modules/npm": { - "version": "11.17.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.17.0.tgz", - "integrity": "sha512-PurxiZexEHDTE4SSaLI3ZrnbAGiZfeyUcQcxcP5D+hfytNAze/D1IzDuInTn9XVLIbAQUnQuSPXJx02LHjLvQw==", + "version": "11.21.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.21.0.tgz", + "integrity": "sha512-Zov8KhamNneiLdELtj5YALtNmJW4L4fCLTzjfpzXG2w6MSHcf0UxgdlK5uloCuksWT+7mGUU7wi79cO6RqivPg==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -17434,8 +18524,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.8.0", - "@npmcli/config": "^10.11.0", + "@npmcli/arborist": "^9.9.2", + "@npmcli/config": "^10.14.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -17459,12 +18549,12 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.10", - "libnpmexec": "^10.3.0", - "libnpmfund": "^7.0.24", + "libnpmdiff": "^8.1.13", + "libnpmexec": "^10.3.3", + "libnpmfund": "^7.0.27", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.10", - "libnpmpublish": "^11.2.0", + "libnpmpack": "^9.1.14", + "libnpmpublish": "^11.2.1", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", "libnpmversion": "^8.0.4", @@ -17479,7 +18569,7 @@ "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.2", "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", + "npm-profile": "^12.0.2", "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", @@ -17488,11 +18578,11 @@ "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.8.4", + "semver": "^7.8.5", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.16", + "tar": "^7.5.22", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -17560,7 +18650,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.8.0", + "version": "9.9.2", "inBundle": true, "license": "ISC", "dependencies": { @@ -17607,7 +18697,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.11.0", + "version": "10.14.0", "inBundle": true, "license": "ISC", "dependencies": { @@ -17924,14 +19014,14 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.6", + "version": "5.0.9", "inBundle": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/npm/node_modules/cacache": { @@ -18191,7 +19281,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.2.0", + "version": "10.5.0", "inBundle": true, "license": "MIT", "engines": { @@ -18264,11 +19354,11 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.10", + "version": "8.1.13", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.2", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -18282,12 +19372,12 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.3.0", + "version": "10.3.3", "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.2", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -18304,11 +19394,11 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.24", + "version": "7.0.27", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0" + "@npmcli/arborist": "^9.9.2" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -18327,11 +19417,11 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.10", + "version": "9.1.14", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.8.0", + "@npmcli/arborist": "^9.9.2", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -18341,7 +19431,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.2.0", + "version": "11.2.1", "inBundle": true, "license": "ISC", "dependencies": { @@ -18672,12 +19762,12 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "version": "12.0.2", "inBundle": true, "license": "ISC", "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -18866,7 +19956,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.8.4", + "version": "7.8.5", "inBundle": true, "license": "ISC", "bin": { @@ -18980,7 +20070,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.16", + "version": "7.5.22", "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -19068,7 +20158,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.26.0", + "version": "6.28.0", "inBundle": true, "license": "MIT", "engines": { @@ -19135,6 +20225,16 @@ "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", + "extraneous": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -19267,9 +20367,9 @@ } }, "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", "devOptional": true, "license": "MIT" }, @@ -19400,13 +20500,14 @@ } }, "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.6", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" }, @@ -19450,9 +20551,9 @@ } }, "node_modules/p-queue": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", - "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.4", @@ -19466,9 +20567,9 @@ } }, "node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", "license": "MIT", "engines": { "node": ">=20" @@ -19494,9 +20595,9 @@ "license": "BlueOak-1.0.0" }, "node_modules/papaparse": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", - "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", + "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", "license": "MIT" }, "node_modules/parent-module": { @@ -19598,9 +20699,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -19614,9 +20715,9 @@ "license": "MIT" }, "node_modules/pbkdf2": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", - "integrity": "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.7.tgz", + "integrity": "sha512-nS4mvFgVwUrecPTRdpdseM2fwpdfnX0/HibA8DgBRQtihUAzHQYJFU2l+OlEtiXtpqkrMvCPDwyXMUnASDt6Qw==", "license": "MIT", "dependencies": { "create-hash": "^1.2.0", @@ -19624,7 +20725,7 @@ "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", "sha.js": "^2.4.12", - "to-buffer": "^1.2.1" + "to-buffer": "^1.2.2" }, "engines": { "node": ">= 0.10" @@ -19638,14 +20739,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", - "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.13.0", + "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", - "pg-protocol": "^1.14.0", + "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -19653,7 +20754,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.4.0" + "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -19665,16 +20766,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", - "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", - "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", "license": "MIT" }, "node_modules/pg-int8": { @@ -19696,9 +20797,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", - "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", "license": "MIT" }, "node_modules/pg-types": { @@ -19742,9 +20843,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -19753,15 +20854,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -19841,17 +20933,24 @@ } }, "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", "devOptional": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", + "confbox": "^0.3.1", + "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/playwright": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", @@ -19884,20 +20983,6 @@ "node": ">=18" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/point-in-polygon-hao": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", @@ -19917,15 +21002,15 @@ } }, "node_modules/postal-mime": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", - "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.6.tgz", + "integrity": "sha512-UUlyE2KlxmvwMGq060onF/VWU3zD6dVW31FwokQ5v26jWd35fbs2MoTFzh+jXnPwAyV2MULMKXcBInGOl5TO6Q==", "license": "MIT-0" }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -19942,7 +21027,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -20039,9 +21124,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -20156,9 +21241,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -20259,16 +21344,16 @@ } }, "node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -20288,17 +21373,17 @@ } }, "node_modules/prisma": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", - "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz", + "integrity": "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "7.8.0", - "@prisma/dev": "0.24.3", - "@prisma/engines": "7.8.0", - "@prisma/studio-core": "0.27.3", + "@prisma/config": "7.10.0", + "@prisma/dev": "0.24.17", + "@prisma/engines": "7.10.0", + "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, @@ -20393,9 +21478,9 @@ "license": "ISC" }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -20403,9 +21488,9 @@ } }, "node_modules/protobufjs": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", - "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -20415,7 +21500,6 @@ "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", @@ -20472,9 +21556,9 @@ } }, "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", "license": "MIT", "engines": { "node": ">=16.0.0" @@ -20537,10 +21621,19 @@ "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", "license": "MIT", - "dependencies": { - "drange": "^1.0.2", - "ret": "^0.2.0" - }, + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/randexp/node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "license": "MIT", "engines": { "node": ">=4" } @@ -20555,13 +21648,13 @@ } }, "node_modules/rc9": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", - "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", + "integrity": "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==", "devOptional": true, "license": "MIT", "dependencies": { - "defu": "^6.1.6", + "defu": "^6.1.7", "destr": "^2.0.5" } }, @@ -20603,16 +21696,16 @@ } }, "node_modules/react-copy-to-clipboard": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", - "integrity": "sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.1.tgz", + "integrity": "sha512-s+HrzLyJBxrpGTYXF15dTgMjAJpEPZT/Yp6NytAtZMRngejxt6Pt5WrfFxLAcsqUDU6sY1Jz6tyHwIicE1U2Xg==", "license": "MIT", "dependencies": { - "copy-to-clipboard": "^3.3.1", + "copy-to-clipboard": "^3.3.3", "prop-types": "^15.8.1" }, "peerDependencies": { - "react": "^15.3.0 || 16 || 17 || 18" + "react": ">=15.3.0" } }, "node_modules/react-debounce-input": { @@ -20705,22 +21798,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", - "dev": true, - "license": "MIT" - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -20894,13 +21971,10 @@ } }, "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "license": "MIT" }, "node_modules/readable-stream": { "version": "1.1.14", @@ -20915,9 +21989,9 @@ } }, "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "devOptional": true, "license": "MIT", "engines": { @@ -20934,15 +22008,6 @@ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, - "node_modules/redux-immutable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz", - "integrity": "sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==", - "license": "BSD-3-Clause", - "peerDependencies": { - "immutable": "^3.8.1 || ^4.0.0-rc.1" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -21179,19 +22244,19 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", "license": "MIT" }, "node_modules/resend": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", - "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", + "version": "6.32.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.32.0.tgz", + "integrity": "sha512-bZZUeKlpKINFT+zGG2iIzfDewkyEHRaE7Xk1haseVz3Gfw6ekkizN6KdKgZSMjt5ywE8vMwr3wTdenlF8P475Q==", "license": "MIT", "dependencies": { - "postal-mime": "2.7.4", - "standardwebhooks": "1.0.0" + "postal-mime": "2.7.6", + "standardwebhooks": "1.1.1" }, "engines": { "node": ">=20" @@ -21273,12 +22338,13 @@ } }, "node_modules/ret": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", - "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=10" } }, "node_modules/retry": { @@ -21440,6 +22506,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -21464,9 +22553,9 @@ } }, "node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -21659,15 +22748,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -21788,25 +22877,14 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -21873,9 +22951,9 @@ } }, "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", "license": "MIT", "dependencies": { "@stablelib/base64": "^1.0.0", @@ -21933,9 +23011,9 @@ } }, "node_modules/streamx": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", - "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -21999,25 +23077,25 @@ } }, "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" + "side-channel": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -22038,19 +23116,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -22060,16 +23139,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -22243,7 +23322,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -22275,24 +23353,24 @@ } }, "node_modules/swagger-client": { - "version": "3.37.4", - "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz", - "integrity": "sha512-3xxqc9s99Vsf47ket2j7D4Tw6b6T7ObNvTqSP009yBeoAo0fy0yprqOVxFISTrvRxN7jgfrEi8GXMhsjzb1M0g==", + "version": "3.38.2", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.38.2.tgz", + "integrity": "sha512-MrMYADBRg7nY5Y+CY9E76/Zm2LSghhln4wNNU6jGn3bNZ76BRQ3hQRyCHCyJK9tVgTDUmYCcYgYWrwGruL1mIw==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.22.15", "@scarf/scarf": "=1.4.0", - "@swagger-api/apidom-core": "^1.11.0", - "@swagger-api/apidom-error": "^1.11.0", - "@swagger-api/apidom-json-pointer": "^1.11.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.11.0", - "@swagger-api/apidom-ns-openapi-3-2": "^1.11.0", - "@swagger-api/apidom-reference": "^1.11.0", + "@swagger-api/apidom-core": "1.11.6", + "@swagger-api/apidom-error": "1.11.6", + "@swagger-api/apidom-json-pointer": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-1": "1.11.6", + "@swagger-api/apidom-ns-openapi-3-2": "1.11.6", + "@swagger-api/apidom-reference": "1.11.6", "@swaggerexpert/cookie": "^2.0.2", "deepmerge": "~4.3.0", "fast-json-patch": "^3.0.0-1", - "js-yaml": "^4.1.0", - "neotraverse": "=0.6.18", + "js-yaml": "^4.3.2", + "neotraverse": "=1.0.1", "node-abort-controller": "^3.1.1", "openapi-path-templating": "^2.2.1", "openapi-server-url-templating": "^1.3.0", @@ -22301,6 +23379,29 @@ }, "engines": { "node": ">=22" + }, + "optionalDependencies": { + "@swagger-api/apidom-ns-asyncapi-2": "1.11.6", + "@swagger-api/apidom-ns-asyncapi-3": "1.11.6", + "@swagger-api/apidom-ns-openapi-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-json-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-arazzo-yaml-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-json-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-3": "1.11.6", + "@swagger-api/apidom-parser-adapter-json": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-json-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "1.11.6", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-2": "1.11.6", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "1.11.6" } }, "node_modules/swagger-jsdoc": { @@ -22335,51 +23436,42 @@ "node": ">=6.0.0" } }, - "node_modules/swagger-jsdoc/node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/swagger-ui-react": { - "version": "5.32.6", - "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.32.6.tgz", - "integrity": "sha512-2q2kXd6eDR+syyWV5HE2CkWANyr2MHPkNezG4M7fC0FPlBUZEsNgyA/2dcb9dIwgE5xd995dO42h89fNMF5/ng==", + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.33.1.tgz", + "integrity": "sha512-AvfsWJrKsiSxiE/GMvya4z4RRNyHAiGM3LAezdJzPe8pY5MP6jAyCB+2/7Bq+YYd+XcnsNxt6GaottJMj5CRzA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.27.1", "@scarf/scarf": "=1.4.0", + "@tanstack/react-virtual": "=3.14.10", "base64-js": "^1.5.1", "buffer": "^6.0.3", "classnames": "^2.5.1", "css.escape": "1.5.1", "deep-extend": "0.6.0", - "dompurify": "^3.4.0", + "dompurify": "^3.4.13", "ieee754": "^1.2.1", - "immutable": "^3.x.x", + "immutable": "^5.1.9", "js-file-download": "^0.4.12", - "js-yaml": "=4.1.1", + "js-yaml": "=4.3.2", "lodash": "^4.18.1", "prop-types": "^15.8.1", "randexp": "^0.5.3", "randombytes": "^2.1.0", - "react-copy-to-clipboard": "5.1.0", + "react-copy-to-clipboard": "5.1.1", "react-debounce-input": "=3.3.0", "react-immutable-proptypes": "2.2.0", "react-immutable-pure-component": "^2.2.0", "react-inspector": "^6.0.1", - "react-redux": "^9.2.0", + "react-redux": "^9.3.0", "react-syntax-highlighter": "^16.0.0", "redux": "^5.0.1", - "redux-immutable": "^4.0.0", "remarkable": "^2.0.1", "reselect": "^5.1.1", "serialize-error": "^8.1.0", "sha.js": "^2.4.12", - "swagger-client": "^3.37.4", + "swagger-client": "^3.38.2", "url-parse": "^1.5.10", "xml": "=1.0.1", "xml-but-prettier": "^1.0.1", @@ -22391,13 +23483,13 @@ } }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -22631,6 +23723,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -22641,18 +23749,57 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=8" + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-decoder": { @@ -22665,9 +23812,9 @@ } }, "node_modules/text-decoder/node_modules/b4a": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", - "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -22744,9 +23891,9 @@ } }, "node_modules/three-globe": { - "version": "2.45.2", - "resolved": "https://registry.npmjs.org/three-globe/-/three-globe-2.45.2.tgz", - "integrity": "sha512-3qJE2LAdyHsUPt02mgMRc+PG3j9kGEA0fUYrwKPGIVtvMR1XjDn9hCXu31AWocdgHOFcXkrRVz7jJZzTIvR0eQ==", + "version": "2.45.3", + "resolved": "https://registry.npmjs.org/three-globe/-/three-globe-2.45.3.tgz", + "integrity": "sha512-fSIcPbMjXUxPIjtvi4q+c9Q2zFHBcKPa4VWccITy8/7zGPBEBkP9eLym37+LKqF8FDOf01BAvZ5y4XokIR0cyQ==", "license": "MIT", "dependencies": { "@tweenjs/tween.js": "18 - 25", @@ -22775,9 +23922,9 @@ } }, "node_modules/three-mesh-bvh": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.10.tgz", - "integrity": "sha512-UOlTgPIeqUURcwaG8knxvBaruwZlC4X3/WSHEFO7rYvMVv/YNUrkfFEszvfj36pXV88dCHoHNnIp0PifkirnTQ==", + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.15.tgz", + "integrity": "sha512-9xdEDkCzFoT8VPFH9DznwFSdbaIEjMvM106Iqq7TJurrIheUKDcdW4prfJTCxryih4XS/UxWs0H3K4MLMRFyOQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -22820,9 +23967,9 @@ } }, "node_modules/three-stdlib/node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", "dev": true, "license": "MIT" }, @@ -22833,9 +23980,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -22848,13 +23995,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -22941,6 +24081,26 @@ } } }, + "node_modules/tree-sitter-json/node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/tree-sitter/node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -22952,14 +24112,14 @@ } }, "node_modules/troika-three-text": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", - "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz", + "integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==", "dev": true, "license": "MIT", "dependencies": { "bidi-js": "^1.0.2", - "troika-three-utils": "^0.52.4", + "troika-three-utils": "^0.52.5", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, @@ -22968,9 +24128,9 @@ } }, "node_modules/troika-three-utils": { - "version": "0.52.4", - "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", - "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz", + "integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -23029,9 +24189,9 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.14", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.14.tgz", + "integrity": "sha512-+943G6wCX5E3VpzTQhBGBFntCmdT+QAkTvUlp03edTONFBvqskuXPhpyX16xdUotO4P5HQKyppldSWjocoltLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -23041,7 +24201,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -23052,7 +24212,7 @@ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", + "@babel/core": ">=7.0.0-beta.0 <9", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", @@ -23116,12 +24276,12 @@ } }, "node_modules/ts-proto": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.11.8.tgz", - "integrity": "sha512-+5hzECnyVB33jxjG1BIdzAHcRBm7hjnm8womdJVp2A7xJWihP0drHHVsXYTr9i/LpWNGfh80I+AVVNzFM5AwJw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.13.0.tgz", + "integrity": "sha512-N8gEulHDCUbdreRBAv7F0k4czTSpuZ8XSLx/vaecbvKu1xMSJp9KeiU9YGy4Hrok1CIkcXx3EjSwZNgy8Wfxlw==", "license": "ISC", "dependencies": { - "@bufbuild/protobuf": "^2.10.2", + "@bufbuild/protobuf": "^2.14.1", "case-anything": "^2.1.13", "ts-poet": "^6.12.0", "ts-proto-descriptors": "2.1.0" @@ -23140,15 +24300,15 @@ } }, "node_modules/ts-proto-descriptors/node_modules/@bufbuild/protobuf": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", - "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/ts-proto/node_modules/@bufbuild/protobuf": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", - "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/ts-toolbelt": { @@ -23233,12 +24393,13 @@ } }, "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=12.20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -23279,19 +24440,18 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", "gopd": "^1.2.0", - "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -23301,18 +24461,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -23350,16 +24510,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", - "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4" + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -23448,6 +24608,15 @@ "uint8arrays": "^5.0.0" } }, + "node_modules/uint8-varint/node_modules/uint8arraylist": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-2.4.9.tgz", + "integrity": "sha512-KxWjyEFzchzik3aoQlK66oaoxIReoMo5bQRm1fcjBUZvE8xv/tyR3CTKhjh6K/faV8VaF6hd5pjr45CzbwuwkA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "uint8arrays": "^5.0.1" + } + }, "node_modules/uint8array-tools": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", @@ -23458,12 +24627,27 @@ } }, "node_modules/uint8arraylist": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-2.4.9.tgz", - "integrity": "sha512-KxWjyEFzchzik3aoQlK66oaoxIReoMo5bQRm1fcjBUZvE8xv/tyR3CTKhjh6K/faV8VaF6hd5pjr45CzbwuwkA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-3.0.2.tgz", + "integrity": "sha512-LDVoq9BQaGJzGDUovEnoX6rpKCvnY/Jbtws4ikwnBzjRbq5qBAFpBZevUEbSmMM87aO0Sp+wOZy2ZXf5yODmXQ==", "license": "Apache-2.0 OR MIT", "dependencies": { - "uint8arrays": "^5.0.1" + "uint8arrays": "^6.0.0" + } + }, + "node_modules/uint8arraylist/node_modules/multiformats": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.5.tgz", + "integrity": "sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/uint8arraylist/node_modules/uint8arrays": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-6.1.1.tgz", + "integrity": "sha512-iz7JN0XCSZYA111lhFG2Ui9EhFvTNekqSRHw3lvMHq+dzwWy1OQftxFQREEh4rffU0oSoXdQHsk2TiHKVm4fsA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^14.0.0" } }, "node_modules/uint8arrays": { @@ -23495,9 +24679,9 @@ } }, "node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -23647,9 +24831,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -23741,9 +24925,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -23822,9 +25006,9 @@ } }, "node_modules/valibot": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", - "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -23837,18 +25021,18 @@ } }, "node_modules/varuint-bitcoin": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz", - "integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.1.tgz", + "integrity": "sha512-TTl5qSPJ3FzjuTHo0Ex9WUE3jTxnUsQHR38vIDhHApoVstzJK4gE20WqmTWOT5oo7ilvOSNejXt4CJrfDcqm5A==", "license": "MIT", "dependencies": { - "uint8array-tools": "^0.0.8" + "uint8array-tools": "^0.0.10" } }, "node_modules/varuint-bitcoin/node_modules/uint8array-tools": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", - "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.10.tgz", + "integrity": "sha512-sSbrZqlr04w4p8KKpHw0ME/R4JzQaadum+BWhMlk2M7LrDyXWTfJOco3d1jNMdQNxgk5DDm88R3IWYH0nR+tzQ==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -23891,16 +25075,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/web-encoding": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", @@ -24096,13 +25270,13 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -24214,9 +25388,9 @@ } }, "node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -24274,10 +25448,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", diff --git a/package.json b/package.json index 01262c4b..8013c327 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@meshsdk/core": "^1.9.0", "@meshsdk/core-csl": "^1.9.0", "@meshsdk/core-cst": "^1.9.0", @@ -74,6 +77,7 @@ "clsx": "^2.1.1", "cors": "^2.8.5", "dexie": "^4.0.11", + "dotenv": "^17.4.2", "formidable": "^3.5.4", "framer-motion": "^11.11.9", "geist": "^1.3.0", @@ -123,6 +127,7 @@ "@types/react-dom": "^19.2.1", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-react": "^5.18.0", + "@types/three": "^0.168.0", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", "eslint": "^9.39.1", diff --git a/prisma/migrations/20260805090000_add_document_signoff/migration.sql b/prisma/migrations/20260805090000_add_document_signoff/migration.sql new file mode 100644 index 00000000..4df95649 --- /dev/null +++ b/prisma/migrations/20260805090000_add_document_signoff/migration.sql @@ -0,0 +1,190 @@ +-- Document Sign-Off (PRD-001) — five-entity model. +-- +-- Approval binds to an exact content hash on a DocumentVersion, never to the +-- mutable Document container. DocumentSignerSnapshot freezes the wallet's +-- signer set + threshold at review start so later membership changes cannot +-- rewrite history. DocumentEvent is append-only and feeds the proof export. + +-- CreateEnum +CREATE TYPE "DocumentStatus" AS ENUM ('Draft', 'InReview', 'Approved', 'Rejected', 'Superseded', 'Archived'); + +-- CreateEnum +CREATE TYPE "DocumentReviewAction" AS ENUM ('approve', 'reject'); + +-- CreateEnum +CREATE TYPE "DocumentStorageMode" AS ENUM ('hashOnly', 'inline', 'external'); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "documentType" TEXT, + "createdBy" TEXT NOT NULL, + "status" "DocumentStatus" NOT NULL DEFAULT 'Draft', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentVersion" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "versionNumber" INTEGER NOT NULL, + "contentHash" TEXT NOT NULL, + "hashAlgorithm" TEXT NOT NULL DEFAULT 'sha256', + "fileName" TEXT, + "mimeType" TEXT, + "fileSize" INTEGER, + "storageMode" "DocumentStorageMode" NOT NULL DEFAULT 'hashOnly', + "contentRef" TEXT, + "contentInline" TEXT, + "reviewInstructions" TEXT, + "status" "DocumentStatus" NOT NULL DEFAULT 'Draft', + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewStartedAt" TIMESTAMP(3), + "decidedAt" TIMESTAMP(3), + "supersededAt" TIMESTAMP(3), + + CONSTRAINT "DocumentVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentReview" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "signerAddress" TEXT NOT NULL, + "action" "DocumentReviewAction" NOT NULL, + "comment" TEXT, + "payload" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "signatureKey" TEXT NOT NULL, + "signedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentReview_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentSignerSnapshot" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "signersAddresses" TEXT[], + "signersDescriptions" TEXT[], + "requiredSigners" INTEGER NOT NULL, + "walletPolicyHash" TEXT NOT NULL, + "capturedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentSignerSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentEvent" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "versionId" TEXT, + "type" TEXT NOT NULL, + "actorAddress" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Document_walletId_idx" ON "Document"("walletId"); + +-- CreateIndex +CREATE INDEX "Document_walletId_status_idx" ON "Document"("walletId", "status"); + +-- CreateIndex +CREATE INDEX "Document_createdBy_idx" ON "Document"("createdBy"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_documentId_idx" ON "DocumentVersion"("documentId"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_contentHash_idx" ON "DocumentVersion"("contentHash"); + +-- CreateIndex +CREATE INDEX "DocumentVersion_status_idx" ON "DocumentVersion"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentVersion_documentId_versionNumber_key" ON "DocumentVersion"("documentId", "versionNumber"); + +-- CreateIndex +CREATE INDEX "DocumentReview_versionId_idx" ON "DocumentReview"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentReview_signerAddress_idx" ON "DocumentReview"("signerAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentReview_versionId_signerAddress_key" ON "DocumentReview"("versionId", "signerAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentSignerSnapshot_versionId_key" ON "DocumentSignerSnapshot"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentSignerSnapshot_walletId_idx" ON "DocumentSignerSnapshot"("walletId"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_documentId_createdAt_idx" ON "DocumentEvent"("documentId", "createdAt"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_versionId_idx" ON "DocumentEvent"("versionId"); + +-- CreateIndex +CREATE INDEX "DocumentEvent_type_idx" ON "DocumentEvent"("type"); + +-- AddForeignKey +ALTER TABLE "DocumentVersion" ADD CONSTRAINT "DocumentVersion_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentSignerSnapshot" ADD CONSTRAINT "DocumentSignerSnapshot_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentEvent" ADD CONSTRAINT "DocumentEvent_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Row Level Security — same contract as 20251215090000_enable_rls_disable_postgrest +-- and 20260706100000_enable_rls_followup_tables: RLS on unconditionally, deny-all +-- policies for the PostgREST roles when those roles exist. Prisma connects as the +-- table owner / service role and continues to bypass RLS. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY[ + 'Document', 'DocumentVersion', 'DocumentReview', + 'DocumentSignerSnapshot', 'DocumentEvent' + ]) + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260813000000_add_proxy_member/migration.sql b/prisma/migrations/20260813000000_add_proxy_member/migration.sql new file mode 100644 index 00000000..0d5c35d1 --- /dev/null +++ b/prisma/migrations/20260813000000_add_proxy_member/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "ProxyMember" ( + "id" TEXT NOT NULL, + "proxyId" TEXT NOT NULL, + "address" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'viewer', + "label" TEXT, + "invitedBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProxyMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ProxyMember_proxyId_idx" ON "ProxyMember"("proxyId"); + +-- CreateIndex +CREATE INDEX "ProxyMember_address_idx" ON "ProxyMember"("address"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProxyMember_proxyId_address_key" ON "ProxyMember"("proxyId", "address"); + +-- Match the RLS posture applied to every other table (see +-- 20251215090000_enable_rls_disable_postgrest): RLS on unconditionally, plus +-- deny-all policies for the PostgREST roles when they exist. Prisma connects +-- with the service role and keeps bypassing RLS. +DO $$ +BEGIN + ALTER TABLE "ProxyMember" ENABLE ROW LEVEL SECURITY; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + CREATE POLICY "deny_all_anon_ProxyMember" ON "ProxyMember" + FOR ALL TO anon USING (false) WITH CHECK (false); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE POLICY "deny_all_authenticated_ProxyMember" ON "ProxyMember" + FOR ALL TO authenticated USING (false) WITH CHECK (false); + END IF; +END $$; diff --git a/prisma/migrations/20260821120000_document_attestation/migration.sql b/prisma/migrations/20260821120000_document_attestation/migration.sql new file mode 100644 index 00000000..adc63736 --- /dev/null +++ b/prisma/migrations/20260821120000_document_attestation/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "DocumentAttestation" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "sequence" INTEGER NOT NULL, + "contentHash" TEXT NOT NULL, + "prevAttestationHash" TEXT NOT NULL, + "attestationHash" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "publicKeyId" TEXT NOT NULL, + "attestedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DocumentAttestation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentAttestation_versionId_key" ON "DocumentAttestation"("versionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentAttestation_attestationHash_key" ON "DocumentAttestation"("attestationHash"); + +-- CreateIndex +CREATE INDEX "DocumentAttestation_documentId_idx" ON "DocumentAttestation"("documentId"); + +-- CreateIndex +CREATE INDEX "DocumentAttestation_contentHash_idx" ON "DocumentAttestation"("contentHash"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentAttestation_documentId_sequence_key" ON "DocumentAttestation"("documentId", "sequence"); + +-- AddForeignKey +ALTER TABLE "DocumentAttestation" ADD CONSTRAINT "DocumentAttestation_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "DocumentVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260822090000_document_draft/migration.sql b/prisma/migrations/20260822090000_document_draft/migration.sql new file mode 100644 index 00000000..b0a2e4b4 --- /dev/null +++ b/prisma/migrations/20260822090000_document_draft/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "DocumentDraft" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "body" TEXT, + "storeBody" BOOLEAN NOT NULL DEFAULT false, + "revision" INTEGER NOT NULL DEFAULT 0, + "updatedBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DocumentDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentDraft_documentId_key" ON "DocumentDraft"("documentId"); + +-- AddForeignKey +ALTER TABLE "DocumentDraft" ADD CONSTRAINT "DocumentDraft_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260822100000_wallet_vault_salt/migration.sql b/prisma/migrations/20260822100000_wallet_vault_salt/migration.sql new file mode 100644 index 00000000..bd9d86eb --- /dev/null +++ b/prisma/migrations/20260822100000_wallet_vault_salt/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Wallet" ADD COLUMN "vaultSalt" TEXT; + diff --git a/prisma/migrations/20260823090000_enable_rls_document_draft_attestation/migration.sql b/prisma/migrations/20260823090000_enable_rls_document_draft_attestation/migration.sql new file mode 100644 index 00000000..d52cfef7 --- /dev/null +++ b/prisma/migrations/20260823090000_enable_rls_document_draft_attestation/migration.sql @@ -0,0 +1,49 @@ +-- Row Level Security for DocumentDraft and DocumentAttestation. +-- +-- Every other table in this schema gets RLS in the migration that creates it — +-- see 20251215090000_enable_rls_disable_postgrest, its follow-up +-- 20260706100000_enable_rls_followup_tables, and the per-table blocks in +-- 20260805090000_add_document_signoff and 20260813000000_add_proxy_member. +-- These two tables were added without it, so they are the only ones in the +-- schema that PostgREST's anon and authenticated roles are not denied on. +-- +-- That matters more for these two than for most: DocumentDraft is the one table +-- in the document stack that holds document BODIES rather than hashes, and +-- DocumentAttestation holds the signed notary chain. +-- +-- Written as a follow-up rather than by editing those migrations, because a +-- migration that any environment has already applied cannot be edited without a +-- checksum failure on the next deploy — and this repo ships migrations through +-- an action that does not self-retry, so a failed deploy blocks every later +-- migration too. +-- +-- Same contract as the migrations above: RLS on unconditionally, deny-all +-- policies for the PostgREST roles only when those roles exist, and Prisma +-- continues to connect as the table owner / service role and bypass RLS. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY['DocumentDraft', 'DocumentAttestation']) + LOOP + -- Skip tables that don't exist + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql b/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql new file mode 100644 index 00000000..c551a40d --- /dev/null +++ b/prisma/migrations/20260823110000_contract_parties_fields_and_roles/migration.sql @@ -0,0 +1,145 @@ +-- CreateEnum +CREATE TYPE "DocumentSigningMode" AS ENUM ('threshold', 'parties'); + +-- CreateEnum +CREATE TYPE "ContractFieldKind" AS ENUM ('signature', 'initials', 'date', 'text', 'checkbox'); + +-- CreateEnum +CREATE TYPE "SignatureMethod" AS ENUM ('cip8Wallet', 'ausweisApp', 'eudiWallet'); + +-- DropIndex +DROP INDEX "DocumentReview_versionId_signerAddress_key"; + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "signingMode" "DocumentSigningMode" NOT NULL DEFAULT 'threshold'; + +-- AlterTable +ALTER TABLE "DocumentReview" ADD COLUMN "method" "SignatureMethod" NOT NULL DEFAULT 'cip8Wallet', +ADD COLUMN "partyId" TEXT; + +-- CreateTable +CREATE TABLE "ContractParty" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "email" TEXT, + "address" TEXT, + "required" BOOLEAN NOT NULL DEFAULT true, + "signingOrder" INTEGER NOT NULL DEFAULT 0, + "inviteTokenHash" TEXT, + "invitedAt" TIMESTAMP(3), + "inviteExpiresAt" TIMESTAMP(3), + "inviteConsumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractParty_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ContractField" ( + "id" TEXT NOT NULL, + "documentId" TEXT NOT NULL, + "partyId" TEXT NOT NULL, + "kind" "ContractFieldKind" NOT NULL, + "label" TEXT, + "anchor" TEXT NOT NULL, + "required" BOOLEAN NOT NULL DEFAULT true, + "value" TEXT, + "filledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ContractField_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractParty_inviteTokenHash_key" ON "ContractParty"("inviteTokenHash"); + +-- CreateIndex +CREATE INDEX "ContractParty_documentId_idx" ON "ContractParty"("documentId"); + +-- CreateIndex +CREATE INDEX "ContractParty_address_idx" ON "ContractParty"("address"); + +-- CreateIndex +CREATE INDEX "ContractParty_inviteExpiresAt_idx" ON "ContractParty"("inviteExpiresAt"); + +-- CreateIndex +CREATE INDEX "ContractField_partyId_idx" ON "ContractField"("partyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractField_documentId_anchor_key" ON "ContractField"("documentId", "anchor"); + +-- CreateIndex +CREATE INDEX "DocumentReview_partyId_idx" ON "DocumentReview"("partyId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DocumentReview_versionId_partyId_key" ON "DocumentReview"("versionId", "partyId"); + +-- AddForeignKey +ALTER TABLE "DocumentReview" ADD CONSTRAINT "DocumentReview_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE NO ACTION ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractParty" ADD CONSTRAINT "ContractParty_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ContractField" ADD CONSTRAINT "ContractField_partyId_fkey" FOREIGN KEY ("partyId") REFERENCES "ContractParty"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Threshold mode keeps its one-action-per-signer guarantee. +-- +-- Dropping DocumentReview_versionId_signerAddress_key above is the direct cost +-- of letting one human hold two roles: a tenant who is also their own guarantor +-- signs the same version twice, once per capacity. But that only applies to +-- party-attributed reviews. For wallet-threshold sign-off — every row where +-- partyId IS NULL — one signer acting twice on one version is still wrong, and +-- the in-transaction "has already acted" check reads rows fetched before the +-- write, so it cannot stop two concurrent submissions on its own. +-- +-- A partial unique index restores exactly the old guarantee, exactly where it +-- still holds. Prisma cannot express `WHERE` on @@unique, so it lives here. +-- +-- KEEP THIS. Migrations in this repo are generated with +-- `prisma migrate diff --from-schema --to-schema `, which compares +-- two schema files and never sees this index, so it will not be dropped by +-- accident — but a diff taken `--from-migrations` would propose removing it. +CREATE UNIQUE INDEX "DocumentReview_versionId_signerAddress_threshold_key" + ON "DocumentReview" ("versionId", "signerAddress") + WHERE "partyId" IS NULL; + +-- Row Level Security — same contract as 20251215090000_enable_rls_disable_postgrest +-- and the per-table block in 20260805090000_add_document_signoff. In this +-- migration rather than a follow-up because ContractParty holds the only +-- identifiable third-party data in the document stack (a counterparty's email), +-- and 20260823090000 is the precedent for what happens when it is forgotten. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY['ContractParty', 'ContractField']) + LOOP + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END IF; + END LOOP; +END $$; diff --git a/prisma/migrations/20260827090000_notification_threshold_ballot_toggles/migration.sql b/prisma/migrations/20260827090000_notification_threshold_ballot_toggles/migration.sql new file mode 100644 index 00000000..4fc8fdb0 --- /dev/null +++ b/prisma/migrations/20260827090000_notification_threshold_ballot_toggles/migration.sql @@ -0,0 +1,7 @@ +-- Two new per-wallet × per-signer email toggles: +-- notifyThresholdReached — a transaction/payload collected enough signatures +-- notifyBallotDeadlines — proposals in a ballot stop accepting votes soon +-- Both default on, matching the existing signature toggles. +ALTER TABLE "WalletSignerNotificationSetting" + ADD COLUMN "notifyThresholdReached" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "notifyBallotDeadlines" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/migrations/20260910090000_add_task_board/migration.sql b/prisma/migrations/20260910090000_add_task_board/migration.sql new file mode 100644 index 00000000..bdfb536a --- /dev/null +++ b/prisma/migrations/20260910090000_add_task_board/migration.sql @@ -0,0 +1,122 @@ +-- Project task board with multisig payouts. +-- +-- Task is the kanban card (status = column). TaskRecipient holds the payment +-- lines in base units. TaskPayout links a task to the pending/submitted +-- Transaction that pays it; Transaction has no relations in this schema, so +-- transactionId is a plain indexed column. + +-- CreateEnum +CREATE TYPE "TaskStatus" AS ENUM ('Backlog', 'InProgress', 'InReview', 'Done'); + +-- CreateEnum +CREATE TYPE "TaskPriority" AS ENUM ('Low', 'Medium', 'High'); + +-- CreateEnum +CREATE TYPE "TaskPayoutStatus" AS ENUM ('Pending', 'Paid', 'Cancelled'); + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "TaskStatus" NOT NULL DEFAULT 'Backlog', + "priority" "TaskPriority", + "assigneeAddress" TEXT, + "dueDate" TIMESTAMP(3), + "position" INTEGER NOT NULL DEFAULT 0, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TaskRecipient" ( + "id" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "address" TEXT NOT NULL, + "unit" TEXT NOT NULL, + "quantity" TEXT NOT NULL, + "label" TEXT, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "TaskRecipient_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TaskPayout" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "transactionId" TEXT NOT NULL, + "status" "TaskPayoutStatus" NOT NULL DEFAULT 'Pending', + "txHash" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "paidAt" TIMESTAMP(3), + + CONSTRAINT "TaskPayout_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Task_walletId_idx" ON "Task"("walletId"); + +-- CreateIndex +CREATE INDEX "Task_walletId_status_position_idx" ON "Task"("walletId", "status", "position"); + +-- CreateIndex +CREATE INDEX "TaskRecipient_taskId_idx" ON "TaskRecipient"("taskId"); + +-- CreateIndex +CREATE INDEX "TaskPayout_walletId_idx" ON "TaskPayout"("walletId"); + +-- CreateIndex +CREATE INDEX "TaskPayout_taskId_idx" ON "TaskPayout"("taskId"); + +-- CreateIndex +CREATE INDEX "TaskPayout_transactionId_idx" ON "TaskPayout"("transactionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TaskPayout_taskId_transactionId_key" ON "TaskPayout"("taskId", "transactionId"); + +-- AddForeignKey +ALTER TABLE "TaskRecipient" ADD CONSTRAINT "TaskRecipient_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskPayout" ADD CONSTRAINT "TaskPayout_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- Row Level Security, in the migration that creates the tables (see +-- 20251215090000_enable_rls_disable_postgrest and the note in +-- 20260823090000_enable_rls_document_draft_attestation): RLS on +-- unconditionally, deny-all policies for the PostgREST roles only when those +-- roles exist; Prisma connects as the table owner and bypasses RLS. +DO $$ +DECLARE + tbl TEXT; +BEGIN + FOR tbl IN + SELECT unnest(ARRAY['Task', 'TaskRecipient', 'TaskPayout']) + LOOP + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = tbl) THEN + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN + EXECUTE format( + 'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)', + tbl, tbl + ); + END IF; + END IF; + END LOOP; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b04f4f22..64d8b518 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,19 @@ model Wallet { profileImageIpfsUrl String? ownerAddress String? + /// Secret used to derive per-document salts for this wallet's vault view. + /// + /// The demo vault at /vault derives salts from the note title, and says in + /// its own comment that this is fine there and nowhere real: a salt that is a + /// public function of the title cannot stop anyone brute-forcing a short + /// document from a guessed title, which is the one thing salts are for here. + /// + /// A real vault needs unguessable salts. One 32-byte secret per wallet gives + /// that with a single column: each node's salt is HMAC(vaultSalt, nodeId), so + /// salts are unguessable without the secret and no per-document storage is + /// needed. Nullable and generated lazily so existing wallets need no backfill. + vaultSalt String? + @@index([ownerAddress]) @@index([signersAddresses(ops: ArrayOps)], type: Gin) } @@ -163,6 +176,24 @@ model Proxy { @@index([userId, isActive]) } +/// In-app access grants for a Proxy. Membership controls who can see and manage +/// a proxy inside the app; it never grants on-chain authority, which stays with +/// the multisig signers holding the auth token. +model ProxyMember { + id String @id @default(cuid()) + proxyId String + address String + role String @default("viewer") + label String? + invitedBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([proxyId, address]) + @@index([proxyId]) + @@index([address]) +} + model BalanceSnapshot { id String @id @default(cuid()) walletId String @@ -221,6 +252,8 @@ model WalletSignerNotificationSetting { emailOptIn Boolean @default(true) notifyTransactionSignatures Boolean @default(true) notifySignableSignatures Boolean @default(true) + notifyThresholdReached Boolean @default(true) + notifyBallotDeadlines Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -245,26 +278,26 @@ model EmailVerificationToken { } model NotificationDelivery { - id String @id @default(cuid()) - eventType String - channel String - recipientAddress String - recipientEmail String? - resourceType String - resourceId String - walletId String? - idempotencyKey String @unique - subject String - payload Json - status String @default("pending") - provider String? - providerMessageId String? - attempts Int @default(0) - lastError String? - nextAttemptAt DateTime @default(now()) - sentAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + eventType String + channel String + recipientAddress String + recipientEmail String? + resourceType String + resourceId String + walletId String? + idempotencyKey String @unique + subject String + payload Json + status String @default("pending") + provider String? + providerMessageId String? + attempts Int @default(0) + lastError String? + nextAttemptAt DateTime @default(now()) + sentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([status, nextAttemptAt]) @@index([recipientAddress]) @@ -390,6 +423,185 @@ model ProposalTally { @@unique([network, proposalId]) } +// --------------------------------------------------------------------------- +// Document Sign-Off (PRD-001) — five-entity model. +// +// A wallet-native, off-chain approval layer. Approval binds to an exact +// content hash, never to the mutable document container, and inherits the +// wallet's signer set + threshold frozen at the moment a review round starts. +// No on-chain dependency in the MVP. +// --------------------------------------------------------------------------- + +enum DocumentStatus { + Draft + InReview + Approved + Rejected + Superseded + Archived +} + +enum DocumentReviewAction { + approve + reject +} + +// How the version's bytes are retained. The MVP never requires the bytes — +// the content hash is the binding — so `hashOnly` is the privacy-preserving +// default (see the vault's "Storage Privacy Risk"). +enum DocumentStorageMode { + hashOnly + inline + external +} + +// The stable container. Title/description may change; approval never attaches +// here, only to a DocumentVersion. +model Document { + id String @id @default(cuid()) + walletId String + title String + description String? + documentType String? + createdBy String // signer address that created it + status DocumentStatus @default(Draft) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + versions DocumentVersion[] + events DocumentEvent[] + draft DocumentDraft? + + /// Which rule decides this document's rounds. Defaulting to `threshold` is + /// what makes this migration a no-op for every document that already exists. + signingMode DocumentSigningMode @default(threshold) + + parties ContractParty[] + contractFields ContractField[] + + @@index([walletId]) + @@index([walletId, status]) + @@index([createdBy]) +} + +// Each version is its own approval object — this is what makes version-bound +// approval and approval-reset-on-new-version possible. +model DocumentVersion { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + versionNumber Int + contentHash String // lowercase hex digest of the exact bytes + hashAlgorithm String @default("sha256") + fileName String? + mimeType String? + fileSize Int? + storageMode DocumentStorageMode @default(hashOnly) + contentRef String? // external URI when storageMode = external + contentInline String? // base64 bytes when storageMode = inline + reviewInstructions String? + status DocumentStatus @default(Draft) + createdBy String + createdAt DateTime @default(now()) + reviewStartedAt DateTime? + decidedAt DateTime? // approved or rejected + supersededAt DateTime? + reviews DocumentReview[] + signerSnapshot DocumentSignerSnapshot? + events DocumentEvent[] + attestation DocumentAttestation? + + @@unique([documentId, versionNumber]) + @@index([documentId]) + @@index([contentHash]) + @@index([status]) +} + +// One signer's action on one version, with the exact payload that was signed. +// Append-only: a signer gets one action per version, and a new version starts +// a fresh round at zero approvals. +model DocumentReview { + id String @id @default(cuid()) + versionId String + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + signerAddress String + action DocumentReviewAction + comment String? + payload String // canonical JSON string that was signed (CIP-8 payload) + signature String // COSE_Sign1 hex + signatureKey String // COSE key hex + signedAt DateTime // client-asserted, server-validated against a window + createdAt DateTime @default(now()) + + /// The party this signature was made as; null in threshold mode, where the + /// signer is a wallet signer and signs in no particular capacity. + /// + /// NoAction, not Prisma's default. The default for an optional relation is + /// SetNull, which would silently strip the capacity off an already-signed, + /// already-exported review on an append-only table. Cascade is worse: it + /// deletes the signature itself, with none of deleteDocument's retype-the- + /// title guard or its pre-delete AuditLog row, and would revert a fully + /// executed contract to InReview on the next recount. NoAction is checked at + /// the end of the statement, so deleting a whole document still cascades + /// cleanly while a party who has signed cannot be deleted on their own. + partyId String? + party ContractParty? @relation(fields: [partyId], references: [id], onDelete: NoAction) + + /// How this signature was produced. Only `cip8Wallet` is written today. + /// + /// This column is not yet part of the signed bytes, and until it is it is an + /// index rather than evidence — see the party-signing work. Nothing but the + /// default is written while parties mode is unimplemented, so there is + /// nothing to misrepresent yet. + method SignatureMethod @default(cip8Wallet) + + /// NOT unique on (versionId, signerAddress) any more: one human holding two + /// roles signs twice on the same version, once per capacity. Threshold mode + /// keeps its one-action-per-signer guarantee through the already-acted check + /// inside submitSignerAction's transaction, which is covered by an + /// integration test rather than by an index that parties mode cannot keep. + /// + /// One review per party per version. NULLs are distinct in Postgres, so every + /// threshold-mode row (partyId null) is unaffected by this. + @@unique([versionId, partyId]) + @@index([versionId]) + @@index([signerAddress]) + @@index([partyId]) +} + +// The signer set and threshold that applied when the round started. Frozen so +// later membership changes never rewrite history. +model DocumentSignerSnapshot { + id String @id @default(cuid()) + versionId String @unique + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + walletId String + signersAddresses String[] + signersDescriptions String[] + requiredSigners Int + walletPolicyHash String // sha256 of the wallet's scriptCbor — binds the round to the policy + capturedAt DateTime @default(now()) + + @@index([walletId]) +} + +// Append-only audit log. Feeds the detail page history and the proof export. +model DocumentEvent { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + versionId String? + version DocumentVersion? @relation(fields: [versionId], references: [id], onDelete: Cascade) + type String // "document.created" | "version.uploaded" | "review.started" | ... + actorAddress String? + metadata Json? + createdAt DateTime @default(now()) + + @@index([documentId, createdAt]) + @@index([versionId]) + @@index([type]) +} + // --------------------------------------------------------------------------- // OAuth 2.1 authorization server // @@ -481,3 +693,321 @@ model OAuthGrant { @@unique([subjectAddress, clientId]) @@index([subjectAddress]) } + +/// Platform attestation over a document version: a timestamp and ordering +/// record, signed by the platform's Ed25519 notary key. +/// +/// Explicitly NOT an approval. Approval is DocumentReview, signed by the +/// wallet's own signers. This row only witnesses that a version existed at a +/// time and in a position, and each row commits to the hash of the previous +/// one, so the history is tamper-evident independently of this table. +model DocumentAttestation { + id String @id @default(cuid()) + versionId String @unique + version DocumentVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + documentId String + /// 1-based position in this document's chain. + sequence Int + contentHash String + /// Link to the previous attestation, or 64 zeroes for the first. + prevAttestationHash String + /// sha256 of the canonical payload — what the NEXT attestation commits to. + attestationHash String @unique + /// The exact canonical JSON that was signed. + payload String + /// Ed25519 signature, lowercase hex. + signature String + /// Which key signed it, so keys can be rotated without reissuing history. + publicKeyId String + attestedAt DateTime + + @@unique([documentId, sequence]) + @@index([documentId]) + @@index([contentHash]) +} + +/// A mutable working copy that sits BEFORE the version model. +/// +/// Document Sign-Off is built on immutability: a signature commits to an exact +/// contentHash, and uploading a new version supersedes the last one and resets +/// approvals to zero. An editor cannot live inside that — autosaving through +/// `uploadVersion` would destroy in-flight approvals on every keystroke batch +/// and append a link to the attestation chain each time. +/// +/// So a draft is deliberately UNSIGNABLE: no contentHash, no attestation, and +/// nothing in the review flow can reach it. Editing touches only this row. +/// Publishing is the one-way door that serialises it into a DocumentVersion, +/// hashes it server-side and attests it. +model DocumentDraft { + id String @id @default(cuid()) + documentId String @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + /// Markdown body. Null while `storeBody` is false — the draft then only + /// records that editing is in progress and no content reaches the server. + body String? + + /// Server-side storage is opt-in PER DOCUMENT. The feature's default posture + /// is hashOnly, where bytes never leave the author's machine; keeping an + /// editable body on the server is a deliberate reversal and must be chosen, + /// never inherited. + storeBody Boolean @default(false) + + /// Optimistic concurrency. A save presents the revision it read; a mismatch + /// is a conflict rather than a silent overwrite, which is what makes several + /// people editing safe without a CRDT or a broker. + revision Int @default(0) + updatedBy String + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) +} + +// --------------------------------------------------------------------------- +// Contracts — named parties on top of Document Sign-Off. +// +// Sign-off is M-of-N over a wallet's own signers. A contract is N-of-N over +// named parties who are usually NOT members of the wallet. The roster lives on +// the Document so it survives a re-issue; everything per-round stays where it +// already is — frozen in DocumentSignerSnapshot, proved by DocumentReview — so +// the two can never drift. +// --------------------------------------------------------------------------- + +/// Which rule decides a version's outcome. +enum DocumentSigningMode { + /// M-of-N over the wallet's signers, read from the Wallet row at startReview. + threshold + /// Every named party signs. startReview builds the snapshot from the parties + /// with requiredSigners = the party count, so `evaluateThreshold` yields + /// N-of-N with no second evaluator and no change to the verifier. + parties +} + +/// What a party is expected to put at an anchor in the body. +enum ContractFieldKind { + signature + initials + date + text + checkbox +} + +/// How a signature was produced. Only `cip8Wallet` is implemented; the others +/// exist now because adding an enum value later is its own migration, and +/// migrations reach production through an action that does not self-retry. +/// +/// Note these describe two different things that will need separating: how the +/// bytes were signed, and how the human was identified. AusweisApp and EUDI are +/// identification protocols — a signature made after an eID check may still be +/// a CIP-8 one. +enum SignatureMethod { + /// CIP-8 COSE_Sign1 from a Cardano wallet, including utxos.dev passkey wallets. + cip8Wallet + /// German eID. Not implemented. + ausweisApp + /// EUDI wallet. Not implemented. + eudiWallet +} + +/// A named party to a contract: who they are, in what capacity they sign, and +/// the invite that lets a non-member of the wallet reach the review at all. +/// +/// Deliberately holds NO per-round state — no status, no viewedAt, no +/// decidedAt. `uploadVersion` and `publishDraft` supersede the version and +/// reset approvals to zero without touching this row, so a per-round column +/// here would survive a reset it has no business surviving: an ordering gate +/// reading a stale "signed" would wave through a countersignature on a version +/// the first party has never seen. Whether a party signed is not an opinion +/// this table stores — it is the existence of a DocumentReview carrying their +/// signature over the canonical payload. +model ContractParty { + id String @id @default(cuid()) + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + /// Free text, because every contract names its parties differently ("Buyer", + /// "Lessee", "Witness"). Copied with displayName into the frozen snapshot's + /// signersDescriptions at startReview — that is how the capacity a signature + /// was made in reaches the exported proof without a proof-format bump. + role String + displayName String + /// Where the invite is sent. The only identifiable third-party data in the + /// document stack, which is why this table gets its RLS block in the same + /// migration rather than a follow-up. + email String? + + /// Null until the party redeems their invite and connects a wallet. A round + /// cannot start while any party is null here: the snapshot freezes addresses + /// and the signed payload binds signerAddress, so a party with no address at + /// startReview could never act on that version. + address String? + + /// Whether this party's signature is needed for the contract to complete. + /// + /// An optional party (a Witness, an observer) may sign or decline without + /// blocking. This is exactly why `evaluateThreshold` cannot decide a + /// contract: it counts approvals anonymously, so an optional party sitting in + /// the snapshot would let one be Approved over a REQUIRED party's explicit + /// rejection. Parties mode uses the party-aware evaluator in payload.ts. + required Boolean @default(true) + + /// Parties with equal values sign in parallel; a party may act only once + /// every party with a strictly lower value has a review on the version being + /// signed. Enforced in submitSignerAction — it is a rule about rows in + /// another table, so no constraint here can express it. + signingOrder Int @default(0) + + /// sha256 of a single-use invite token; the token itself is never stored. + /// This is what makes the feature reachable at all — a party is not in + /// wallet.signersAddresses, so assertWalletAccess rejects them. + inviteTokenHash String? @unique + + invitedAt DateTime? + /// Expiry and consumption, mirroring EmailVerificationToken and + /// BotClaimToken. Without them the invite is a bearer credential valid + /// forever: whoever holds the link last claims the party slot, so a mistyped + /// or later-compromised mailbox could bind a stranger's wallet to "Buyer". + inviteExpiresAt DateTime? + inviteConsumedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + fields ContractField[] + reviews DocumentReview[] + + /// Deliberately NOT unique on (documentId, address): one human may hold two + /// roles — a tenant who is also their own guarantor — so the same wallet can + /// back two parties. That is only safe because the signed payload carries + /// partyId, so two signatures from one address are distinguishable by the + /// capacity they were made in rather than collapsing into one. + @@index([documentId]) + @@index([address]) + @@index([inviteExpiresAt]) +} + +/// A per-party placeholder in the body: which anchor belongs to whom, and what +/// kind of thing goes there. +/// +/// `value` is PRE-PUBLISH input only. A field is a term of the contract, and +/// the whole model rests on a signature binding an exact contentHash — so +/// values must be rendered into the body by publishDraft and hashed with it. A +/// value left live after the freeze would be an unsigned, mutable contract +/// term: change 50000 to 5000 after signing and every signature still verifies. +model ContractField { + id String @id @default(cuid()) + + /// Denormalised from the party so anchor uniqueness can be enforced across + /// the whole body, which spans parties. Two fields on one anchor are + /// unresolvable for the renderer, and ContractField has no other path to the + /// document. Same pattern DocumentAttestation uses for documentId. + documentId String + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + + partyId String + party ContractParty @relation(fields: [partyId], references: [id], onDelete: Cascade) + + kind ContractFieldKind + label String? + /// Stable marker in the body that the renderer resolves. + anchor String + required Boolean @default(true) + + /// Pre-publish input. publishDraft renders these into the markdown body + /// before hashing, so contentHash covers them; nothing may write here once + /// the document's latest version has a snapshot. + value String? + filledAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([documentId, anchor]) + @@index([partyId]) +} + +/// Project task board: a kanban column per TaskStatus. Payout state is not a +/// column — it is derived from the task's recipients and TaskPayout rows (see +/// src/lib/task-payout/state.ts) and shown as a badge. +enum TaskStatus { + Backlog + InProgress + InReview + Done +} + +enum TaskPriority { + Low + Medium + High +} + +/// Lifecycle of one task ↔ transaction link. Pending while the transaction +/// awaits signatures, Paid once it reaches state 1, Cancelled when the pending +/// transaction is deleted or replaced (the task becomes payable again). +enum TaskPayoutStatus { + Pending + Paid + Cancelled +} + +model Task { + id String @id @default(cuid()) + /// Plain column, no relation — same convention as Transaction/Contact/Document. + walletId String + title String + description String? + status TaskStatus @default(Backlog) + priority TaskPriority? + assigneeAddress String? + dueDate DateTime? + /// Order within its column; dense 0..n, renumbered by `task.move`. + position Int @default(0) + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + recipients TaskRecipient[] + payouts TaskPayout[] + + @@index([walletId]) + @@index([walletId, status, position]) +} + +/// One payment line on a task. `quantity` is in BASE units as a BigInt-safe +/// string (lovelace, or the token's raw quantity) — the same convention as +/// TxSpec / AssetQuantity, so a payout spec is built without a display round-trip. +model TaskRecipient { + id String @id @default(cuid()) + taskId String + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + address String + /// "lovelace" or policyId + hex asset name. + unit String + quantity String + label String? + position Int @default(0) + + @@index([taskId]) +} + +/// One row per (task, transaction). Transaction has no relations in this +/// schema, so transactionId is a plain indexed column; the transaction side +/// carries the link too, under the top-level `tasks` key of its txJson. +model TaskPayout { + id String @id @default(cuid()) + walletId String + taskId String + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + transactionId String + status TaskPayoutStatus @default(Pending) + txHash String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + paidAt DateTime? + + @@unique([taskId, transactionId]) + @@index([walletId]) + @@index([taskId]) + @@index([transactionId]) +} diff --git a/public/og-image.png b/public/og-image.png index cd6bf8da..18849f22 100644 Binary files a/public/og-image.png and b/public/og-image.png differ diff --git a/public/og/api-docs.png b/public/og/api-docs.png new file mode 100644 index 00000000..da1f7e87 Binary files /dev/null and b/public/og/api-docs.png differ diff --git a/public/og/blog.png b/public/og/blog.png new file mode 100644 index 00000000..4f77533a Binary files /dev/null and b/public/og/blog.png differ diff --git a/public/og/dapps.png b/public/og/dapps.png new file mode 100644 index 00000000..de864d0e Binary files /dev/null and b/public/og/dapps.png differ diff --git a/public/og/drep.png b/public/og/drep.png new file mode 100644 index 00000000..104c9987 Binary files /dev/null and b/public/og/drep.png differ diff --git a/public/og/features.png b/public/og/features.png new file mode 100644 index 00000000..9854caf1 Binary files /dev/null and b/public/og/features.png differ diff --git a/public/og/governance.png b/public/og/governance.png new file mode 100644 index 00000000..fa0c6134 Binary files /dev/null and b/public/og/governance.png differ diff --git a/public/og/import-wallet.png b/public/og/import-wallet.png new file mode 100644 index 00000000..d7276222 Binary files /dev/null and b/public/og/import-wallet.png differ diff --git a/public/og/roadmap-graph.png b/public/og/roadmap-graph.png new file mode 100644 index 00000000..6888b365 Binary files /dev/null and b/public/og/roadmap-graph.png differ diff --git a/public/og/roadmap.png b/public/og/roadmap.png new file mode 100644 index 00000000..b1500117 Binary files /dev/null and b/public/og/roadmap.png differ diff --git a/public/og/vault.png b/public/og/vault.png new file mode 100644 index 00000000..ec1dda4b Binary files /dev/null and b/public/og/vault.png differ diff --git a/public/og/verify.png b/public/og/verify.png new file mode 100644 index 00000000..8e23d6f5 Binary files /dev/null and b/public/og/verify.png differ diff --git a/scripts/bot-ref/README.md b/scripts/bot-ref/README.md index 56191ce0..c0955c89 100644 --- a/scripts/bot-ref/README.md +++ b/scripts/bot-ref/README.md @@ -178,6 +178,19 @@ cd scripts/bot-ref && npx tsx create-wallet-us.ts Uses the owner’s address from `botMe` and the bot’s address from config. **The bot must have its own wallet and address** (not the same as the owner). Set `paymentAddress` in `bot-config.json` to the bot’s Cardano address, complete register -> claim -> pickup, then run `auth` and this script. +### 12. Task board (read-only for bots) + +```bash +curl -sS -H "Authorization: Bearer $BOT_TOKEN" \ + "http://localhost:3000/api/v1/tasks?walletId=&address=&payable=true" +``` + +Returns `{ tasks, count, payableCount }`: every task with its column, recipients (base +units) and derived `payout` state (`payable`, `blocker`, pending `transactionId`). +Optional `status=Backlog|InProgress|InReview|Done` and `payable=true` filters. Any +granted wallet access is enough (observer included). Writes — `POST /api/v1/taskUpsert` +— are for human wallet JWTs only; bot keys get 403. + ## Cursor agent testing 1. Self-register the bot (`POST /api/v1/botRegister`) and capture `pendingBotId` + `claimCode`. diff --git a/scripts/ci/README.md b/scripts/ci/README.md index b6374fda..855b5a39 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -115,6 +115,7 @@ The manifest currently covers: - route discovery (`walletIds`, `proxies`) - **pending-transactions zero-check** at bootstrap for each wallet type — catches stale state from a previous incomplete run before the ring transfer begins - **public wallet lookup** (`lookupMultisigWallet`) — smoke-tests the unauthenticated on-chain metadata lookup endpoint +- **public script resolution** (`resolveScript`) — per wallet type, resolves the bootstrap multisig address to its signer key hashes and asserts signer 0 is among them (backs lookup-by-policy on the Discover tab) - route health checks (`freeUtxos`, `nativeScript`) — `nativeScript` now asserts a `payment` script entry is present and, when the root type is `atLeast`, that `required` matches `CI_NUM_REQUIRED_SIGNERS` - **wallet creation via API** (`createWallet`) — creates a wallet through the bot-authenticated API path and confirms it appears in `walletIds`; runs early to avoid prior default-bot smoke checks consuming the shared bot rate-limit budget - bot identity (`botAuth` explicit response shape, `botMe`) diff --git a/scripts/ci/scenarios/steps/discovery.ts b/scripts/ci/scenarios/steps/discovery.ts index 74a9b4b8..105e54ae 100644 --- a/scripts/ci/scenarios/steps/discovery.ts +++ b/scripts/ci/scenarios/steps/discovery.ts @@ -136,6 +136,53 @@ function createLookupMultisigWalletStep(ctx: CIBootstrapContext): RouteStep { }; } +function createResolveScriptStep(walletType: string): RouteStep { + return { + id: `v1.resolveScript.walletAddress.${walletType}`, + description: `Smoke-test public /api/v1/resolveScript with the ${walletType} wallet address`, + severity: "non-critical", + execute: async (runCtx) => { + const wallet = getWalletByType(runCtx, walletType); + if (!wallet?.walletAddress) { + throw new Error(`resolveScript: no ${walletType} wallet address in bootstrap context`); + } + const signerAddress = runCtx.signerAddresses[0]; + if (!signerAddress) { + throw new Error("resolveScript: no signer addresses in bootstrap context"); + } + const { resolvePaymentKeyHash } = await import("@meshsdk/core"); + const signerKeyHash = resolvePaymentKeyHash(signerAddress).toLowerCase(); + + const response = await requestJson< + { scriptHash?: string; sigHashes?: string[] } | { error?: string } + >({ + url: `${runCtx.apiBaseUrl}/api/v1/resolveScript?address=${encodeURIComponent(wallet.walletAddress)}&network=${runCtx.networkId}`, + method: "GET", + }); + if (response.status !== 200 || !response.data || typeof response.data !== "object") { + throw new Error( + `resolveScript failed for ${walletType} (${response.status}): ${stringifyRedacted(response.data)}`, + ); + } + const data = response.data as { scriptHash?: string; sigHashes?: string[] }; + if (!Array.isArray(data.sigHashes)) { + throw new Error(`resolveScript returned no sigHashes array for ${walletType}`); + } + // The multisig address's script must name the bootstrap signer 0 — + // otherwise the route resolved the wrong script (or none). + if (!data.sigHashes.includes(signerKeyHash)) { + throw new Error( + `resolveScript: signer 0 key hash not among ${data.sigHashes.length} resolved sig hashes for ${walletType}`, + ); + } + return { + message: `resolveScript resolved ${data.sigHashes.length} signer hashes for the ${walletType} wallet address`, + artifacts: { walletType, scriptHash: data.scriptHash, sigHashCount: data.sigHashes.length }, + }; + }, + }; +} + function createFreeUtxosStep(walletType: string): RouteStep { return { id: `v1.freeUtxos.${walletType}`, @@ -234,6 +281,7 @@ export function createScenarioPendingAndDiscovery(ctx: CIBootstrapContext): Scen ...ctx.walletTypes.map((walletType) => createPendingTransactionsZeroStep(walletType)), ...ctx.walletTypes.map((walletType) => createProxiesListStep(walletType)), createLookupMultisigWalletStep(ctx), + ...ctx.walletTypes.map((walletType) => createResolveScriptStep(walletType)), ], }; } diff --git a/scripts/generate-attestation-key.mjs b/scripts/generate-attestation-key.mjs new file mode 100644 index 00000000..a58d2772 --- /dev/null +++ b/scripts/generate-attestation-key.mjs @@ -0,0 +1,48 @@ +// Generates the Ed25519 keypair used to attest document versions. +// +// Run with: node scripts/generate-attestation-key.mjs +// +// The private half goes in DOCUMENT_ATTESTATION_KEY. The public half is safe to +// publish and is what lets anyone verify a document's attestation chain without +// this app — print it, commit it to a status page, hand it to an auditor. +// +// This key is a NOTARY, not an approver. It signs "this version existed at this +// time, in this position"; it cannot approve a document and cannot witness a +// transaction. See src/lib/documents/attestation.ts for the full threat model. +// +// TO ROTATE: generate a new key, move the OLD public key into +// DOCUMENT_ATTESTATION_PRIOR_PUBLIC_KEYS as {"":""} so +// history signed by it keeps verifying, then set the new private key. +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; + +const { privateKey } = generateKeyPairSync("ed25519"); + +const privateBase64 = privateKey + .export({ type: "pkcs8", format: "der" }) + .toString("base64"); + +const publicKeyHex = createPublicKey(privateKey) + .export({ type: "spki", format: "der" }) + .toString("hex"); + +const keyId = createHash("sha256") + .update(publicKeyHex, "utf8") + .digest("hex") + .slice(0, 16); + +console.log(` +Document attestation keypair +============================ + +key id ${keyId} +public key ${publicKeyHex} + +Set this on the server (Railway variable, .env locally) and keep it secret: + +DOCUMENT_ATTESTATION_KEY=${privateBase64} + +Publish the public key and key id above so attestation chains can be verified +without this app. Rotating? Keep the previous public key verifiable: + +DOCUMENT_ATTESTATION_PRIOR_PUBLIC_KEYS={"":""} +`); diff --git a/scripts/generate-og-image.mjs b/scripts/generate-og-image.mjs index f714e027..67cf6e11 100644 --- a/scripts/generate-og-image.mjs +++ b/scripts/generate-og-image.mjs @@ -1,14 +1,27 @@ -// Generates the static social card at public/og-image.png (1200×630). +// Generates the branded 1200×630 social cards under public/ (see OG_CARDS). // // Run with: node scripts/generate-og-image.mjs // -// We rasterise a hand-written SVG with `sharp` (already a dependency via -// next/image) and composite the white Mesh logo on top. Keeping this as a -// committed script means the card is reproducible and tweakable without any -// design tooling or runtime/edge dependency. +// Why a script and not a runtime/edge route: the cards are pure functions of the +// copy below, so rasterising them once and committing the PNGs keeps social +// previews free of a runtime dependency, an image budget and a cold start — +// while staying reproducible and reviewable in the diff. +// +// Every marketing route gets its OWN card. A shared card means a /governance +// link and a /roadmap link are indistinguishable in a feed; the eyebrow, the +// headline and the accent tint make each share say what it actually links to. +// +// We rasterise hand-written SVG with `sharp` (already a dependency via +// next/image) and composite the white Mesh logo on top. Text is rendered by +// librsvg using system Helvetica/Arial, so regenerate on a machine that has +// them and commit the result — CI does not rebuild these. +// +// Keep in sync with `routeSeo[...].image` in src/lib/seo.ts. The `og cards` +// test asserts every referenced card actually exists on disk. import sharp from "sharp"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { mkdir } from "node:fs/promises"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, ".."); @@ -16,56 +29,279 @@ const root = join(__dirname, ".."); const W = 1200; const H = 630; -const svg = ` +/** Left margin; the headline block is inset further by the accent bar. */ +const MARGIN = 76; +const TEXT_X = 112; +const CONTENT_W = W - TEXT_X - MARGIN; + +/** + * The cards. `headline` and `subhead` are arrays because SVG has no line + * wrapping — authoring the breaks explicitly makes the layout exact instead of + * dependent on a width estimate. + */ +const OG_CARDS = [ + { + // The site-wide default, and the home page. Path kept as /og-image.png so + // links shared before per-route cards existed still resolve. + file: "public/og-image.png", + accent: "#c9c9d6", + eyebrow: "Multi-signature wallet", + headline: ["Cardano treasuries,", "secured by multisig."], + subhead: ["Free, open-source, Cardano-native multisig for teams & DAOs."], + chips: ["Treasury", "Governance", "Collaboration"], + }, + { + file: "public/og/features.png", + accent: "#5fbdd0", + eyebrow: "Features", + headline: ["Everything a Cardano", "team treasury needs."], + subhead: ["M-of-N approvals, signer invites, governance voting, staking."], + chips: ["Approvals", "Invites", "History"], + }, + { + file: "public/og/governance.png", + accent: "#8b7ff0", + eyebrow: "Governance", + headline: ["Vote on Cardano", "governance as a team."], + subhead: ["Browse proposals and cast votes with M-of-N approval."], + chips: ["Proposals", "DRep", "On-chain votes"], + }, + { + file: "public/og/drep.png", + accent: "#7fb2f0", + eyebrow: "DRep explorer", + headline: ["Find the DRep that", "votes like you do."], + subhead: ["Voting records, delegated stake and metadata for every DRep."], + chips: ["Voting records", "Delegated stake"], + }, + { + file: "public/og/roadmap.png", + accent: "#e0a45e", + eyebrow: "Roadmap", + headline: ["Twelve months of", "Mesh Multisig."], + subhead: ["What shipped, what is in progress, and what comes next."], + chips: ["April 2026", "March 2027"], + }, + { + file: "public/og/roadmap-graph.png", + accent: "#e0a45e", + eyebrow: "Feature graph", + headline: ["Every feature, and", "what it connects to."], + subhead: ["An interactive graph of delivered, planned and blocked work."], + chips: ["Delivered", "Planned", "Blocked"], + }, + { + file: "public/og/blog.png", + accent: "#6fc79b", + eyebrow: "Blog", + headline: ["Notes on multisig,", "governance and agents."], + subhead: ["Guides and updates from the team behind Mesh Multisig."], + chips: ["Guides", "Release notes"], + }, + { + file: "public/og/api-docs.png", + accent: "#5fbdd0", + eyebrow: "API & bots", + headline: ["Drive your treasury", "from code."], + subhead: ["OpenAPI-documented REST endpoints for bots and agents."], + chips: ["REST", "OpenAPI", "Bot auth"], + }, + { + file: "public/og/dapps.png", + accent: "#d97fae", + eyebrow: "DApps", + headline: ["Use any Cardano dApp", "in multisig mode."], + subhead: ["Connect Mesh Multisig to the dApps your team already uses."], + chips: ["CIP-30", "Multisig mode"], + }, + { + file: "public/og/import-wallet.png", + accent: "#c9c9d6", + eyebrow: "Import wallet", + headline: ["Bring your multisig", "wallet with you."], + subhead: ["Import from another instance, Summon, raw CBOR or a backup."], + chips: ["Instance", "Summon", "CBOR", "JSON"], + }, + { + file: "public/og/vault.png", + accent: "#6aa8a0", + eyebrow: "Shielded sign-off", + headline: ["Prove one document.", "Reveal nothing else."], + subhead: ["A hash-linked document vault with selective disclosure."], + chips: ["Trust graph", "Selective disclosure"], + }, + { + file: "public/og/verify.png", + accent: "#4f9d69", + eyebrow: "Verify a proof", + headline: ["Check a signed-off", "document yourself."], + subhead: ["Re-hash the file, verify every signature, count the threshold."], + chips: ["No account", "CIP-8", "Offline steps"], + }, +]; + +const FONT = "Helvetica, Arial, sans-serif"; + +/** XML-escape a copy string before it goes into the SVG. */ +const esc = (s) => + String(s).replace(/&/g, "&").replace(//g, ">"); + +/** + * Rough advance-width estimate for Helvetica, calibrated against the rendered + * card. Only used to warn about copy that would overflow — never to lay out. + */ +function estimateWidth( + text, + fontSize, + { bold = false, uppercase = false, letterSpacing = 0 } = {}, +) { + // Calibrated against rendered output: uppercase Helvetica is materially wider + // per glyph than mixed case, and under-measuring it overflows the pill. + const factor = uppercase ? 0.635 : bold ? 0.51 : 0.47; + return text.length * (fontSize * factor + letterSpacing); +} + +function warnIfWide(label, text, width, limit) { + if (width > limit) { + console.warn( + ` ! "${text}" is ~${Math.round(width)}px wide, over the ${limit}px ${label} limit — shorten it.`, + ); + } +} + +function buildSvg(card) { + const { accent, eyebrow, headline, subhead, chips } = card; + + // --- eyebrow pill, right-aligned in the header row ----------------------- + const eyebrowText = eyebrow.toUpperCase(); + const eyebrowSize = 19; + const eyebrowLs = 2.6; + const eyebrowTextW = estimateWidth(eyebrowText, eyebrowSize, { + bold: true, + uppercase: true, + letterSpacing: eyebrowLs, + }); + // 44px lead-in covers the dot, 26px trails the text. + const pillW = Math.round(eyebrowTextW + 44 + 26); + const pillH = 44; + const pillX = W - MARGIN - pillW; + const pillY = 62; + + // --- headline block ------------------------------------------------------ + const headSize = 72; + const headLead = 84; + const firstBaseline = headline.length > 1 ? 300 : 342; + const lastBaseline = firstBaseline + (headline.length - 1) * headLead; + + const barTop = firstBaseline - 60; + const barBottom = lastBaseline + 18; + + const subSize = 27; + const subLead = 38; + const subFirst = lastBaseline + 66; + + headline.forEach((line) => + warnIfWide( + "headline", + line, + estimateWidth(line, headSize, { bold: true, letterSpacing: -1.8 }), + CONTENT_W, + ), + ); + subhead.forEach((line) => + warnIfWide("subhead", line, estimateWidth(line, subSize), CONTENT_W), + ); + + const headlineSvg = headline + .map( + (line, i) => + `${esc(line)}`, + ) + .join("\n "); + + const subheadSvg = subhead + .map( + (line, i) => + `${esc(line)}`, + ) + .join("\n "); + + return ` - - + + - - - - + + + + + + + + + + - - - - + + - - + + + + + + - - Mesh Multisig + + - - Cardano treasuries, - secured by multisig. + + Mesh Multisig - - Free, open-source, Cardano-native multi-signature wallet for teams & DAOs. + + + + ${esc(eyebrowText)} - - - multisig.meshjs.dev - Treasury · Governance · Collaboration + + + + ${headlineSvg} + + ${subheadSvg} + + + + multisig.meshjs.dev + ${esc(chips.join(" · "))} `; +} const logo = await sharp( join(root, "public/logo-mesh/white/logo-mesh-white-512x512.png"), ) - .resize(96, 96, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .resize(84, 84, { + fit: "contain", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) .png() .toBuffer(); -await sharp(Buffer.from(svg)) - .composite([{ input: logo, top: 50, left: 80 }]) - .png() - .toFile(join(root, "public/og-image.png")); +await mkdir(join(root, "public/og"), { recursive: true }); + +for (const card of OG_CARDS) { + console.log(`Rendering ${card.file}`); + await sharp(Buffer.from(buildSvg(card))) + .composite([{ input: logo, top: 54, left: MARGIN }]) + .png({ compressionLevel: 9 }) + .toFile(join(root, card.file)); +} -console.log("Wrote public/og-image.png"); +console.log(`Wrote ${OG_CARDS.length} social cards.`); diff --git a/src/__tests__/ballotDeadlines.test.ts b/src/__tests__/ballotDeadlines.test.ts new file mode 100644 index 00000000..e5f2d8fc --- /dev/null +++ b/src/__tests__/ballotDeadlines.test.ts @@ -0,0 +1,636 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("@/lib/governance/provider", () => ({ + __esModule: true, + getGovernanceProvider: () => null, + providerGet: jest.fn(async () => { + throw new Error("network access not expected in tests"); + }), +})); + +jest.mock("@/utils/multisigSDK", () => ({ + __esModule: true, + addressToNetwork: (address: string) => (address.includes("test") ? 0 : 1), +})); + +jest.mock("@/lib/notifications/worker", () => ({ + __esModule: true, + drainNotificationOutbox: jest.fn(async () => []), +})); + +import { + ballotDeadlineIdempotencyKey, + computeEpochEndMs, + enqueueBallotDeadlineReminders, + selectReminderWindow, + type LatestEpoch, + type ProposalDeadlineDetails, +} from "@/lib/notifications/ballotDeadlines"; +import { + getNotificationPreferenceField, + NOTIFICATION_EVENT_BALLOT_DEADLINE, + NOTIFICATION_STATUS_PENDING, +} from "@/lib/notifications/events"; +import { renderBallotDeadlineEmail } from "@/lib/notifications/templates/ballotDeadline"; +import { extractVoteProposalIds } from "@/lib/notifications/voteProposals"; + +const HOUR = 60 * 60 * 1000; +const PROPOSAL_A = "aa".repeat(32) + "#0"; +const PROPOSAL_B = "bb".repeat(32) + "#1"; + +describe("computeEpochEndMs", () => { + it("adds whole epochs to the latest epoch's end time", () => { + expect( + computeEpochEndMs({ latestEpoch: 500, latestEndTimeSec: 1_000_000, epoch: 500 }), + ).toBe(1_000_000_000); + expect( + computeEpochEndMs({ latestEpoch: 500, latestEndTimeSec: 1_000_000, epoch: 502 }), + ).toBe((1_000_000 + 2 * 432_000) * 1000); + }); +}); + +describe("selectReminderWindow", () => { + it("picks exactly one window and none once the deadline passed", () => { + expect(selectReminderWindow(-1)).toBeNull(); + expect(selectReminderWindow(0)).toBeNull(); + expect(selectReminderWindow(1)).toBe("24h"); + expect(selectReminderWindow(24 * HOUR)).toBe("24h"); + expect(selectReminderWindow(24 * HOUR + 1)).toBe("48h"); + expect(selectReminderWindow(48 * HOUR)).toBe("48h"); + expect(selectReminderWindow(48 * HOUR + 1)).toBeNull(); + }); +}); + +describe("ballotDeadlineIdempotencyKey", () => { + it("scopes reminders per resource, signer, window and expiration epoch", () => { + expect( + ballotDeadlineIdempotencyKey({ + resourceId: "b1", + walletId: "w1", + recipientAddress: "addr", + window: "24h", + expirationEpoch: 510, + }), + ).toBe("ballot.deadline:email:ballot:b1:w1:addr:24h:510"); + expect( + ballotDeadlineIdempotencyKey({ + resourceType: "transaction", + resourceId: "tx1", + walletId: "w1", + recipientAddress: "addr", + window: "48h", + expirationEpoch: 510, + }), + ).toBe("ballot.deadline:email:transaction:tx1:w1:addr:48h:510"); + }); +}); + +describe("getNotificationPreferenceField for ballot.deadline", () => { + it("gates both ballot and transaction keyed rows by notifyBallotDeadlines", () => { + expect(getNotificationPreferenceField(NOTIFICATION_EVENT_BALLOT_DEADLINE, "ballot")).toBe( + "notifyBallotDeadlines", + ); + expect( + getNotificationPreferenceField(NOTIFICATION_EVENT_BALLOT_DEADLINE, "transaction"), + ).toBe("notifyBallotDeadlines"); + expect(getNotificationPreferenceField(NOTIFICATION_EVENT_BALLOT_DEADLINE, "wallet")).toBeNull(); + }); +}); + +describe("extractVoteProposalIds", () => { + it("reads client-built votes from txJson.votes", () => { + const txJson = JSON.stringify({ + votes: [ + { + type: "SimpleScriptVote", + vote: { + voter: { type: "DRep", drepId: "drep1" }, + govActionId: { txHash: "aa".repeat(32), txIndex: 0 }, + votingProcedure: { voteKind: "Yes" }, + }, + }, + { + type: "BasicVote", + vote: { govActionId: { txHash: "bb".repeat(32), txIndex: 1 } }, + }, + ], + }); + expect(extractVoteProposalIds(txJson)).toEqual([PROPOSAL_A, PROPOSAL_B]); + }); + + it("reads bot proxy votes from txJson.proxyBot and dedupes across shapes", () => { + const txJson = { + votes: [{ vote: { govActionId: { txHash: "aa".repeat(32), txIndex: 0 } } }], + proxyBot: { + kind: "proxyVote", + votes: [ + { proposalId: PROPOSAL_A, voteKind: "Yes" }, + { proposalId: PROPOSAL_B, voteKind: "No" }, + ], + }, + }; + expect(extractVoteProposalIds(txJson)).toEqual([PROPOSAL_A, PROPOSAL_B]); + }); + + it("ignores malformed entries, other proxy kinds and unparsable input", () => { + expect( + extractVoteProposalIds({ + votes: [{ vote: { govActionId: { txHash: 123, txIndex: "0" } } }, null], + proxyBot: { kind: "proxyDRepCertificate", votes: [{ proposalId: PROPOSAL_A }] }, + }), + ).toEqual([]); + expect(extractVoteProposalIds({ proxyBot: { kind: "proxyVote", votes: [{ proposalId: "nope" }] } })).toEqual([]); + expect(extractVoteProposalIds("{not json")).toEqual([]); + expect(extractVoteProposalIds(null)).toEqual([]); + }); +}); + +type Scenario = { + ballots?: Array>; + transactions?: Array>; + wallets?: Array>; + settings?: Array>; + votedTx?: Record | null; + proposals?: Record; + latest?: LatestEpoch; +}; + +const NOW = new Date("2026-08-27T12:00:00Z"); +const LATEST: LatestEpoch = { + epoch: 600, + // Latest epoch ends 30h from NOW → a proposal expiring in epoch 600 lands in the 48h window. + end_time: Math.floor(NOW.getTime() / 1000) + 30 * 3600, +}; + +function setting(address: string, overrides: Record = {}) { + return { + walletId: "wallet_1", + signerAddress: address, + email: `${address}@example.com`, + emailNormalized: `${address}@example.com`, + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + notifyThresholdReached: true, + notifyBallotDeadlines: true, + ...overrides, + }; +} + +function voteTxJson(...proposalIds: string[]) { + return JSON.stringify({ + votes: proposalIds.map((id) => { + const [txHash, txIndex] = id.split("#"); + return { vote: { govActionId: { txHash, txIndex: Number(txIndex) } } }; + }), + }); +} + +const DEFAULT_BALLOT = { + id: "ballot_1", + walletId: "wallet_1", + description: "August votes", + items: [PROPOSAL_A, PROPOSAL_B], + itemDescriptions: ["Treasury withdrawal", "Info action"], + createdAt: new Date("2026-08-20T00:00:00Z"), +}; + +function makeScenario(overrides: Scenario = {}) { + const ballots = overrides.ballots ?? [DEFAULT_BALLOT]; + const transactions = overrides.transactions ?? []; + const wallets = overrides.wallets ?? [ + { + id: "wallet_1", + name: "Treasury", + signersAddresses: ["addr_test_one", "addr_test_two"], + numRequiredSigners: 2, + type: "atLeast", + isArchived: false, + }, + ]; + const settings = overrides.settings ?? [setting("addr_test_one")]; + const proposals = overrides.proposals ?? { + [PROPOSAL_A]: { expiration: 600 }, + [PROPOSAL_B]: { expiration: 605 }, + }; + const latest = overrides.latest ?? LATEST; + + const upsert = jest.fn( + async (args: { create: Record }) => args.create, + ); + const fetchLatestEpoch = jest.fn(async (_network: string) => latest); + const fetchProposal = jest.fn( + async (_network: string, txHash: string, certIndex: number) => + proposals[`${txHash}#${certIndex}`] ?? null, + ); + const db = { + ballot: { findMany: jest.fn(async () => ballots) }, + wallet: { findMany: jest.fn(async () => wallets) }, + transaction: { + findMany: jest.fn(async (_args: unknown) => transactions), + findFirst: jest.fn(async (_args: unknown) => overrides.votedTx ?? null), + }, + walletSignerNotificationSetting: { + findMany: jest.fn(async () => settings), + }, + notificationDelivery: { upsert }, + }; + return { db, upsert, fetchLatestEpoch, fetchProposal }; +} + +function createdRows(upsert: ReturnType["upsert"]) { + return upsert.mock.calls.map((call) => call[0].create); +} + +describe("enqueueBallotDeadlineReminders — ballots", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("enqueues a 48h reminder for the earliest expiring active proposal", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario(); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result).toMatchObject({ + ballotsScanned: 1, + ballotsDue: 1, + transactionsScanned: 0, + transactionsDue: 0, + remindersEnqueued: 1, + skipped: 1, + errors: [], + }); + expect(fetchLatestEpoch).toHaveBeenCalledTimes(1); + expect(fetchLatestEpoch).toHaveBeenCalledWith("0"); + expect(fetchProposal).toHaveBeenCalledTimes(2); + expect(upsert).toHaveBeenCalledTimes(2); + + const created = createdRows(upsert); + const eligible = created.find((row) => row.recipientAddress === "addr_test_one")!; + expect(eligible).toMatchObject({ + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "ballot", + resourceId: "ballot_1", + walletId: "wallet_1", + status: NOTIFICATION_STATUS_PENDING, + idempotencyKey: + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_one:48h:600", + subject: "Ballot closes in 48 hours: Treasury", + }); + const payload = eligible.payload as Record; + expect(payload.window).toBe("48h"); + expect(payload.kind).toBe("ballot"); + expect(payload.deadlineEpoch).toBe(600); + // Only the proposal that sets the deadline is listed. + expect(payload.proposals).toEqual([ + { id: PROPOSAL_A, title: "Treasury withdrawal", expirationEpoch: 600 }, + ]); + + const skipped = created.find((row) => row.recipientAddress === "addr_test_two")!; + expect(skipped).toMatchObject({ status: "skipped_no_email", recipientEmail: null }); + }); + + it("uses the 24h window (and never both) inside the final day", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + latest: { epoch: 600, end_time: Math.floor(NOW.getTime() / 1000) + 5 * 3600 }, + }); + + await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(createdRows(upsert).map((row) => row.idempotencyKey)).toEqual([ + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_one:24h:600", + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_two:24h:600", + ]); + }); + + it("does nothing when the deadline is more than 48h away", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + proposals: { + [PROPOSAL_A]: { expiration: 601 }, + [PROPOSAL_B]: { expiration: 605 }, + }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result.ballotsDue).toBe(0); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("ignores proposals that are no longer active or already past expiry", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + proposals: { + [PROPOSAL_A]: { expiration: 600, enacted_epoch: 599 }, + [PROPOSAL_B]: { expiration: 599 }, + }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result.ballotsDue).toBe(0); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("skips ballots whose vote transaction was already submitted", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + votedTx: { id: "tx_voted" }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(db.transaction.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + walletId: "wallet_1", + state: 1, + description: { + in: ["Ballot Vote: August votes", "Proxy Ballot Vote: August votes"], + }, + }), + }), + ); + expect(result).toMatchObject({ ballotsDue: 1, remindersEnqueued: 0, skipped: 1 }); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("skips archived wallets and fetches each proposal once across ballots", async () => { + const { db, upsert, fetchProposal, fetchLatestEpoch } = makeScenario({ + ballots: [ + { ...DEFAULT_BALLOT, id: "ballot_1", items: [PROPOSAL_A], itemDescriptions: ["Shared"] }, + { ...DEFAULT_BALLOT, id: "ballot_2", items: [PROPOSAL_A], itemDescriptions: ["Shared"] }, + { + ...DEFAULT_BALLOT, + id: "ballot_3", + walletId: "wallet_archived", + items: [PROPOSAL_A], + itemDescriptions: ["Shared"], + }, + ], + wallets: [ + { + id: "wallet_1", + name: "Treasury", + signersAddresses: ["addr_test_one"], + numRequiredSigners: 1, + type: "atLeast", + isArchived: false, + }, + ], + proposals: { [PROPOSAL_A]: { expiration: 600 } }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(fetchProposal).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ ballotsScanned: 3, ballotsDue: 2, remindersEnqueued: 2 }); + expect(upsert).toHaveBeenCalledTimes(2); + }); + + it("records provider failures without aborting the scan", async () => { + const { db, upsert, fetchLatestEpoch } = makeScenario(); + const failingFetch = jest.fn( + async ( + _network: string, + _txHash: string, + _certIndex: number, + ): Promise => { + throw new Error("blockfrost down"); + }, + ); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal: failingFetch, + }); + + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toContain("blockfrost down"); + expect(upsert).not.toHaveBeenCalled(); + }); +}); + +describe("enqueueBallotDeadlineReminders — pending vote transactions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const DIRECT_VOTE_TX = { + id: "tx_vote", + walletId: "wallet_1", + txJson: voteTxJson(PROPOSAL_A), + description: "Vote: Yes - Treasury withdrawal", + signedAddresses: ["addr_test_one"], + createdAt: new Date("2026-08-26T00:00:00Z"), + }; + + it("reminds signers about a direct vote that was never added to a ballot", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + ballots: [], + transactions: [DIRECT_VOTE_TX], + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(db.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ state: 0 }), + }), + ); + expect(result).toMatchObject({ + ballotsScanned: 0, + transactionsScanned: 1, + transactionsDue: 1, + remindersEnqueued: 1, + skipped: 1, + }); + expect(fetchProposal).toHaveBeenCalledTimes(1); + + const created = createdRows(upsert); + const eligible = created.find((row) => row.recipientAddress === "addr_test_one")!; + expect(eligible).toMatchObject({ + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "transaction", + resourceId: "tx_vote", + idempotencyKey: + "ballot.deadline:email:transaction:tx_vote:wallet_1:addr_test_one:48h:600", + subject: "Vote closes in 48 hours: Treasury", + }); + const payload = eligible.payload as Record; + expect(payload.kind).toBe("transaction"); + expect(payload.signedCount).toBe(1); + expect(payload.requiredCount).toBe(2); + expect(String(payload.text)).toContain("1 of 2 required signatures collected"); + expect(String(payload.text)).toContain("Review and sign: "); + expect(String(payload.text)).toContain("/wallets/wallet_1/transactions"); + }); + + it("ignores pending transactions without votes and far-off deadlines", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + ballots: [], + transactions: [ + { ...DIRECT_VOTE_TX, id: "tx_plain", txJson: JSON.stringify({ outputs: [] }) }, + { ...DIRECT_VOTE_TX, id: "tx_far", txJson: voteTxJson(PROPOSAL_B) }, + ], + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result).toMatchObject({ + transactionsScanned: 1, + transactionsDue: 0, + remindersEnqueued: 0, + }); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("defers a ballot to the pending vote transaction that covers its expiring proposals", async () => { + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + transactions: [ + { + ...DIRECT_VOTE_TX, + id: "tx_ballot_vote", + txJson: voteTxJson(PROPOSAL_A, PROPOSAL_B), + description: "Ballot Vote: August votes", + }, + ], + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + // One email per signer about this deadline: the transaction's. + expect(result).toMatchObject({ + ballotsDue: 1, + transactionsDue: 1, + remindersEnqueued: 1, + skipped: 2, // ballot deferred + co-signer without email + }); + expect(createdRows(upsert).map((row) => row.idempotencyKey)).toEqual([ + "ballot.deadline:email:transaction:tx_ballot_vote:wallet_1:addr_test_one:48h:600", + "ballot.deadline:email:transaction:tx_ballot_vote:wallet_1:addr_test_two:48h:600", + ]); + expect(db.transaction.findFirst).not.toHaveBeenCalled(); + }); + + it("still reminds about a ballot that is only partially covered by a pending vote", async () => { + const proposalC = "cc".repeat(32) + "#2"; + const { db, upsert, fetchLatestEpoch, fetchProposal } = makeScenario({ + ballots: [ + { + ...DEFAULT_BALLOT, + items: [PROPOSAL_A, proposalC], + itemDescriptions: ["Treasury withdrawal", "Constitution"], + }, + ], + transactions: [DIRECT_VOTE_TX], + proposals: { + [PROPOSAL_A]: { expiration: 600 }, + [proposalC]: { expiration: 600 }, + }, + }); + + const result = await enqueueBallotDeadlineReminders(db as any, { + now: NOW, + fetchLatestEpoch, + fetchProposal, + }); + + expect(result).toMatchObject({ ballotsDue: 1, transactionsDue: 1, remindersEnqueued: 2 }); + const keys = createdRows(upsert) + .filter((row) => row.recipientAddress === "addr_test_one") + .map((row) => row.idempotencyKey); + expect(keys).toEqual([ + "ballot.deadline:email:transaction:tx_vote:wallet_1:addr_test_one:48h:600", + "ballot.deadline:email:ballot:ballot_1:wallet_1:addr_test_one:48h:600", + ]); + }); +}); + +describe("renderBallotDeadlineEmail", () => { + it("lists proposals, the UTC deadline and escapes values", () => { + const template = renderBallotDeadlineEmail({ + walletName: "Treasury & Co", + kind: "ballot", + label: "August", + window: "24h", + deadline: new Date("2026-08-28T12:00:00Z"), + deadlineEpoch: 600, + proposals: [ + { id: PROPOSAL_A, title: "Withdraw", expirationEpoch: 600 }, + { id: "cc".repeat(32) + "#2", title: null, expirationEpoch: 600 }, + ], + actionUrl: "https://example.com/wallets/w/governance", + preferencesUrl: "https://example.com/wallets/w/info", + }); + + expect(template.subject).toBe("Ballot closes in 24 hours: Treasury & Co"); + expect(template.html).toContain("Treasury & Co"); + expect(template.html).toContain("<b>August</b>"); + expect(template.html).toContain("2026-08-28 12:00 UTC (end of epoch 600)"); + expect(template.html).toContain("Withdraw"); + expect(template.text).toContain("- Withdraw (expires epoch 600)"); + expect(template.text).toContain(`- ${"cc".repeat(32)}#2 (expires epoch 600)`); + expect(template.text).toContain("Open governance: https://example.com/wallets/w/governance"); + expect(template.text).toContain("you can ignore this reminder"); + }); + + it("renders the pending-transaction variant with signature progress", () => { + const template = renderBallotDeadlineEmail({ + walletName: "Treasury", + kind: "transaction", + label: "Vote: Yes - Withdraw", + window: "48h", + deadline: new Date("2026-08-29T12:00:00Z"), + deadlineEpoch: 600, + proposals: [{ id: PROPOSAL_A, title: null, expirationEpoch: 600 }], + signedCount: 1, + requiredCount: 3, + actionUrl: "https://example.com/wallets/w/transactions", + preferencesUrl: "https://example.com/wallets/w/info", + }); + + expect(template.subject).toBe("Vote closes in 48 hours: Treasury"); + expect(template.text).toContain('pending vote transaction "Vote: Yes - Withdraw"'); + expect(template.text).toContain("1 of 3 required signatures collected"); + expect(template.text).toContain("Review and sign: https://example.com/wallets/w/transactions"); + expect(template.text).not.toContain("you can ignore this reminder"); + }); +}); diff --git a/src/__tests__/botSetupGuide.test.ts b/src/__tests__/botSetupGuide.test.ts new file mode 100644 index 00000000..0ce34507 --- /dev/null +++ b/src/__tests__/botSetupGuide.test.ts @@ -0,0 +1,107 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import type { + GetServerSidePropsContext, + NextApiRequest, + NextApiResponse, +} from "next"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { SITE_URL } from "@/lib/seo"; +import handler from "@/pages/api/v1/botSetupGuide"; +import BotSetupPage, { getServerSideProps } from "@/pages/bot-setup"; + +function apiResponse(method: string, headers: NextApiRequest["headers"]) { + const res = { + setHeader: jest.fn(), + status: jest.fn(), + send: jest.fn(), + end: jest.fn(), + }; + res.status.mockReturnValue(res); + handler( + { method, headers } as NextApiRequest, + res as unknown as NextApiResponse, + ); + return res; +} + +beforeEach(() => { + // Any accidental server-side request is intercepted, including private targets. + jest + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("untrusted upstream")); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("bot setup guide security boundary", () => { + it.each([ + {}, + { host: new URL(SITE_URL).host, "x-forwarded-proto": "https" }, + { host: "169.254.169.254", "x-forwarded-proto": "http" }, + { host: "[::1]:3000", "x-forwarded-proto": "http" }, + { host: "attacker.example", "x-forwarded-proto": "https" }, + { host: "attacker.example", "x-forwarded-proto": "javascript:alert(1)//" }, + { + host: "trusted.example@attacker.example", + "x-forwarded-proto": ["http", "https"], + }, + ])( + "ignores request headers %j and renders the local guide without fetching", + async (headers) => { + const result = await getServerSideProps({ + req: { headers }, + } as unknown as GetServerSidePropsContext); + expect(globalThis.fetch).not.toHaveBeenCalled(); + if (!("props" in result)) throw new Error("Expected guide props"); + const props = await result.props; + const res = apiResponse("GET", headers); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith(props.markdown); + expect(props.markdown).toContain(`Instance base URL: \`${SITE_URL}\``); + expect(props.markdown).toContain(`POST ${SITE_URL}/api/v1/botRegister`); + expect(props.markdown).toContain(`POST ${SITE_URL}/api/v1/botAuth`); + expect(props.markdown).not.toContain("attacker.example"); + expect(props.markdown).not.toContain("169.254.169.254"); + expect(props.markdown).not.toContain("javascript:"); + + const html = renderToStaticMarkup( + React.createElement(BotSetupPage, props), + ); + expect(html).toContain('href="/api/v1/botSetupGuide"'); + expect(html).toContain("Mesh Multisig Bot Setup Guide"); + expect(html).not.toContain("attacker.example"); + expect(html).not.toContain("javascript:"); + }, + ); + + it("preserves the raw API response headers", () => { + const res = apiResponse("GET", {}); + expect(res.setHeader).toHaveBeenCalledWith( + "Content-Type", + "text/markdown; charset=utf-8", + ); + expect(res.setHeader).toHaveBeenCalledWith( + "Cache-Control", + "public, max-age=300", + ); + }); + + it("continues to reject non-GET API requests", () => { + const res = apiResponse("POST", {}); + expect(res.status).toHaveBeenCalledWith(405); + expect(res.setHeader).toHaveBeenCalledWith("Allow", "GET"); + expect(res.end).toHaveBeenCalled(); + expect(res.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/cip146Registration.test.ts b/src/__tests__/cip146Registration.test.ts index 4f8f6186..f9865571 100644 --- a/src/__tests__/cip146Registration.test.ts +++ b/src/__tests__/cip146Registration.test.ts @@ -5,6 +5,7 @@ import { chunkMetadataString, isRegistrationUpToDate, joinMetadataString, + participantsInclude, participantsMatchExactly, truncateMetadataString, } from "../utils/cip146Registration"; @@ -143,6 +144,32 @@ describe("participantsMatchExactly", () => { }); }); +describe("participantsInclude", () => { + const item = { + tx_hash: "1".repeat(64), + json_metadata: { + types: [0], + participants: { + [hashA.toUpperCase()]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + }, + }, + }; + + it("accepts subsets and the full set, case-insensitively", () => { + expect(participantsInclude(item, [hashA])).toBe(true); + expect(participantsInclude(item, [hashA.toUpperCase(), hashB])).toBe(true); + }); + + it("rejects supersets, empty queries and items without participants", () => { + expect(participantsInclude(item, [hashA, hashB, hashC])).toBe(false); + expect(participantsInclude(item, [])).toBe(false); + expect(participantsInclude({ tx_hash: "2".repeat(64) }, [hashA])).toBe( + false, + ); + }); +}); + describe("isRegistrationUpToDate", () => { it("recognizes matching on-chain metadata, including chunked strings", () => { const wallet = makeWallet("A Very Long Wallet Name", "desc"); diff --git a/src/__tests__/contractOutcome.test.ts b/src/__tests__/contractOutcome.test.ts new file mode 100644 index 00000000..573e1083 --- /dev/null +++ b/src/__tests__/contractOutcome.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + buildSignOffPayload, + canonicalizeSignOffPayload, + evaluateContractOutcome, + evaluateThreshold, +} from "@/lib/documents/payload"; + +/** + * The contract outcome rule, and why it cannot be `evaluateThreshold`. + * + * A threshold counts approvals anonymously. A contract is a set of named + * obligations — so the two differ precisely where it matters: an optional + * party's refusal must not sink the agreement, and a required party's refusal + * must, no matter how many other signatures exist. + */ + +const P = (id: string, required = true) => ({ id, required }); +const R = (partyId: string | null, action: "approve" | "reject") => ({ + partyId, + action, +}); + +describe("evaluateContractOutcome", () => { + it("approves once every required party has approved", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve"), R("seller", "approve")], + }), + ).toBe("Approved"); + }); + + it("stays in review while a required party has not acted", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve")], + }), + ).toBe("InReview"); + }); + + it("is rejected the moment any required party rejects", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller")], + reviews: [R("buyer", "approve"), R("seller", "reject")], + }), + ).toBe("Rejected"); + }); + + it("ignores an optional party's refusal", () => { + // The case a threshold cannot express. A Witness declining is not the + // contract failing. + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [ + R("buyer", "approve"), + R("seller", "approve"), + R("witness", "reject"), + ], + }), + ).toBe("Approved"); + }); + + it("does not let an optional party's approval stand in for a required one", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [R("buyer", "approve"), R("witness", "approve")], + }), + ).toBe("InReview"); + }); + + it("completes without the optional party ever acting", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("witness", false)], + reviews: [R("buyer", "approve")], + }), + ).toBe("Approved"); + }); + + it("refuses to call a party set with nothing required decided", () => { + // "Every required party approved" is vacuously true over an empty set, and + // approving a contract nobody had to sign is the worst possible default. + expect( + evaluateContractOutcome({ + parties: [P("witness", false)], + reviews: [R("witness", "approve")], + }), + ).toBe("InReview"); + expect(evaluateContractOutcome({ parties: [], reviews: [] })).toBe( + "InReview", + ); + }); + + it("ignores reviews that carry no party", () => { + expect( + evaluateContractOutcome({ + parties: [P("buyer")], + reviews: [R(null, "approve")], + }), + ).toBe("InReview"); + }); + + it("counts one human holding two roles as two obligations", () => { + // Both parties may resolve to the same wallet address; the rule never sees + // addresses, only capacities, which is what makes dual roles safe. + expect( + evaluateContractOutcome({ + parties: [P("tenant"), P("guarantor")], + reviews: [R("tenant", "approve")], + }), + ).toBe("InReview"); + expect( + evaluateContractOutcome({ + parties: [P("tenant"), P("guarantor")], + reviews: [R("tenant", "approve"), R("guarantor", "approve")], + }), + ).toBe("Approved"); + }); + + it("differs from evaluateThreshold on the case that matters", () => { + // Same facts, both rules. Two approvals out of three signers with a + // required party having rejected: the anonymous count says Approved. + expect( + evaluateThreshold({ + approvals: 2, + rejections: 1, + signerCount: 3, + requiredSigners: 2, + }), + ).toBe("Approved"); + + expect( + evaluateContractOutcome({ + parties: [P("buyer"), P("seller"), P("witness", false)], + reviews: [ + R("buyer", "approve"), + R("witness", "approve"), + R("seller", "reject"), + ], + }), + ).toBe("Rejected"); + }); +}); + +describe("partyId in the signed bytes", () => { + const base = { + action: "approve" as const, + contentHash: "a".repeat(64), + documentId: "d", + signedAt: "2026-01-01T00:00:00.000Z", + signerAddress: "addr", + versionId: "v", + versionNumber: 1, + walletId: "w", + walletPolicyHash: "p", + }; + + it("leaves threshold bytes byte-identical to before contracts existed", () => { + // The reason this needs no SIGNOFF_DOMAIN bump: canonicalize drops + // undefined keys, so every proof already issued keeps verifying. + const threshold = canonicalizeSignOffPayload(buildSignOffPayload(base)); + expect(threshold).not.toContain("partyId"); + expect( + canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: null }), + ), + ).toBe(threshold); + }); + + it("binds the capacity when there is one", () => { + const parties = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_buyer" }), + ); + expect(parties).toContain('"partyId":"cp_buyer"'); + // Sorted like every other key — canonical form is not insertion order. + expect(parties.indexOf('"documentId"')).toBeLessThan( + parties.indexOf('"partyId"'), + ); + }); + + it("gives two roles of one human different bytes to sign", () => { + const asTenant = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_tenant" }), + ); + const asGuarantor = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, partyId: "cp_guarantor" }), + ); + // Same address, same version, same action — and not interchangeable. This + // is what stops one signature counting twice. + expect(asTenant).not.toBe(asGuarantor); + }); +}); diff --git a/src/__tests__/createPendingMultisigTransaction.test.ts b/src/__tests__/createPendingMultisigTransaction.test.ts index 0b583409..cc0433ee 100644 --- a/src/__tests__/createPendingMultisigTransaction.test.ts +++ b/src/__tests__/createPendingMultisigTransaction.test.ts @@ -2,18 +2,37 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals import type { PrismaClient } from "@prisma/client"; const submitTxMock = jest.fn<(txCbor: string) => Promise>(); +const enqueueMock = jest + .fn<(...args: unknown[]) => Promise>() + .mockResolvedValue([]); jest.mock("@/utils/get-provider", () => ({ __esModule: true, getProvider: () => ({ submitTx: submitTxMock }), })); +jest.mock("@/lib/notifications/center", () => ({ + __esModule: true, + enqueueSignatureRequiredNotifications: enqueueMock, +})); + let createPendingMultisigTransaction: typeof import("@/lib/server/createPendingMultisigTransaction").createPendingMultisigTransaction; function makeDb() { return { transaction: { - create: jest.fn<() => Promise<{ id: string }>>().mockResolvedValue({ id: "tx-1" }), + create: jest + .fn<() => Promise<{ id: string; signedAddresses: string[]; rejectedAddresses: string[] }>>() + .mockResolvedValue({ id: "tx-1", signedAddresses: [], rejectedAddresses: [] }), + }, + wallet: { + findUnique: jest.fn<() => Promise>().mockResolvedValue({ + id: "wallet-1", + name: "Treasury", + signersAddresses: ["addr_test_proposer", "addr_test_other"], + numRequiredSigners: 2, + type: "atLeast", + }), }, } as unknown as PrismaClient; } @@ -83,6 +102,85 @@ describe("createPendingMultisigTransaction", () => { expect(submitTxMock).not.toHaveBeenCalled(); }); + it("treats the proposer as the notification creator by default", async () => { + await createPendingMultisigTransaction(makeDb(), baseArgs); + + expect(enqueueMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ creatorAddress: "addr_test_proposer" }), + ); + }); + + it("can notify the proposer too when they have not signed (MCP drafts)", async () => { + // The recipient resolver skips the creator on the assumption they signed + // at creation. An MCP draft's proposer has not, so the caller opts out. + await createPendingMultisigTransaction(makeDb(), { + ...baseArgs, + initialSignedAddresses: [], + notificationCreatorAddress: null, + }); + + expect(enqueueMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ creatorAddress: null, signedAddresses: [] }), + ); + }); + + it("runs an after-create hook inside the insert's database transaction", async () => { + // Task payouts link tasks to the row here; both must exist or neither. + const db = makeDb(); + const txClient = { + transaction: { + create: jest + .fn<() => Promise<{ id: string; signedAddresses: string[]; rejectedAddresses: string[] }>>() + .mockResolvedValue({ id: "tx-1", signedAddresses: [], rejectedAddresses: [] }), + }, + }; + (db as unknown as { $transaction: unknown }).$transaction = jest + .fn<(fn: (tx: unknown) => Promise) => Promise>() + .mockImplementation((fn) => fn(txClient)); + const afterCreate = jest.fn<(...args: unknown[]) => Promise>().mockResolvedValue(undefined); + + const result = await createPendingMultisigTransaction(db, { + ...baseArgs, + initialSignedAddresses: [], + afterCreate, + }); + + expect((db as unknown as { $transaction: jest.Mock }).$transaction).toHaveBeenCalledTimes(1); + expect(txClient.transaction.create).toHaveBeenCalledTimes(1); + expect(db.transaction.create).not.toHaveBeenCalled(); + expect(afterCreate).toHaveBeenCalledWith(txClient, expect.objectContaining({ id: "tx-1" })); + expect(result).toMatchObject({ id: "tx-1" }); + // Notifications still go out afterwards, on the real client. + expect(enqueueMock).toHaveBeenCalledTimes(1); + }); + + it("propagates a hook failure and sends no notification", async () => { + const db = makeDb(); + (db as unknown as { $transaction: unknown }).$transaction = jest + .fn<(fn: (tx: unknown) => Promise) => Promise>() + .mockImplementation((fn) => + fn({ + transaction: { + create: jest.fn<() => Promise>().mockResolvedValue({ id: "tx-1", signedAddresses: [], rejectedAddresses: [] }), + }, + }), + ); + const boom = new Error("tasks changed"); + + await expect( + createPendingMultisigTransaction(db, { + ...baseArgs, + initialSignedAddresses: [], + afterCreate: async () => { + throw boom; + }, + }), + ).rejects.toBe(boom); + expect(enqueueMock).not.toHaveBeenCalled(); + }); + it("submits single-signer transactions without creating a pending row", async () => { const db = makeDb(); diff --git a/src/__tests__/discoverQuery.test.ts b/src/__tests__/discoverQuery.test.ts new file mode 100644 index 00000000..f71b8498 --- /dev/null +++ b/src/__tests__/discoverQuery.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@jest/globals"; +import { + deserializeAddress, + pubKeyAddress, + resolvePaymentKeyHash, + resolveStakeKeyHash, + serializeAddressObj, + serializeNativeScript, + type NativeScript, +} from "@meshsdk/core"; + +import { + classifyDiscoverInput, + describeDiscoverQuery, +} from "../utils/discoverQuery"; +import { participantsInclude } from "../utils/cip146Registration"; +import { + externalStakeCredential, + mockAddresses, + mockKeyHashes, + realTestAddresses, +} from "./testUtils"; + +const hashA = mockKeyHashes.payment1; +const hashB = mockKeyHashes.payment2; + +const script: NativeScript = { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: hashA }, + { type: "sig", keyHash: hashB }, + ], +}; + +describe("classifyDiscoverInput", () => { + it("treats blank input as the default (own keys) query", () => { + expect(classifyDiscoverInput("", 0)).toEqual({ kind: "empty" }); + expect(classifyDiscoverInput(" \n", 0)).toEqual({ kind: "empty" }); + }); + + it("classifies a bare 56-hex hash as ambiguous and lowercases it", () => { + expect(classifyDiscoverInput(` ${hashA.toUpperCase()} `, 0)).toEqual({ + kind: "hash", + hash: hashA, + }); + }); + + it("classifies a base address as a signer query with payment + stake hashes", () => { + const address = realTestAddresses.address1; + const result = classifyDiscoverInput(address, 0); + expect(result).toEqual({ + kind: "signer", + keyHashes: [ + resolvePaymentKeyHash(address).toLowerCase(), + deserializeAddress(address).stakeCredentialHash.toLowerCase(), + ], + }); + }); + + it("classifies an enterprise address as a payment-hash-only signer query", () => { + const enterprise = serializeAddressObj(pubKeyAddress(hashA), 0); + expect(classifyDiscoverInput(enterprise, 0)).toEqual({ + kind: "signer", + keyHashes: [hashA], + }); + }); + + it("classifies a stake address as a signer query by stake hash", () => { + expect(classifyDiscoverInput(externalStakeCredential, 0)).toEqual({ + kind: "signer", + keyHashes: [resolveStakeKeyHash(externalStakeCredential).toLowerCase()], + }); + }); + + it("classifies a multisig (script) address as a policy query", () => { + const { address, scriptCbor } = serializeNativeScript( + script, + undefined, + 0, + true, + ); + expect(scriptCbor).toBeTruthy(); + const scriptHash = deserializeAddress(address).scriptHash.toLowerCase(); + expect(scriptHash).toMatch(/^[0-9a-f]{56}$/); + expect(classifyDiscoverInput(address, 0)).toEqual({ + kind: "policy", + scriptHash, + address, + }); + }); + + it("rejects addresses from the other network", () => { + expect(classifyDiscoverInput(mockAddresses.mainnet, 0)).toEqual({ + kind: "invalid", + reason: "wrong-network", + }); + expect(classifyDiscoverInput(realTestAddresses.address1, 1)).toEqual({ + kind: "invalid", + reason: "wrong-network", + }); + expect(classifyDiscoverInput(externalStakeCredential, 1)).toEqual({ + kind: "invalid", + reason: "wrong-network", + }); + }); + + it("rejects malformed input", () => { + expect(classifyDiscoverInput("hello", 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + expect(classifyDiscoverInput("addr_test1notreallyanaddress", 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + expect(classifyDiscoverInput("stake_test1nope", 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + // 55 hex chars — one short of a hash, not an address either + expect(classifyDiscoverInput("a".repeat(55), 0)).toEqual({ + kind: "invalid", + reason: "malformed", + }); + }); +}); + +describe("describeDiscoverQuery", () => { + it("labels each query kind for result copy", () => { + expect(describeDiscoverQuery({ kind: "empty" })).toBe("your keys"); + expect(describeDiscoverQuery({ kind: "signer", keyHashes: [hashA] })).toBe( + "this signer", + ); + expect( + describeDiscoverQuery({ kind: "policy", scriptHash: hashA }), + ).toBe("this wallet"); + expect(describeDiscoverQuery({ kind: "hash", hash: hashA })).toBe( + "this hash", + ); + }); +}); + +describe("participantsInclude", () => { + const item = { + tx_hash: "1".repeat(64), + json_metadata: { + types: [0, 2], + participants: { + [hashA.toUpperCase()]: { name: "Alice" }, + [hashB]: { name: "Bob" }, + [mockKeyHashes.stake1]: { name: "Alice stake" }, + }, + }, + }; + + it("accepts a script whose signers are all participants (subset)", () => { + expect(participantsInclude(item, [hashA, hashB])).toBe(true); + expect(participantsInclude(item, [hashA.toUpperCase()])).toBe(true); + }); + + it("rejects a script with a signer the registration does not list", () => { + expect(participantsInclude(item, [hashA, mockKeyHashes.drep1])).toBe(false); + }); + + it("rejects empty queries and items without participants", () => { + expect(participantsInclude(item, [])).toBe(false); + expect(participantsInclude({ tx_hash: "2".repeat(64) }, [hashA])).toBe( + false, + ); + }); +}); diff --git a/src/__tests__/documentAttestation.test.ts b/src/__tests__/documentAttestation.test.ts new file mode 100644 index 00000000..01cd6330 --- /dev/null +++ b/src/__tests__/documentAttestation.test.ts @@ -0,0 +1,280 @@ +import { generateKeyPairSync } from "crypto"; +import { describe, expect, it } from "@jest/globals"; + +import { + ATTESTATION_DOMAIN, + ATTESTATION_STATEMENT, + GENESIS_PREV, + attestationHash, + attestationKeyId, + buildAttestationPayload, + signAttestation, + verifyAttestation, + verifyAttestationChain, + type AttestationRecord, +} from "@/lib/documents/attestation"; + +/** + * The platform attestation is a timestamp and ordering record, so the only + * properties worth testing are the ones an auditor would otherwise have to + * trust the database for: that a chain verifies when it is honest, and that + * every way of altering history is detected. + */ + +function keypair() { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const spkiHex = publicKey + .export({ type: "spki", format: "der" }) + .toString("hex"); + return { privateKey, spkiHex, keyId: attestationKeyId(spkiHex) }; +} + +const KEY = keypair(); +const KEYS = { [KEY.keyId]: KEY.spkiHex }; + +const DOC = "doc_abc"; +const WALLET = "wallet_1"; + +/** A chain of `n` versions, honestly signed and correctly linked. */ +function chain(n: number, key = KEY): AttestationRecord[] { + const out: AttestationRecord[] = []; + let prev = GENESIS_PREV; + for (let i = 1; i <= n; i++) { + const payload = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, i, 12)), + contentHash: String(i).repeat(64).slice(0, 64), + documentId: DOC, + prevAttestationHash: prev, + sequence: i, + versionId: `ver_${i}`, + versionNumber: i, + walletId: WALLET, + }); + out.push({ + payload, + signature: signAttestation(payload, key.privateKey), + publicKeyId: key.keyId, + }); + prev = attestationHash(payload); + } + return out; +} + +describe("a single attestation", () => { + it("verifies under the key that signed it", () => { + const [record] = chain(1); + expect( + verifyAttestation(record!.payload, record!.signature, KEY.spkiHex), + ).toBe(true); + }); + + it("says in its own bytes that it is not an approval", () => { + const [record] = chain(1); + expect(record!.payload.statement).toBe(ATTESTATION_STATEMENT); + expect(record!.payload.statement).toMatch(/not an approval/i); + expect(record!.payload.statement).toMatch(/grants no authority/i); + expect(record!.payload.domain).toBe(ATTESTATION_DOMAIN); + }); + + it("does not verify under a different key", () => { + const other = keypair(); + const [record] = chain(1); + expect( + verifyAttestation(record!.payload, record!.signature, other.spkiHex), + ).toBe(false); + }); + + it("does not verify once any signed field is changed", () => { + const [record] = chain(1); + for (const mutate of [ + { contentHash: "b".repeat(64) }, + { versionNumber: 99 }, + { attestedAt: "2020-01-01T00:00:00.000Z" }, + { walletId: "wallet_2" }, + { statement: "I approve this document." }, + ]) { + expect( + verifyAttestation( + { ...record!.payload, ...mutate }, + record!.signature, + KEY.spkiHex, + ), + ).toBe(false); + } + }); + + it("rejects a malformed signature rather than throwing", () => { + const [record] = chain(1); + for (const bad of ["", "zz", "ff", "f".repeat(127), "g".repeat(128)]) { + expect(verifyAttestation(record!.payload, bad, KEY.spkiHex)).toBe(false); + } + }); + + it("rejects a malformed public key rather than throwing", () => { + const [record] = chain(1); + expect( + verifyAttestation(record!.payload, record!.signature, "not-a-key"), + ).toBe(false); + }); +}); + +describe("the chain", () => { + it("verifies an honest history and reports its head", () => { + const records = chain(3); + const result = verifyAttestationChain(records, KEYS); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + expect(result.head).toBe(attestationHash(records[2]!.payload)); + }); + + it("starts from genesis", () => { + expect(chain(1)[0]!.payload.prevAttestationHash).toBe(GENESIS_PREV); + }); + + it("refuses an empty chain", () => { + expect(verifyAttestationChain([], KEYS).ok).toBe(false); + }); + + it("detects a reordered history", () => { + const [a, b, c] = chain(3); + const swapped = [a!, c!, b!]; + const result = verifyAttestationChain(swapped, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/does not link|expected sequence/); + }); + + it("detects a removed version", () => { + const [a, , c] = chain(3); + const result = verifyAttestationChain([a!, c!], KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/does not link|expected sequence/); + }); + + it("detects an altered version, even re-signed by the real key", () => { + // The strongest case: an attacker who HAS the key rewrites history in + // place. The link from the next attestation still commits to the original, + // so the tampering surfaces anyway. + const records = chain(3); + const rewritten = { + ...records[1]!.payload, + contentHash: "e".repeat(64), + }; + records[1] = { + payload: rewritten, + signature: signAttestation(rewritten, KEY.privateKey), + publicKeyId: KEY.keyId, + }; + const result = verifyAttestationChain(records, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/does not link/); + }); + + it("detects a back-dated attestation", () => { + const records = chain(3); + const backdated = { + ...records[2]!.payload, + attestedAt: "2000-01-01T00:00:00.000Z", + }; + records[2] = { + payload: backdated, + signature: signAttestation(backdated, KEY.privateKey), + publicKeyId: KEY.keyId, + }; + const result = verifyAttestationChain(records, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/attested before/); + }); + + it("refuses an attestation signed by a key it does not know", () => { + const rogue = keypair(); + const records = chain(2, rogue); + const result = verifyAttestationChain(records, KEYS); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/unknown key/); + }); + + it("refuses a forged attestation appended without the key", () => { + const records = chain(2); + const forgedPayload = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, 3, 12)), + contentHash: "c".repeat(64), + documentId: DOC, + prevAttestationHash: attestationHash(records[1]!.payload), + sequence: 3, + versionId: "ver_3", + versionNumber: 3, + walletId: WALLET, + }); + // Correctly linked, correctly sequenced — and unsigned by anyone who could. + const result = verifyAttestationChain( + [ + ...records, + { + payload: forgedPayload, + signature: "0".repeat(128), + publicKeyId: KEY.keyId, + }, + ], + KEYS, + ); + expect(result.ok).toBe(false); + expect(result.errors.join(" ")).toMatch(/signature does not verify/); + }); + + it("refuses a chain that splices in another document's attestation", () => { + const mine = chain(1); + const theirs = chain(1); + const spliced = [ + mine[0]!, + { + ...theirs[0]!, + payload: { ...theirs[0]!.payload, documentId: "doc_other" }, + }, + ]; + expect(verifyAttestationChain(spliced, KEYS).ok).toBe(false); + }); + + it("accepts a rotated key when both keys are known", () => { + const older = KEY; + const newer = keypair(); + + const first = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, 1, 12)), + contentHash: "a".repeat(64), + documentId: DOC, + prevAttestationHash: GENESIS_PREV, + sequence: 1, + versionId: "ver_1", + versionNumber: 1, + walletId: WALLET, + }); + const second = buildAttestationPayload({ + attestedAt: new Date(Date.UTC(2026, 0, 2, 12)), + contentHash: "b".repeat(64), + documentId: DOC, + prevAttestationHash: attestationHash(first), + sequence: 2, + versionId: "ver_2", + versionNumber: 2, + walletId: WALLET, + }); + + const result = verifyAttestationChain( + [ + { + payload: first, + signature: signAttestation(first, older.privateKey), + publicKeyId: older.keyId, + }, + { + payload: second, + signature: signAttestation(second, newer.privateKey), + publicKeyId: newer.keyId, + }, + ], + { ...KEYS, [newer.keyId]: newer.spkiHex }, + ); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + }); +}); diff --git a/src/__tests__/documentProofPdf.test.ts b/src/__tests__/documentProofPdf.test.ts new file mode 100644 index 00000000..0ad15afb --- /dev/null +++ b/src/__tests__/documentProofPdf.test.ts @@ -0,0 +1,301 @@ +/** + * The PDF half of the sign-off proof export (PRD-001). + * + * A PDF is easy to get subtly wrong in ways no assertion about "did it throw" + * would catch: a byte offset in the cross-reference table that points at the + * wrong object, or a /Length that disagrees with the stream it describes, and + * the file opens in one reader and fails in another. So these tests parse the + * bytes back out and check the structural invariants a reader relies on, plus + * the one property that makes a proof artifact worth anything — that the + * evidence in the package actually reached the page. + */ + +import { describe, expect, it } from "@jest/globals"; + +import { + buildProofPdf, + proofPdfFileName, + toWinAnsi, +} from "@/lib/documents/proof-pdf"; +import { + PROOF_FORMAT, + VERIFICATION_INSTRUCTIONS, + type ProofPackage, + type ProofReview, +} from "@/lib/documents/proof"; + +const CONTENT_HASH = + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; +const POLICY_HASH = + "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"; +/** Long enough to wrap, like the real thing. */ +const SIGNATURE = "84584da3012704".repeat(40); + +function makeReview(overrides: Partial = {}): ProofReview { + return { + signerAddress: "addr_test1qzsigneraaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + signerDescription: "Alice", + action: "approve", + comment: "Matches the board minutes.", + payload: `{"action":"approve","contentHash":"${CONTENT_HASH}","documentId":"doc_1"}`, + signature: SIGNATURE, + signatureKey: "a4010103272006215820" + "ab".repeat(32), + signedAt: "2026-08-05T09:00:00.000Z", + ...overrides, + }; +} + +function makeProof(overrides: Partial = {}): ProofPackage { + return { + format: PROOF_FORMAT, + exportedAt: "2026-08-05T10:00:00.000Z", + document: { + id: "doc_1", + walletId: "wallet_1", + title: "Q3 Treasury Budget", + description: "The quarterly allocation, for signature.", + documentType: "budget", + createdBy: "addr_test1qzcreator", + createdAt: "2026-08-01T00:00:00.000Z", + }, + version: { + id: "ver_1", + versionNumber: 2, + contentHash: CONTENT_HASH, + hashAlgorithm: "sha256", + fileName: "budget.pdf", + mimeType: "application/pdf", + fileSize: 20480, + status: "Approved", + createdBy: "addr_test1qzcreator", + createdAt: "2026-08-02T00:00:00.000Z", + reviewStartedAt: "2026-08-03T00:00:00.000Z", + decidedAt: "2026-08-05T09:00:00.000Z", + }, + policy: { + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + requiredSigners: 2, + signersAddresses: [ + "addr_test1qzsigneraaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "addr_test1qzsignerbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "addr_test1qzsignerccccccccccccccccccccccccccccccccccccccc", + ], + signersDescriptions: ["Alice", "Bob", "Carol"], + capturedAt: "2026-08-03T00:00:00.000Z", + }, + reviews: [makeReview()], + events: [ + { + type: "document.created", + actorAddress: "addr_test1qzcreator", + createdAt: "2026-08-01T00:00:00.000Z", + metadata: null, + }, + { + type: "review.started", + actorAddress: "addr_test1qzcreator", + createdAt: "2026-08-03T00:00:00.000Z", + metadata: null, + }, + ], + verification: { + domain: "mesh-multisig.document-signoff.v1", + instructions: VERIFICATION_INSTRUCTIONS, + }, + ...overrides, + }; +} + +/** The bytes as a string where one character is one byte, as the file is written. */ +function asLatin1(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("latin1"); +} + +/** Every string the content streams actually draw, in order. */ +function drawnText(pdf: string): string[] { + return [...pdf.matchAll(/\((.*?)\) Tj/g)].map((m) => + (m[1] ?? "").replace(/\\([\\()])/g, "$1"), + ); +} + +describe("buildProofPdf", () => { + it("writes a file a reader can parse: header, xref, trailer", () => { + const pdf = asLatin1(buildProofPdf(makeProof())); + + expect(pdf.startsWith("%PDF-1.4\n")).toBe(true); + expect(pdf.endsWith("%%EOF\n")).toBe(true); + + const startxref = /startxref\n(\d+)\n%%EOF/.exec(pdf); + expect(startxref).not.toBeNull(); + const xrefOffset = Number(startxref![1]); + expect(pdf.slice(xrefOffset, xrefOffset + 4)).toBe("xref"); + + expect(pdf).toContain("/Type /Catalog"); + expect(pdf).toContain("/Root 1 0 R"); + }); + + it("points every cross-reference entry at the object it claims", () => { + const pdf = asLatin1(buildProofPdf(makeProof())); + const xrefOffset = Number(/startxref\n(\d+)\n/.exec(pdf)![1]); + const table = pdf.slice(xrefOffset); + + const header = /^xref\n0 (\d+)\n/.exec(table); + expect(header).not.toBeNull(); + const count = Number(header![1]); + + const entries = [...table.matchAll(/^(\d{10}) \d{5} [nf] $/gm)]; + expect(entries).toHaveLength(count); + + // Entry 0 is the free head; 1..n must each land on " 0 obj". + entries.slice(1).forEach((entry, index) => { + const offset = Number(entry[1]); + expect(pdf.startsWith(`${index + 1} 0 obj`, offset)).toBe(true); + }); + }); + + it("declares a /Length that matches the stream it describes", () => { + const pdf = asLatin1(buildProofPdf(makeProof())); + const streams = [ + ...pdf.matchAll(/<< \/Length (\d+) >>\nstream\n([\s\S]*?)\nendstream/g), + ]; + + expect(streams.length).toBeGreaterThan(0); + for (const [, declared, body] of streams) { + expect(body!.length).toBe(Number(declared)); + } + }); + + it("counts its pages the same way in /Count, /Kids and the page objects", () => { + // Enough signatures, each carrying a wrapping signature, to overflow a page. + const proof = makeProof({ + reviews: [ + makeReview({ signerDescription: "Alice" }), + makeReview({ signerDescription: "Bob", action: "reject" }), + makeReview({ signerDescription: "Carol" }), + ], + }); + const pdf = asLatin1(buildProofPdf(proof)); + + const count = Number(/\/Type \/Pages \/Kids \[(.*?)\] \/Count (\d+)/.exec(pdf)![2]); + const kids = /\/Kids \[(.*?)\]/.exec(pdf)![1]!.trim().split(" 0 R").filter(Boolean); + const pageObjects = [...pdf.matchAll(/\/Type \/Page /g)]; + + expect(count).toBeGreaterThan(1); + expect(kids).toHaveLength(count); + expect(pageObjects).toHaveLength(count); + expect(pdf).toContain(`Page ${count} of ${count}`); + }); + + it("carries the evidence: hash, signers, signature and instructions", () => { + const proof = makeProof(); + const drawn = drawnText(asLatin1(buildProofPdf(proof))); + const joined = drawn.join(""); + + // The hash fits one line, so it must survive verbatim. + expect(drawn).toContain(CONTENT_HASH); + expect(drawn).toContain(POLICY_HASH); + // The signature wraps, so it is only whole once the lines are rejoined. + expect(joined).toContain(SIGNATURE); + for (const address of proof.policy.signersAddresses) { + expect(drawn).toContain(address); + } + expect(joined).toContain(proof.reviews[0]!.payload); + expect(joined).toContain("2 of 3 signers"); + // The disclaimer that keeps this an attestation rather than a signature. + expect(joined).toContain("not a qualified electronic signature"); + }); + + it("says the JSON is the authoritative artifact, and where to check it", () => { + const joined = drawnText( + asLatin1( + buildProofPdf(makeProof(), { + verifyUrl: "https://multisig.meshjs.dev/verify", + }), + ), + ).join(" "); + + expect(joined).toContain("authoritative"); + expect(joined).toContain("https://multisig.meshjs.dev/verify"); + }); + + it("prints a verification result when one is supplied, including a failure", () => { + const failed = drawnText( + asLatin1( + buildProofPdf(makeProof(), { + verification: { + valid: false, + format: PROOF_FORMAT, + contentHashMatches: false, + approvals: 1, + rejections: 0, + requiredSigners: 2, + thresholdReached: false, + reviews: [], + errors: ["The supplied document does not hash to the approved content hash"], + }, + }), + ), + ).join(" "); + + expect(failed).toContain("did NOT verify"); + expect(failed).toContain("does NOT hash to the approved content hash"); + }); + + it("is deterministic — the same package always produces the same bytes", () => { + const first = buildProofPdf(makeProof()); + const second = buildProofPdf(makeProof()); + expect(Buffer.from(second).equals(Buffer.from(first))).toBe(true); + }); + + it("survives text the base-14 fonts cannot encode", () => { + const proof = makeProof({ + document: { + ...makeProof().document, + // Latin-1 must survive; the emoji cannot, and must not corrupt the file. + title: "Übergabe – Q3 💸", + }, + }); + const bytes = buildProofPdf(proof); + const pdf = asLatin1(bytes); + + expect(drawnText(pdf).join(" ")).toContain("Übergabe - Q3 ?"); + // Every byte is a byte: the declared stream lengths still hold. + for (const [, declared, body] of pdf.matchAll( + /<< \/Length (\d+) >>\nstream\n([\s\S]*?)\nendstream/g, + )) { + expect(body!.length).toBe(Number(declared)); + } + expect(bytes.every((b) => b <= 0xff)).toBe(true); + }); + + it("escapes the delimiters that would otherwise end a PDF string early", () => { + const proof = makeProof({ + reviews: [makeReview({ comment: "Approved (with a caveat) \\ see annex" })], + }); + const pdf = asLatin1(buildProofPdf(proof)); + + expect(pdf).toContain("\\(with a caveat\\)"); + expect(drawnText(pdf).join(" ")).toContain( + "Approved (with a caveat) \\ see annex", + ); + }); + + it("names the file the way the JSON export does", () => { + expect(proofPdfFileName(makeProof())).toBe("signoff-proof-doc_1-v2.pdf"); + }); +}); + +describe("toWinAnsi", () => { + it("keeps Latin-1, folds typographic punctuation, replaces the rest", () => { + expect(toWinAnsi("Grüße")).toBe("Grüße"); + expect(toWinAnsi("“quoted” — done…")).toBe( + '"quoted" - done...', + ); + expect(toWinAnsi("日本語")).toBe("???"); + }); + + it("flattens newlines and tabs, which would break a PDF string", () => { + expect(toWinAnsi("one\ntwo\tthree")).toBe("one two three"); + }); +}); diff --git a/src/__tests__/documentSignoff.test.ts b/src/__tests__/documentSignoff.test.ts new file mode 100644 index 00000000..32bf6389 --- /dev/null +++ b/src/__tests__/documentSignoff.test.ts @@ -0,0 +1,446 @@ +/** + * Document Sign-Off (PRD-001) — payload binding, threshold, and proof verification. + * + * These cover the two rules the feature stands on: a signature is bound to one + * exact document version, and the threshold comes from the frozen signer + * snapshot. Both are enforced server-side, so both are tested server-side. + */ + +import { + SIGNOFF_DOMAIN, + SIGNOFF_STATEMENTS, + buildSignOffPayload, + canonicalize, + canonicalizeSignOffPayload, + evaluateThreshold, + isSha256Hex, + isSignedAtWithinTolerance, + sha256Hex, + walletPolicyHash, +} from "@/lib/documents/payload"; +import { + PROOF_FORMAT, + VERIFICATION_INSTRUCTIONS, + parseProofPackage, + proofPackageProblem, + verifyProofPackage, + type ProofPackage, + type ProofReview, +} from "@/lib/documents/proof"; + +const SIGNER_A = "addr_test1_signer_a"; +const SIGNER_B = "addr_test1_signer_b"; +const SIGNER_C = "addr_test1_signer_c"; +const OUTSIDER = "addr_test1_outsider"; + +const CONTENT_HASH = sha256Hex("the budget, version 1"); +const OTHER_HASH = sha256Hex("the budget, version 2"); +const POLICY_HASH = walletPolicyHash("8200581c-script-cbor"); +const SIGNED_AT = "2026-08-05T09:00:00.000Z"; + +/** Accepts anything — isolates the non-signature checks. */ +const acceptAll = async () => true; +const rejectAll = async () => false; + +function makeReview( + signerAddress: string, + action: "approve" | "reject" = "approve", + overrides: Partial<{ contentHash: string; versionId: string; comment: string }> = {}, +): ProofReview { + const payload = buildSignOffPayload({ + action, + comment: overrides.comment, + contentHash: overrides.contentHash ?? CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress, + versionId: overrides.versionId ?? "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + return { + signerAddress, + action, + comment: overrides.comment ?? null, + payload: canonicalizeSignOffPayload(payload), + signature: "cose_sign1_hex", + signatureKey: "cose_key_hex", + signedAt: SIGNED_AT, + }; +} + +function makeProof(reviews: ProofReview[], requiredSigners = 2): ProofPackage { + return { + format: PROOF_FORMAT, + exportedAt: "2026-08-05T10:00:00.000Z", + document: { + id: "doc_1", + walletId: "wallet_1", + title: "Q3 Treasury Budget", + description: null, + documentType: null, + createdBy: SIGNER_A, + createdAt: "2026-08-01T00:00:00.000Z", + }, + version: { + id: "ver_1", + versionNumber: 1, + contentHash: CONTENT_HASH, + hashAlgorithm: "sha256", + fileName: "budget.pdf", + mimeType: "application/pdf", + fileSize: 1024, + status: "Approved", + createdBy: SIGNER_A, + createdAt: "2026-08-01T00:00:00.000Z", + reviewStartedAt: "2026-08-02T00:00:00.000Z", + decidedAt: "2026-08-05T09:00:00.000Z", + }, + policy: { + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + requiredSigners, + signersAddresses: [SIGNER_A, SIGNER_B, SIGNER_C], + signersDescriptions: ["Alice", "Bob", "Carol"], + capturedAt: "2026-08-02T00:00:00.000Z", + }, + reviews, + events: [], + verification: { + domain: SIGNOFF_DOMAIN, + instructions: VERIFICATION_INSTRUCTIONS, + }, + }; +} + +// --------------------------------------------------------------------------- + +describe("canonicalization", () => { + it("is independent of key insertion order", () => { + expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 })); + }); + + it("produces no incidental whitespace", () => { + expect(canonicalize({ a: 1, b: "x" })).toBe('{"a":1,"b":"x"}'); + }); + + it("drops undefined but keeps null", () => { + expect(canonicalize({ a: undefined, b: null })).toBe('{"b":null}'); + }); + + it("recurses into nested objects and arrays", () => { + expect(canonicalize({ z: [{ b: 1, a: 2 }] })).toBe('{"z":[{"a":2,"b":1}]}'); + }); +}); + +describe("buildSignOffPayload", () => { + it("carries the plain-language statement that matches the action", () => { + const approve = buildSignOffPayload({ + action: "approve", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + expect(approve.statement).toBe(SIGNOFF_STATEMENTS.approve); + expect(approve.statement).toMatch(/I approve this exact document version/); + expect(approve.domain).toBe(SIGNOFF_DOMAIN); + }); + + it("always includes comment, so an empty comment is still signed", () => { + const payload = buildSignOffPayload({ + action: "reject", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }); + expect(payload.comment).toBe(""); + expect(canonicalizeSignOffPayload(payload)).toContain('"comment":""'); + }); + + it("rejects an unparseable signedAt rather than silently stamping now()", () => { + expect(() => + buildSignOffPayload({ + action: "approve", + contentHash: CONTENT_HASH, + documentId: "doc_1", + signedAt: "not-a-date", + signerAddress: SIGNER_A, + versionId: "ver_1", + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }), + ).toThrow(/not a valid date/i); + }); +}); + +describe("version-hash binding", () => { + const base = { + action: "approve" as const, + documentId: "doc_1", + signedAt: SIGNED_AT, + signerAddress: SIGNER_A, + versionNumber: 1, + walletId: "wallet_1", + walletPolicyHash: POLICY_HASH, + }; + + it("produces a different payload for a different content hash", () => { + const v1 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const v2 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: OTHER_HASH, versionId: "ver_1" }), + ); + expect(v1).not.toBe(v2); + }); + + it("produces a different payload for a different version id", () => { + const v1 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const v2 = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_2" }), + ); + expect(v1).not.toBe(v2); + }); + + it("a tampered comment changes the payload, so the signature no longer matches", () => { + const clean = canonicalizeSignOffPayload( + buildSignOffPayload({ ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }), + ); + const tampered = canonicalizeSignOffPayload( + buildSignOffPayload({ + ...base, + contentHash: CONTENT_HASH, + versionId: "ver_1", + comment: "actually I meant no", + }), + ); + expect(clean).not.toBe(tampered); + }); + + it("rebuilding from identical inputs is byte-identical — the server-side check", () => { + const input = { ...base, contentHash: CONTENT_HASH, versionId: "ver_1" }; + expect(canonicalizeSignOffPayload(buildSignOffPayload(input))).toBe( + canonicalizeSignOffPayload(buildSignOffPayload(input)), + ); + }); +}); + +describe("hash + time helpers", () => { + it("recognises a sha256 digest and rejects near-misses", () => { + expect(isSha256Hex(CONTENT_HASH)).toBe(true); + expect(isSha256Hex(CONTENT_HASH.toUpperCase())).toBe(false); + expect(isSha256Hex(CONTENT_HASH.slice(0, 63))).toBe(false); + expect(isSha256Hex("")).toBe(false); + }); + + it("accepts a signedAt inside the window and rejects one outside it", () => { + const now = new Date("2026-08-05T09:00:00.000Z"); + expect(isSignedAtWithinTolerance("2026-08-05T09:05:00.000Z", now)).toBe(true); + expect(isSignedAtWithinTolerance("2026-08-05T08:45:00.000Z", now)).toBe(false); + expect(isSignedAtWithinTolerance("nonsense", now)).toBe(false); + }); +}); + +describe("evaluateThreshold", () => { + it("approves once the threshold is met", () => { + expect( + evaluateThreshold({ approvals: 2, rejections: 0, signerCount: 3, requiredSigners: 2 }), + ).toBe("Approved"); + }); + + it("stays open while the threshold is still reachable", () => { + expect( + evaluateThreshold({ approvals: 1, rejections: 1, signerCount: 3, requiredSigners: 2 }), + ).toBe("InReview"); + }); + + it("rejects as soon as the threshold has become unreachable", () => { + expect( + evaluateThreshold({ approvals: 1, rejections: 2, signerCount: 3, requiredSigners: 2 }), + ).toBe("Rejected"); + }); + + it("handles unanimous policies", () => { + expect( + evaluateThreshold({ approvals: 2, rejections: 1, signerCount: 3, requiredSigners: 3 }), + ).toBe("Rejected"); + expect( + evaluateThreshold({ approvals: 3, rejections: 0, signerCount: 3, requiredSigners: 3 }), + ).toBe("Approved"); + }); +}); + +describe("verifyProofPackage", () => { + it("accepts a well-formed, fully signed, threshold-reaching package", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(true); + expect(result.approvals).toBe(2); + expect(result.thresholdReached).toBe(true); + expect(result.reviews.every((r) => r.valid)).toBe(true); + }); + + it("confirms a re-hashed document against the approved content hash", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const ok = await verifyProofPackage(proof, { + checkSignature: acceptAll, + expectedContentHash: CONTENT_HASH, + }); + expect(ok.contentHashMatches).toBe(true); + expect(ok.valid).toBe(true); + + const wrong = await verifyProofPackage(proof, { + checkSignature: acceptAll, + expectedContentHash: OTHER_HASH, + }); + expect(wrong.contentHashMatches).toBe(false); + expect(wrong.valid).toBe(false); + expect(wrong.errors.join(" ")).toMatch(/does not hash to the approved content hash/i); + }); + + it("fails when a signature does not verify", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: rejectAll }); + expect(result.valid).toBe(false); + expect(result.approvals).toBe(0); + expect(result.reviews[0]?.signatureValid).toBe(false); + }); + + it("fails when a review's payload names a different version's hash", async () => { + const proof = makeProof([ + makeReview(SIGNER_A), + makeReview(SIGNER_B, "approve", { contentHash: OTHER_HASH }), + ]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.payloadBindsToVersion).toBe(false); + expect(result.reviews[1]?.errors.join(" ")).toMatch(/payload\.contentHash/); + }); + + it("rejects a signer who is not in the frozen snapshot", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(OUTSIDER)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.signerInSnapshot).toBe(false); + expect(result.approvals).toBe(1); + }); + + it("rejects a duplicated signer rather than counting them twice", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_A)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[1]?.errors.join(" ")).toMatch(/duplicate/i); + expect(result.approvals).toBe(1); + }); + + it("rejects a payload that is not in canonical form", async () => { + const review = makeReview(SIGNER_A); + const reordered = JSON.stringify(JSON.parse(review.payload), null, 2); + const proof = makeProof([{ ...review, payload: reordered }, makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.reviews[0]?.errors.join(" ")).toMatch(/canonical/i); + }); + + it("reports not-yet-approved when the threshold is unmet", async () => { + const proof = makeProof([makeReview(SIGNER_A)]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.thresholdReached).toBe(false); + expect(result.valid).toBe(false); + expect(result.approvals).toBe(1); + expect(result.requiredSigners).toBe(2); + }); + + it("counts rejections separately and does not credit them as approvals", async () => { + const proof = makeProof([ + makeReview(SIGNER_A, "approve"), + makeReview(SIGNER_B, "reject"), + ]); + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.approvals).toBe(1); + expect(result.rejections).toBe(1); + expect(result.thresholdReached).toBe(false); + }); + + it("flags an unknown proof format", async () => { + const proof = { ...makeProof([makeReview(SIGNER_A)]), format: "something-else" } as unknown as ProofPackage; + const result = await verifyProofPackage(proof, { checkSignature: acceptAll }); + expect(result.valid).toBe(false); + expect(result.errors.join(" ")).toMatch(/unknown proof format/i); + }); + + it("survives a signature checker that throws", async () => { + const proof = makeProof([makeReview(SIGNER_A), makeReview(SIGNER_B)]); + const result = await verifyProofPackage(proof, { + checkSignature: async () => { + throw new Error("cbor decode failed"); + }, + }); + expect(result.valid).toBe(false); + expect(result.reviews[0]?.errors.join(" ")).toMatch(/cbor decode failed/); + }); +}); + +/** + * The gate in front of the verifier. A counterparty pasting the wrong file into + * the public verifier is the common case, not the exception — the answer has to + * say which wrong thing it was, not just "invalid". + */ +describe("parseProofPackage", () => { + it("accepts a proof package", () => { + const proof = makeProof([makeReview(SIGNER_A)]); + const result = parseProofPackage(JSON.stringify(proof)); + expect(result.ok).toBe(true); + expect(result.ok && result.proof.version.contentHash).toBe(CONTENT_HASH); + }); + + it("separates 'not JSON' from 'not a proof'", () => { + expect(parseProofPackage("{nope")).toEqual({ + ok: false, + error: "That is not valid JSON.", + }); + const notAProof = parseProofPackage('{"hello":"world"}'); + expect(notAProof.ok).toBe(false); + expect(!notAProof.ok && notAProof.error).toMatch(/no version or policy/i); + }); + + it("names the format it does not understand", () => { + const foreign = JSON.stringify({ + ...makeProof([makeReview(SIGNER_A)]), + format: "acme.signoff.v9", + }); + const result = parseProofPackage(foreign); + expect(result.ok).toBe(false); + expect(!result.ok && result.error).toContain("acme.signoff.v9"); + expect(!result.ok && result.error).toContain(PROOF_FORMAT); + }); + + it("leaves an unfamiliar format to the verifier, which reports on it", () => { + // The paste box refuses it; verification still runs and says why, so a + // counterparty holding a newer package learns whether the signatures hold. + const foreign = { ...makeProof([makeReview(SIGNER_A)]), format: "acme.v9" }; + expect(proofPackageProblem(foreign)).toBeNull(); + }); + + it("rejects the values JSON.parse will happily return", () => { + for (const value of [null, 42, "a string", true]) { + expect(proofPackageProblem(value)).not.toBeNull(); + } + // An array is an object, so it has to fail on the missing fields instead. + expect(proofPackageProblem([])).toMatch(/no version or policy/i); + }); +}); diff --git a/src/__tests__/documentsVaultPreview.test.ts b/src/__tests__/documentsVaultPreview.test.ts new file mode 100644 index 00000000..d5312bd3 --- /dev/null +++ b/src/__tests__/documentsVaultPreview.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; + +import type { VaultTrustView } from "@/lib/vault-trust-types"; + +type Props = { props: { view: VaultTrustView | null } }; + +const MODULE = "@/lib/documents/vault-preview"; + +/** + * The preview embeds the demo vault inside a wallet page, which puts a + * filesystem read and a hash graph on the request path of a treasury surface. + * Two things must hold: the props Next hands the client are actually + * serialisable, and a vault that fails to build hides the panel instead of + * taking the page down. + */ +describe("documents vault preview page", () => { + afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + }); + + it("supplies a view that survives Next's JSON serialisation", async () => { + const { loadVaultPreviewProps } = await import(MODULE); + const result = { props: loadVaultPreviewProps() } as Props; + + expect(result.props.view).not.toBeNull(); + expect(result.props.view!.hubs.length).toBeGreaterThan(0); + expect(result.props.view!.notes.length).toBeGreaterThan(0); + expect(result.props.view!.rootHash).toMatch(/^[0-9a-f]{64}$/); + + // getServerSideProps props are JSON-serialised. A Map, Set, Date or + // undefined in there is a runtime "Error serializing" on the deployed page + // that no type check catches, so assert the round trip is lossless. + const roundTripped: unknown = JSON.parse(JSON.stringify(result.props.view)); + expect(roundTripped).toEqual(result.props.view); + }); + + it("hides the panel instead of 500-ing when the vault will not build", async () => { + jest.resetModules(); + jest.doMock("@/lib/vault-trust", () => ({ + loadVaultTrustView: () => { + throw new Error("vault trust graph: Trust cycle: A -> B -> A"); + }, + })); + const errors = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + const { loadVaultPreviewProps } = await import(MODULE); + const result = { props: loadVaultPreviewProps() } as Props; + + // Degraded, not thrown: a Markdown edit in an unrelated directory must not + // be able to take down a wallet's Documents section. + expect(result.props.view).toBeNull(); + expect(errors).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/draftSync.test.ts b/src/__tests__/draftSync.test.ts new file mode 100644 index 00000000..b688b5b6 --- /dev/null +++ b/src/__tests__/draftSync.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "@jest/globals"; + +import { decideDraftSync } from "@/lib/documents/draft-sync"; + +/** + * The failure mode this guards against is silent data loss, so every branch is + * pinned — especially the two that would destroy work: adopting the server's + * copy over unsaved edits, and rolling an author backwards on a stale poll. + */ +describe("decideDraftSync", () => { + it("adopts on first load", () => { + expect( + decideDraftSync({ baseRevision: null, dirty: false, remoteRevision: 3 }), + ).toBe("adopt"); + }); + + it("adopts on first load even mid-typing — there is no base to conflict with", () => { + expect( + decideDraftSync({ baseRevision: null, dirty: true, remoteRevision: 3 }), + ).toBe("adopt"); + }); + + it("does nothing when there is no draft on the server", () => { + expect( + decideDraftSync({ + baseRevision: null, + dirty: false, + remoteRevision: null, + }), + ).toBe("ignore"); + }); + + it("follows a newer revision while nothing is unsaved", () => { + expect( + decideDraftSync({ baseRevision: 4, dirty: false, remoteRevision: 5 }), + ).toBe("adopt"); + }); + + it("refuses to overwrite unsaved edits", () => { + expect( + decideDraftSync({ baseRevision: 4, dirty: true, remoteRevision: 5 }), + ).toBe("conflict"); + }); + + it("ignores its own revision echoed back", () => { + expect( + decideDraftSync({ baseRevision: 5, dirty: false, remoteRevision: 5 }), + ).toBe("ignore"); + }); + + it("never rolls the author back on a stale poll", () => { + // A poll issued before our save can answer after it. Adopting that older + // body would silently undo what was just written. + expect( + decideDraftSync({ baseRevision: 6, dirty: false, remoteRevision: 5 }), + ).toBe("ignore"); + expect( + decideDraftSync({ baseRevision: 6, dirty: true, remoteRevision: 5 }), + ).toBe("ignore"); + }); +}); diff --git a/src/__tests__/drepStatus.test.ts b/src/__tests__/drepStatus.test.ts new file mode 100644 index 00000000..34dbd366 --- /dev/null +++ b/src/__tests__/drepStatus.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { fetchDrepStatus } from "@/lib/governance/drep-status"; + +/** + * The DRep registration probe behind the MCP draft pipeline's vote check. + * It reads Blockfrost's raw DRep record: only `active` counts (a retired + * DRep still has a record), a 404 for a never-registered DRep is the "not + * active" answer, and any other failure must surface rather than read as + * inactive. + */ + +function provider(impl: (url: string) => Promise) { + return { get: jest.fn(impl) }; +} + +describe("fetchDrepStatus", () => { + it("reports a registered DRep", async () => { + const p = provider(async () => ({ drep_id: "drep1abc", active: true, amount: "1000" })); + await expect(fetchDrepStatus(p, " drep1abc ")).resolves.toEqual({ active: true }); + expect(p.get).toHaveBeenCalledWith("/governance/dreps/drep1abc"); + }); + + it("treats a retired DRep as inactive even though its record exists", async () => { + const p = provider(async () => ({ drep_id: "drep1abc", active: false, retired: true })); + await expect(fetchDrepStatus(p, "drep1abc")).resolves.toEqual({ active: false }); + }); + + it("treats a record without an active flag as inactive", async () => { + const p = provider(async () => ({ drep_id: "drep1abc" })); + await expect(fetchDrepStatus(p, "drep1abc")).resolves.toEqual({ active: false }); + const empty = provider(async () => null); + await expect(fetchDrepStatus(empty, "drep1abc")).resolves.toEqual({ active: false }); + }); + + it("treats a 404 (never registered) as inactive", async () => { + const byStatus = provider(async () => { + throw Object.assign(new Error("Not found"), { status: 404 }); + }); + await expect(fetchDrepStatus(byStatus, "drep1abc")).resolves.toEqual({ active: false }); + + const byMessage = provider(async () => { + throw new Error("Request failed with status 404"); + }); + await expect(fetchDrepStatus(byMessage, "drep1abc")).resolves.toEqual({ active: false }); + }); + + it("rethrows any other provider failure", async () => { + const p = provider(async () => { + throw Object.assign(new Error("Blockfrost 500"), { status: 500 }); + }); + await expect(fetchDrepStatus(p, "drep1abc")).rejects.toThrow("Blockfrost 500"); + }); +}); diff --git a/src/__tests__/mcpReviewCardApp.test.ts b/src/__tests__/mcpReviewCardApp.test.ts new file mode 100644 index 00000000..25378337 --- /dev/null +++ b/src/__tests__/mcpReviewCardApp.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + MCP_APPS_PROTOCOL_VERSION, + REVIEW_CARD_HTML, + REVIEW_CARD_MIME_TYPE, + REVIEW_CARD_RESOURCE_META, + REVIEW_CARD_RESOURCE_URI, + reviewCardResourceContents, +} from "@/lib/mcp/apps/review-card"; +import { MCP_TOOLS } from "@/lib/mcp/tools"; + +/** + * The MCP App view is a hand-rolled implementation of the ext-apps bridge. + * These tests pin the parts of the contract a host actually checks — the + * URI scheme, the MIME type, the handshake and notification names — and the + * sandbox constraints the page must live within (no external loads). + */ + +describe("review-card MCP App resource", () => { + it("uses the ui:// scheme and the mcp-app HTML profile", () => { + expect(REVIEW_CARD_RESOURCE_URI).toMatch(/^ui:\/\//); + expect(REVIEW_CARD_MIME_TYPE).toBe("text/html;profile=mcp-app"); + const { contents } = reviewCardResourceContents(); + expect(contents).toHaveLength(1); + expect(contents[0]).toMatchObject({ + uri: REVIEW_CARD_RESOURCE_URI, + mimeType: REVIEW_CARD_MIME_TYPE, + _meta: REVIEW_CARD_RESOURCE_META, + }); + expect(contents[0]!.text.startsWith("")).toBe(true); + }); + + it("declares no network or external asset origins", () => { + expect(REVIEW_CARD_RESOURCE_META.ui.csp).toEqual({ + connectDomains: [], + resourceDomains: [], + frameDomains: [], + }); + // Self-contained: nothing loaded from a URL, nothing fetched. + expect(REVIEW_CARD_HTML).not.toMatch(/]+src=/i); + expect(REVIEW_CARD_HTML).not.toMatch(/]+href=/i); + expect(REVIEW_CARD_HTML).not.toMatch(/\bfetch\(/); + expect(REVIEW_CARD_HTML).not.toMatch(/https?:\/\//); + }); + + it("speaks the ext-apps bridge: handshake, result delivery, confirm, sizing", () => { + for (const method of [ + "ui/initialize", + "ui/notifications/initialized", + "ui/notifications/tool-result", + "ui/notifications/tool-cancelled", + "ui/notifications/host-context-changed", + "ui/resource-teardown", + "ui/notifications/size-changed", + "ui/open-link", + "ui/update-model-context", + "tools/call", + ]) { + expect(REVIEW_CARD_HTML).toContain(`"${method}"`); + } + expect(REVIEW_CARD_HTML).toContain(`"${MCP_APPS_PROTOCOL_VERSION}"`); + // Only ever calls the propose tool, with the token the preview minted. + const calls = REVIEW_CARD_HTML.match(/name: "([a-z_]+)", arguments/g) ?? []; + expect(calls).toEqual(['name: "transaction_propose", arguments']); + expect(REVIEW_CARD_HTML).toContain("draftToken: state.draftToken"); + // Inbound messages are accepted from the parent frame only. + expect(REVIEW_CARD_HTML).toContain("event.source !== window.parent"); + }); + + it("renders the tool's image block as a data: URL", () => { + expect(REVIEW_CARD_HTML).toContain('src="data:'); + expect(REVIEW_CARD_HTML).toContain('type === "image"'); + }); +}); + +/** + * Layout and theming follow the MCP Apps spec's host-context contract and + * Anthropic's design guidelines for inline apps. The suite has no DOM, so + * these pin the mechanisms in the source: how size is measured and reported, + * what host context is applied, and the CSS invariants that keep scrollbars + * away and controls off the edges. + */ +describe("review-card MCP App layout and theming", () => { + it("is borderless and pads itself", () => { + // Claude web is borderless by default and adds no padding; the view owns + // its gutters (16px + safeAreaInsets) and paints no background. + expect(REVIEW_CARD_RESOURCE_META.ui.prefersBorder).toBe(false); + expect(REVIEW_CARD_HTML).toContain(''); + expect(REVIEW_CARD_HTML).toContain("background: transparent"); + expect(REVIEW_CARD_HTML).toContain("box-sizing: border-box"); + expect(REVIEW_CARD_HTML).toContain("overflow-x: hidden"); + for (const side of ["top", "right", "bottom", "left"]) { + expect(REVIEW_CARD_HTML).toContain(`calc(16px + var(--inset-${side}))`); + } + expect(REVIEW_CARD_HTML).toContain("min-height: 44px"); // tap target + }); + + it("measures size the way the ext-apps SDK does and pins the document height", () => { + // Width is the viewport, never the scroll width (the SDK refuses to report + // fit-content width); height is the max-content bounding height. + expect(REVIEW_CARD_HTML).not.toContain("scrollWidth"); + expect(REVIEW_CARD_HTML).not.toContain("scrollHeight"); + expect(REVIEW_CARD_HTML).toContain("var width = Math.ceil(window.innerWidth);"); + expect(REVIEW_CARD_HTML).toContain('notify("ui/notifications/size-changed", { width: width, height: height })'); + expect(REVIEW_CARD_HTML).toContain('html.style.height = "max-content"'); + expect(REVIEW_CARD_HTML).toContain("html.getBoundingClientRect().height"); + expect(REVIEW_CARD_HTML).toContain("requestAnimationFrame(measure)"); + // claude-ai-mcp #69: the host reads the DOM height itself, so pin it — + // synchronously after every DOM mutation, never as 0px from a hidden + // frame; a fixed host height means 100vh per the spec. + expect(REVIEW_CARD_HTML).toContain('fixedHeight() ? "100vh" : height + "px"'); + expect(REVIEW_CARD_HTML).toContain('if (!height) { html.style.height = ""; return; }'); + expect(REVIEW_CARD_HTML).toContain('html.classList.remove("compact");\n root.innerHTML = markup;\n measureNow();'); + // Under a host height cap the drawn card tightens its spacing (one-way per + // render) before the host has to scroll; the document is still never clamped. + expect(REVIEW_CARD_HTML).toContain('if (cap && height > cap && !html.classList.contains("compact"))'); + expect(REVIEW_CARD_HTML).toContain("html.compact .card { gap: 8px; padding: 12px; }"); + expect(REVIEW_CARD_HTML).toContain("el.textContent = text; measureNow();"); + // An advertised maxHeight is never applied to the document (inline cards + // auto-fit); the PNG is scaled to fit inside it instead, with a floor. + expect(REVIEW_CARD_HTML).not.toContain("html.style.maxHeight"); + expect(REVIEW_CARD_HTML).not.toContain("html.style.maxWidth"); + // width:100% (not auto) keeps the IHDR-reserved box before decode; the cap + // is a max-width derived from the aspect ratio, never a max-height. + expect(REVIEW_CARD_HTML).toContain("width: 100%; max-width: var(--img-max-w, 100%); height: auto;"); + expect(REVIEW_CARD_HTML).not.toContain("max-height:"); + expect(REVIEW_CARD_HTML).toContain('typeof cd.maxHeight === "number"'); + expect(REVIEW_CARD_HTML).toContain('html.style.setProperty("--img-max-w", next + "px")'); + expect(REVIEW_CARD_HTML).toContain("Math.max(IMG_MIN_HEIGHT, cap - chrome)"); + expect(REVIEW_CARD_HTML).toContain("if (fitImage(height)) height = Math.ceil(html.getBoundingClientRect().height);"); + expect(REVIEW_CARD_HTML).toContain("ro.observe(html)"); + expect(REVIEW_CARD_HTML).toContain("ro.observe(document.body)"); + // The PNG box is reserved before decode, and re-measured after it. + expect(REVIEW_CARD_HTML).toContain('"IHDR"'); + expect(REVIEW_CARD_HTML).toContain("img.decode()"); + expect(REVIEW_CARD_HTML).toContain("whenDecoded(img).then(sizeChanged)"); + }); + + it("applies the host context: theme, style variables, insets, dimensions", () => { + expect(REVIEW_CARD_HTML).toContain("html.style.colorScheme = ctx.theme"); + expect(REVIEW_CARD_HTML).toContain("ctx.styles.variables"); + expect(REVIEW_CARD_HTML).toContain("html.style.setProperty(k, String(vars[k]))"); + expect(REVIEW_CARD_HTML).toContain("ctx.safeAreaInsets"); + expect(REVIEW_CARD_HTML).toContain("ctx.containerDimensions"); + // The debugging-era diagnostics readout is gone from the user-facing card. + expect(REVIEW_CARD_HTML).not.toContain("Diagnostics"); + expect(REVIEW_CARD_HTML).not.toContain("hostCapabilities"); + }); + + it("draws the card from the structured summary when no image block is attached", () => { + // html mode (the default): the tool result carries no PNG, so the view + // renders the same sections and strings as src/lib/tx-review/card.ts + // from structuredContent.summary. The image path stays for card: "image". + expect(REVIEW_CARD_HTML).toContain("function cardMarkup(s)"); + expect(REVIEW_CARD_HTML).toContain("} else if (summary.wallet) {\n markup += cardMarkup(summary);"); + for (const literal of [ + "UNSIGNED PREVIEW", + '"PENDING · " + signed + " OF " + threshold.required + " SIGNED"', + "Unknown address", + "no amount", + '"Change to this wallet"', + "Nothing has been signed or broadcast. Signers approve in the Mesh Multisig app.", + "Nothing is broadcast until the threshold is met.", + 'esc("Warning: " + warnings[w])', + 'esc("Tx hash " + (s.txHash || ""))', + "s.recipients", + "s.actions", + "rationale.status === \"will-publish-on-confirm\"", + ]) { + expect(REVIEW_CARD_HTML).toContain(literal); + } + // Nothing from the summary reaches the DOM unescaped: every interpolation + // of a summary field is wrapped in esc(...). + const cardSource = REVIEW_CARD_HTML.slice( + REVIEW_CARD_HTML.indexOf("function cardMarkup(s)"), + REVIEW_CARD_HTML.indexOf("function render()"), + ); + for (const field of ["wallet.name", "r.label", "r.address", "a.label", "a.title", "a.detail", "s.description", "s.metadataMessage", "warnings[w]"]) { + expect(cardSource).toMatch(new RegExp(`esc\\([^;]*${field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + } + // Layout: no fixed sizes on the drawn card; rows wrap and long values break. + expect(REVIEW_CARD_HTML).toContain(".card { display: flex; flex-direction: column; gap: 12px; min-width: 0;"); + expect(REVIEW_CARD_HTML).toContain("overflow-wrap: anywhere; }"); + expect(REVIEW_CARD_HTML).toContain(".card .line { display: flex; flex-wrap: wrap;"); + expect(REVIEW_CARD_HTML).toMatch(/var\(--color-text-info, /); + expect(REVIEW_CARD_HTML).toMatch(/var\(--color-text-warning, /); + }); + + it("uses Claude's style tokens with fallbacks", () => { + for (const token of [ + "--color-text-primary", + "--color-text-secondary", + "--color-background-secondary", + "--color-border-secondary", + "--color-text-success", + "--color-text-danger", + "--border-radius-md", + "--font-text-sm-size", + "--font-weight-semibold", + ]) { + expect(REVIEW_CARD_HTML).toMatch(new RegExp(`var\\(${token}, `)); + } + // Host fonts are not loaded (zero-origin CSP); the system stack stays. + expect(REVIEW_CARD_HTML).not.toContain("--font-sans"); + expect(REVIEW_CARD_HTML).toContain("font-family: system-ui"); + }); +}); + +describe("tools that render the app", () => { + it("are exactly the review tools", () => { + // task_prepare_payout returns the same card and draftToken shape as + // transaction_preview, so the view's Confirm button works for it unchanged. + const withUi = MCP_TOOLS.filter((t) => t.uiResourceUri).map((t) => t.name); + expect(withUi).toEqual([ + "transaction_preview", + "transaction_propose", + "multisig_review_pending_transaction", + "task_prepare_payout", + ]); + for (const tool of MCP_TOOLS) { + if (tool.uiResourceUri) expect(tool.uiResourceUri).toBe(REVIEW_CARD_RESOURCE_URI); + } + }); +}); diff --git a/src/__tests__/mcpRoute.test.ts b/src/__tests__/mcpRoute.test.ts index 3ed4b49b..bc178dfd 100644 --- a/src/__tests__/mcpRoute.test.ts +++ b/src/__tests__/mcpRoute.test.ts @@ -51,6 +51,14 @@ jest.mock("@/server/db", () => ({ }, })); +// The review pipeline builds real transactions and renders WASM; the route +// test only needs to prove its result shape reaches the wire. +const runTransactionPreviewMock: jest.Mock = jest.fn(); +jest.mock("@/lib/tx-review/preview", () => ({ + __esModule: true, + runTransactionPreview: runTransactionPreviewMock, +})); + const HUMAN_ADDRESS = "addr_test1qphuman000000000000000000000000000000"; type CapturedResponse = NextApiResponse & { @@ -149,8 +157,14 @@ function createRequest(body: unknown, extraHeaders: Record = {}) /** A modern-era (2026-07-28) request: envelope in the body, method in a header. */ function modern(method: string, params: Record = {}, id = 1) { - const headers: Record = { "mcp-method": method }; + const headers: Record = { + "mcp-method": method, + "mcp-protocol-version": "2026-07-28", + }; + // The envelope cross-checks Mcp-Name against params.name (tools/call, + // prompts/get) or params.uri (resources/read). if (typeof params.name === "string") headers["mcp-name"] = params.name; + if (typeof params.uri === "string") headers["mcp-name"] = params.uri; return { headers, body: { @@ -248,6 +262,44 @@ describe("POST /api/mcp — transport", () => { expect(whoami?.inputSchema).toMatchObject({ type: "object" }); }); + it("advertises the review-card app on the review tools only", async () => { + // MCP Apps: the host reads `_meta.ui.resourceUri` from tools/list and + // renders that resource inline when the tool is called. + const { headers, body } = modern("tools/list"); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + const payload = res.body() as { + result?: { tools?: { name: string; _meta?: Record }[] }; + }; + const byName = new Map((payload.result?.tools ?? []).map((t) => [t.name, t])); + for (const name of ["transaction_preview", "transaction_propose", "multisig_review_pending_transaction"]) { + expect(byName.get(name)?._meta).toEqual({ + ui: { resourceUri: "ui://mesh-multisig/review-card" }, + "ui/resourceUri": "ui://mesh-multisig/review-card", + }); + } + expect(byName.get("multisig_whoami")?._meta).toBeUndefined(); + }); + + it("serves the review-card app HTML as an mcp-app resource", async () => { + const { headers, body } = modern("resources/read", { + uri: "ui://mesh-multisig/review-card", + }); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + expect(res._status).toBe(200); + const payload = res.body() as { + result?: { contents?: { uri: string; mimeType: string; text: string; _meta?: unknown }[] }; + }; + const content = payload.result?.contents?.[0]; + expect(content?.uri).toBe("ui://mesh-multisig/review-card"); + expect(content?.mimeType).toBe("text/html;profile=mcp-app"); + expect(content?.text).toContain("ui/initialize"); + expect(content?._meta).toMatchObject({ ui: { prefersBorder: false } }); + }); + it("serves the legacy 2025 initialize handshake", async () => { const res = createResponse(); await handler( @@ -270,6 +322,14 @@ describe("POST /api/mcp — transport", () => { }; expect(payload.result?.serverInfo?.name).toBe("mesh-multisig"); expect(payload.result?.protocolVersion).toBe("2025-06-18"); + // Server instructions ride on the initialize result — that is how the + // model learns to surface the review card without being asked. + const instructions = (payload.result as { instructions?: string })?.instructions ?? ""; + expect(instructions).toContain("review card"); + expect(instructions).toContain("same turn"); + for (const name of ["transaction_preview", "transaction_propose", "multisig_review_pending_transaction"]) { + expect(instructions).toContain(name); + } }); it("survives two sequential requests", async () => { @@ -376,6 +436,9 @@ describe("POST /api/mcp — tools/call", () => { address: HUMAN_ADDRESS, identityType: "wallet", }); + // Plain wire shape everywhere: no annotations on any block. + const content = (payload.result as { content?: { annotations?: unknown }[] })?.content; + expect(content?.[0]?.annotations).toBeUndefined(); }); it("rejects arguments that violate the tool's JSON Schema", async () => { @@ -411,6 +474,116 @@ describe("POST /api/mcp — tools/call", () => { const payload = res.body() as { result?: { isError?: boolean } }; expect(typeof payload.result?.isError).toBe("boolean"); }); + + it("delivers the review card as an image block after the text when asked for one", async () => { + // A human v1 bearer holds every scope, transactions:write included. + (runTransactionPreviewMock as any).mockResolvedValue({ + status: 200, + body: { draftToken: "tok", txHash: "beef", persisted: false }, + text: "Treasury (preprod) — UNSIGNED PREVIEW", + images: [{ data: "iVBORw0KGgo=", mimeType: "image/png" }], + audit: { walletId: "wallet-1", previewTxHash: "beef" }, + }); + const { headers, body } = modern("tools/call", { + name: "transaction_preview", + arguments: { walletId: "wallet-1", outputs: [{ address: "addr_test1qpx", ada: "1" }], card: "image" }, + }); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + expect(res._status).toBe(200); + // card: "image" reaches the pipeline as "render the PNG". + expect(runTransactionPreviewMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ omitCard: false }), + ); + const payload = res.body() as { + result?: { + isError?: boolean; + content?: { type: string; text?: string; data?: string; mimeType?: string }[]; + structuredContent?: Record; + }; + }; + expect(payload.result?.isError).toBe(false); + // Exact shapes, no extra keys: the Claude app rejected an image block that + // carried spec `annotations` ("Unexpected response type"), so the wire + // shape is pinned here to the minimal one. + expect(payload.result?.content?.[0]).toEqual({ + type: "text", + text: "Treasury (preprod) — UNSIGNED PREVIEW", + }); + expect(payload.result?.content?.[1]).toEqual({ + type: "image", + data: "iVBORw0KGgo=", + mimeType: "image/png", + }); + // The token is in structuredContent for the follow-up call; the image is not. + expect(payload.result?.structuredContent).toMatchObject({ draftToken: "tok" }); + expect(JSON.stringify(payload.result?.structuredContent)).not.toContain("iVBORw0KGgo="); + // Audit picks up the wallet id from the result, and the tool's own ids. + expect(auditCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + resourceType: "wallet", + resourceId: "wallet-1", + metadata: expect.objectContaining({ tool: "transaction_preview", previewTxHash: "beef" }), + }), + }), + ); + }); + + it("leaves the card to the inline view by default: no image block, summary in structuredContent", async () => { + (runTransactionPreviewMock as any).mockResolvedValue({ + status: 200, + body: { + draftToken: "tok", + txHash: "beef", + persisted: false, + summary: { kind: "preview", wallet: { name: "Treasury" } }, + reviewCard: { attached: false, inline: true }, + }, + text: "Review card: clients with the inline card view draw it from this result.", + audit: { walletId: "wallet-1", previewTxHash: "beef" }, + }); + const { headers, body } = modern("tools/call", { + name: "transaction_preview", + arguments: { walletId: "wallet-1", outputs: [{ address: "addr_test1qpx", ada: "1" }] }, + }); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + expect(res._status).toBe(200); + expect(runTransactionPreviewMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ omitCard: true }), + ); + const payload = res.body() as { + result?: { isError?: boolean; content?: { type: string }[]; structuredContent?: Record }; + }; + expect(payload.result?.isError).toBe(false); + expect(payload.result?.content?.map((c) => c.type)).toEqual(["text"]); + // The app draws the card from this; the token rides alongside for Confirm. + expect(payload.result?.structuredContent).toMatchObject({ + draftToken: "tok", + summary: { kind: "preview" }, + reviewCard: { attached: false, inline: true }, + }); + }); + + it("rejects a preview whose arguments break the schema before any build", async () => { + const { headers, body } = modern("tools/call", { + name: "transaction_preview", + arguments: { walletId: "wallet-1", outputs: [{ address: "addr_test1qpx", lovelace: "1" }] }, + }); + const res = createResponse(); + await handler(createRequest(body, headers), res); + + const payload = res.body() as { result?: { isError?: boolean } }; + expect(payload.result?.isError).toBe(true); + expect(runTransactionPreviewMock).not.toHaveBeenCalled(); + }); }); describe("POST /api/mcp — the stored grant is authoritative", () => { diff --git a/src/__tests__/mcpTools.test.ts b/src/__tests__/mcpTools.test.ts index a7fe7eee..29f7b2f1 100644 --- a/src/__tests__/mcpTools.test.ts +++ b/src/__tests__/mcpTools.test.ts @@ -34,6 +34,14 @@ describe("MCP tool registry", () => { "governance_open_proposals", "ballot_upsert", "ballot_publish_rationale", + "document_list", + "document_get", + "transaction_preview", + "transaction_propose", + "multisig_review_pending_transaction", + "task_list", + "task_upsert", + "task_prepare_payout", ]); }); @@ -52,17 +60,22 @@ describe("MCP tool registry", () => { }); it("exposes no tool that can sign, spend or broadcast", () => { - // The agreed boundary: agents may read, draft ballots, and publish a - // rationale to IPFS. Submitting a vote and signing stay with humans. Any + // The agreed boundary: agents may read, draft ballots, publish a + // rationale to IPFS, and draft unsigned transactions for humans to sign. + // Signing, submitting a vote and broadcasting stay with humans. Any // further write tool must be a deliberate decision that updates this list, // not a quiet registry addition. const writable = MCP_TOOLS.filter((t) => !t.annotations.readOnlyHint); expect(writable.map((t) => t.name)).toEqual([ "ballot_upsert", "ballot_publish_rationale", + "transaction_propose", + // Board rows only: a task is a record, not a transaction. Paying it + // goes through task_prepare_payout → transaction_propose. + "task_upsert", ]); - // Neither write tool may be destructive: they add or replace drafts and - // anchors, they never remove a ballot or move value. + // No write tool may be destructive: they add or replace drafts, anchors + // and pending rows; they never remove a ballot or move value. for (const tool of writable) { expect(tool.annotations.destructiveHint).toBe(false); } @@ -92,6 +105,113 @@ describe("MCP tool registry", () => { expect(ballot?.annotations.idempotentHint).toBe(true); }); + it("wraps the task board tools around v1 handlers", () => { + // Bots reach task_list under wallets:read, and a v1 handler is where the + // bot-grant vs. signer authorization is defined once. A tool body that + // called the tRPC router directly would admit bots by signer membership + // (which they never have) and skip the handler's rate limits. + expect(MCP_TOOLS.find((t) => t.name === "task_list")?.v1Path).toBe("tasks.ts"); + expect(MCP_TOOLS.find((t) => t.name === "task_upsert")?.v1Path).toBe("taskUpsert.ts"); + }); + + it("keeps the transaction preview read-only and off the v1 surface", () => { + // The preview builds in memory and stores nothing, so it must advertise + // as read-only; and it wraps no v1 handler, because no REST route builds + // an arbitrary transaction for a caller. + const preview = MCP_TOOLS.find((t) => t.name === "transaction_preview"); + expect(preview?.annotations.readOnlyHint).toBe(true); + expect(preview?.v1Path).toBeNull(); + expect(preview?.scope).toBe("transactions:write"); + }); + + it("lets propose accept nothing but the draft token", () => { + // The whole safety argument of preview→confirm rests on this: if propose + // took recipients or amounts, the model could alter them after the + // human approved the card. + const propose = MCP_TOOLS.find((t) => t.name === "transaction_propose"); + expect(Object.keys(propose?.inputSchema.properties as object)).toEqual([ + "draftToken", + ]); + expect(propose?.inputSchema.required).toEqual(["draftToken"]); + expect(propose?.annotations.idempotentHint).toBe(true); + expect(propose?.scope).toBe("transactions:write"); + }); + + it("tells the model how the review card is delivered and how to get a picture", () => { + // The card is the deliverable. By default the client's inline view draws + // it; a model that is not told so paraphrases it, or never learns that + // card: "image" exists for clients (or users) that want the PNG. + for (const name of [ + "transaction_preview", + "transaction_propose", + "multisig_review_pending_transaction", + "task_prepare_payout", + ]) { + const tool = MCP_TOOLS.find((t) => t.name === name); + expect(tool?.description).toMatch(/inline card view/); + expect(tool?.description).toMatch(/card: "image"/); + expect(tool?.description).toMatch(/show/i); + } + }); + + it("offers the card option on every card tool except propose", () => { + // Propose inherits the mode from the draft token, so the human confirms + // the card exactly as they saw it and propose still takes nothing else. + for (const name of [ + "transaction_preview", + "multisig_review_pending_transaction", + "task_prepare_payout", + ]) { + const tool = MCP_TOOLS.find((t) => t.name === name); + const card = (tool?.inputSchema.properties as Record).card; + expect(card?.enum).toEqual(["html", "image"]); + expect(tool?.inputSchema.required).not.toContain("card"); + } + const propose = MCP_TOOLS.find((t) => t.name === "transaction_propose"); + expect((propose?.inputSchema.properties as object)).not.toHaveProperty("card"); + }); + + it("hides the transaction tools from a read-only grant", () => { + const names = toolsForScopes(["wallets:read"]).map((t) => t.name); + expect(names).not.toContain("transaction_preview"); + expect(names).not.toContain("transaction_propose"); + expect(names).not.toContain("task_prepare_payout"); + expect(names).not.toContain("task_upsert"); + // Reviewing an existing pending transaction is a read; so is the board. + expect(names).toContain("multisig_review_pending_transaction"); + expect(names).toContain("task_list"); + }); + + it("routes a task payout through the same preview → propose contract", () => { + // The payout preview mints a draft token that only transaction_propose + // consumes, so it needs the drafting scope, is read-only (stores + // nothing), and renders in the same inline card view whose Confirm + // button calls transaction_propose. + const payout = MCP_TOOLS.find((t) => t.name === "task_prepare_payout"); + const preview = MCP_TOOLS.find((t) => t.name === "transaction_preview"); + expect(payout?.scope).toBe("transactions:write"); + expect(payout?.annotations.readOnlyHint).toBe(true); + expect(payout?.v1Path).toBeNull(); + expect(payout?.uiResourceUri).toBe(preview?.uiResourceUri); + expect(payout?.description).toMatch(/transaction_propose/); + expect(payout?.description).toMatch(/\bDone\b/); + // taskIds is optional: omitted means every payable task, and the preview + // names the ones it picked before the human confirms anything. + expect(payout?.inputSchema.required).toEqual(["walletId"]); + const taskIds = (payout?.inputSchema.properties as Record).taskIds; + expect(taskIds?.description).toMatch(/\bDone\b/); + expect(taskIds?.description).toMatch(/omit/i); + // An agent must not have to derive payability from column + state itself. + const list = MCP_TOOLS.find((t) => t.name === "task_list"); + expect(list?.description).toMatch(/payable/); + expect(list?.inputSchema.properties).toHaveProperty("payable"); + const upsert = MCP_TOOLS.find((t) => t.name === "task_upsert"); + expect(upsert?.description).toMatch(/read-only after payment/i); + // Board writes never reach the transaction table. + expect(upsert?.scope).toBe("tasks:write"); + expect(upsert?.uiResourceUri).toBeUndefined(); + }); + it("gives every tool a closed input schema", () => { // additionalProperties:false makes a mistyped argument fail loudly in ajv // instead of being silently ignored. @@ -102,6 +222,18 @@ describe("MCP tool registry", () => { } }); + it("lets the wallet lookup select by signer, policy or address", () => { + // Exactly-one-selector is enforced in the run body (JSON Schema can't + // express it without oneOf, which the MCP client UIs render poorly), so + // the schema must not `require` any single selector. + const tool = MCP_TOOLS.find((t) => t.name === "multisig_lookup_wallet"); + const props = tool?.inputSchema.properties as Record; + expect(Object.keys(props)).toEqual( + expect.arrayContaining(["pubKeyHashes", "scriptHash", "address"]), + ); + expect(tool?.inputSchema.required).toBeUndefined(); + }); + it("caps the governance page size", () => { const tool = MCP_TOOLS.find( (t) => t.name === "governance_list_active_proposals", @@ -169,17 +301,19 @@ describe("bot scope projection", () => { expect(mcpScopesForBot(["multisig:sign"] as BotScope[])).toEqual([]); }); - it("maps the full bot scope set onto the full MCP set", () => { + it("maps the full bot scope set onto the MCP set, minus transaction drafting", () => { const all = [ + "multisig:create", "multisig:read", + "multisig:sign", "governance:read", "ballot:write", ] as BotScope[]; - expect(mcpScopesForBot(all)).toEqual([ - "wallets:read", - "governance:read", - "ballots:write", - ]); + const granted = mcpScopesForBot(all); + expect(granted).toEqual(["wallets:read", "governance:read", "ballots:write"]); + // Transaction drafting is a human, consent-screen scope: a bot with + // multisig:sign already has the REST route and needs no chat review. + expect(granted).not.toContain("transactions:write"); }); }); diff --git a/src/__tests__/notificationWorker.test.ts b/src/__tests__/notificationWorker.test.ts index 58110c14..1b946009 100644 --- a/src/__tests__/notificationWorker.test.ts +++ b/src/__tests__/notificationWorker.test.ts @@ -8,8 +8,10 @@ jest.mock("@/lib/notifications/channels/email/resend", () => ({ })); import { + NOTIFICATION_EVENT_BALLOT_DEADLINE, NOTIFICATION_EVENT_EMAIL_VERIFY, NOTIFICATION_EVENT_SIGNATURE_REQUIRED, + NOTIFICATION_EVENT_THRESHOLD_REACHED, NOTIFICATION_STATUS_PENDING, NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, } from "@/lib/notifications/events"; @@ -49,6 +51,8 @@ function makeSetting(overrides: Record) { emailOptIn: true, notifyTransactionSignatures: true, notifySignableSignatures: true, + notifyThresholdReached: true, + notifyBallotDeadlines: true, ...overrides, }; } @@ -152,6 +156,89 @@ describe("drainNotificationOutbox preference re-check", () => { }); }); + it("skips a threshold-reached delivery when that toggle is off", async () => { + const delivery = makeDelivery({ + id: "delivery_threshold", + eventType: NOTIFICATION_EVENT_THRESHOLD_REACHED, + }); + const db = makeDb( + [delivery], + [makeSetting({ notifyThresholdReached: false })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_threshold", + status: "skipped_disabled", + }); + }); + + it("still sends a threshold-reached delivery when only signature toggles are off", async () => { + const delivery = makeDelivery({ + id: "delivery_threshold_ok", + eventType: NOTIFICATION_EVENT_THRESHOLD_REACHED, + }); + const db = makeDb( + [delivery], + [ + makeSetting({ + notifyTransactionSignatures: false, + notifySignableSignatures: false, + }), + ], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(results[0]).toMatchObject({ + id: "delivery_threshold_ok", + status: "sent", + }); + }); + + it("skips a ballot-deadline delivery when that toggle is off", async () => { + const delivery = makeDelivery({ + id: "delivery_ballot", + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "ballot", + }); + const db = makeDb( + [delivery], + [makeSetting({ notifyBallotDeadlines: false })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_ballot", + status: "skipped_disabled", + }); + }); + + it("gates transaction-keyed ballot-deadline deliveries by the same toggle", async () => { + const delivery = makeDelivery({ + id: "delivery_ballot_tx", + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + resourceType: "transaction", + }); + const db = makeDb( + [delivery], + [makeSetting({ notifyBallotDeadlines: false })], + ); + + const results = await drainNotificationOutbox(db as any); + + expect(sendMock).not.toHaveBeenCalled(); + expect(results[0]).toMatchObject({ + id: "delivery_ballot_tx", + status: "skipped_disabled", + }); + }); + it("treats a missing settings row as no email at send time", async () => { const delivery = makeDelivery({ id: "delivery_missing" }); const db = makeDb([delivery], []); diff --git a/src/__tests__/ogCards.test.ts b/src/__tests__/ogCards.test.ts new file mode 100644 index 00000000..2a9259a8 --- /dev/null +++ b/src/__tests__/ogCards.test.ts @@ -0,0 +1,105 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { + OG_CARD, + OG_IMAGE_HEIGHT, + OG_IMAGE_PATH, + OG_IMAGE_VERSION, + OG_IMAGE_WIDTH, + getRouteSeo, + ogImageUrl, + routeSeo, +} from "@/lib/seo"; + +/** + * The social cards are committed PNGs produced by scripts/generate-og-image.mjs + * and referenced by path from seo.ts. Nothing at build time links the two, so a + * renamed or forgotten card would ship as a broken og:image and only surface + * when someone shared a link. These tests are that link. + */ + +const PUBLIC_DIR = join(process.cwd(), "public"); + +/** Read width/height straight out of the PNG IHDR chunk — no image library. */ +function pngSize(absPath: string): { width: number; height: number } { + const buf = readFileSync(absPath); + expect(buf.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); + expect(buf.subarray(12, 16).toString("ascii")).toBe("IHDR"); + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + +const allCards = [OG_IMAGE_PATH, ...Object.values(OG_CARD)]; + +describe("Open Graph cards", () => { + it.each(allCards)("%s exists in /public", (cardPath) => { + expect(existsSync(join(PUBLIC_DIR, cardPath))).toBe(true); + }); + + it.each(allCards)("%s is a %sx%s PNG", (cardPath) => { + // Facebook, X and LinkedIn all crop away from 1.91:1; an off-size card gets + // letterboxed or centre-cropped through the headline. + expect(pngSize(join(PUBLIC_DIR, cardPath))).toEqual({ + width: OG_IMAGE_WIDTH, + height: OG_IMAGE_HEIGHT, + }); + }); + + it("gives every card a distinct file", () => { + expect(new Set(allCards).size).toBe(allCards.length); + }); + + it("only points routes at cards that exist", () => { + for (const [pathname, entry] of Object.entries(routeSeo)) { + if (!entry.image) continue; + expect([pathname, existsSync(join(PUBLIC_DIR, entry.image))]).toEqual([ + pathname, + true, + ]); + } + }); + + it("pairs every route-level card with alt text", () => { + for (const [pathname, entry] of Object.entries(routeSeo)) { + if (!entry.image) continue; + expect([pathname, entry.imageAlt?.length ?? 0]).not.toEqual([pathname, 0]); + } + }); +}); + +describe("getRouteSeo — card resolution", () => { + it("returns the route's own card", () => { + expect(getRouteSeo("/governance").image).toBe(OG_CARD.governance); + expect(getRouteSeo("/roadmap/graph").image).toBe(OG_CARD.roadmapGraph); + }); + + it("shares the DRep card between the explorer and a single DRep", () => { + expect(getRouteSeo("/governance/drep/[id]").image).toBe( + getRouteSeo("/governance/drep").image, + ); + }); + + it("falls back to the site card for routes without one", () => { + expect(getRouteSeo("/").image).toBe(OG_IMAGE_PATH); + expect(getRouteSeo("/wallets/[wallet]").image).toBe(OG_IMAGE_PATH); + }); +}); + +describe("ogImageUrl", () => { + it("returns an absolute, version-tagged URL", () => { + const url = ogImageUrl(OG_CARD.blog); + expect(url).toMatch(/^https?:\/\//); + expect(url).toContain(OG_CARD.blog); + expect(url.endsWith(`?v=${OG_IMAGE_VERSION}`)).toBe(true); + }); + + it("defaults to the site card", () => { + expect(ogImageUrl()).toContain(OG_IMAGE_PATH); + }); + + it("appends rather than replaces an existing query string", () => { + expect(ogImageUrl("/og/blog.png?foo=1")).toContain( + `?foo=1&v=${OG_IMAGE_VERSION}`, + ); + }); +}); diff --git a/src/__tests__/resolveScript.test.ts b/src/__tests__/resolveScript.test.ts new file mode 100644 index 00000000..2f4c205a --- /dev/null +++ b/src/__tests__/resolveScript.test.ts @@ -0,0 +1,246 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { createMockResponse } from "./apiTestUtils"; + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const providerGetMock = jest.fn<(path: string) => Promise>(); +const deserializeAddressMock = jest.fn<(address: string) => unknown>(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, +})); + +jest.mock("@/utils/get-provider", () => ({ + __esModule: true, + getProvider: () => ({ + get: providerGetMock, + }), +})); + +jest.mock("@meshsdk/core", () => ({ + __esModule: true, + deserializeAddress: (address: string) => deserializeAddressMock(address), +})); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/resolveScript")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); +}); + +const scriptHash = "1".repeat(56); +const scriptAddress = "addr_test1scriptaddress"; +const keyAddress = "addr_test1keyaddress"; +const sigA = "b".repeat(56); +const sigB = "c".repeat(56); +const scriptJson = { + type: "atLeast", + required: 2, + scripts: [ + { type: "sig", keyHash: sigA.toUpperCase() }, + { type: "sig", keyHash: sigB }, + ], +}; +const notFound = { + response: { data: { error: "Not Found", status_code: 404 } }, +}; + +function makeRequest(query: Record): NextApiRequest { + return { + method: "GET", + headers: {}, + query, + } as unknown as NextApiRequest; +} + +describe("resolveScript API", () => { + it("rejects a request with neither selector", async () => { + const res = createMockResponse(); + await handler(makeRequest({ network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(providerGetMock).not.toHaveBeenCalled(); + }); + + it("rejects a request with both selectors", async () => { + const res = createMockResponse(); + await handler( + makeRequest({ scriptHash, address: scriptAddress, network: "0" }), + res, + ); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an invalid scriptHash", async () => { + const res = createMockResponse(); + await handler(makeRequest({ scriptHash: "nope", network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an invalid network", async () => { + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "7" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("rejects an address without a script payment credential", async () => { + deserializeAddressMock.mockReturnValue({ + pubKeyHash: "d".repeat(56), + scriptHash: "", + stakeCredentialHash: "", + stakeScriptCredentialHash: "", + }); + const res = createMockResponse(); + await handler(makeRequest({ address: keyAddress, network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Address is not a multisig (script) address", + }); + }); + + it("rejects an address that does not deserialize", async () => { + deserializeAddressMock.mockImplementation(() => { + throw new Error("bad bech32"); + }); + const res = createMockResponse(); + await handler(makeRequest({ address: "garbage", network: "0" }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it("resolves sig hashes for a script hash", async () => { + providerGetMock.mockImplementation(async (path: unknown) => { + if (path === `/scripts/${scriptHash}/json`) return { json: scriptJson }; + throw new Error(`Unexpected provider path: ${String(path)}`); + }); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash: scriptHash.toUpperCase(), network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson, + sigHashes: [sigA, sigB], + }); + expect(res.setHeader).toHaveBeenCalledWith( + "Cache-Control", + "public, max-age=300, stale-while-revalidate=600", + ); + }); + + it("resolves via a multisig address and carries its stake credential", async () => { + deserializeAddressMock.mockReturnValue({ + pubKeyHash: "", + scriptHash, + stakeCredentialHash: "", + stakeScriptCredentialHash: "2".repeat(56), + }); + providerGetMock.mockResolvedValue({ json: scriptJson }); + + const res = createMockResponse(); + await handler(makeRequest({ address: scriptAddress, network: "1" }), res); + + expect(deserializeAddressMock).toHaveBeenCalledWith(scriptAddress); + expect(providerGetMock).toHaveBeenCalledWith(`/scripts/${scriptHash}/json`); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: "2".repeat(56), + scriptJson, + sigHashes: [sigA, sigB], + }); + }); + + it("returns an empty resolution when the provider has no such script", async () => { + providerGetMock.mockRejectedValue(notFound); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson: null, + sigHashes: [], + }); + expect(res.setHeader).toHaveBeenCalledWith( + "Cache-Control", + "public, max-age=60, stale-while-revalidate=120", + ); + }); + + it("returns an empty resolution for a Plutus script (no timelock json)", async () => { + providerGetMock.mockResolvedValue({ json: null }); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson: null, + sigHashes: [], + }); + }); + + it("surfaces unsupported script json with no signers", async () => { + const weird = { type: "mystery" }; + providerGetMock.mockResolvedValue({ json: weird }); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + scriptHash, + stakeCredentialHash: null, + scriptJson: weird, + sigHashes: [], + }); + }); + + it("returns 500 on unexpected provider failures", async () => { + providerGetMock.mockRejectedValue(new Error("boom")); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + + expect(res.status).toHaveBeenCalledWith(500); + errorSpy.mockRestore(); + }); + + it("stops when the rate limiter rejects the request", async () => { + applyRateLimitMock.mockReturnValue(false); + const res = createMockResponse(); + await handler(makeRequest({ scriptHash, network: "0" }), res); + expect(corsMock).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("rejects non-GET methods", async () => { + const res = createMockResponse(); + await handler( + { ...makeRequest({ scriptHash }), method: "POST" } as NextApiRequest, + res, + ); + expect(res.status).toHaveBeenCalledWith(405); + }); +}); diff --git a/src/__tests__/seoFallback.test.ts b/src/__tests__/seoFallback.test.ts index dc4414a4..e8adcc74 100644 --- a/src/__tests__/seoFallback.test.ts +++ b/src/__tests__/seoFallback.test.ts @@ -2,6 +2,7 @@ import React from "react"; import ReactDOMServer from "react-dom/server"; import SeoFallback from "@/components/ui/seo-fallback"; +import { publicRoutes } from "@/data/public-routes"; import { buildJsonLd, DEFAULT_DESCRIPTION, INDEXABLE_ROUTES } from "@/lib/seo"; import { buildLlmsTxt } from "@/pages/llms.txt"; @@ -89,3 +90,25 @@ describe("buildJsonLd — structured data for the initial HTML", () => { expect(types("/features")).not.toContain("SoftwareApplication"); }); }); + + +/** + * A route can be in the sitemap, carry its own metadata and still show the + * visitor something else entirely: the layout swaps any route that is not in + * `publicRoutes` for the marketing homepage whenever no wallet is connected. + * The page then advertises itself correctly in and renders the wrong + * body — which is exactly how /verify shipped, and is invisible to anyone + * testing while connected. + */ +describe("indexable routes render themselves without a wallet", () => { + // Written by getServerSideProps as plain text; it never mounts the layout. + const NOT_RENDERED_BY_THE_LAYOUT = ["/llms.txt"]; + + it.each( + INDEXABLE_ROUTES.map((route) => route.path).filter( + (path) => !NOT_RENDERED_BY_THE_LAYOUT.includes(path), + ), + )("%s is in publicRoutes", (path) => { + expect([path, publicRoutes.includes(path)]).toEqual([path, true]); + }); +}); diff --git a/src/__tests__/signDataRoundTrip.test.ts b/src/__tests__/signDataRoundTrip.test.ts new file mode 100644 index 00000000..05def933 --- /dev/null +++ b/src/__tests__/signDataRoundTrip.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "@jest/globals"; +import { MeshWallet, checkSignature, generateNonce } from "@meshsdk/core"; + +import { sign } from "@/utils/signing"; + +/** + * End-to-end round trip for `sign()`, against the REAL @meshsdk helpers. + * + * The existing signing.test.ts mocks `checkSignature` and `generateNonce`, so it + * proves the control flow (throw when verification fails) but can never prove + * that verification actually succeeds for a genuine signature. A bug lived in + * exactly that gap: `sign()` verified against `generateNonce(payload)` — which + * is `hex(payload + 32 random characters)` — while the wallet had signed + * `payload`, so verification returned false for every honest signature and + * `sign()` threw "Signature failed verification" every single time. + * + * This test signs with a real key and asserts the round trip closes, so no mock + * can hide the same class of defect again. + */ + +// Deterministic throwaway key. Never used for anything but this test. +const MNEMONIC = Array(24).fill("solution"); + +async function testWallet() { + const wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: MNEMONIC }, + }); + await wallet.init(); + const address = + (await wallet.getUsedAddresses())[0] ?? + (await wallet.getUnusedAddresses())[0]; + if (!address) throw new Error("test wallet produced no address"); + return { wallet, address }; +} + +// A canonical sign-off statement, the shape src/lib/documents/payload.ts emits. +const PAYLOAD = JSON.stringify({ + action: "approve", + contentHash: "a".repeat(64), + domain: "mesh-multisig.document-signoff.v1", + versionNumber: 1, +}); + +describe("sign() round trip against real @meshsdk helpers", () => { + it("returns a signature that verifies, instead of throwing", async () => { + const { wallet, address } = await testWallet(); + + const signature = await sign(PAYLOAD, wallet, 0, address); + + expect(signature.signature).toEqual(expect.any(String)); + expect(signature.key).toEqual(expect.any(String)); + await expect(checkSignature(PAYLOAD, signature, address)).resolves.toBe( + true, + ); + }, 60_000); + + it("verifies against the payload, never against a freshly generated nonce", async () => { + // This is the defect stated as a property. `generateNonce` appends random + // characters, so a nonce built client-side cannot be what the wallet signed. + // Verifying against it is always false — which is why sign() must not. + const { wallet, address } = await testWallet(); + const signature = await wallet.signData(PAYLOAD, address); + + await expect(checkSignature(PAYLOAD, signature, address)).resolves.toBe( + true, + ); + await expect( + checkSignature(generateNonce(PAYLOAD), signature, address), + ).resolves.toBe(false); + }, 60_000); + + it("rejects a signature made over different bytes", async () => { + const { wallet, address } = await testWallet(); + const signature = await wallet.signData(PAYLOAD, address); + + await expect( + checkSignature(`${PAYLOAD} tampered`, signature, address), + ).resolves.toBe(false); + }, 60_000); +}); diff --git a/src/__tests__/signTransaction.bot.test.ts b/src/__tests__/signTransaction.bot.test.ts index 0544cd2d..070b1d95 100644 --- a/src/__tests__/signTransaction.bot.test.ts +++ b/src/__tests__/signTransaction.bot.test.ts @@ -19,6 +19,12 @@ const submitTxWithScriptRecoveryMock: jest.Mock = jest.fn(); const findWalletMock: jest.Mock = jest.fn(); const findTransactionMock: jest.Mock = jest.fn(); const updateManyTransactionMock: jest.Mock = jest.fn(); +const enqueueThresholdReachedMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/notifications/center", () => ({ + __esModule: true, + enqueueThresholdReachedNotifications: enqueueThresholdReachedMock, +})); jest.mock("@/lib/cors", () => ({ __esModule: true, @@ -166,9 +172,56 @@ beforeEach(() => { txHex: "deadbeef-merged", }); (updateManyTransactionMock as any).mockResolvedValue({ count: 1 }); + (enqueueThresholdReachedMock as any).mockResolvedValue([]); }); describe("signTransaction bot API", () => { + it("hands the before/after signer sets to the threshold notifier", async () => { + const req = { + method: "POST", + headers: makeBearerAuth(), + body: { + walletId: "wallet-1", + transactionId: "tx-1", + address: BOT_TEST_ADDRESS, + signature: "aa".repeat(64), + key: "bb".repeat(64), + broadcast: false, + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(enqueueThresholdReachedMock).toHaveBeenCalledTimes(1); + expect(enqueueThresholdReachedMock.mock.calls[0]![1]).toMatchObject({ + resourceType: "transaction", + resourceId: "tx-1", + previousSignedAddresses: [], + signedAddresses: [BOT_TEST_ADDRESS], + actorAddress: BOT_TEST_ADDRESS, + txHash: null, + }); + }); + + it("still records the witness when the threshold notifier throws", async () => { + (enqueueThresholdReachedMock as any).mockRejectedValueOnce(new Error("boom")); + const req = { + method: "POST", + headers: makeBearerAuth(), + body: { + walletId: "wallet-1", + transactionId: "tx-1", + address: BOT_TEST_ADDRESS, + signature: "aa".repeat(64), + key: "bb".repeat(64), + broadcast: false, + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(200); + }); + it("returns 403 when bot is not cosigner", async () => { (getBotWalletAccessMock as any).mockResolvedValue({ allowed: true, role: "observer" }); const req = { diff --git a/src/__tests__/signTransaction.test.ts b/src/__tests__/signTransaction.test.ts index b6591a23..478803c5 100644 --- a/src/__tests__/signTransaction.test.ts +++ b/src/__tests__/signTransaction.test.ts @@ -81,11 +81,19 @@ const dbTransactionUpdateManyMock = jest.fn< }) => Promise<{ count: number }> >(); +// A submitted transaction flips any task-payout links to Paid +// (src/lib/task-payout/sync.ts); the handler must reach that table without +// logging, or the "no console.error" assertions below fail. +const dbTaskPayoutUpdateManyMock = jest.fn<(args: unknown) => Promise<{ count: number }>>(); + const dbMock = { transaction: { findUnique: dbTransactionFindUniqueMock, updateMany: dbTransactionUpdateManyMock, }, + taskPayout: { + updateMany: dbTaskPayoutUpdateManyMock, + }, }; jest.unstable_mockModule( @@ -414,6 +422,8 @@ beforeEach(() => { walletGetWalletMock.mockReset(); dbTransactionFindUniqueMock.mockReset(); dbTransactionUpdateManyMock.mockReset(); + dbTaskPayoutUpdateManyMock.mockReset(); + dbTaskPayoutUpdateManyMock.mockResolvedValue({ count: 0 }); getProviderMock.mockReset(); addressToNetworkMock.mockReset(); shouldSubmitMultisigTxMock.mockReset(); diff --git a/src/__tests__/signing.test.ts b/src/__tests__/signing.test.ts index 81d46b3d..7c04a325 100644 --- a/src/__tests__/signing.test.ts +++ b/src/__tests__/signing.test.ts @@ -32,30 +32,43 @@ describe("signing.ts source contract", () => { // failure path. We mock the @meshsdk/core helpers because they pull in // CSL/serialization which is heavyweight for a unit test. // --------------------------------------------------------------------------- -const checkSignatureMock = jest.fn< - (nonce: string, signature: { signature: string; key: string }, address?: string) => Promise ->(); -const generateNonceMock = jest.fn<(payload: string) => string>(); +const checkSignatureMock = + jest.fn< + ( + nonce: string, + signature: { signature: string; key: string }, + address?: string, + ) => Promise + >(); jest.unstable_mockModule("@meshsdk/core", () => ({ __esModule: true, checkSignature: checkSignatureMock, - generateNonce: generateNonceMock, })); const { sign } = await import("../utils/signing"); type MockWallet = { - signData: jest.Mock<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>; + signData: jest.Mock< + ( + payload: string, + address?: string, + ) => Promise<{ signature: string; key: string }> + >; getRewardAddresses: jest.Mock<() => Promise>; }; function createWallet(overrides?: Partial): MockWallet { return { - signData: jest.fn<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>( - async () => ({ signature: "deadbeef", key: "cafe" }), - ), - getRewardAddresses: jest.fn<() => Promise>(async () => ["stake_addr"]), + signData: jest.fn< + ( + payload: string, + address?: string, + ) => Promise<{ signature: string; key: string }> + >(async () => ({ signature: "deadbeef", key: "cafe" })), + getRewardAddresses: jest.fn<() => Promise>(async () => [ + "stake_addr", + ]), ...overrides, } as MockWallet; } @@ -63,8 +76,6 @@ function createWallet(overrides?: Partial): MockWallet { describe("sign", () => { beforeEach(() => { checkSignatureMock.mockReset(); - generateNonceMock.mockReset(); - generateNonceMock.mockReturnValue("nonce-payload"); }); it("role=0 signs with the user payment address and returns the signature", async () => { @@ -92,16 +103,16 @@ describe("sign", () => { it("throws when the chosen role has no resolved address", async () => { const wallet = createWallet(); - await expect(sign("payload", wallet as never, 0, undefined)).rejects.toThrow( - /missing address/i, - ); + await expect( + sign("payload", wallet as never, 0, undefined), + ).rejects.toThrow(/missing address/i); }); it("throws when checkSignature returns false (no silent ternary fallback)", async () => { checkSignatureMock.mockResolvedValueOnce(false); const wallet = createWallet(); - await expect(sign("payload", wallet as never, 0, "addr_test_user")).rejects.toThrow( - /Signature failed verification/i, - ); + await expect( + sign("payload", wallet as never, 0, "addr_test_user"), + ).rejects.toThrow(/Signature failed verification/i); }); }); diff --git a/src/__tests__/stakeAccountStatus.test.ts b/src/__tests__/stakeAccountStatus.test.ts new file mode 100644 index 00000000..c044b24d --- /dev/null +++ b/src/__tests__/stakeAccountStatus.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { fetchStakeAccountStatus } from "@/lib/staking/stake-account-status"; + +/** + * The registration probe shared by the stakeAccountInfo route and the MCP + * draft pipeline. It reads Blockfrost's raw account record: only `active` + * counts (a deregistered account keeps its `active_epoch`), a 404 for a + * never-seen account is the "not active" answer, and any other failure must + * surface rather than read as inactive. + */ + +function provider(impl: (url: string) => Promise) { + return { get: jest.fn(impl) }; +} + +describe("fetchStakeAccountStatus", () => { + it("reports a registered, delegated account", async () => { + const p = provider(async () => ({ active: true, active_epoch: 300, pool_id: "pool1abc" })); + await expect(fetchStakeAccountStatus(p, " stake_test1uqx ")).resolves.toEqual({ + active: true, + poolId: "pool1abc", + }); + expect(p.get).toHaveBeenCalledWith("/accounts/stake_test1uqx"); + }); + + it("normalizes a registered but undelegated account", async () => { + const p = provider(async () => ({ active: true, active_epoch: 300, pool_id: null })); + await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).resolves.toEqual({ + active: true, + poolId: null, + }); + }); + + it("treats a deregistered account as inactive even though active_epoch is set", async () => { + // The case Mesh's fetchAccountInfo gets wrong (active || active_epoch !== null). + const p = provider(async () => ({ active: false, active_epoch: 310, pool_id: null })); + await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).resolves.toEqual({ + active: false, + poolId: null, + }); + }); + + it("treats an account seen on chain but never registered as inactive", async () => { + const p = provider(async () => ({ active: false, active_epoch: null, pool_id: null })); + await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).resolves.toEqual({ + active: false, + poolId: null, + }); + }); + + it("treats a 404 (never seen on chain) as inactive", async () => { + const byStatus = provider(async () => { + throw Object.assign(new Error("Not found"), { status: 404 }); + }); + await expect(fetchStakeAccountStatus(byStatus, "stake_test1uqx")).resolves.toEqual({ + active: false, + poolId: null, + }); + + const byMessage = provider(async () => { + throw new Error("Request failed with status 404"); + }); + await expect(fetchStakeAccountStatus(byMessage, "stake_test1uqx")).resolves.toEqual({ + active: false, + poolId: null, + }); + }); + + it("rethrows any other provider failure", async () => { + const p = provider(async () => { + throw Object.assign(new Error("Blockfrost 500"), { status: 500 }); + }); + await expect(fetchStakeAccountStatus(p, "stake_test1uqx")).rejects.toThrow("Blockfrost 500"); + }); +}); diff --git a/src/__tests__/summonWallet.test.ts b/src/__tests__/summonWallet.test.ts new file mode 100644 index 00000000..36307e66 --- /dev/null +++ b/src/__tests__/summonWallet.test.ts @@ -0,0 +1,90 @@ +import { buildWallet } from "../utils/common"; +import { Wallet as DbWallet } from "@prisma/client"; +import { RawImportBodies } from "../types/wallet"; + +describe("Summon Wallet Capabilities", () => { + const network = 0; // Testnet + + const mockSummonWallet: DbWallet & { rawImportBodies: RawImportBodies } = { + id: "test-summon-uuid", + name: "Test Summon Wallet", + description: "A test summon wallet", + address: "addr_test1wpnlxv2xv988tvv9z06m6pax76r98slymr6uzy958tclv6sgp98k8", + type: "atLeast", + numRequiredSigners: 2, + signersAddresses: ["addr_test1vpu5vl76u73su6p0657cw6q0657cw6q0657cw6q0657cw6q0657cw"], + signersStakeKeys: [], + signersDRepKeys: [], + scriptCbor: "8201828200581caf000000000000000000000000000000000000000000000000000000008200581cb0000000000000000000000000000000000000000000000000000000", + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + profileImageIpfsUrl: null, + stakeCredentialHash: null, + dRepId: "", + rawImportBodies: { + multisig: { + address: "addr_test1wpnlxv2xv988tvv9z06m6pax76r98slymr6uzy958tclv6sgp98k8", + payment_script: "8200581c00000000000000000000000000000000000000000000000000000000", + stake_script: "8200581c11111111111111111111111111111111111111111111111111111111", + } + } + } as any; + + it("should correctly populate capabilities for a Summon wallet with staking", () => { + const wallet = buildWallet(mockSummonWallet, network); + + expect(wallet.capabilities).toBeDefined(); + expect(wallet.capabilities!.canStake).toBe(true); + expect(wallet.capabilities!.canVote).toBe(false); + expect(wallet.capabilities!.address).toBe(mockSummonWallet.rawImportBodies.multisig!.address); + expect(wallet.capabilities!.stakeAddress).toBeDefined(); + expect(wallet.capabilities!.stakeAddress).toMatch(/^stake_test/); + }); + + it("should correctly populate capabilities for a Summon wallet without staking", () => { + const mockNoStake = { + ...mockSummonWallet, + rawImportBodies: { + multisig: { + ...mockSummonWallet.rawImportBodies.multisig, + stake_script: undefined + } + } + }; + const wallet = buildWallet(mockNoStake, network); + + expect(wallet.capabilities!.canStake).toBe(false); + expect(wallet.capabilities!.stakeAddress).toBeUndefined(); + }); + + it("should correctly handle Summon wallets with unordered CBOR lists", () => { + // Swap the two sigs in the CBOR string to make it "unordered" + // Original: 82 01 82 [sigA] [sigB] + // [sigA] = 8200581caf00000000000000000000000000000000000000000000000000000000 (32 bytes = 64 chars) + // [sigB] = 8200581cb00000000000000000000000000000000000000000000000000000000 (32 bytes = 64 chars) + const sigA = "8200581caf00000000000000000000000000000000000000000000000000000000"; + const sigB = "8200581cb00000000000000000000000000000000000000000000000000000000"; + const unorderedCbor = "820182" + sigB + sigA; + + const mockUnordered = { + ...mockSummonWallet, + rawImportBodies: { + multisig: { + ...mockSummonWallet.rawImportBodies.multisig, + payment_script: unorderedCbor + } + } + }; + + const wallet = buildWallet(mockUnordered, network); + + // The address should still be the one from metadata, even if we can't decode the "unordered" CBOR correctly + // This ensures compatibility with legacy scripts that might not follow modern canonical rules. + expect(wallet.capabilities!.address).toBe(mockSummonWallet.rawImportBodies.multisig!.address); + expect(wallet.scriptCbor).toBe(unorderedCbor); + + // Even if decoding fails, we should still have a nativeScript object (fallback) + expect(wallet.nativeScript).toBeDefined(); + }); +}); diff --git a/src/__tests__/taskBoardModel.test.ts b/src/__tests__/taskBoardModel.test.ts new file mode 100644 index 00000000..e55307df --- /dev/null +++ b/src/__tests__/taskBoardModel.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + applyMove, + filterPaidTasks, + formatTotals, + groupByColumn, + isPayoutReady, + isTaskSettled, + reorderColumn, +} from "@/components/pages/wallet/tasks/board-model"; +import type { BoardTask } from "@/components/pages/wallet/tasks/types"; + +/** + * The board's optimistic move must produce the same dense positions the + * server writes, or the cards jump when the real list arrives. + */ + +function task(id: string, status: BoardTask["status"], position: number): BoardTask { + return { + id, + walletId: "w", + title: id, + description: null, + status, + priority: null, + assigneeAddress: null, + dueDate: null, + position, + createdBy: "addr", + createdAt: new Date(2026, 0, 1, 0, 0, position), + updatedAt: new Date(), + recipients: [], + payouts: [], + payout: { state: "none", transactionId: null, txHash: null, payable: false, blocker: "no_recipients" }, + }; +} + +const tasks = [ + task("a", "Backlog", 0), + task("b", "Backlog", 1), + task("c", "Backlog", 2), + task("d", "InProgress", 0), +]; + +describe("groupByColumn", () => { + it("orders ids by position within each of the four columns", () => { + expect(groupByColumn(tasks)).toEqual({ + Backlog: ["a", "b", "c"], + InProgress: ["d"], + InReview: [], + Done: [], + }); + }); +}); + +describe("reorderColumn", () => { + it("moves a card downward using indices from the original list", () => { + expect(reorderColumn(["a", "b", "c"], "a", "b")).toEqual(["b", "a", "c"]); + }); + + it("moves a card upward", () => { + expect(reorderColumn(["a", "b", "c"], "c", "b")).toEqual(["a", "c", "b"]); + }); + + it("moves a card to the end when the column itself is the drop target", () => { + expect(reorderColumn(["a", "b", "c"], "a", null)).toEqual(["b", "c", "a"]); + }); + + it("returns the same list when the drop does not change its position", () => { + const ids = ["a", "b", "c"]; + expect(reorderColumn(ids, "b", "b")).toBe(ids); + }); +}); + +describe("applyMove", () => { + it("moves across columns and renumbers both densely", () => { + const next = applyMove(tasks, "b", "InProgress", 0); + expect(groupByColumn(next)).toMatchObject({ Backlog: ["a", "c"], InProgress: ["b", "d"] }); + const byId = new Map(next.map((t) => [t.id, t])); + expect([byId.get("a")!.position, byId.get("c")!.position]).toEqual([0, 1]); + expect([byId.get("b")!.position, byId.get("d")!.position]).toEqual([0, 1]); + expect(byId.get("b")!.status).toBe("InProgress"); + }); + + it("reorders within a column", () => { + const next = applyMove(tasks, "c", "Backlog", 0); + expect(groupByColumn(next).Backlog).toEqual(["c", "a", "b"]); + }); + + it("clamps an out-of-range position to the end", () => { + const next = applyMove(tasks, "a", "InProgress", 99); + expect(groupByColumn(next).InProgress).toEqual(["d", "a"]); + }); + + it("leaves the list untouched for an unknown id", () => { + expect(applyMove(tasks, "zzz", "Done", 0)).toBe(tasks); + }); +}); + +describe("formatTotals", () => { + it("sums per unit, ADA first, using registry decimals and names", () => { + const unit = "b".repeat(56) + "aa"; + const totals = formatTotals( + [ + { unit, quantity: "1500" }, + { unit: "lovelace", quantity: "2500000" }, + { unit: "lovelace", quantity: "500000" }, + { unit, quantity: "500" }, + ], + { [unit]: { assetName: "HOSKY", decimals: 2 } }, + ); + expect(totals).toEqual(["3 ADA", "20 HOSKY"]); + }); + + it("falls back to a truncated unit for unknown tokens and skips bad quantities", () => { + const unit = "c".repeat(56) + "dd"; + expect(formatTotals([{ unit, quantity: "7" }, { unit, quantity: "x" }], {})).toEqual([ + `7 ${unit.slice(0, 6)}…${unit.slice(-4)}`, + ]); + }); +}); + +describe("payout board state", () => { + it("only makes completed work selectable for payout", () => { + // The server decides; the board reads its answer rather than re-deriving it. + const configured = { + ...task("pay", "InReview", 0), + payout: { state: "ready" as const, transactionId: null, txHash: null, payable: false, blocker: "not_done" as const }, + }; + expect(isPayoutReady(configured)).toBe(false); + expect( + isPayoutReady({ payout: { ...configured.payout, payable: true, blocker: null } }), + ).toBe(true); + }); + + it("hides paid history by default without removing it", () => { + const active = task("active", "Done", 0); + const paid = { + ...task("paid", "Done", 1), + payout: { state: "paid" as const, transactionId: "tx-1", txHash: "ab", payable: false, blocker: "paid" as const }, + }; + expect(filterPaidTasks([active, paid], false)).toEqual([active]); + expect(filterPaidTasks([active, paid], true)).toEqual([active, paid]); + }); + + it("locks pending and paid tasks, but not configured or cancelled ones", () => { + const base = task("task", "Done", 0); + expect(isTaskSettled(base)).toBe(false); + expect( + isTaskSettled({ ...base, payout: { state: "ready", transactionId: null, txHash: null, payable: true, blocker: null } }), + ).toBe(false); + expect( + isTaskSettled({ ...base, payout: { state: "pending", transactionId: "tx-1", txHash: null, payable: false, blocker: "pending" } }), + ).toBe(true); + expect( + isTaskSettled({ ...base, payout: { state: "paid", transactionId: "tx-1", txHash: "ab", payable: false, blocker: "paid" } }), + ).toBe(true); + }); +}); diff --git a/src/__tests__/taskPayoutHooks.test.ts b/src/__tests__/taskPayoutHooks.test.ts new file mode 100644 index 00000000..15291a07 --- /dev/null +++ b/src/__tests__/taskPayoutHooks.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import type { Prisma } from "@prisma/client"; + +import type { ProposeDeps } from "@/lib/tx-review/propose"; +import type { VerifiedDraftToken } from "@/lib/tx-review/draft-token"; +import { TxReviewError } from "@/lib/tx-review/context"; +import { recipientsHash, TASKS_TXJSON_KEY } from "@/lib/task-payout/spec"; +import { withTaskPayoutHooks } from "@/lib/task-payout/hooks"; + +/** + * The confirm side of a task payout. Runs inside the pending row's insert + * transaction: links the tasks only if they still pay exactly what the + * token was minted for, and is a no-op for tokens without a task origin. + */ + +const SUBJECT = "addr_test1qpsubject"; +const ALICE = "addr_test1qpalice"; + +const rows = [ + { + id: "t1", + title: "Docs", + status: "Done", + recipients: [{ address: ALICE, unit: "lovelace", quantity: "5000000" }], + payouts: [] as { status: string }[], + }, + { + id: "t2", + title: "Tests", + status: "Done", + recipients: [{ address: ALICE, unit: "lovelace", quantity: "1000000" }], + payouts: [] as { status: string }[], + }, +]; + +const hash = recipientsHash(rows.map((r) => ({ id: r.id, title: r.title, recipients: r.recipients }))); + +function claims(overrides: Partial = {}): VerifiedDraftToken { + return { + jti: "jti-1", + subject: SUBJECT, + walletId: "wallet-1", + clientId: "app", + spec: { v: 1, walletId: "wallet-1", outputs: [], certificates: [], votes: [], description: "", metadataMessage: "" }, + previewTxHash: "beef", + card: "html", + origin: { kind: "tasks", taskIds: ["t1", "t2"], recipientsHash: hash }, + expiresAt: 0, + ...overrides, + }; +} + +function makeTx(found = rows) { + return { + task: { findMany: jest.fn<() => Promise>().mockResolvedValue(found) }, + taskPayout: { createMany: jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ count: found.length }) }, + } as unknown as Prisma.TransactionClient & { + task: { findMany: jest.Mock }; + taskPayout: { createMany: jest.Mock }; + }; +} + +const baseDeps = { + db: {} as never, + fetchFreeUtxos: async () => ({ status: 200, body: [] }), +} satisfies ProposeDeps; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("withTaskPayoutHooks", () => { + it("stamps the tasks namespace next to mcp and links every task inside the insert transaction", async () => { + const deps = withTaskPayoutHooks(baseDeps); + const c = claims(); + + const extras = deps.txJsonExtras!(c); + expect(extras[TASKS_TXJSON_KEY]).toMatchObject({ + taskIds: ["t1", "t2"], + recipientsHash: hash, + preparedBy: SUBJECT, + }); + expect(typeof (extras[TASKS_TXJSON_KEY] as { preparedAt: string }).preparedAt).toBe("string"); + + const tx = makeTx(); + await deps.afterCreate!(tx, { id: "tx-new" }, c); + expect(tx.task.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: { in: ["t1", "t2"] }, walletId: "wallet-1" } }), + ); + expect(tx.taskPayout.createMany).toHaveBeenCalledWith({ + data: [ + { walletId: "wallet-1", taskId: "t1", transactionId: "tx-new", status: "Pending", createdBy: SUBJECT }, + { walletId: "wallet-1", taskId: "t2", transactionId: "tx-new", status: "Pending", createdBy: SUBJECT }, + ], + }); + }); + + it("does nothing for a draft that did not come from tasks", async () => { + const deps = withTaskPayoutHooks(baseDeps); + const c = claims({ origin: null }); + expect(deps.txJsonExtras!(c)).toEqual({}); + const tx = makeTx(); + await deps.afterCreate!(tx, { id: "tx-new" }, c); + expect(tx.task.findMany).not.toHaveBeenCalled(); + expect(tx.taskPayout.createMany).not.toHaveBeenCalled(); + }); + + it("refuses when a recipient row changed since the preview, creating no link", async () => { + const deps = withTaskPayoutHooks(baseDeps); + const edited = rows.map((r) => + r.id === "t1" ? { ...r, recipients: [{ ...r.recipients[0]!, quantity: "5000001" }] } : r, + ); + const tx = makeTx(edited); + await expect(deps.afterCreate!(tx, { id: "tx-new" }, claims())).rejects.toMatchObject({ + status: 409, + code: "TASK_CHANGED", + }); + expect(tx.taskPayout.createMany).not.toHaveBeenCalled(); + }); + + it("refuses when a payout for one of the tasks appeared in the meantime", async () => { + const deps = withTaskPayoutHooks(baseDeps); + const raced = rows.map((r) => (r.id === "t2" ? { ...r, payouts: [{ status: "Pending" }] } : r)); + const tx = makeTx(raced); + const error = await deps.afterCreate!(tx, { id: "tx-new" }, claims()).catch((e: unknown) => e); + expect(error).toBeInstanceOf(TxReviewError); + expect(error).toMatchObject({ status: 409, code: "TASK_NOT_PAYABLE" }); + expect(tx.taskPayout.createMany).not.toHaveBeenCalled(); + }); + + it("refuses when a task moved out of Done after the preview", async () => { + const deps = withTaskPayoutHooks(baseDeps); + const reopened = rows.map((r) => (r.id === "t2" ? { ...r, status: "InReview" } : r)); + const tx = makeTx(reopened); + await expect(deps.afterCreate!(tx, { id: "tx-new" }, claims())).rejects.toMatchObject({ + status: 409, + code: "TASK_NOT_DONE", + }); + expect(tx.taskPayout.createMany).not.toHaveBeenCalled(); + }); + + it("refuses when a task vanished (or moved wallets), as not found", async () => { + const deps = withTaskPayoutHooks(baseDeps); + const tx = makeTx([rows[0]!]); + await expect(deps.afterCreate!(tx, { id: "tx-new" }, claims())).rejects.toMatchObject({ + status: 404, + code: "TASK_NOT_FOUND", + }); + }); + + it("composes with hooks already on the deps", async () => { + const innerExtras = jest.fn<() => Record>().mockReturnValue({ other: 1 }); + const innerAfter = jest.fn<() => Promise>().mockResolvedValue(undefined); + const deps = withTaskPayoutHooks({ ...baseDeps, txJsonExtras: innerExtras, afterCreate: innerAfter }); + const c = claims(); + expect(deps.txJsonExtras!(c)).toMatchObject({ other: 1, [TASKS_TXJSON_KEY]: expect.anything() }); + await deps.afterCreate!(makeTx(), { id: "tx-new" }, c); + expect(innerAfter).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/taskPayoutLoad.test.ts b/src/__tests__/taskPayoutLoad.test.ts new file mode 100644 index 00000000..344dc29b --- /dev/null +++ b/src/__tests__/taskPayoutLoad.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +import { loadAllPayableTasks, loadPayableTasks, MAX_PAYOUT_TASKS } from "@/lib/task-payout/load"; + +const ALICE = "addr_test1qpalice"; + +function row(id: string, status: "Backlog" | "InReview" | "Done") { + return { + id, + title: `Task ${id}`, + status, + recipients: [{ address: ALICE, unit: "lovelace", quantity: "1000000" }], + payouts: [], + }; +} + +function dbWith(rows: ReturnType[]) { + return { + task: { + findMany: jest.fn<(args: unknown) => Promise[]>>().mockResolvedValue(rows), + }, + }; +} + +describe("loadPayableTasks", () => { + it("refuses work that has not reached Done", async () => { + await expect( + loadPayableTasks(dbWith([row("t1", "InReview")]) as never, "wallet-1", ["t1"]), + ).rejects.toMatchObject({ + status: 409, + code: "TASK_NOT_DONE", + details: { taskIds: ["t1"] }, + }); + }); + + it("loads Done tasks in requested order", async () => { + const tasks = await loadPayableTasks( + dbWith([row("t2", "Done"), row("t1", "Done")]) as never, + "wallet-1", + ["t1", "t2"], + ); + expect(tasks.map((task) => task.id)).toEqual(["t1", "t2"]); + }); +}); + +describe("loadAllPayableTasks", () => { + // "Prepare payout" with nothing picked means every payable task. The query + // itself filters on Done + recipients + no active link, so the loader only + // has to bound the result and refuse an empty one. + it("returns the wallet's payable tasks in board order", async () => { + const db = dbWith([row("t1", "Done"), row("t2", "Done")]); + const tasks = await loadAllPayableTasks(db as never, "wallet-1"); + expect(tasks.map((task) => task.id)).toEqual(["t1", "t2"]); + expect(db.task.findMany.mock.calls[0]![0]).toMatchObject({ + where: { walletId: "wallet-1", status: "Done", recipients: { some: {} } }, + }); + }); + + it("refuses when nothing is payable", async () => { + await expect(loadAllPayableTasks(dbWith([]) as never, "wallet-1")).rejects.toMatchObject({ + status: 409, + code: "NO_PAYABLE_TASKS", + }); + }); + + it("asks for explicit ids beyond the per-payout cap", async () => { + const rows = Array.from({ length: MAX_PAYOUT_TASKS + 1 }, (_, i) => row(`t${i}`, "Done")); + await expect(loadAllPayableTasks(dbWith(rows) as never, "wallet-1")).rejects.toMatchObject({ + status: 400, + code: "TOO_MANY_TASKS", + details: { payableCount: MAX_PAYOUT_TASKS + 1, max: MAX_PAYOUT_TASKS }, + }); + }); +}); diff --git a/src/__tests__/taskPayoutSpec.test.ts b/src/__tests__/taskPayoutSpec.test.ts new file mode 100644 index 00000000..b8382fb3 --- /dev/null +++ b/src/__tests__/taskPayoutSpec.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@jest/globals"; + +import { MAX_DESCRIPTION_LENGTH } from "@/lib/tx-review/spec"; +import { + buildPayoutSpec, + payoutDescription, + recipientsHash, + type PayableTask, +} from "@/lib/task-payout/spec"; + +/** + * From task rows to the canonical spec the review pipeline builds from. The + * spec is what the draft token binds, so the merge, ordering and hashing + * rules here decide what the human confirms. + */ + +const ALICE = "addr_test1qpalice"; +const BOB = "addr_test1qpbob"; +const TOKEN = "a".repeat(56) + "48455348"; + +function task(id: string, recipients: PayableTask["recipients"], title = `Task ${id}`): PayableTask { + return { id, title, recipients }; +} + +describe("buildPayoutSpec", () => { + it("makes one output per address across tasks and sums per unit, lovelace first", () => { + const spec = buildPayoutSpec("wallet-1", [ + task("t1", [ + { address: ALICE, unit: "lovelace", quantity: "5000000" }, + { address: ALICE, unit: TOKEN.toUpperCase(), quantity: "10" }, + ]), + task("t2", [ + { address: BOB, unit: "lovelace", quantity: "1000000" }, + { address: ALICE, unit: "lovelace", quantity: "2500000" }, + { address: ALICE, unit: TOKEN, quantity: "5" }, + ]), + ]); + + expect(spec.v).toBe(1); + expect(spec.walletId).toBe("wallet-1"); + expect(spec.outputs).toEqual([ + { + address: ALICE, + assets: [ + { unit: "lovelace", quantity: "7500000" }, + { unit: TOKEN, quantity: "15" }, + ], + }, + { address: BOB, assets: [{ unit: "lovelace", quantity: "1000000" }] }, + ]); + expect(spec.certificates).toEqual([]); + expect(spec.votes).toEqual([]); + expect(spec.metadataMessage).toBe(""); + }); + + it("names the task in the description, and counts them when there are several", () => { + expect(buildPayoutSpec("w", [task("t1", [{ address: ALICE, unit: "lovelace", quantity: "1" }], "Ship docs")]).description).toBe( + "Payout: Ship docs", + ); + expect( + buildPayoutSpec("w", [ + task("t1", [{ address: ALICE, unit: "lovelace", quantity: "1" }], "A"), + task("t2", [{ address: BOB, unit: "lovelace", quantity: "1" }], "B"), + ]).description, + ).toBe("Payout for 2 tasks: A, B"); + }); + + it("keeps the description within the signer-facing limit", () => { + const long = payoutDescription([{ title: "x".repeat(300) }]); + expect(long.length).toBeLessThanOrEqual(MAX_DESCRIPTION_LENGTH); + expect(long.endsWith("…")).toBe(true); + }); + + it("refuses a task without recipients, naming it", () => { + expect(() => + buildPayoutSpec("w", [ + task("t1", [{ address: ALICE, unit: "lovelace", quantity: "1" }], "Paid one"), + task("t2", [], "Empty one"), + ]), + ).toThrow(/no payment recipients: "Empty one"/); + }); + + it("refuses an empty selection, a zero amount and a non-integer amount", () => { + expect(() => buildPayoutSpec("w", [])).toThrow(/No tasks/); + expect(() => buildPayoutSpec("w", [task("t", [{ address: ALICE, unit: "lovelace", quantity: "0" }])])).toThrow( + /zero amount/, + ); + expect(() => buildPayoutSpec("w", [task("t", [{ address: ALICE, unit: "lovelace", quantity: "1.5" }])])).toThrow( + /non-integer/, + ); + }); + + it("uses BigInt math, so large totals do not lose precision", () => { + const big = "9007199254740993"; // 2^53 + 1 + const spec = buildPayoutSpec("w", [ + task("t1", [{ address: ALICE, unit: "lovelace", quantity: big }]), + task("t2", [{ address: ALICE, unit: "lovelace", quantity: "1" }]), + ]); + expect(spec.outputs[0]!.assets[0]!.quantity).toBe("9007199254740994"); + }); +}); + +describe("recipientsHash", () => { + const base = [ + task("t1", [ + { address: ALICE, unit: "lovelace", quantity: "5000000" }, + { address: ALICE, unit: TOKEN, quantity: "10" }, + ]), + task("t2", [{ address: BOB, unit: "lovelace", quantity: "1000000" }]), + ]; + + it("is a hex sha256 that ignores task and recipient order", () => { + const a = recipientsHash(base); + expect(a).toMatch(/^[0-9a-f]{64}$/); + const reordered = [ + task("t2", [{ address: BOB, unit: "lovelace", quantity: "1000000" }]), + task("t1", [ + { address: ALICE, unit: TOKEN.toUpperCase(), quantity: "10" }, + { address: ALICE, unit: "lovelace", quantity: "5000000" }, + ]), + ]; + expect(recipientsHash(reordered)).toBe(a); + }); + + it("changes when an amount, an address, a unit or the task set changes", () => { + const a = recipientsHash(base); + const amount = [task("t1", base[0]!.recipients.map((r, i) => (i === 0 ? { ...r, quantity: "5000001" } : r))), base[1]!]; + const address = [base[0]!, task("t2", [{ address: "addr_test1qpmallory", unit: "lovelace", quantity: "1000000" }])]; + const otherTask = [base[0]!, task("t3", base[1]!.recipients)]; + expect(recipientsHash(amount)).not.toBe(a); + expect(recipientsHash(address)).not.toBe(a); + expect(recipientsHash(otherTask)).not.toBe(a); + expect(recipientsHash([base[0]!])).not.toBe(a); + }); +}); diff --git a/src/__tests__/taskPayoutState.test.ts b/src/__tests__/taskPayoutState.test.ts new file mode 100644 index 00000000..973edc23 --- /dev/null +++ b/src/__tests__/taskPayoutState.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@jest/globals"; + +import { derivePayoutState, PAYOUT_BLOCKER_LABELS, PAYOUT_STATE_LABELS } from "@/lib/task-payout/state"; + +/** + * The badge on a task card, and the "can this be paid now?" answer the + * board, the dialog and the MCP tools share. Derived, never stored, so the + * precedence lives in one place and the server and the board cannot disagree. + */ +describe("derivePayoutState", () => { + const one = { length: 1 }; + const none = { length: 0 }; + + it("is none without recipients and ready with them", () => { + expect(derivePayoutState({ status: "Done", recipients: none, payouts: [] })).toEqual({ + state: "none", + transactionId: null, + txHash: null, + payable: false, + blocker: "no_recipients", + }); + expect(derivePayoutState({ status: "Done", recipients: one, payouts: [] })).toEqual({ + state: "ready", + transactionId: null, + txHash: null, + payable: true, + blocker: null, + }); + }); + + it("is ready but not payable until the work reaches Done", () => { + const configured = derivePayoutState({ status: "InReview", recipients: one, payouts: [] }); + expect(configured.state).toBe("ready"); + expect(configured.payable).toBe(false); + expect(configured.blocker).toBe("not_done"); + }); + + it("is pending while a link awaits signatures, pointing at that transaction", () => { + expect( + derivePayoutState({ + status: "Done", + recipients: one, + payouts: [{ status: "Pending", transactionId: "tx-1" }], + }), + ).toEqual({ state: "pending", transactionId: "tx-1", txHash: null, payable: false, blocker: "pending" }); + }); + + it("is paid once any link is paid, even if a stale pending one remains", () => { + expect( + derivePayoutState({ + status: "Done", + recipients: one, + payouts: [ + { status: "Pending", transactionId: "tx-2" }, + { status: "Paid", transactionId: "tx-1", txHash: "abc" }, + ], + }), + ).toEqual({ state: "paid", transactionId: "tx-1", txHash: "abc", payable: false, blocker: "paid" }); + }); + + it("ignores cancelled links, so the task is payable again", () => { + const cancelled = [{ status: "Cancelled" as const, transactionId: "tx-1" }]; + const again = derivePayoutState({ status: "Done", recipients: one, payouts: cancelled }); + expect(again.state).toBe("ready"); + expect(again.payable).toBe(true); + expect(derivePayoutState({ status: "Done", recipients: none, payouts: cancelled }).state).toBe("none"); + }); + + it("ranks blockers: a payment outranks missing recipients, which outrank the column", () => { + // A paid task in Backlog with no recipients is still "paid", not "not done". + expect( + derivePayoutState({ + status: "Backlog", + recipients: none, + payouts: [{ status: "Paid", transactionId: "tx-1", txHash: "ab" }], + }).blocker, + ).toBe("paid"); + expect(derivePayoutState({ status: "Backlog", recipients: none, payouts: [] }).blocker).toBe("no_recipients"); + }); + + it("has a label for every visible state and every blocker", () => { + expect(PAYOUT_STATE_LABELS.none).toBe(""); + for (const state of ["ready", "pending", "paid"] as const) { + expect(PAYOUT_STATE_LABELS[state].length).toBeGreaterThan(0); + } + for (const blocker of ["paid", "pending", "no_recipients", "not_done"] as const) { + expect(PAYOUT_BLOCKER_LABELS[blocker].length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/__tests__/taskRecipients.test.ts b/src/__tests__/taskRecipients.test.ts new file mode 100644 index 00000000..219962f4 --- /dev/null +++ b/src/__tests__/taskRecipients.test.ts @@ -0,0 +1,87 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; + +/** + * `recipientsToBaseUnits`: the one conversion between the recipients a + * caller writes (display units, `transaction_preview`'s output shape) and + * the rows the board stores. It rides the tx-review normalizer, so the + * rules are the same as for a drafted payment: registry decimals, no + * guessing for a token without them. + */ + +const resolveAssetMetadataMock: jest.Mock = jest.fn(); +const getProviderMock: jest.Mock = jest.fn(); + +class TxReviewErrorMock extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly details?: Record, + ) { + super(message); + } +} + +jest.mock("@/lib/tx-review/context", () => ({ + __esModule: true, + TxReviewError: TxReviewErrorMock, +})); + +jest.mock("@/lib/tx-review/metadata", () => ({ + __esModule: true, + resolveAssetMetadata: resolveAssetMetadataMock, +})); + +jest.mock("@/utils/get-provider", () => ({ + __esModule: true, + getProvider: getProviderMock, +})); + +const POLICY = "a".repeat(56); +const HOSKY = `${POLICY}${Buffer.from("HOSKY").toString("hex")}`; +const decimals = new Map([[HOSKY, 0]]); + +let recipientsToBaseUnits: typeof import("@/lib/task-payout/recipients").recipientsToBaseUnits; + +beforeAll(async () => { + ({ recipientsToBaseUnits } = await import("@/lib/task-payout/recipients")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + getProviderMock.mockReturnValue({ provider: "stub" }); + (resolveAssetMetadataMock as any).mockResolvedValue({ + metadata: {}, + decimalsFor: (unit: string) => decimals.get(unit), + }); +}); + +describe("recipientsToBaseUnits", () => { + it("returns no rows for no recipients without touching the registry", async () => { + await expect(recipientsToBaseUnits(0, "wallet-1", [])).resolves.toEqual([]); + expect(resolveAssetMetadataMock).not.toHaveBeenCalled(); + }); + + it("converts ADA and tokens to base units, one row per (address, unit)", async () => { + const rows = await recipientsToBaseUnits(0, "wallet-1", [ + { address: "addr_test1qpx", ada: "5", assets: [{ unit: HOSKY, quantity: "1000" }] }, + { address: "addr_test1qpy", ada: "1.5" }, + ]); + expect(getProviderMock).toHaveBeenCalledWith(0); + expect(resolveAssetMetadataMock).toHaveBeenCalledWith({ provider: "stub" }, [HOSKY], 0); + expect(rows).toEqual([ + { address: "addr_test1qpx", unit: "lovelace", quantity: "5000000" }, + { address: "addr_test1qpx", unit: HOSKY, quantity: "1000" }, + { address: "addr_test1qpy", unit: "lovelace", quantity: "1500000" }, + ]); + }); + + it("refuses a fractional amount for a token without registered decimals", async () => { + (resolveAssetMetadataMock as any).mockResolvedValue({ metadata: {}, decimalsFor: () => undefined }); + await expect( + recipientsToBaseUnits(0, "wallet-1", [ + { address: "addr_test1qpx", assets: [{ unit: HOSKY, quantity: "2.5" }] }, + ]), + ).rejects.toMatchObject({ status: 400, code: "INVALID_SPEC", details: { issues: expect.any(Array) } }); + }); +}); diff --git a/src/__tests__/taskUpsert.test.ts b/src/__tests__/taskUpsert.test.ts new file mode 100644 index 00000000..90ac67ce --- /dev/null +++ b/src/__tests__/taskUpsert.test.ts @@ -0,0 +1,285 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { TRPCError } from "@trpc/server"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { createMockResponse, makeBearerAuth } from "./apiTestUtils"; + +/** + * POST /api/v1/taskUpsert — the handler behind the `task_upsert` MCP tool. + * + * Authorization and validation belong to the `task` router, which the + * handler runs in-process; what is tested here is the handler's own + * contract: bots are refused, recipients are converted from display units + * before they reach the router, create/update/move are sequenced the way + * the tool documents, and router errors come back as HTTP. + */ + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); +const applyAddressRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, address: string) => boolean>(); +const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean>(); +const verifyJwtMock: jest.Mock = jest.fn(); +const isBotJwtMock: jest.Mock = jest.fn(); +const createCallerMock: jest.Mock = jest.fn(); +const taskCreateMock: jest.Mock = jest.fn(); +const taskUpdateMock: jest.Mock = jest.fn(); +const taskMoveMock: jest.Mock = jest.fn(); +const recipientsToBaseUnitsMock: jest.Mock = jest.fn(); +const networkFromAddressMock: jest.Mock = jest.fn(); + +/** Stand-in for the pipeline's error class; the real module pulls Mesh. */ +class TxReviewErrorMock extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly details?: Record, + ) { + super(message); + } + toResult() { + return { status: this.status, body: { error: this.message, code: this.code, ...(this.details ?? {}) } }; + } +} + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + applyAddressRateLimit: applyAddressRateLimitMock, + enforceBodySize: enforceBodySizeMock, +})); + +jest.mock("@/lib/security/rateLimit", () => ({ + __esModule: true, + getClientIP: () => "127.0.0.1", +})); + +jest.mock("@/lib/verifyJwt", () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, +})); + +jest.mock("@/server/db", () => ({ + __esModule: true, + db: {}, +})); + +jest.mock("@/server/api/root", () => ({ + __esModule: true, + createCaller: createCallerMock, +})); + +jest.mock("@/lib/task-payout/recipients", () => ({ + __esModule: true, + recipientsToBaseUnits: recipientsToBaseUnitsMock, +})); + +jest.mock("@/lib/tx-review/context", () => ({ + __esModule: true, + TxReviewError: TxReviewErrorMock, + networkFromAddress: networkFromAddressMock, +})); + +const HUMAN_ADDRESS = "addr_test1qphumanfixture0000000000000000000000000000000000"; + +function task(over: Record = {}) { + return { + id: "task-1", + walletId: "wallet-1", + title: "Ship it", + description: null, + status: "Backlog", + priority: null, + assigneeAddress: null, + dueDate: null, + position: 0, + createdBy: HUMAN_ADDRESS, + createdAt: new Date("2026-09-01T00:00:00Z"), + updatedAt: new Date("2026-09-02T00:00:00Z"), + recipients: [], + payouts: [], + payout: { state: "none", transactionId: null, txHash: null, payable: false, blocker: "no_recipients" }, + ...over, + }; +} + +function request(body: Record): NextApiRequest { + return { method: "POST", headers: makeBearerAuth(), body } as unknown as NextApiRequest; +} + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/taskUpsert")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + applyBotRateLimitMock.mockReturnValue(true); + applyAddressRateLimitMock.mockReturnValue(true); + enforceBodySizeMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); + verifyJwtMock.mockReturnValue({ address: HUMAN_ADDRESS }); + isBotJwtMock.mockReturnValue(false); + networkFromAddressMock.mockReturnValue(0); + (recipientsToBaseUnitsMock as any).mockResolvedValue([]); + (taskCreateMock as any).mockResolvedValue(task()); + (taskUpdateMock as any).mockResolvedValue(task()); + (taskMoveMock as any).mockResolvedValue(task({ status: "Done", position: 2 })); + createCallerMock.mockReturnValue({ + task: { create: taskCreateMock, update: taskUpdateMock, move: taskMoveMock }, + }); +}); + +describe("taskUpsert v1 API", () => { + it("refuses bot keys before touching the board", async () => { + verifyJwtMock.mockReturnValue({ address: "addr_test1qbot", botId: "bot-1", type: "bot" }); + isBotJwtMock.mockReturnValue(true); + const res = createMockResponse(); + await handler(request({ walletId: "wallet-1", title: "x" }), res); + expect(applyBotRateLimitMock).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(createCallerMock).not.toHaveBeenCalled(); + }); + + it("creates a task as the token's address, with recipients converted from display units", async () => { + (recipientsToBaseUnitsMock as any).mockResolvedValue([ + { address: "addr_test1qrecipient", unit: "lovelace", quantity: "5000000" }, + ]); + const res = createMockResponse(); + await handler( + request({ + walletId: "wallet-1", + title: "Ship it", + status: "InProgress", + recipients: [{ address: "addr_test1qrecipient", ada: "5" }], + }), + res, + ); + expect(createCallerMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionAddress: HUMAN_ADDRESS, sessionWallets: [HUMAN_ADDRESS] }), + ); + expect(recipientsToBaseUnitsMock).toHaveBeenCalledWith(0, "wallet-1", [ + { address: "addr_test1qrecipient", ada: "5" }, + ]); + expect(taskCreateMock).toHaveBeenCalledWith({ + walletId: "wallet-1", + title: "Ship it", + status: "InProgress", + recipients: [{ address: "addr_test1qrecipient", unit: "lovelace", quantity: "5000000" }], + }); + expect(taskMoveMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ created: true, task: expect.objectContaining({ id: "task-1" }) }), + ); + }); + + it("creates with an empty recipient list when none is given, then moves when a position is", async () => { + const res = createMockResponse(); + await handler(request({ walletId: "wallet-1", title: "Ship it", position: 2 }), res); + expect(recipientsToBaseUnitsMock).not.toHaveBeenCalled(); + expect(taskCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ recipients: [] }), + ); + expect(taskMoveMock).toHaveBeenCalledWith({ id: "task-1", status: "Backlog", position: 2 }); + expect(res.status).toHaveBeenCalledWith(201); + }); + + it("requires a title to create", async () => { + const res = createMockResponse(); + await handler(request({ walletId: "wallet-1", description: "no title" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(taskCreateMock).not.toHaveBeenCalled(); + }); + + it("updates then moves an existing task when a status is given", async () => { + const res = createMockResponse(); + await handler( + request({ walletId: "wallet-1", taskId: "task-1", title: "Renamed", status: "Done", dueDate: null }), + res, + ); + expect(taskUpdateMock).toHaveBeenCalledWith({ id: "task-1", title: "Renamed", dueDate: null }); + expect(taskMoveMock).toHaveBeenCalledWith({ id: "task-1", status: "Done", position: 0 }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ created: false, task: expect.objectContaining({ status: "Done", position: 2 }) }), + ); + }); + + it("updates without moving when neither status nor position is given", async () => { + const res = createMockResponse(); + await handler(request({ walletId: "wallet-1", taskId: "task-1", priority: "High" }), res); + expect(taskUpdateMock).toHaveBeenCalledWith({ id: "task-1", priority: "High" }); + expect(taskMoveMock).not.toHaveBeenCalled(); + }); + + it("maps a locked task (router CONFLICT) onto 409", async () => { + (taskUpdateMock as any).mockRejectedValue( + new TRPCError({ + code: "CONFLICT", + message: "This task is locked while its payout is awaiting signatures. Delete the pending transaction first.", + }), + ); + const res = createMockResponse(); + await handler(request({ walletId: "wallet-1", taskId: "task-1", title: "x" }), res); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: "CONFLICT", error: expect.stringMatching(/awaiting signatures/) }), + ); + }); + + it("flattens a zod input failure to its first issue", async () => { + (taskCreateMock as any).mockRejectedValue( + new TRPCError({ + code: "BAD_REQUEST", + message: JSON.stringify([ + { path: ["recipients", 0, "address"], message: "Recipient must be a bech32 payment address" }, + ]), + }), + ); + const res = createMockResponse(); + await handler(request({ walletId: "wallet-1", title: "x" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "recipients.0.address: Recipient must be a bech32 payment address", + code: "BAD_REQUEST", + }); + }); + + it("returns the recipient normalizer's INVALID_SPEC with its issue list", async () => { + (recipientsToBaseUnitsMock as any).mockRejectedValue( + new TxReviewErrorMock(400, "INVALID_SPEC", "The recipients could not be understood: bad amount", { + issues: [{ level: "error", message: "bad amount" }], + }), + ); + const res = createMockResponse(); + await handler( + request({ walletId: "wallet-1", title: "x", recipients: [{ address: "addr_test1qrecipient", ada: "abc" }] }), + res, + ); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: "INVALID_SPEC", issues: [{ level: "error", message: "bad amount" }] }), + ); + expect(taskCreateMock).not.toHaveBeenCalled(); + }); + + it("requires walletId", async () => { + const res = createMockResponse(); + await handler(request({ title: "x" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(createCallerMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/tasks.bot.test.ts b/src/__tests__/tasks.bot.test.ts new file mode 100644 index 00000000..e735e2b7 --- /dev/null +++ b/src/__tests__/tasks.bot.test.ts @@ -0,0 +1,247 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { TRPCError } from "@trpc/server"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { + BOT_TEST_ADDRESS, + createMockResponse, + makeBearerAuth, + makeBotJwtPayload, +} from "./apiTestUtils"; + +/** + * GET /api/v1/tasks — the handler behind the `task_list` MCP tool. + * + * The point of the handler is dual-identity authorization: a bot is admitted + * by its WalletBotAccess grant (its payment address is not a signer, so the + * router's session check cannot be what admits it), a human by the router's + * signer-or-owner rule. Everything else is projection and filtering. + */ + +const addCorsHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); +const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); +const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); +const verifyJwtMock: jest.Mock = jest.fn(); +const isBotJwtMock: jest.Mock = jest.fn(); +const getBotWalletAccessMock: jest.Mock = jest.fn(); +const listWalletTasksMock: jest.Mock = jest.fn(); +const taskListMock: jest.Mock = jest.fn(); +const createCallerMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/cors", () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsHeadersMock, + cors: corsMock, +})); + +jest.mock("@/lib/security/requestGuards", () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, +})); + +jest.mock("@/lib/security/rateLimit", () => ({ + __esModule: true, + getClientIP: () => "127.0.0.1", +})); + +jest.mock("@/lib/verifyJwt", () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, +})); + +jest.mock("@/lib/auth/botAccess", () => ({ + __esModule: true, + getBotWalletAccess: getBotWalletAccessMock, +})); + +jest.mock("@/server/db", () => ({ + __esModule: true, + db: {}, +})); + +jest.mock("@/server/api/root", () => ({ + __esModule: true, + createCaller: createCallerMock, +})); + +// The router module pulls tRPC and Prisma; the handler needs only the shared +// board query and the status vocabulary from it. +jest.mock("@/server/api/routers/tasks", () => ({ + __esModule: true, + TASK_STATUSES: ["Backlog", "InProgress", "InReview", "Done"], + listWalletTasks: listWalletTasksMock, +})); + +const HUMAN_ADDRESS = "addr_test1qphumanfixture0000000000000000000000000000000000"; + +function task(over: Record = {}) { + return { + id: "task-1", + walletId: "wallet-1", + title: "Ship it", + description: null, + status: "Done", + priority: null, + assigneeAddress: null, + dueDate: null, + position: 0, + createdBy: HUMAN_ADDRESS, + createdAt: new Date("2026-09-01T00:00:00Z"), + updatedAt: new Date("2026-09-02T00:00:00Z"), + recipients: [{ address: "addr_test1qrecipient", unit: "lovelace", quantity: "5000000", label: null }], + payouts: [], + payout: { state: "ready", transactionId: null, txHash: null, payable: true, blocker: null }, + ...over, + }; +} + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import("../pages/api/v1/tasks")); +}); + +beforeEach(() => { + jest.clearAllMocks(); + applyRateLimitMock.mockReturnValue(true); + applyBotRateLimitMock.mockReturnValue(true); + corsMock.mockResolvedValue(undefined); + verifyJwtMock.mockReturnValue(makeBotJwtPayload()); + isBotJwtMock.mockReturnValue(true); + (getBotWalletAccessMock as any).mockResolvedValue({ allowed: true, role: "observer" }); + (listWalletTasksMock as any).mockResolvedValue([task()]); + (taskListMock as any).mockResolvedValue([task()]); + createCallerMock.mockReturnValue({ task: { list: taskListMock } }); +}); + +function botRequest(query: Record): NextApiRequest { + return { + method: "GET", + headers: makeBearerAuth(), + query: { walletId: "wallet-1", address: BOT_TEST_ADDRESS, ...query }, + } as unknown as NextApiRequest; +} + +describe("tasks v1 API — bot identity", () => { + it("returns 403 when the bot has no grant on the wallet (and never reads the board)", async () => { + (getBotWalletAccessMock as any).mockResolvedValue({ allowed: false, reason: "not_granted" }); + const res = createMockResponse(); + await handler(botRequest({}), res); + expect(res.status).toHaveBeenCalledWith(403); + expect(listWalletTasksMock).not.toHaveBeenCalled(); + expect(createCallerMock).not.toHaveBeenCalled(); + }); + + it("returns 404 for an unknown wallet", async () => { + (getBotWalletAccessMock as any).mockResolvedValue({ allowed: false, reason: "wallet_not_found" }); + const res = createMockResponse(); + await handler(botRequest({}), res); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it("admits a granted observer through the shared board query, not the tRPC session", async () => { + const res = createMockResponse(); + await handler(botRequest({}), res); + expect(getBotWalletAccessMock).toHaveBeenCalledWith(expect.anything(), "wallet-1", expect.any(String)); + expect(listWalletTasksMock).toHaveBeenCalledWith(expect.anything(), "wallet-1"); + // A bot's payment address is not a signer: the router's session check + // would refuse it, so the handler must not go that way. + expect(createCallerMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + const body = (res.json as jest.Mock).mock.calls[0]![0] as { + tasks: { id: string; dueDate: null; createdAt: string; payout: { payable: boolean } }[]; + count: number; + payableCount: number; + }; + expect(body.count).toBe(1); + expect(body.payableCount).toBe(1); + expect(body.tasks[0]!.createdAt).toBe("2026-09-01T00:00:00.000Z"); + expect(body.tasks[0]!.payout.payable).toBe(true); + }); + + it("filters by status and payable while payableCount stays wallet-wide", async () => { + (listWalletTasksMock as any).mockResolvedValue([ + task({ id: "done-payable" }), + task({ + id: "done-blocked", + payout: { state: "pending", transactionId: "tx-1", txHash: null, payable: false, blocker: "pending" }, + }), + task({ + id: "backlog", + status: "Backlog", + payout: { state: "ready", transactionId: null, txHash: null, payable: false, blocker: "not_done" }, + }), + ]); + + let res = createMockResponse(); + await handler(botRequest({ status: "Done" }), res); + let body = (res.json as jest.Mock).mock.calls[0]![0] as { tasks: { id: string }[]; count: number; payableCount: number }; + expect(body.tasks.map((t) => t.id)).toEqual(["done-payable", "done-blocked"]); + expect(body.payableCount).toBe(1); + + res = createMockResponse(); + await handler(botRequest({ payable: "true" }), res); + body = (res.json as jest.Mock).mock.calls[0]![0] as typeof body; + expect(body.tasks.map((t) => t.id)).toEqual(["done-payable"]); + expect(body.count).toBe(1); + expect(body.payableCount).toBe(1); + }); + + it("rejects a status outside the board's columns", async () => { + const res = createMockResponse(); + await handler(botRequest({ status: "Archived" }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(listWalletTasksMock).not.toHaveBeenCalled(); + }); +}); + +describe("tasks v1 API — human identity", () => { + beforeEach(() => { + verifyJwtMock.mockReturnValue({ address: HUMAN_ADDRESS }); + isBotJwtMock.mockReturnValue(false); + }); + + it("refuses an address that does not match the token", async () => { + const res = createMockResponse(); + await handler( + { method: "GET", headers: makeBearerAuth(), query: { walletId: "wallet-1", address: "addr_test1qsomeoneelse" } } as unknown as NextApiRequest, + res, + ); + expect(res.status).toHaveBeenCalledWith(403); + expect(createCallerMock).not.toHaveBeenCalled(); + }); + + it("lists through the task router as the token's address only", async () => { + const res = createMockResponse(); + await handler( + { method: "GET", headers: makeBearerAuth(), query: { walletId: "wallet-1", address: HUMAN_ADDRESS } } as unknown as NextApiRequest, + res, + ); + expect(createCallerMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionAddress: HUMAN_ADDRESS, + sessionWallets: [HUMAN_ADDRESS], + primaryWallet: HUMAN_ADDRESS, + }), + ); + expect(taskListMock).toHaveBeenCalledWith({ walletId: "wallet-1" }); + expect(getBotWalletAccessMock).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("maps the router's FORBIDDEN onto 403 with its message", async () => { + (taskListMock as any).mockRejectedValue( + new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this wallet" }), + ); + const res = createMockResponse(); + await handler( + { method: "GET", headers: makeBearerAuth(), query: { walletId: "wallet-1", address: HUMAN_ADDRESS } } as unknown as NextApiRequest, + res, + ); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: "Not authorized for this wallet", code: "FORBIDDEN" }); + }); +}); diff --git a/src/__tests__/thresholdReached.test.ts b/src/__tests__/thresholdReached.test.ts new file mode 100644 index 00000000..5ee79f73 --- /dev/null +++ b/src/__tests__/thresholdReached.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +jest.mock("@/lib/notifications/worker", () => ({ + __esModule: true, + drainNotificationOutbox: jest.fn(async () => []), +})); + +import { + crossedSignatureThreshold, + enqueueThresholdReachedNotifications, +} from "@/lib/notifications/center"; +import { + NOTIFICATION_EVENT_THRESHOLD_REACHED, + NOTIFICATION_STATUS_PENDING, + NOTIFICATION_STATUS_SKIPPED_NO_EMAIL, +} from "@/lib/notifications/events"; +import { resolveWalletSignerRecipients } from "@/lib/notifications/recipients"; +import { renderThresholdReachedEmail } from "@/lib/notifications/templates/thresholdReached"; + +const wallet = { + id: "wallet_1", + name: "Treasury", + signersAddresses: ["addr_creator", "addr_second", "addr_third"], + numRequiredSigners: 2, + type: "atLeast", +}; + +function makeSetting(address: string, overrides: Record = {}) { + return { + walletId: "wallet_1", + signerAddress: address, + email: `${address}@example.com`, + emailNormalized: `${address}@example.com`, + emailVerifiedAt: new Date(), + emailOptIn: true, + notifyTransactionSignatures: true, + notifySignableSignatures: true, + notifyThresholdReached: true, + notifyBallotDeadlines: true, + ...overrides, + }; +} + +function makeDb(settings: unknown[]) { + const upsert = jest.fn( + async (args: { create: Record }) => args.create, + ); + return { + db: { + walletSignerNotificationSetting: { + findMany: jest.fn(async () => settings), + }, + notificationDelivery: { upsert }, + }, + upsert, + }; +} + +describe("crossedSignatureThreshold", () => { + it("fires only when the count moves from below to at-or-above required", () => { + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 1, signedCount: 2 }), + ).toBe(true); + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 0, signedCount: 1 }), + ).toBe(false); + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 2, signedCount: 3 }), + ).toBe(false); + expect( + crossedSignatureThreshold({ wallet, previousSignedCount: 0, signedCount: 3 }), + ).toBe(true); + }); + + it("uses the signer count for 'all' wallets and 1 for 'any'", () => { + expect( + crossedSignatureThreshold({ + wallet: { ...wallet, type: "all", numRequiredSigners: null }, + previousSignedCount: 2, + signedCount: 3, + }), + ).toBe(true); + expect( + crossedSignatureThreshold({ + wallet: { ...wallet, type: "any" }, + previousSignedCount: 0, + signedCount: 1, + }), + ).toBe(true); + }); +}); + +describe("resolveWalletSignerRecipients", () => { + it("includes every signer except excluded ones and reports skip reasons", async () => { + const { db } = makeDb([makeSetting("addr_creator"), makeSetting("addr_second")]); + + const result = await resolveWalletSignerRecipients(db as any, { + walletId: "wallet_1", + signerAddresses: wallet.signersAddresses, + preferenceField: "notifyThresholdReached", + excludeAddresses: ["addr_second", null], + }); + + expect(result.eligible.map((r) => r.address)).toEqual(["addr_creator"]); + expect(result.skipped).toEqual([ + { address: "addr_third", reason: NOTIFICATION_STATUS_SKIPPED_NO_EMAIL }, + ]); + }); +}); + +describe("enqueueThresholdReachedNotifications", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("does nothing when the update did not cross the threshold", async () => { + const { db, upsert } = makeDb([makeSetting("addr_creator")]); + + const deliveries = await enqueueThresholdReachedNotifications(db as any, { + wallet, + resourceType: "transaction", + resourceId: "tx_1", + previousSignedAddresses: ["addr_creator"], + signedAddresses: ["addr_creator"], + actorAddress: "addr_creator", + }); + + expect(deliveries).toEqual([]); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("notifies the creator and other signers but not the actor once crossed", async () => { + const { db, upsert } = makeDb([ + makeSetting("addr_creator"), + makeSetting("addr_second"), + makeSetting("addr_third", { notifyThresholdReached: false }), + ]); + + const deliveries = await enqueueThresholdReachedNotifications(db as any, { + wallet, + resourceType: "transaction", + resourceId: "tx_1", + previousSignedAddresses: ["addr_creator"], + signedAddresses: ["addr_creator", "addr_second"], + actorAddress: "addr_second", + description: "Pay contractor", + txHash: "abc123", + }); + + expect(upsert).toHaveBeenCalledTimes(2); + const byAddress = new Map( + deliveries.map((delivery: any) => [delivery.recipientAddress, delivery]), + ); + expect(byAddress.get("addr_creator")).toMatchObject({ + eventType: NOTIFICATION_EVENT_THRESHOLD_REACHED, + status: NOTIFICATION_STATUS_PENDING, + recipientEmail: "addr_creator@example.com", + idempotencyKey: + "threshold.reached:email:transaction:tx_1:wallet_1:addr_creator", + subject: "Signatures complete: Treasury", + }); + expect(byAddress.get("addr_third")).toMatchObject({ + status: "skipped_disabled", + recipientEmail: null, + }); + expect(byAddress.has("addr_second")).toBe(false); + const payload = byAddress.get("addr_creator")!.payload as Record; + expect(payload.txHash).toBe("abc123"); + expect(String(payload.text)).toContain("Submitted to the network."); + }); +}); + +describe("renderThresholdReachedEmail", () => { + const base = { + walletName: "Treasury + + +`; + +/** The `resources/read` payload for the review-card view. */ +export function reviewCardResourceContents() { + return { + contents: [ + { + uri: REVIEW_CARD_RESOURCE_URI, + mimeType: REVIEW_CARD_MIME_TYPE, + text: REVIEW_CARD_HTML, + _meta: REVIEW_CARD_RESOURCE_META, + }, + ], + }; +} diff --git a/src/lib/mcp/schemas.ts b/src/lib/mcp/schemas.ts index bd671318..adea50e8 100644 --- a/src/lib/mcp/schemas.ts +++ b/src/lib/mcp/schemas.ts @@ -17,13 +17,14 @@ export type JsonSchema = Record; const walletId = { type: "string", minLength: 1, - description: "Wallet UUID from the multisig database (not a Cardano address).", + description: + "Wallet UUID from the multisig database (not a Cardano address).", } as const; const network = { type: "string", enum: ["0", "1"], - description: "Cardano network: \"0\" = preprod, \"1\" = mainnet.", + description: 'Cardano network: "0" = preprod, "1" = mainnet.', } as const; export const EMPTY_INPUT: JsonSchema = { @@ -32,6 +33,169 @@ export const EMPTY_INPUT: JsonSchema = { additionalProperties: false, }; +/** + * Transaction drafting. Amounts are DISPLAY units — "12.5" ADA, "100" of a + * token with its registered decimals — because that is how a person states + * a payment; the server converts using the token registry and refuses to + * guess when a token has no registered decimals. + */ +/** One payment recipient in display units; shared by transaction and task drafting. */ +const paymentOutputItem = { + type: "object", + properties: { + address: { + type: "string", + pattern: "^addr(_test)?1[0-9a-z]+$", + description: "Recipient payment address (bech32, addr1... or addr_test1...).", + }, + ada: { + type: "string", + pattern: "^\\d+(\\.\\d{1,6})?$", + description: 'ADA to send, in ADA (not lovelace), e.g. "12.5".', + }, + assets: { + type: "array", + maxItems: 10, + description: "Native assets to send with this output.", + items: { + type: "object", + properties: { + unit: { + type: "string", + pattern: "^[0-9a-fA-F]{56,120}$", + description: + "Asset unit: policy id followed by the hex-encoded asset name.", + }, + quantity: { + type: "string", + pattern: "^\\d+(\\.\\d+)?$", + description: + "Quantity in the token's display units (its registered decimals). For a token with no registered decimals, a whole number of raw units.", + }, + }, + required: ["unit", "quantity"], + additionalProperties: false, + }, + }, + }, + required: ["address"], + additionalProperties: false, +} as const; + +/** + * How the review card is delivered. Shared by every tool that returns one + * except transaction_propose, which inherits the mode from the draft token. + */ +const cardMode = { + type: "string", + enum: ["html", "image"], + description: + 'How to deliver the review card. "html" (default): the client\'s inline card view draws it from the structured summary; no image is attached. "image": also attach the card as a PNG image block — use it when the user asks for a picture of the card, or when this client shows images but not inline card views.', +} as const; + +export const TRANSACTION_PREVIEW_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + outputs: { + type: "array", + maxItems: 20, + description: + "Recipients. Each needs an address and at least an ADA amount or one asset.", + items: paymentOutputItem, + }, + certificates: { + type: "array", + maxItems: 3, + description: + "Staking certificates for the wallet's own stake credential. DelegateStake needs poolId. If the credential is not yet registered on chain, a RegisterStake (2 ADA refundable deposit) is added automatically ahead of a DelegateStake and reported in warnings — say so when showing the card. A RegisterStake for an already-registered credential is rejected.", + items: { + type: "object", + properties: { + kind: { + type: "string", + enum: ["RegisterStake", "DelegateStake", "DeregisterStake"], + }, + poolId: { + type: "string", + description: "Stake pool id, bech32 (pool1...) or 56-char hex. DelegateStake only.", + }, + }, + required: ["kind"], + additionalProperties: false, + }, + }, + votes: { + type: "array", + maxItems: 10, + description: + "Governance votes cast as the wallet's DRep. The wallet must be registered as a DRep on chain: if it is not, the tool refuses the draft — tell the user the wallet cannot vote until it is registered as a DRep in the app.", + items: { + type: "object", + properties: { + proposalId: { + type: "string", + pattern: "^[0-9a-fA-F]{64}#\\d+$", + description: 'Governance action id as "#".', + }, + vote: { type: "string", enum: ["Yes", "No", "Abstain"] }, + rationale: { + type: "string", + maxLength: 10000, + description: + "Optional rationale text. It is NOT published at preview time; on transaction_propose it becomes a public CIP-100 document on IPFS and is anchored to the vote.", + }, + }, + required: ["proposalId", "vote"], + additionalProperties: false, + }, + }, + description: { + type: "string", + maxLength: 128, + description: "Off-chain note shown to signers in the app.", + }, + metadataMessage: { + type: "string", + maxLength: 64, + description: "Optional on-chain CIP-20 message (metadata label 674). Public and permanent.", + }, + card: cardMode, + }, + required: ["walletId"], + additionalProperties: false, +}; + +export const TRANSACTION_PROPOSE_INPUT: JsonSchema = { + type: "object", + properties: { + draftToken: { + type: "string", + minLength: 1, + description: + "The draftToken returned by transaction_preview for the card the user approved. Nothing else is accepted: the created transaction is exactly the one that was reviewed.", + }, + }, + required: ["draftToken"], + additionalProperties: false, +}; + +export const REVIEW_PENDING_TRANSACTION_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + transactionId: { + type: "string", + minLength: 1, + description: + "Pending transaction id, as listed by multisig_list_pending_transactions.", + }, + card: cardMode, + }, + required: ["walletId", "transactionId"], + additionalProperties: false, +}; + export const WALLET_ONLY_INPUT: JsonSchema = { type: "object", properties: { walletId }, @@ -39,6 +203,33 @@ export const WALLET_ONLY_INPUT: JsonSchema = { additionalProperties: false, }; +export const DOCUMENT_LIST_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + includeArchived: { + type: "boolean", + default: false, + description: "Include archived documents. Off by default.", + }, + }, + required: ["walletId"], + additionalProperties: false, +}; + +export const DOCUMENT_GET_INPUT: JsonSchema = { + type: "object", + properties: { + documentId: { + type: "string", + description: + "Document id, as returned by document_list in the documentId field.", + }, + }, + required: ["documentId"], + additionalProperties: false, +}; + export const FREE_UTXOS_INPUT: JsonSchema = { type: "object", properties: { @@ -70,6 +261,8 @@ export const PROXY_DREP_INFO_INPUT: JsonSchema = { export const LOOKUP_WALLET_INPUT: JsonSchema = { type: "object", + description: + "Provide exactly one selector: pubKeyHashes (by signer), scriptHash (by policy) or address (by multisig wallet address).", properties: { pubKeyHashes: { type: "array", @@ -77,11 +270,22 @@ export const LOOKUP_WALLET_INPUT: JsonSchema = { minItems: 1, maxItems: 50, description: - "Payment public key hashes (56 lowercase hex chars each) to match against on-chain CIP-1854 registration metadata.", + "Participant public key hashes (56 lowercase hex chars each) to match against on-chain CIP-1854 registration metadata. Returns every registration listing ANY of them.", + }, + scriptHash: { + type: "string", + pattern: "^[0-9a-f]{56}$", + description: + "Native-script hash (policy id) of the multisig wallet. The script is resolved on-chain to its signer key hashes and only registrations listing ALL of them are returned.", + }, + address: { + type: "string", + minLength: 1, + description: + "Bech32 multisig wallet address (script payment credential). Resolved the same way as scriptHash.", }, network, }, - required: ["pubKeyHashes"], additionalProperties: false, }; @@ -132,11 +336,13 @@ export const BALLOT_UPSERT_INPUT: JsonSchema = { proposalId: { type: "string", minLength: 1, - description: "Governance proposal id, in # form.", + description: + "Governance proposal id, in # form.", }, proposalTitle: { type: "string", - description: "Human-readable proposal title. Required by the handler.", + description: + "Human-readable proposal title. Required by the handler.", }, choice: { type: "string", @@ -213,7 +419,8 @@ export const PUBLISH_RATIONALE_INPUT: JsonSchema = { proposalId: { type: "string", minLength: 1, - description: "Governance proposal id (#) on that ballot.", + description: + "Governance proposal id (#) on that ballot.", }, summary: { type: "string", @@ -246,3 +453,88 @@ export const PUBLISH_RATIONALE_INPUT: JsonSchema = { required: ["walletId", "ballotId", "proposalId"], additionalProperties: false, }; + +/** + * Project task board. Tasks live in four fixed columns; a task may carry + * payment recipients, and `task_prepare_payout` turns selected tasks into a + * transaction draft through the same preview → confirm flow as + * `transaction_preview`. + */ +const taskStatus = { + type: "string", + enum: ["Backlog", "InProgress", "InReview", "Done"], + description: "Board column.", +} as const; + +export const TASK_LIST_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + status: { ...taskStatus, description: "Only tasks in this column." }, + payable: { + type: "boolean", + description: + "Only tasks that can be paid right now: in Done, with recipients, and no pending or paid payout.", + }, + }, + required: ["walletId"], + additionalProperties: false, +}; + +export const TASK_UPSERT_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + taskId: { + type: "string", + minLength: 1, + description: "Existing task to update. Omit to create a new task (title required).", + }, + title: { type: "string", minLength: 1, maxLength: 200 }, + description: { type: ["string", "null"], maxLength: 4000 }, + status: taskStatus, + priority: { type: ["string", "null"], enum: ["Low", "Medium", "High", null] }, + assigneeAddress: { + type: ["string", "null"], + description: "Payment address of the signer responsible, or null to clear.", + }, + dueDate: { + type: ["string", "null"], + format: "date-time", + description: "ISO 8601 date-time, or null to clear.", + }, + position: { + type: "integer", + minimum: 0, + description: "Index within the column after the move (0 = top).", + }, + recipients: { + type: "array", + maxItems: 20, + description: + "Replaces the task's payment recipients (display units, like transaction_preview outputs). An empty array clears them. The whole task is read-only while a payout awaits signatures and after it is paid.", + items: paymentOutputItem, + }, + }, + required: ["walletId"], + additionalProperties: false, +}; + +export const TASK_PREPARE_PAYOUT_INPUT: JsonSchema = { + type: "object", + properties: { + walletId, + taskIds: { + type: "array", + minItems: 1, + maxItems: 20, + uniqueItems: true, + items: { type: "string", minLength: 1 }, + description: + "Tasks to pay in one transaction. Omit to pay every payable task in the wallet (task_list marks them payable: true). Each must be in Done, have recipients and have no pending or paid payout.", + }, + card: cardMode, + }, + required: ["walletId"], + additionalProperties: false, +}; diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts index c98e931e..fcc9bef4 100644 --- a/src/lib/mcp/scopes.ts +++ b/src/lib/mcp/scopes.ts @@ -14,6 +14,9 @@ export const MCP_SCOPES = [ "wallets:read", "governance:read", "ballots:write", + "documents:read", + "transactions:write", + "tasks:write", ] as const; export type McpScope = (typeof MCP_SCOPES)[number]; @@ -28,6 +31,20 @@ export const MCP_SCOPE_DESCRIPTIONS: Record = { // public and effectively permanent — "ballot drafts" alone undersells that. "ballots:write": "Create and update governance ballot drafts, and publish rationale documents publicly to IPFS. Cannot vote on-chain.", + // Read-only and says so. Sign-off approvals are CIP-8 signatures from a named + // human signer; nothing reachable through MCP can produce one. + "documents:read": + "Read your wallets' sign-off documents: titles, version history, content hashes and who still needs to sign. Cannot create, edit, approve or sign anything.", + // The only scope that reaches the transaction table. It drafts: every + // transaction it creates starts with zero signatures and is signed by humans + // in the app. The IPFS side effect is named here for the same reason as on + // ballots:write — a pinned rationale is public and permanent. + "transactions:write": + "Draft unsigned transactions (payments, staking certificates, DRep votes) for this wallet's signers to review and sign in the app. Cannot sign or broadcast. Vote rationales you supply are published publicly to IPFS when a draft is confirmed.", + // Board writes only. Reading the board rides wallets:read; turning a task + // into a payout draft is transactions:write, like any other draft. + "tasks:write": + "Create, edit and move project tasks on this wallet's task board, including their payment recipients. Cannot create, sign or broadcast a transaction; preparing a payout needs transactions:write.", }; export function isMcpScope(value: string): value is McpScope { diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index bed18f37..adcc25d8 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -2,13 +2,40 @@ import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server"; import { db } from "@/server/db"; import { audit } from "@/lib/observability/audit"; +import { + REVIEW_CARD_MIME_TYPE, + REVIEW_CARD_RESOURCE_META, + REVIEW_CARD_RESOURCE_URI, + reviewCardResourceContents, +} from "@/lib/mcp/apps/review-card"; import type { McpCaller } from "@/lib/mcp/auth"; -import type { V1Result } from "@/lib/mcp/invokeV1"; -import { toolsForScopes, type McpToolDef } from "@/lib/mcp/tools"; +import { + toolsForScopes, + type McpToolDef, + type McpToolResult, +} from "@/lib/mcp/tools"; export const MCP_SERVER_NAME = "mesh-multisig"; export const MCP_SERVER_VERSION = "0.1.0"; +/** + * Server instructions, returned in the initialize result. MCP clients feed + * this into the model's context for the whole conversation, so it is kept + * short and behavioural: what the model must DO with what the tools return. + * + * The review tools return the transaction card as an image block. Without + * this, a model treats the image as optional and describes it in prose; the + * whole point of the card is that the human reads the picture. + */ +export const MCP_SERVER_INSTRUCTIONS = [ + "Mesh Multisig: read Cardano multisig wallets, draft ballots, and draft unsigned transactions for humans to sign in the app.", + "transaction_preview, transaction_propose and multisig_review_pending_transaction return the transaction review card. By default the client's inline card view draws it next to the tool call; if the user cannot see a card there, relay the result's summary in the same turn. When the user wants a picture of the card, or the client shows images but not inline views, pass card: \"image\" and present the returned image in your reply, never a prose description.", + "After transaction_preview, make sure the user has seen the card and ask them to confirm before calling transaction_propose with the returned draftToken. If the client renders the inline card view, the user may instead confirm by clicking its Confirm button, and you will be told when that happens.", + "task_prepare_payout works the same way for the project task board: Done tasks with recipients are payable (task_list marks them payable: true); call it with their ids, or with no taskIds to pay all of them, and transaction_propose creates the payout and links the tasks.", + "Nothing on this server signs or broadcasts; every transaction is signed by the wallet's signers in the app.", +].join(" "); + + /** * Build the MCP server for a single request. * @@ -18,10 +45,33 @@ export const MCP_SERVER_VERSION = "0.1.0"; * than the first, which is exactly the kind of bug a one-shot smoke test misses. */ export function createMcpServer(caller: McpCaller, clientIp: string): McpServer { - const server = new McpServer({ - name: MCP_SERVER_NAME, - version: MCP_SERVER_VERSION, - }); + const server = new McpServer( + { + name: MCP_SERVER_NAME, + version: MCP_SERVER_VERSION, + }, + { instructions: MCP_SERVER_INSTRUCTIONS }, + ); + + // The review-card MCP App view. Static HTML, no data of its own — it is + // filled by the tool result the host hands it — so it is registered for + // every caller and never gated on the client advertising the UI extension + // (claude.ai renders apps without advertising it). Each request is a fresh + // server, so `resources/read` works from any session, which the Claude app + // relies on: it may read the resource from a different session than the + // one that called the tool. + server.registerResource( + "review-card", + REVIEW_CARD_RESOURCE_URI, + { + title: "Transaction review card", + description: + "Inline view of a transaction review card with a Confirm button (MCP Apps).", + mimeType: REVIEW_CARD_MIME_TYPE, + _meta: REVIEW_CARD_RESOURCE_META, + }, + async () => reviewCardResourceContents(), + ); // Register only what this caller's scopes actually permit, so `tools/list` // never advertises a tool that would come back 403. The model does not see @@ -34,6 +84,15 @@ export function createMcpServer(caller: McpCaller, clientIp: string): McpServer description: tool.description, inputSchema: fromJsonSchema(tool.inputSchema), annotations: tool.annotations, + // Both the current and the deprecated key: older hosts read the flat one. + ...(tool.uiResourceUri + ? { + _meta: { + ui: { resourceUri: tool.uiResourceUri }, + "ui/resourceUri": tool.uiResourceUri, + }, + } + : {}), }, async (args: unknown) => { const input = (args ?? {}) as Record; @@ -60,6 +119,7 @@ export function createMcpServer(caller: McpCaller, clientIp: string): McpServer clientIp, status: result.status, durationMs: Date.now() - startedAt, + extra: result.audit, }); return toToolResult(result); }, @@ -82,7 +142,9 @@ export const MCP_TOOL_ACTION = "mcp.tool.called"; * * Only the walletId is taken from the arguments. Tool inputs can carry * user-authored prose — ballot rationales, descriptions — which has no place in - * an audit row. + * an audit row. A tool whose input names no wallet (`transaction_propose` + * takes only a token) supplies it through `extra` instead, along with the + * identifiers of what it created. */ function recordToolCall(args: { tool: McpToolDef; @@ -92,9 +154,15 @@ function recordToolCall(args: { status: number; durationMs: number; reason?: string; + extra?: McpToolResult["audit"]; }) { + const extra = args.extra ?? {}; const walletId = - typeof args.input.walletId === "string" ? args.input.walletId : null; + typeof args.input.walletId === "string" + ? args.input.walletId + : typeof extra.walletId === "string" + ? extra.walletId + : null; return audit(db, { actorAddress: args.caller.subject, @@ -114,23 +182,36 @@ function recordToolCall(args: { readOnly: args.tool.annotations.readOnlyHint, status: args.status, durationMs: args.durationMs, + ...extra, }, }); } /** - * Map a v1 handler result onto an MCP tool result. + * Map a tool result onto the MCP wire shape. * * A non-2xx becomes `isError: true` with the handler's own error body rather * than a thrown exception, so the model can read what went wrong and correct * itself (wrong walletId, missing scope) instead of just seeing a failure. + * + * The text block is the JSON body unless the tool supplied a readable `text`; + * any `images` follow it as `image` blocks. Image bytes never enter + * `structuredContent` — a base64 PNG there would be re-serialized into the + * model's context as a string of tens of kilobytes for no benefit. + * + * Content blocks carry NO `annotations`, although the spec allows them + * (`audience`, `priority`): the Claude app rejected a tool result whose image + * block carried them ("Unexpected response type"), and the model is steered + * by the server instructions, the tool descriptions and the text block + * instead. Keep the wire shape to `type`/`text` and `type`/`data`/`mimeType`. */ -export function toToolResult(result: V1Result) { +export function toToolResult(result: McpToolResult) { const isError = result.status >= 400; const body = result.body; const text = - typeof body === "string" ? body : JSON.stringify(body ?? null, null, 2); + result.text ?? + (typeof body === "string" ? body : JSON.stringify(body ?? null, null, 2)); // `structuredContent` must be a JSON object. Arrays are already wrapped by the // registry; anything else (a bare string body) travels as text only. @@ -139,8 +220,14 @@ export function toToolResult(result: V1Result) { ? (body as Record) : undefined; + const images = (result.images ?? []).map((image) => ({ + type: "image" as const, + data: image.data, + mimeType: image.mimeType, + })); + return { - content: [{ type: "text" as const, text }], + content: [{ type: "text" as const, text }, ...images], ...(structured ? { structuredContent: structured } : {}), isError, }; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 910a1d45..68809d40 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -6,8 +6,16 @@ import type { McpScope } from "@/lib/mcp/scopes"; import { ACTIVE_PROPOSALS_INPUT, BALLOT_UPSERT_INPUT, + DOCUMENT_GET_INPUT, + DOCUMENT_LIST_INPUT, OPEN_PROPOSALS_INPUT, PUBLISH_RATIONALE_INPUT, + REVIEW_PENDING_TRANSACTION_INPUT, + TASK_LIST_INPUT, + TASK_PREPARE_PAYOUT_INPUT, + TASK_UPSERT_INPUT, + TRANSACTION_PREVIEW_INPUT, + TRANSACTION_PROPOSE_INPUT, VOTE_HISTORY_INPUT, WALLET_BALLOTS_INPUT, EMPTY_INPUT, @@ -17,17 +25,31 @@ import { WALLET_ONLY_INPUT, type JsonSchema, } from "@/lib/mcp/schemas"; +// Pure metadata helper (its only Mesh reference is a type import), so it is +// safe to load statically without dragging the WASM into the MCP cold path. +import { + participantsInclude, + type Label1854LookupItem, +} from "@/utils/cip146Registration"; +import { REVIEW_CARD_RESOURCE_URI } from "@/lib/mcp/apps/review-card"; /** * The MCP tool registry — the single source of truth for the exposed surface. * - * This release is read-only plus ballot drafts. Nothing here can sign a - * transaction, move funds, or broadcast to chain. That is a deliberate boundary, - * not an oversight: tool results carry user-authored strings (wallet names, - * transaction descriptions, ballot rationales), so anything an attacker can - * write into a wallet the caller can read is text that reaches the model. Adding - * a write tool alongside that turns prompt injection into a funds-movement path, - * so a signing surface needs its own design pass rather than a new registry row. + * Nothing here can sign a transaction, move funds, or broadcast to chain. That + * is a deliberate boundary, not an oversight: tool results carry user-authored + * strings (wallet names, transaction descriptions, ballot rationales), so + * anything an attacker can write into a wallet the caller can read is text that + * reaches the model. A signing tool alongside that would turn prompt injection + * into a funds-movement path. + * + * What the surface CAN do beyond reading: draft ballots, pin rationales, and — + * under the opt-in `transactions:write` scope — draft unsigned transactions in + * two steps. `transaction_preview` builds the transaction and returns a review + * card (PNG) plus a signed draft token; nothing is stored. `transaction_propose` + * accepts only that token, so the pending transaction it creates is exactly + * what the human saw in chat, and it starts with zero signatures: every witness + * is still added by a signer in the app. See `src/lib/tx-review/`. */ export type ToolContext = { @@ -35,6 +57,20 @@ export type ToolContext = { clientIp: string; }; +/** + * What a tool body returns. `status`/`body` mirror the v1 handler contract + * (`body` becomes `structuredContent`). The optional fields exist for the + * review tools: a readable `text` block in place of the JSON dump, `images` + * that become `image` content blocks (base64 PNG, never placed in + * `structuredContent`), and an `audit` bag of identifiers merged into the + * audit row — ids only, never prose. + */ +export type McpToolResult = V1Result & { + text?: string; + images?: { data: string; mimeType: "image/png" }[]; + audit?: Record; +}; + export type McpToolDef = { name: string; title: string; @@ -54,11 +90,27 @@ export type McpToolDef = { * `src/__tests__/mcpTools.test.ts`, so a handler rename breaks CI. */ v1Path: string | null; - run: (args: Record, ctx: ToolContext) => Promise; + /** + * MCP Apps: the `ui://` resource the host renders inline when this tool + * is called (`_meta.ui.resourceUri`). Only the review tools set it. + */ + uiResourceUri?: string; + run: ( + args: Record, + ctx: ToolContext, + ) => Promise; }; -const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false } as const; -const READ_ONLY_CHAIN = { readOnlyHint: true, idempotentHint: true, openWorldHint: true } as const; +const READ_ONLY = { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, +} as const; +const READ_ONLY_CHAIN = { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: true, +} as const; /** * Lazy handler imports. @@ -76,6 +128,7 @@ const load = { proxies: () => import("@/pages/api/v1/proxies"), proxyDRepInfo: () => import("@/pages/api/v1/proxyDRepInfo"), lookupMultisigWallet: () => import("@/pages/api/v1/lookupMultisigWallet"), + resolveScript: () => import("@/pages/api/v1/resolveScript"), governanceActiveProposals: () => import("@/pages/api/v1/governanceActiveProposals"), botBallotsUpsert: () => import("@/pages/api/v1/botBallotsUpsert"), @@ -83,6 +136,20 @@ const load = { drepInfo: () => import("@/pages/api/v1/drepInfo"), drepVotes: () => import("@/pages/api/governance/drepVotes"), ballotRationaleAnchor: () => import("@/pages/api/v1/ballotRationaleAnchor"), + documents: () => import("@/pages/api/v1/documents"), + documentDetail: () => import("@/pages/api/v1/documentDetail"), + // The review pipeline pulls Mesh (builder, address parsing) and, on first + // render, the next/og WASM — same lazy rule as the handlers above. + txPreview: () => import("@/lib/tx-review/preview"), + txPropose: () => import("@/lib/tx-review/propose"), + txReview: () => import("@/lib/tx-review/review"), + // Task board: two v1 handlers, and the payout side of the same review + // pipeline. + tasks: () => import("@/pages/api/v1/tasks"), + taskUpsert: () => import("@/pages/api/v1/taskUpsert"), + taskPayoutPreview: () => import("@/lib/task-payout/preview"), + taskPayoutHooks: () => import("@/lib/task-payout/hooks"), + db: () => import("@/server/db"), }; /** Vote history is two hops: resolve the wallet's DRep, then read its votes. */ @@ -117,7 +184,7 @@ type VoteRow = { blockTime: number; }; -async function callV1( +export async function callV1( loader: () => Promise<{ default: NextApiHandler }>, ctx: ToolContext, init: { @@ -137,9 +204,34 @@ async function callV1( }); } +/** + * The review pipeline's UTxO source: `multisig_list_free_utxos`'s v1 handler, + * run in-process for the caller and always fresh from chain. One definition + * for every pipeline entry point (the three drafting tools here, and the web + * app's task-payout procedures via `src/lib/task-payout/deps.ts`), so + * pending-input locking and wallet authorization stay in that handler. + */ +export function freeUtxosFetcher( + ctx: ToolContext, +): (walletId: string) => Promise { + return (walletId) => + callV1(load.freeUtxos, ctx, { + method: "GET", + query: { walletId, address: ctx.caller.subject, fresh: "true" }, + }); +} + const str = (value: unknown): string | undefined => typeof value === "string" ? value : undefined; +/** + * The review tools' `card` option: the PNG is rendered only on request. The + * default leaves the card to the client's inline view (drawn from + * `structuredContent.summary`), which reflows instead of fitting a raster. + */ +const wantsCardImage = (args: Record): boolean => + args.card === "image"; + /** JSON Schema validation has already run by the time a `run` body executes. */ export const MCP_TOOLS: McpToolDef[] = [ { @@ -169,7 +261,7 @@ export const MCP_TOOLS: McpToolDef[] = [ name: "multisig_list_wallets", title: "List wallets", description: - "List every multisig wallet this identity can see, with its id and name. The returned walletId is what every other wallet tool takes.", + "List the multisig wallets this identity owns or has verified, with id and name. The returned walletId is what every other wallet tool takes. Wallets you have merely been named in but never accepted are NOT listed — they are reported only as a pendingInvitations count, because their names are chosen by whoever created them.", scope: "wallets:read", inputSchema: EMPTY_INPUT, annotations: READ_ONLY, @@ -177,12 +269,15 @@ export const MCP_TOOLS: McpToolDef[] = [ run: async (_args, ctx) => { const result = await callV1(load.walletIds, ctx, { method: "GET", - query: { address: ctx.caller.subject }, + // Opt into the object shape: the bare array is the documented contract + // for bots and dApps, so the pending count is additive rather than a + // breaking change. + query: { address: ctx.caller.subject, includePending: "true" }, }); // The endpoint answers 404 when the list is empty. "You have no wallets" // is a valid answer, not a failure, so it must not surface as isError. if (result.status === 404) { - return { status: 200, body: { wallets: [] } }; + return { status: 200, body: { wallets: [], pendingInvitations: 0 } }; } return wrapArray(result, "wallets"); }, @@ -274,7 +369,7 @@ export const MCP_TOOLS: McpToolDef[] = [ name: "multisig_lookup_wallet", title: "Look up a multisig wallet on-chain", description: - "Find on-chain CIP-1854 multisig registration metadata by participant public key hash. Public chain data — works for wallets this identity is not a signer of.", + "Find on-chain CIP-1854 multisig registration metadata by participant public key hash, native-script hash (policy) or multisig wallet address. Public chain data — works for wallets this identity is not a signer of.", scope: "wallets:read", inputSchema: LOOKUP_WALLET_INPUT, annotations: READ_ONLY_CHAIN, @@ -283,16 +378,72 @@ export const MCP_TOOLS: McpToolDef[] = [ const hashes = Array.isArray(args.pubKeyHashes) ? args.pubKeyHashes.filter((h): h is string => typeof h === "string") : []; - return wrapArray( - await callV1(load.lookupMultisigWallet, ctx, { - method: "GET", - query: { - pubKeyHashes: hashes.join(","), - network: str(args.network) ?? "1", + const scriptHash = str(args.scriptHash); + const address = str(args.address); + const network = str(args.network) ?? "1"; + + const selectors = [hashes.length > 0, !!scriptHash, !!address].filter( + Boolean, + ).length; + if (selectors !== 1) { + return { + status: 400, + body: { + error: "Provide exactly one of pubKeyHashes, scriptHash or address", }, - }), - "matches", + }; + } + + if (hashes.length > 0) { + return wrapArray( + await callV1(load.lookupMultisigWallet, ctx, { + method: "GET", + query: { pubKeyHashes: hashes.join(","), network }, + }), + "matches", + ); + } + + // Policy lookup: the 1854 metadata carries participants, not the + // script hash, so resolve the script to its signer hashes first and + // keep only registrations that list all of them. + const resolved = await callV1(load.resolveScript, ctx, { + method: "GET", + query: { scriptHash, address, network }, + }); + if (resolved.status >= 400) return resolved; + const script = resolved.body as { + scriptHash: string; + sigHashes: string[]; + }; + if (script.sigHashes.length === 0) { + return { + status: 200, + body: { + matches: [], + count: 0, + scriptHash: script.scriptHash, + sigHashes: [], + }, + }; + } + const lookup = await callV1(load.lookupMultisigWallet, ctx, { + method: "GET", + query: { pubKeyHashes: script.sigHashes.join(","), network }, + }); + if (lookup.status >= 400 || !Array.isArray(lookup.body)) return lookup; + const matches = (lookup.body as Label1854LookupItem[]).filter((item) => + participantsInclude(item, script.sigHashes), ); + return { + status: 200, + body: { + matches, + count: matches.length, + scriptHash: script.scriptHash, + sigHashes: script.sigHashes, + }, + }; }, }, { @@ -367,12 +518,19 @@ export const MCP_TOOLS: McpToolDef[] = [ const active = await callV1(load.governanceActiveProposals, ctx, { method: "GET", - query: { network, count: String(count), page: "1", order: "desc", details: "false" }, + query: { + network, + count: String(count), + page: "1", + order: "desc", + details: "false", + }, }); if (active.status >= 400) return active; const proposals = - (active.body as { proposals?: { proposalId: string }[] }).proposals ?? []; + (active.body as { proposals?: { proposalId: string }[] }).proposals ?? + []; // A missing DRep or a Koios hiccup must not sink the whole answer — fall // back to "we don't know what was voted" rather than failing the call. @@ -388,10 +546,16 @@ export const MCP_TOOLS: McpToolDef[] = [ const annotated = proposals.map((p) => { const ours = voted.get(p.proposalId); - return { ...p, alreadyVoted: Boolean(ours), ourVote: ours?.vote ?? null }; + return { + ...p, + alreadyVoted: Boolean(ours), + ourVote: ours?.vote ?? null, + }; }); const includeVoted = args.includeVoted === true; - const rows = includeVoted ? annotated : annotated.filter((p) => !p.alreadyVoted); + const rows = includeVoted + ? annotated + : annotated.filter((p) => !p.alreadyVoted); return { status: 200, @@ -467,11 +631,235 @@ export const MCP_TOOLS: McpToolDef[] = [ ...(args.counterargumentDiscussion !== undefined ? { counterargumentDiscussion: args.counterargumentDiscussion } : {}), - ...(args.conclusion !== undefined ? { conclusion: args.conclusion } : {}), - ...(args.references !== undefined ? { references: args.references } : {}), + ...(args.conclusion !== undefined + ? { conclusion: args.conclusion } + : {}), + ...(args.references !== undefined + ? { references: args.references } + : {}), + }, + }), + }, + { + name: "document_list", + title: "List sign-off documents", + description: + "List a wallet's sign-off documents with their version history: content hashes, approval counts, the threshold each round needs, and which signers have not signed yet. Read-only — approving a document requires a signature from a wallet signer and cannot be done through this tool.", + scope: "documents:read", + inputSchema: DOCUMENT_LIST_INPUT, + annotations: READ_ONLY, + v1Path: "documents.ts", + run: async (args, ctx) => + wrapArray( + await callV1(load.documents, ctx, { + method: "GET", + query: { + walletId: String(args.walletId), + address: ctx.caller.subject, + ...(args.includeArchived ? { includeArchived: "true" } : {}), + }, + }), + "documents", + ), + }, + { + name: "document_get", + title: "Get a sign-off document", + description: + "Get one sign-off document by id: every version with its content hash and status, who approved or rejected each one, and the document's audit history. Read-only.", + scope: "documents:read", + inputSchema: DOCUMENT_GET_INPUT, + annotations: READ_ONLY, + v1Path: "documentDetail.ts", + run: async (args, ctx) => + callV1(load.documentDetail, ctx, { + method: "GET", + query: { + documentId: String(args.documentId), + address: ctx.caller.subject, }, }), }, + { + name: "transaction_preview", + title: "Preview an unsigned transaction", + description: + "Build an unsigned multisig transaction — payments in ADA and native assets, staking certificates, DRep votes — for the user to check in chat. The result is the review card: by default the client's inline card view draws it next to this call, with a Confirm button the user may click (you will be told) instead of replying; if the user cannot see a card, relay the summary in the same turn, then ask them to confirm. Pass card: \"image\" when the user wants a picture of the card (or this client shows images but not inline views) and show the returned image in your reply. NOTHING is saved, signed or broadcast. Only after the user confirms, call transaction_propose with the returned draftToken. Amounts are in display units (ADA, not lovelace). Change always returns to the wallet itself. Votes need the wallet to be registered as a DRep on chain; if it is not, the draft is refused and you must tell the user the wallet cannot vote until it registers as a DRep in the app.", + scope: "transactions:write", + inputSchema: TRANSACTION_PREVIEW_INPUT, + annotations: { + // Builds in memory against live chain state and stores nothing. + readOnlyHint: true, + idempotentHint: false, + openWorldHint: true, + }, + v1Path: null, + uiResourceUri: REVIEW_CARD_RESOURCE_URI, + run: async (args, ctx) => { + const [{ runTransactionPreview }, { db }] = await Promise.all([ + load.txPreview(), + load.db(), + ]); + return runTransactionPreview(args as never, ctx, { + db, + omitCard: !wantsCardImage(args), + fetchFreeUtxos: freeUtxosFetcher(ctx), + }); + }, + }, + { + name: "transaction_propose", + title: "Create the previewed transaction for signers", + description: + "Create the pending multisig transaction that transaction_preview showed, so the wallet's signers can review and sign it in the app. Takes ONLY the draftToken from the preview the user approved — it is rebuilt from that exact draft, starts with zero signatures, and is never signed or broadcast by this tool. The result is the final review card, delivered the same way the preview was (inline card view by default; as an image when the preview used card: \"image\" — then show that image in your reply). Any vote rationale in the draft is published to IPFS at this point. Calling again with the same token returns the same transaction.", + scope: "transactions:write", + inputSchema: TRANSACTION_PROPOSE_INPUT, + annotations: { + readOnlyHint: false, + // Adds a pending row; deletes nothing, moves no value, signs nothing. + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + v1Path: null, + uiResourceUri: REVIEW_CARD_RESOURCE_URI, + run: async (args, ctx) => { + const [{ runTransactionPropose }, { withTaskPayoutHooks }, { db }] = + await Promise.all([load.txPropose(), load.taskPayoutHooks(), load.db()]); + // A token minted by task_prepare_payout carries the task ids; the hooks + // link them to the created transaction. Other tokens pass through. + return runTransactionPropose( + { draftToken: String(args.draftToken ?? "") }, + ctx, + withTaskPayoutHooks({ + db, + clientIp: ctx.clientIp, + fetchFreeUtxos: freeUtxosFetcher(ctx), + }), + ); + }, + }, + { + name: "multisig_review_pending_transaction", + title: "Review a pending transaction", + description: + "Render one pending transaction as a review card: recipients and amounts, staking or governance actions, fee, change, and who has signed or rejected so far. By default the client's inline card view draws the card next to this call; if the user cannot see it, relay the summary. Pass card: \"image\" for a picture of the card and show the returned image in your reply rather than paraphrasing it. Read-only; works for transactions created in the app or through MCP.", + scope: "wallets:read", + inputSchema: REVIEW_PENDING_TRANSACTION_INPUT, + annotations: READ_ONLY_CHAIN, + v1Path: "pendingTransactions.ts", + uiResourceUri: REVIEW_CARD_RESOURCE_URI, + run: async (args, ctx) => { + const [{ runPendingTransactionReview }, { db }] = await Promise.all([ + load.txReview(), + load.db(), + ]); + return runPendingTransactionReview( + { + walletId: String(args.walletId), + transactionId: String(args.transactionId), + }, + ctx, + { + db, + omitCard: !wantsCardImage(args), + fetchPendingTransactions: (walletId) => + callV1(load.pendingTransactions, ctx, { + method: "GET", + query: { walletId, address: ctx.caller.subject }, + }), + }, + ); + }, + }, + { + name: "task_list", + title: "List project tasks", + description: + "The wallet's project task board: every task with its column (Backlog, InProgress, InReview, Done), assignee, due date, payment recipients (amounts in base units: lovelace, or a token's raw quantity) and payout — its state (none, ready, pending with the transactionId awaiting signatures, or paid), payable (true when the task can be paid right now: Done, with recipients, no pending or paid payout) and blocker (why not: not_done, no_recipients, pending, paid, or null). Pass payable tasks' ids to task_prepare_payout. Optionally filter by column or to payable tasks only; payableCount is the wallet-wide total.", + scope: "wallets:read", + inputSchema: TASK_LIST_INPUT, + annotations: READ_ONLY, + v1Path: "tasks.ts", + run: async (args, ctx) => + callV1(load.tasks, ctx, { + method: "GET", + query: { + walletId: str(args.walletId), + address: ctx.caller.subject, + status: str(args.status), + ...(args.payable === true ? { payable: "true" } : {}), + }, + }), + }, + { + name: "task_upsert", + title: "Create, edit or move a task", + description: + "Create a task on the wallet's board (title required) or update an existing one by taskId: title, description, priority, assignee, due date, column (status) and position, and its payment recipients in display units (ADA, or a token with its registered decimals — the same shape as transaction_preview outputs). Moving a task that has recipients to Done makes it payable. A task becomes entirely read-only while its payout awaits signatures and remains read-only after payment; cancelling the pending transaction unlocks an unpaid task. This records a task only; it creates, signs and broadcasts nothing — use task_prepare_payout to draft the payout.", + scope: "tasks:write", + inputSchema: TASK_UPSERT_INPUT, + annotations: { + readOnlyHint: false, + // Adds or edits a board row; never removes a task, never moves value. + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + v1Path: "taskUpsert.ts", + run: async (args, ctx) => { + // The tool's arguments are the handler's body verbatim (recipients in + // display units); the handler owns validation and authorization. + const result = await callV1(load.taskUpsert, ctx, { + method: "POST", + body: args, + }); + const body = result.body as + | { task?: { id?: string }; created?: boolean } + | null; + return result.status < 400 && body?.task?.id + ? { + ...result, + audit: { taskId: body.task.id, created: body.created === true }, + } + : result; + }, + }, + { + name: "task_prepare_payout", + title: "Preview a payout for tasks", + description: + "Build the unsigned transaction that pays one or more Done tasks' recipients (merged per address) against the wallet's spendable UTxOs, and show it. Work must reach the Done column before it can be paid; omit taskIds to pay every payable task, and read the result's tasks list to see exactly which were included. Nothing is stored, signed or sent. The result is the review card: the client's inline card view draws it next to this call (with a Confirm button); if the user cannot see it, relay the summary, or pass card: \"image\" for a picture and show the returned image. Ask the user to confirm; on confirmation call transaction_propose with the returned draftToken — the tasks are linked to the pending transaction automatically and show as awaiting signatures on the board. The token expires in 15 minutes and is bound to exactly these tasks and amounts.", + scope: "transactions:write", + inputSchema: TASK_PREPARE_PAYOUT_INPUT, + annotations: { + // Builds in memory against live chain state and stores nothing. + readOnlyHint: true, + idempotentHint: false, + openWorldHint: true, + }, + v1Path: null, + uiResourceUri: REVIEW_CARD_RESOURCE_URI, + run: async (args, ctx) => { + const [{ prepareTaskPayoutPreview }, { db }] = await Promise.all([ + load.taskPayoutPreview(), + load.db(), + ]); + // Absent means "every payable task"; the preview names the ones it picked. + const taskIds = Array.isArray(args.taskIds) + ? args.taskIds.map((id) => String(id)) + : undefined; + return prepareTaskPayoutPreview( + { walletId: String(args.walletId), taskIds }, + ctx, + { + db, + omitCard: !wantsCardImage(args), + fetchFreeUtxos: freeUtxosFetcher(ctx), + }, + ); + }, + }, ]; /** diff --git a/src/lib/notifications/ballotDeadlines.ts b/src/lib/notifications/ballotDeadlines.ts new file mode 100644 index 00000000..6ab35a6d --- /dev/null +++ b/src/lib/notifications/ballotDeadlines.ts @@ -0,0 +1,532 @@ +import type { PrismaClient } from "@prisma/client"; + +import { getProposalStatus, parseProposalId } from "@/lib/governance"; +import { getGovernanceProvider, providerGet } from "@/lib/governance/provider"; +import { getProviderErrorStatus } from "@/lib/server/providerErrors"; +import { addressToNetwork } from "@/utils/multisigSDK"; +import { getRequiredSignerCount, getSiteUrl } from "./center"; +import { + NOTIFICATION_EVENT_BALLOT_DEADLINE, + NOTIFICATION_STATUS_PENDING, +} from "./events"; +import { createNotificationDelivery } from "./outbox"; +import { resolveWalletSignerRecipients } from "./recipients"; +import { + renderBallotDeadlineEmail, + type BallotDeadlineKind, + type BallotDeadlineProposal, + type BallotDeadlineWindow, +} from "./templates/ballotDeadline"; +import { extractVoteProposalIds } from "./voteProposals"; + +/** Both mainnet and preprod run 5-day epochs. */ +export const EPOCH_LENGTH_SECONDS = 432_000; + +const HOUR_MS = 60 * 60 * 1000; +const WINDOW_24H_MS = 24 * HOUR_MS; +const WINDOW_48H_MS = 48 * HOUR_MS; + +export type LatestEpoch = { epoch: number; end_time: number }; + +export type ProposalDeadlineDetails = { + expiration?: number | null; + expired_epoch?: number | null; + dropped_epoch?: number | null; + enacted_epoch?: number | null; + ratified_epoch?: number | null; +}; + +export type BallotDeadlineFetchers = { + fetchLatestEpoch: (network: string) => Promise; + fetchProposal: ( + network: string, + txHash: string, + certIndex: number, + ) => Promise; +}; + +/** + * Blockfrost's `expiration` is the ledger's `expiresAfter`: the proposal is + * votable through the whole of that epoch and is stamped expired at the + * boundary into `expiration + 1`. So the voting deadline is the end of the + * `expiration` epoch, derived from the latest epoch's end time. + */ +export function computeEpochEndMs(args: { + latestEpoch: number; + latestEndTimeSec: number; + epoch: number; +}): number { + return ( + (args.latestEndTimeSec + + (args.epoch - args.latestEpoch) * EPOCH_LENGTH_SECONDS) * + 1000 + ); +} + +/** + * Exactly one window per run: once inside the final 24h the 48h reminder is + * never emitted, so a cron outage longer than a day cannot double-send. + */ +export function selectReminderWindow( + remainingMs: number, +): BallotDeadlineWindow | null { + if (remainingMs <= 0) return null; + if (remainingMs <= WINDOW_24H_MS) return "24h"; + if (remainingMs <= WINDOW_48H_MS) return "48h"; + return null; +} + +export function ballotDeadlineIdempotencyKey(args: { + resourceType?: BallotDeadlineKind; + resourceId: string; + walletId: string; + recipientAddress: string; + window: BallotDeadlineWindow; + expirationEpoch: number; +}): string { + return [ + NOTIFICATION_EVENT_BALLOT_DEADLINE, + "email", + args.resourceType ?? "ballot", + args.resourceId, + args.walletId, + args.recipientAddress, + args.window, + String(args.expirationEpoch), + ].join(":"); +} + +/** Descriptions written by governance/ballot/ballot.tsx when a vote tx is built. */ +export function ballotVoteDescriptions(description: string | null): string[] { + const label = description ?? ""; + return [`Ballot Vote: ${label}`, `Proxy Ballot Vote: ${label}`]; +} + +function defaultFetchers(): BallotDeadlineFetchers { + const providers = new Map>(); + const providerFor = (network: string) => { + if (!providers.has(network)) { + providers.set(network, getGovernanceProvider(network)); + } + return providers.get(network) ?? null; + }; + return { + fetchLatestEpoch: (network) => + providerGet({ + provider: providerFor(network), + network, + path: "/epochs/latest", + }), + fetchProposal: async (network, txHash, certIndex) => { + try { + return await providerGet({ + provider: providerFor(network), + network, + path: `/governance/proposals/${txHash}/${certIndex}`, + }); + } catch (error) { + if (getProviderErrorStatus(error) === 404) return null; + throw error; + } + }, + }; +} + +function resolveWalletNetwork(signersAddresses: string[]): string | null { + for (const address of signersAddresses) { + if (typeof address !== "string" || !address.trim()) continue; + try { + return String(addressToNetwork(address.trim())); + } catch { + // try the next signer + } + } + return null; +} + +export type BallotDeadlineScanResult = { + ballotsScanned: number; + ballotsDue: number; + transactionsScanned: number; + transactionsDue: number; + remindersEnqueued: number; + skipped: number; + errors: string[]; +}; + +export type EnqueueBallotDeadlineRemindersOptions = Partial & { + now?: Date; +}; + +type WalletShape = { + id: string; + name: string; + signersAddresses: string[]; + numRequiredSigners: number | null; + type: string; +}; + +type ResolvedDeadline = { + deadlineEpoch: number; + deadlineMs: number; + /** Only the proposals that set the deadline (earliest expiration). */ + expiringSoon: BallotDeadlineProposal[]; +}; + +/** + * Scans every ballot with proposals and every pending transaction that votes, + * derives the voting deadline from the earliest active proposal's expiration + * epoch, and enqueues 48h/24h reminder emails for the wallet's signers. + * Idempotent per resource × signer × window × expiration epoch, so it can run + * on any schedule. + */ +export async function enqueueBallotDeadlineReminders( + db: PrismaClient, + options: EnqueueBallotDeadlineRemindersOptions = {}, +): Promise { + const now = options.now ?? new Date(); + const fetchers = defaultFetchers(); + const fetchLatestEpoch = options.fetchLatestEpoch ?? fetchers.fetchLatestEpoch; + const fetchProposal = options.fetchProposal ?? fetchers.fetchProposal; + + const result: BallotDeadlineScanResult = { + ballotsScanned: 0, + ballotsDue: 0, + transactionsScanned: 0, + transactionsDue: 0, + remindersEnqueued: 0, + skipped: 0, + errors: [], + }; + + const ballots = await db.ballot.findMany({ + where: { items: { isEmpty: false } }, + }); + result.ballotsScanned = ballots.length; + + // Pending transactions that vote. The `contains` filters keep the txJson + // payloads we load down to candidates; extractVoteProposalIds decides. + const pendingVoteTxs = ( + await db.transaction.findMany({ + where: { + state: 0, + OR: [ + { txJson: { contains: '"govActionId"' } }, + { txJson: { contains: '"proxyVote"' } }, + ], + }, + select: { + id: true, + walletId: true, + txJson: true, + description: true, + signedAddresses: true, + createdAt: true, + }, + }) + ) + .map((tx) => ({ ...tx, proposalIds: extractVoteProposalIds(tx.txJson) })) + .filter((tx) => tx.proposalIds.length > 0); + result.transactionsScanned = pendingVoteTxs.length; + + if (ballots.length === 0 && pendingVoteTxs.length === 0) return result; + + const walletIds = new Set(); + for (const ballot of ballots) walletIds.add(ballot.walletId); + for (const tx of pendingVoteTxs) walletIds.add(tx.walletId); + const wallets = await db.wallet.findMany({ + where: { id: { in: Array.from(walletIds) }, isArchived: false }, + }); + const walletsById = new Map( + wallets.map((wallet) => [wallet.id, wallet]), + ); + + const latestEpochByNetwork = new Map(); + const proposalCache = new Map(); + + const latestEpochFor = async (network: string) => { + if (!latestEpochByNetwork.has(network)) { + try { + latestEpochByNetwork.set(network, await fetchLatestEpoch(network)); + } catch (error) { + result.errors.push( + `epochs/latest (${network}): ${error instanceof Error ? error.message : String(error)}`, + ); + latestEpochByNetwork.set(network, null); + } + } + return latestEpochByNetwork.get(network) ?? null; + }; + + const proposalFor = async (network: string, proposalId: string) => { + const key = `${network}:${proposalId}`; + if (proposalCache.has(key)) return proposalCache.get(key) ?? null; + let details: ProposalDeadlineDetails | null = null; + try { + const { txHash, certIndex } = parseProposalId(proposalId); + details = await fetchProposal(network, txHash, certIndex); + } catch (error) { + result.errors.push( + `proposal ${proposalId} (${network}): ${error instanceof Error ? error.message : String(error)}`, + ); + details = null; + } + proposalCache.set(key, details); + return details; + }; + + const resolveDeadline = async ( + network: string, + latest: LatestEpoch, + proposalIds: string[], + titleFor: (proposalId: string, index: number) => string | null, + ): Promise => { + const active: BallotDeadlineProposal[] = []; + for (let index = 0; index < proposalIds.length; index++) { + const proposalId = proposalIds[index]!; + const details = await proposalFor(network, proposalId); + if (!details) continue; + const status = getProposalStatus({ + id: proposalId, + tx_hash: "", + cert_index: 0, + governance_type: "", + deposit: "", + return_address: "", + governance_description: { tag: "" }, + ratified_epoch: details.ratified_epoch ?? null, + enacted_epoch: details.enacted_epoch ?? null, + dropped_epoch: details.dropped_epoch ?? null, + expired_epoch: details.expired_epoch ?? null, + expiration: details.expiration ?? null, + }); + if (status !== "active") continue; + if ( + typeof details.expiration !== "number" || + details.expiration < latest.epoch + ) { + continue; + } + active.push({ + id: proposalId, + title: titleFor(proposalId, index), + expirationEpoch: details.expiration, + }); + } + if (active.length === 0) return null; + + const deadlineEpoch = Math.min(...active.map((p) => p.expirationEpoch)); + return { + deadlineEpoch, + deadlineMs: computeEpochEndMs({ + latestEpoch: latest.epoch, + latestEndTimeSec: latest.end_time, + epoch: deadlineEpoch, + }), + expiringSoon: active.filter((p) => p.expirationEpoch === deadlineEpoch), + }; + }; + + const siteUrl = getSiteUrl(); + + const enqueueRemindersFor = async (args: { + wallet: WalletShape; + kind: BallotDeadlineKind; + resourceId: string; + label: string | null; + window: BallotDeadlineWindow; + deadline: ResolvedDeadline; + signedCount?: number; + requiredCount?: number; + }) => { + const { wallet, kind, deadline } = args; + const actionUrl = + kind === "transaction" + ? `${siteUrl}/wallets/${wallet.id}/transactions` + : `${siteUrl}/wallets/${wallet.id}/governance`; + const preferencesUrl = `${siteUrl}/wallets/${wallet.id}/info`; + const template = renderBallotDeadlineEmail({ + walletName: wallet.name, + kind, + label: args.label, + window: args.window, + deadline: new Date(deadline.deadlineMs), + deadlineEpoch: deadline.deadlineEpoch, + proposals: deadline.expiringSoon, + signedCount: args.signedCount, + requiredCount: args.requiredCount, + actionUrl, + preferencesUrl, + }); + + const recipients = await resolveWalletSignerRecipients(db, { + walletId: wallet.id, + signerAddresses: wallet.signersAddresses, + preferenceField: "notifyBallotDeadlines", + }); + + const payloadBase = { + walletId: wallet.id, + walletName: wallet.name, + resourceType: kind, + resourceId: args.resourceId, + kind, + label: args.label, + window: args.window, + deadline: new Date(deadline.deadlineMs).toISOString(), + deadlineEpoch: deadline.deadlineEpoch, + proposals: deadline.expiringSoon, + signedCount: args.signedCount ?? null, + requiredCount: args.requiredCount ?? null, + actionUrl, + preferencesUrl, + }; + const keyFor = (recipientAddress: string) => + ballotDeadlineIdempotencyKey({ + resourceType: kind, + resourceId: args.resourceId, + walletId: wallet.id, + recipientAddress, + window: args.window, + expirationEpoch: deadline.deadlineEpoch, + }); + + for (const recipient of recipients.eligible) { + await createNotificationDelivery(db, { + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + recipientAddress: recipient.address, + recipientEmail: recipient.email, + resourceType: kind, + resourceId: args.resourceId, + walletId: wallet.id, + idempotencyKey: keyFor(recipient.address), + subject: template.subject, + payload: { + ...payloadBase, + recipientAddress: recipient.address, + html: template.html, + text: template.text, + }, + status: NOTIFICATION_STATUS_PENDING, + }); + result.remindersEnqueued += 1; + } + + for (const skipped of recipients.skipped) { + await createNotificationDelivery(db, { + eventType: NOTIFICATION_EVENT_BALLOT_DEADLINE, + recipientAddress: skipped.address, + recipientEmail: null, + resourceType: kind, + resourceId: args.resourceId, + walletId: wallet.id, + idempotencyKey: keyFor(skipped.address), + subject: "Ballot deadline reminder skipped", + payload: { + ...payloadBase, + recipientAddress: skipped.address, + skipReason: skipped.reason, + }, + status: skipped.reason, + }); + result.skipped += 1; + } + }; + + // Proposals already covered by a pending vote transaction, per wallet. A + // ballot whose expiring proposals are all covered defers to the transaction + // reminder so signers get one email about the same vote, not two. + const coveredProposalsByWallet = new Map>(); + + for (const tx of pendingVoteTxs) { + const wallet = walletsById.get(tx.walletId); + if (!wallet) continue; + const network = resolveWalletNetwork(wallet.signersAddresses); + if (!network) continue; + const latest = await latestEpochFor(network); + if (!latest) continue; + + const deadline = await resolveDeadline(network, latest, tx.proposalIds, () => null); + if (!deadline) continue; + + let covered = coveredProposalsByWallet.get(wallet.id); + if (!covered) { + covered = new Set(); + coveredProposalsByWallet.set(wallet.id, covered); + } + for (const proposalId of tx.proposalIds) covered.add(proposalId); + + const window = selectReminderWindow(deadline.deadlineMs - now.getTime()); + if (!window) continue; + result.transactionsDue += 1; + + await enqueueRemindersFor({ + wallet, + kind: "transaction", + resourceId: tx.id, + label: tx.description, + window, + deadline, + signedCount: tx.signedAddresses.length, + requiredCount: getRequiredSignerCount(wallet), + }); + } + + for (const ballot of ballots) { + const wallet = walletsById.get(ballot.walletId); + if (!wallet) continue; + const network = resolveWalletNetwork(wallet.signersAddresses); + if (!network) continue; + const latest = await latestEpochFor(network); + if (!latest) continue; + + const deadline = await resolveDeadline( + network, + latest, + ballot.items, + (_proposalId, index) => ballot.itemDescriptions[index] ?? null, + ); + if (!deadline) continue; + + const window = selectReminderWindow(deadline.deadlineMs - now.getTime()); + if (!window) continue; + result.ballotsDue += 1; + + const covered = coveredProposalsByWallet.get(wallet.id); + if ( + covered && + deadline.expiringSoon.every((proposal) => covered.has(proposal.id)) + ) { + result.skipped += 1; + continue; + } + + // Best-effort: a submitted vote transaction for this ballot created after + // the ballot means the reminder is moot. Anchored on createdAt, not + // updatedAt, which bumps on any rationale/anchor edit. + const voted = await db.transaction.findFirst({ + where: { + walletId: wallet.id, + state: 1, + description: { in: ballotVoteDescriptions(ballot.description) }, + createdAt: { gte: ballot.createdAt }, + }, + select: { id: true }, + }); + if (voted) { + result.skipped += 1; + continue; + } + + await enqueueRemindersFor({ + wallet, + kind: "ballot", + resourceId: ballot.id, + label: ballot.description, + window, + deadline, + }); + } + + return result; +} diff --git a/src/lib/notifications/center.ts b/src/lib/notifications/center.ts index c087c727..d2c11874 100644 --- a/src/lib/notifications/center.ts +++ b/src/lib/notifications/center.ts @@ -4,17 +4,22 @@ import { env } from "@/env"; import { SITE_URL } from "@/lib/seo"; import { NOTIFICATION_EVENT_SIGNATURE_REQUIRED, + NOTIFICATION_EVENT_THRESHOLD_REACHED, NOTIFICATION_STATUS_PENDING, type NotificationEventType, type SignatureResourceType, } from "./events"; import { createNotificationDelivery } from "./outbox"; -import { resolveSignatureRecipients } from "./recipients"; +import { + resolveSignatureRecipients, + resolveWalletSignerRecipients, +} from "./recipients"; import { summarizeTransactionSignatureContext, type SignatureContext, } from "./signatureContext"; import { renderSignatureRequiredEmail } from "./templates/signatureRequired"; +import { renderThresholdReachedEmail } from "./templates/thresholdReached"; import { drainNotificationOutbox } from "./worker"; type WalletNotificationShape = { @@ -57,7 +62,7 @@ export function getSiteUrl(): string { return SITE_URL; } -function getRequiredSignerCount(wallet: WalletNotificationShape): number { +export function getRequiredSignerCount(wallet: WalletNotificationShape): number { if (wallet.type === "any") return 1; if (wallet.type === "all") return wallet.signersAddresses.length; if (typeof wallet.numRequiredSigners === "number") { @@ -66,6 +71,19 @@ function getRequiredSignerCount(wallet: WalletNotificationShape): number { return wallet.signersAddresses.length; } +/** + * True when a signature update moved a resource from below the wallet's + * required signature count to at or above it. + */ +export function crossedSignatureThreshold(args: { + wallet: WalletNotificationShape; + previousSignedCount: number; + signedCount: number; +}): boolean { + const required = getRequiredSignerCount(args.wallet); + return args.previousSignedCount < required && args.signedCount >= required; +} + function actionPathFor(resourceType: SignatureResourceType, walletId: string) { return resourceType === "transaction" ? `/wallets/${walletId}/transactions` @@ -89,6 +107,16 @@ function signatureIdempotencyKey(args: { ].join(":"); } +async function drainAfterEnqueue(db: PrismaClient, eligibleCount: number) { + if (notificationsEmailEnabled() && eligibleCount > 0) { + try { + await drainNotificationOutbox(db, { limit: 10 }); + } catch (error) { + console.error("Notification outbox drain failed", error); + } + } +} + export async function enqueueSignatureRequiredNotifications( db: PrismaClient, input: EnqueueSignatureRequiredInput, @@ -203,13 +231,151 @@ export async function enqueueSignatureRequiredNotifications( ); } - if (notificationsEmailEnabled() && recipients.eligible.length > 0) { - try { - await drainNotificationOutbox(db, { limit: 10 }); - } catch (error) { - console.error("Notification outbox drain failed", error); - } + await drainAfterEnqueue(db, recipients.eligible.length); + + return deliveries; +} + +export type EnqueueThresholdReachedInput = { + wallet: WalletNotificationShape; + resourceType: SignatureResourceType; + resourceId: string; + /** Signed addresses before the update that may have crossed the threshold. */ + previousSignedAddresses: string[]; + /** Signed addresses after the update. */ + signedAddresses: string[]; + /** The signer whose signature completed the set; not notified. */ + actorAddress?: string | null; + description?: string | null; + txJson?: unknown; + signatureContext?: SignatureContext | null; + txHash?: string | null; +}; + +/** + * Emails every wallet signer (except the actor) once a transaction or + * signable payload crosses the wallet's required signature count. No-op + * when the update did not cross the threshold, so it is safe to call on + * every signature update. + */ +export async function enqueueThresholdReachedNotifications( + db: PrismaClient, + input: EnqueueThresholdReachedInput, +) { + if ( + !crossedSignatureThreshold({ + wallet: input.wallet, + previousSignedCount: input.previousSignedAddresses.length, + signedCount: input.signedAddresses.length, + }) + ) { + return []; } + const eventType = NOTIFICATION_EVENT_THRESHOLD_REACHED; + const requiredCount = getRequiredSignerCount(input.wallet); + const siteUrl = getSiteUrl(); + const actionUrl = `${siteUrl}${actionPathFor(input.resourceType, input.wallet.id)}`; + const preferencesUrl = `${siteUrl}/wallets/${input.wallet.id}/info`; + const signatureContext = + input.signatureContext ?? + (input.resourceType === "transaction" + ? summarizeTransactionSignatureContext(input.txJson, input.description) + : null); + const txHash = input.txHash?.trim() || null; + const template = renderThresholdReachedEmail({ + walletName: input.wallet.name, + resourceType: input.resourceType, + description: input.description ?? null, + signatureContext, + signedCount: input.signedAddresses.length, + requiredCount, + totalSigners: input.wallet.signersAddresses.length, + txHash, + actionUrl, + preferencesUrl, + }); + + const recipients = await resolveWalletSignerRecipients(db, { + walletId: input.wallet.id, + signerAddresses: input.wallet.signersAddresses, + preferenceField: "notifyThresholdReached", + excludeAddresses: [input.actorAddress], + }); + + const payloadBase = { + walletId: input.wallet.id, + walletName: input.wallet.name, + resourceType: input.resourceType, + resourceId: input.resourceId, + actionUrl, + preferencesUrl, + signedCount: input.signedAddresses.length, + requiredCount, + totalSigners: input.wallet.signersAddresses.length, + description: input.description ?? null, + signatureContext, + txHash, + }; + + const deliveries = []; + + for (const recipient of recipients.eligible) { + deliveries.push( + await createNotificationDelivery(db, { + eventType, + recipientAddress: recipient.address, + recipientEmail: recipient.email, + resourceType: input.resourceType, + resourceId: input.resourceId, + walletId: input.wallet.id, + idempotencyKey: signatureIdempotencyKey({ + eventType, + resourceType: input.resourceType, + resourceId: input.resourceId, + walletId: input.wallet.id, + recipientAddress: recipient.address, + }), + subject: template.subject, + payload: { + ...payloadBase, + recipientAddress: recipient.address, + html: template.html, + text: template.text, + }, + status: NOTIFICATION_STATUS_PENDING, + }), + ); + } + + for (const skipped of recipients.skipped) { + deliveries.push( + await createNotificationDelivery(db, { + eventType, + recipientAddress: skipped.address, + recipientEmail: null, + resourceType: input.resourceType, + resourceId: input.resourceId, + walletId: input.wallet.id, + idempotencyKey: signatureIdempotencyKey({ + eventType, + resourceType: input.resourceType, + resourceId: input.resourceId, + walletId: input.wallet.id, + recipientAddress: skipped.address, + }), + subject: "Signatures complete notification skipped", + payload: { + ...payloadBase, + recipientAddress: skipped.address, + skipReason: skipped.reason, + }, + status: skipped.reason, + }), + ); + } + + await drainAfterEnqueue(db, recipients.eligible.length); + return deliveries; } diff --git a/src/lib/notifications/events.ts b/src/lib/notifications/events.ts index 3812d955..705cfdfa 100644 --- a/src/lib/notifications/events.ts +++ b/src/lib/notifications/events.ts @@ -5,6 +5,9 @@ export const NOTIFICATION_EVENT_SIGNATURE_REQUIRED = "signature.required" as const; export const NOTIFICATION_EVENT_SIGNATURE_REMINDER = "signature.reminder" as const; +export const NOTIFICATION_EVENT_THRESHOLD_REACHED = + "threshold.reached" as const; +export const NOTIFICATION_EVENT_BALLOT_DEADLINE = "ballot.deadline" as const; export const NOTIFICATION_STATUS_PENDING = "pending" as const; export const NOTIFICATION_STATUS_RETRYING = "retrying" as const; @@ -22,9 +25,15 @@ export const NOTIFICATION_STATUS_SKIPPED_DISABLED = export type NotificationEventType = | typeof NOTIFICATION_EVENT_EMAIL_VERIFY | typeof NOTIFICATION_EVENT_SIGNATURE_REQUIRED - | typeof NOTIFICATION_EVENT_SIGNATURE_REMINDER; + | typeof NOTIFICATION_EVENT_SIGNATURE_REMINDER + | typeof NOTIFICATION_EVENT_THRESHOLD_REACHED + | typeof NOTIFICATION_EVENT_BALLOT_DEADLINE; -export type NotificationResourceType = "transaction" | "signable" | "wallet"; +export type NotificationResourceType = + | "transaction" + | "signable" + | "wallet" + | "ballot"; export type SignatureResourceType = Extract< NotificationResourceType, @@ -42,10 +51,56 @@ export type NotificationDeliveryStatus = | typeof NOTIFICATION_STATUS_SKIPPED_OPTED_OUT | typeof NOTIFICATION_STATUS_SKIPPED_DISABLED; +/** + * Column on WalletSignerNotificationSetting that gates a given event type. + */ +export type NotificationPreferenceField = + | "notifyTransactionSignatures" + | "notifySignableSignatures" + | "notifyThresholdReached" + | "notifyBallotDeadlines"; + +export function isSignatureResourceType( + value: string, +): value is SignatureResourceType { + return value === "transaction" || value === "signable"; +} + export function getSignatureNotificationPreferenceField( resourceType: SignatureResourceType, -) { +): NotificationPreferenceField { return resourceType === "transaction" ? "notifyTransactionSignatures" : "notifySignableSignatures"; } + +/** + * Maps a delivery's (eventType, resourceType) to the preference column that + * must be on for it to be sent. Returns null for events that are exempt from + * preference checks (email.verify must reach an address that has not opted in + * or verified yet) and for unknown combinations. + */ +export function getNotificationPreferenceField( + eventType: string, + resourceType: string, +): NotificationPreferenceField | null { + switch (eventType) { + case NOTIFICATION_EVENT_SIGNATURE_REQUIRED: + case NOTIFICATION_EVENT_SIGNATURE_REMINDER: + return isSignatureResourceType(resourceType) + ? getSignatureNotificationPreferenceField(resourceType) + : null; + case NOTIFICATION_EVENT_THRESHOLD_REACHED: + return isSignatureResourceType(resourceType) + ? "notifyThresholdReached" + : null; + case NOTIFICATION_EVENT_BALLOT_DEADLINE: + // Deadline reminders are keyed on a ballot or on a pending vote + // transaction; both are gated by the same toggle. + return resourceType === "ballot" || resourceType === "transaction" + ? "notifyBallotDeadlines" + : null; + default: + return null; + } +} diff --git a/src/lib/notifications/recipients.ts b/src/lib/notifications/recipients.ts index da13a8af..38ef648c 100644 --- a/src/lib/notifications/recipients.ts +++ b/src/lib/notifications/recipients.ts @@ -7,6 +7,7 @@ import { NOTIFICATION_STATUS_SKIPPED_NOT_VERIFIED, NOTIFICATION_STATUS_SKIPPED_OPTED_OUT, type NotificationDeliveryStatus, + type NotificationPreferenceField, type SignatureResourceType, } from "./events"; @@ -40,25 +41,30 @@ export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } -export type SignatureEligibilitySetting = { +export type NotificationEligibilitySetting = { email: string | null; emailNormalized: string | null; emailVerifiedAt: Date | null; emailOptIn: boolean; notifyTransactionSignatures: boolean; notifySignableSignatures: boolean; + notifyThresholdReached: boolean; + notifyBallotDeadlines: boolean; }; +/** @deprecated alias kept for existing imports */ +export type SignatureEligibilitySetting = NotificationEligibilitySetting; + /** - * Single source of truth for whether a signer may receive a signature - * notification email. Returns the skip status, or null when eligible. - * Used both at enqueue time (resolveSignatureRecipients) and at send time + * Single source of truth for whether a signer may receive a notification + * email gated by `preferenceField`. Returns the skip status, or null when + * eligible. Used both at enqueue time and at send time * (drainNotificationOutbox) so preference changes are honored for rows * already sitting in the outbox. */ -export function getSignatureSkipReason( - setting: SignatureEligibilitySetting | null | undefined, - resourceType: SignatureResourceType, +export function getSkipReason( + setting: NotificationEligibilitySetting | null | undefined, + preferenceField: NotificationPreferenceField, ): NotificationDeliveryStatus | null { if (!setting?.email || !setting.emailNormalized) { return NOTIFICATION_STATUS_SKIPPED_NO_EMAIL; @@ -69,12 +75,85 @@ export function getSignatureSkipReason( if (!setting.emailOptIn) { return NOTIFICATION_STATUS_SKIPPED_OPTED_OUT; } - if (!setting[getSignatureNotificationPreferenceField(resourceType)]) { + if (!setting[preferenceField]) { return NOTIFICATION_STATUS_SKIPPED_DISABLED; } return null; } +export function getSignatureSkipReason( + setting: NotificationEligibilitySetting | null | undefined, + resourceType: SignatureResourceType, +): NotificationDeliveryStatus | null { + return getSkipReason( + setting, + getSignatureNotificationPreferenceField(resourceType), + ); +} + +export type ResolveWalletSignerRecipientsInput = { + walletId: string; + signerAddresses: string[]; + preferenceField: NotificationPreferenceField; + /** Addresses that must not be notified (e.g. the signer who just acted). */ + excludeAddresses?: Array; +}; + +/** + * Resolves every wallet signer (minus `excludeAddresses`) to an eligible + * recipient or a skip reason for the given preference. Unlike + * resolveSignatureRecipients this does not drop the creator or signers who + * already signed — it is the audience for informational events such as + * threshold-reached and ballot-deadline reminders. + */ +export async function resolveWalletSignerRecipients( + db: PrismaClient, + input: ResolveWalletSignerRecipientsInput, +): Promise { + const excluded = new Set( + (input.excludeAddresses ?? []).filter( + (address): address is string => typeof address === "string" && !!address, + ), + ); + const candidateAddresses = Array.from(new Set(input.signerAddresses)).filter( + (address) => !excluded.has(address), + ); + + if (candidateAddresses.length === 0) { + return { eligible: [], skipped: [] }; + } + + const settings = await db.walletSignerNotificationSetting.findMany({ + where: { + walletId: input.walletId, + signerAddress: { in: candidateAddresses }, + }, + }); + const settingsByAddress = new Map( + settings.map((setting) => [setting.signerAddress, setting]), + ); + + const eligible: SignatureRecipient[] = []; + const skipped: SkippedSignatureRecipient[] = []; + + for (const address of candidateAddresses) { + const setting = settingsByAddress.get(address); + const skipReason = getSkipReason(setting, input.preferenceField); + if (skipReason) { + skipped.push({ address, reason: skipReason }); + continue; + } + + eligible.push({ + address, + email: setting!.email!, + emailNormalized: setting!.emailNormalized!, + }); + } + + return { eligible, skipped }; +} + export async function resolveSignatureRecipients( db: PrismaClient, input: ResolveSignatureRecipientsInput, diff --git a/src/lib/notifications/templates/ballotDeadline.ts b/src/lib/notifications/templates/ballotDeadline.ts new file mode 100644 index 00000000..b70ee477 --- /dev/null +++ b/src/lib/notifications/templates/ballotDeadline.ts @@ -0,0 +1,151 @@ +import { escapeHtml, renderEmailLayout, type EmailTemplate } from "./shared"; + +export type BallotDeadlineWindow = "48h" | "24h"; + +/** What the reminder is anchored on: a saved ballot or a pending vote transaction. */ +export type BallotDeadlineKind = "ballot" | "transaction"; + +export type BallotDeadlineProposal = { + id: string; + title: string | null; + expirationEpoch: number; +}; + +export type BallotDeadlineTemplateInput = { + walletName: string; + kind: BallotDeadlineKind; + /** Ballot description, or the pending transaction's description. */ + label?: string | null; + window: BallotDeadlineWindow; + /** End of the earliest expiring proposal's final epoch. */ + deadline: Date; + deadlineEpoch: number; + proposals: BallotDeadlineProposal[]; + /** Signature progress of the pending vote transaction (transaction kind only). */ + signedCount?: number; + requiredCount?: number; + actionUrl: string; + preferencesUrl: string; +}; + +const MAX_LISTED_PROPOSALS = 10; + +function formatUtc(date: Date): string { + return `${date.toISOString().replace("T", " ").slice(0, 16)} UTC`; +} + +function shortProposalId(id: string): string { + const [hash, index] = id.split("#"); + if (!hash || hash.length <= 16) return id; + return `${hash.slice(0, 8)}…${hash.slice(-6)}${index !== undefined ? `#${index}` : ""}`; +} + +export function renderBallotDeadlineEmail( + input: BallotDeadlineTemplateInput, +): EmailTemplate { + const hours = input.window === "48h" ? 48 : 24; + const isTransaction = input.kind === "transaction"; + const label = + input.label?.trim() || + (isTransaction ? "Untitled vote transaction" : "Untitled ballot"); + const subject = isTransaction + ? `Vote closes in ${hours} hours: ${input.walletName}` + : `Ballot closes in ${hours} hours: ${input.walletName}`; + const deadlineText = `${formatUtc(input.deadline)} (end of epoch ${input.deadlineEpoch})`; + const listed = input.proposals.slice(0, MAX_LISTED_PROPOSALS); + const omitted = input.proposals.length - listed.length; + const progress = + isTransaction && + typeof input.signedCount === "number" && + typeof input.requiredCount === "number" + ? `${input.signedCount} of ${input.requiredCount} required signatures collected` + : null; + + const intro = isTransaction + ? `The pending vote transaction ${escapeHtml(label)} in ${escapeHtml(input.walletName)} votes on proposals that stop accepting votes in about ${hours} hours.` + : `The ballot ${escapeHtml(label)} in ${escapeHtml(input.walletName)} has proposals that stop accepting votes in about ${hours} hours.`; + const introText = isTransaction + ? `The pending vote transaction "${label}" in ${input.walletName} votes on proposals that stop accepting votes in about ${hours} hours.` + : `The ballot "${label}" in ${input.walletName} has proposals that stop accepting votes in about ${hours} hours.`; + const urgency = isTransaction + ? `Votes must be on-chain before ${escapeHtml(deadlineText)}. The transaction still needs the remaining signatures and must be submitted before then.` + : `Votes must be on-chain before ${escapeHtml(deadlineText)}. The vote transaction still needs to collect the wallet's signatures before then.`; + const urgencyText = isTransaction + ? `Votes must be on-chain before ${deadlineText}. The transaction still needs the remaining signatures and must be submitted before then.` + : `Votes must be on-chain before ${deadlineText}. The vote transaction still needs to collect the wallet's signatures before then.`; + + const proposalRows = listed + .map((proposal) => { + const title = proposal.title?.trim() || shortProposalId(proposal.id); + return ` + + ${escapeHtml(title)} + epoch ${proposal.expirationEpoch} + `; + }) + .join(""); + + const bodyHtml = ` +

${intro}

+

${urgency}

+ ${ + progress + ? `

${escapeHtml(progress)}

` + : "" + } + + + + + + ${proposalRows} + ${ + omitted > 0 + ? `` + : "" + } +
ProposalExpires
and ${omitted} more
+ ${ + isTransaction + ? "" + : `

If this ballot's votes have already been submitted, you can ignore this reminder.

` + } + `; + + const ctaLabel = isTransaction ? "Review and sign" : "Open governance"; + const html = renderEmailLayout({ + title: isTransaction + ? `Vote closes in ${hours} hours` + : `Ballot closes in ${hours} hours`, + preview: `${input.walletName}: ${label} stops accepting votes ${deadlineText}.`, + bodyHtml, + ctaLabel, + ctaUrl: input.actionUrl, + footerHtml: `Manage wallet notification preferences here: ${escapeHtml(input.preferencesUrl)}`, + }); + + const text = [ + subject, + "", + introText, + urgencyText, + progress ?? undefined, + "", + "Proposals:", + ...listed.map( + (proposal) => + `- ${proposal.title?.trim() || proposal.id} (expires epoch ${proposal.expirationEpoch})`, + ), + omitted > 0 ? `- and ${omitted} more` : undefined, + "", + isTransaction + ? undefined + : "If this ballot's votes have already been submitted, you can ignore this reminder.", + `${ctaLabel}: ${input.actionUrl}`, + `Manage notification preferences: ${input.preferencesUrl}`, + ] + .filter((line) => line !== undefined) + .join("\n"); + + return { subject, html, text }; +} diff --git a/src/lib/notifications/templates/thresholdReached.ts b/src/lib/notifications/templates/thresholdReached.ts new file mode 100644 index 00000000..321257e6 --- /dev/null +++ b/src/lib/notifications/templates/thresholdReached.ts @@ -0,0 +1,110 @@ +import { escapeHtml, renderEmailLayout, type EmailTemplate } from "./shared"; +import type { SignatureContext } from "../signatureContext"; + +const DESCRIPTION_PREVIEW_MAX_LENGTH = 240; + +export type ThresholdReachedTemplateInput = { + walletName: string; + resourceType: "transaction" | "signable"; + description?: string | null; + signatureContext?: SignatureContext | null; + signedCount: number; + requiredCount: number; + totalSigners: number; + /** Present when the transaction was broadcast as part of the final signature. */ + txHash?: string | null; + actionUrl: string; + preferencesUrl: string; +}; + +function truncateDescription(description: string): string { + if (description.length <= DESCRIPTION_PREVIEW_MAX_LENGTH) { + return description; + } + return `${description.slice(0, DESCRIPTION_PREVIEW_MAX_LENGTH - 3).trimEnd()}...`; +} + +function row(label: string, value: string, bold = false, last = false) { + const border = last ? "" : "border-bottom:1px solid #e5e7eb;"; + return ` + + ${escapeHtml(label)} + ${escapeHtml(value)} + `; +} + +export function renderThresholdReachedEmail( + input: ThresholdReachedTemplateInput, +): EmailTemplate { + const resourceLabel = + input.resourceType === "transaction" ? "transaction" : "signable payload"; + const subject = `Signatures complete: ${input.walletName}`; + const progress = `${input.signedCount} of ${input.requiredCount} required signatures collected`; + const description = input.description?.trim(); + const descriptionPreview = description + ? truncateDescription(description) + : undefined; + const context = input.signatureContext; + const txHash = input.txHash?.trim() || null; + + const statusLine = + input.resourceType === "transaction" + ? txHash + ? "Submitted to the network." + : "Ready to submit — open the wallet to broadcast it." + : "All required signatures have been collected."; + + const rows = [ + descriptionPreview ? row("Description", descriptionPreview, true) : "", + context?.summary ? row("What was signed", context.summary, true) : "", + row("Progress", progress, true), + row("Status", statusLine, false, !txHash && input.totalSigners <= 0), + txHash ? row("Transaction hash", txHash) : "", + row("Signers", String(input.totalSigners), false, true), + ...(context?.details + ?.filter((detail) => detail.label.trim() && detail.value.trim()) + .map( + (detail) => ` + + ${escapeHtml(detail.label)} + ${escapeHtml(detail.value)} + `, + ) ?? []), + ].join(""); + + const bodyHtml = ` +

A ${escapeHtml(resourceLabel)} in ${escapeHtml(input.walletName)} has collected enough signatures. ${escapeHtml(statusLine)}

+ + ${rows} +
+ `; + + const html = renderEmailLayout({ + title: "Signatures complete", + preview: `${input.walletName}: a ${resourceLabel} has all required signatures.`, + bodyHtml, + ctaLabel: txHash ? "View transaction" : "Open wallet", + ctaUrl: input.actionUrl, + footerHtml: `Manage wallet notification preferences here: ${escapeHtml(input.preferencesUrl)}`, + }); + + const text = [ + subject, + "", + `A ${resourceLabel} in ${input.walletName} has collected enough signatures.`, + statusLine, + descriptionPreview ? `Description: ${descriptionPreview}` : undefined, + context?.summary ? `What was signed: ${context.summary}` : undefined, + ...(context?.details?.map((detail) => `${detail.label}: ${detail.value}`) ?? []), + progress, + txHash ? `Transaction hash: ${txHash}` : undefined, + `Total signers: ${input.totalSigners}`, + "", + `${txHash ? "View transaction" : "Open wallet"}: ${input.actionUrl}`, + `Manage notification preferences: ${input.preferencesUrl}`, + ] + .filter(Boolean) + .join("\n"); + + return { subject, html, text }; +} diff --git a/src/lib/notifications/voteProposals.ts b/src/lib/notifications/voteProposals.ts new file mode 100644 index 00000000..d787c9f3 --- /dev/null +++ b/src/lib/notifications/voteProposals.ts @@ -0,0 +1,72 @@ +import { parseProposalId } from "@/lib/governance"; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function parseJsonRecord(value: unknown): Record | null { + if (typeof value === "string") { + try { + return asRecord(JSON.parse(value)); + } catch { + return null; + } + } + return asRecord(value); +} + +function isValidProposalId(id: string): boolean { + try { + parseProposalId(id); + return true; + } catch { + return false; + } +} + +/** + * Governance proposal ids (`#`) a stored transaction votes + * on. Understands both shapes the app persists in `Transaction.txJson`: + * - client-built votes (direct and ballot): `votes[].vote.govActionId` + * - bot proxy votes: `proxyBot.votes[].proposalId` when `proxyBot.kind === "proxyVote"` + * Client-side proxy votes carry neither (the vote lives in a Plutus redeemer) + * and yield an empty list. Order is preserved; duplicates are dropped. + */ +export function extractVoteProposalIds(txJson: unknown): string[] { + const parsed = parseJsonRecord(txJson); + if (!parsed) return []; + + const ids: string[] = []; + const seen = new Set(); + const push = (id: string) => { + if (seen.has(id) || !isValidProposalId(id)) return; + seen.add(id); + ids.push(id); + }; + + if (Array.isArray(parsed.votes)) { + for (const entry of parsed.votes) { + const govActionId = asRecord(asRecord(asRecord(entry)?.vote)?.govActionId); + if ( + typeof govActionId?.txHash === "string" && + typeof govActionId.txIndex === "number" + ) { + push(`${govActionId.txHash}#${govActionId.txIndex}`); + } + } + } + + const proxyBot = asRecord(parsed.proxyBot); + if (proxyBot?.kind === "proxyVote" && Array.isArray(proxyBot.votes)) { + for (const entry of proxyBot.votes) { + const proposalId = asRecord(entry)?.proposalId; + if (typeof proposalId === "string") { + push(proposalId); + } + } + } + + return ids; +} diff --git a/src/lib/notifications/worker.ts b/src/lib/notifications/worker.ts index ca6e6a7a..da57f5d6 100644 --- a/src/lib/notifications/worker.ts +++ b/src/lib/notifications/worker.ts @@ -4,16 +4,15 @@ import type { } from "@prisma/client"; import { - NOTIFICATION_EVENT_SIGNATURE_REMINDER, - NOTIFICATION_EVENT_SIGNATURE_REQUIRED, + getNotificationPreferenceField, NOTIFICATION_STATUS_FAILED, NOTIFICATION_STATUS_PENDING, NOTIFICATION_STATUS_RETRYING, NOTIFICATION_STATUS_SENDING, NOTIFICATION_STATUS_SENT, - type SignatureResourceType, + type NotificationPreferenceField, } from "./events"; -import { getSignatureSkipReason } from "./recipients"; +import { getSkipReason } from "./recipients"; import { sendEmailViaResend } from "./channels/email/resend"; const RETRY_DELAYS_MS = [ @@ -42,29 +41,19 @@ function getPayloadString( return typeof value === "string" && value.trim() ? value : null; } -const SIGNATURE_EVENT_TYPES: readonly string[] = [ - NOTIFICATION_EVENT_SIGNATURE_REQUIRED, - NOTIFICATION_EVENT_SIGNATURE_REMINDER, -]; - -function isSignatureResourceType( - value: string, -): value is SignatureResourceType { - return value === "transaction" || value === "signable"; -} - -// Signature deliveries re-check the signer's current preferences at send -// time; email.verify deliveries are exempt (verification must be able to -// reach an address that is not yet opted in or verified). -function isSignatureDelivery< - T extends { eventType: string; walletId: string | null; resourceType: string }, ->( - delivery: T, -): delivery is T & { walletId: string; resourceType: SignatureResourceType } { - return ( - SIGNATURE_EVENT_TYPES.includes(delivery.eventType) && - delivery.walletId !== null && - isSignatureResourceType(delivery.resourceType) +// Preference-gated deliveries re-check the signer's current settings at send +// time so a toggle flipped after enqueue is still honored. Returns null for +// exempt events (email.verify must be able to reach an address that is not +// yet opted in or verified) and for rows without a wallet scope. +function preferenceFieldFor(delivery: { + eventType: string; + walletId: string | null; + resourceType: string; +}): NotificationPreferenceField | null { + if (!delivery.walletId) return null; + return getNotificationPreferenceField( + delivery.eventType, + delivery.resourceType, ); } @@ -84,13 +73,15 @@ export async function drainNotificationOutbox( take: limit, }); - const signatureDeliveries = deliveries.filter(isSignatureDelivery); + const gatedDeliveries = deliveries.filter( + (delivery) => preferenceFieldFor(delivery) !== null, + ); const settingsByKey = new Map(); - if (signatureDeliveries.length > 0) { + if (gatedDeliveries.length > 0) { const settings = await db.walletSignerNotificationSetting.findMany({ where: { - OR: signatureDeliveries.map((delivery) => ({ - walletId: delivery.walletId, + OR: gatedDeliveries.map((delivery) => ({ + walletId: delivery.walletId!, signerAddress: delivery.recipientAddress, })), }, @@ -103,11 +94,12 @@ export async function drainNotificationOutbox( const results = []; for (const delivery of deliveries) { - if (isSignatureDelivery(delivery)) { + const preferenceField = preferenceFieldFor(delivery); + if (preferenceField) { const setting = settingsByKey.get( `${delivery.walletId}:${delivery.recipientAddress}`, ); - const skipReason = getSignatureSkipReason(setting, delivery.resourceType); + const skipReason = getSkipReason(setting, preferenceField); if (skipReason) { const marked = await db.notificationDelivery.updateMany({ where: { diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 34491946..312e61aa 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -25,22 +25,59 @@ export const DEFAULT_DESCRIPTION = export const DEFAULT_KEYWORDS = "cardano multisig, multi-signature wallet, cardano treasury, dao treasury, cardano governance, drep, cardano wallet, meshjs, ada, blockchain"; -/** Branded 1200×630 social card served from /public (generated by scripts/generate-og-image.mjs). */ +/** + * Branded 1200×630 social cards served from /public, generated by + * scripts/generate-og-image.mjs. `OG_IMAGE_PATH` is the site-wide default (and + * the home-page card); most marketing routes override it below with their own + * card so a shared link says which page it points at. + */ export const OG_IMAGE_PATH = "/og-image.png"; export const OG_IMAGE_WIDTH = 1200; export const OG_IMAGE_HEIGHT = 630; +/** Per-route cards. Keep in step with the `OG_CARDS` list in the generator. */ +export const OG_CARD = { + features: "/og/features.png", + governance: "/og/governance.png", + drep: "/og/drep.png", + roadmap: "/og/roadmap.png", + roadmapGraph: "/og/roadmap-graph.png", + blog: "/og/blog.png", + apiDocs: "/og/api-docs.png", + dapps: "/og/dapps.png", + importWallet: "/og/import-wallet.png", + vault: "/og/vault.png", + verify: "/og/verify.png", +} as const; + +/** + * Bumped whenever the card artwork changes. Social scrapers cache by image URL + * and will happily serve a year-old card forever, so the query string is what + * actually forces X, LinkedIn and Slack to re-fetch. + */ +export const OG_IMAGE_VERSION = "2"; + /** Build an absolute URL for a site-relative path. */ export function absoluteUrl(path = "/"): string { if (/^https?:\/\//i.test(path)) return path; return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`; } +/** Absolute, cache-busted URL for a social card. See {@link OG_IMAGE_VERSION}. */ +export function ogImageUrl(path: string = OG_IMAGE_PATH): string { + const url = absoluteUrl(path); + return `${url}${url.includes("?") ? "&" : "?"}v=${OG_IMAGE_VERSION}`; +} + export type RouteSeo = { /** Page-specific lead used to compose the . Omit on "/" (uses DEFAULT_TITLE). */ title?: string; description?: string; keywords?: string; + /** Site-relative social card; defaults to {@link OG_IMAGE_PATH}. */ + image?: string; + /** Alt text for the card. Describe the card, not the page. */ + imageAlt?: string; noindex?: boolean; }; @@ -55,46 +92,64 @@ export const routeSeo: Record<string, RouteSeo> = { }, "/features": { title: "Features", + image: OG_CARD.features, + imageAlt: "Mesh Multisig — everything a Cardano team treasury needs.", description: "Multi-signature security, signer invites and verification, full transaction history, governance voting, DRep registration, staking and a REST API — everything Mesh Multisig offers Cardano teams.", }, "/governance": { title: "Cardano Governance", + image: OG_CARD.governance, + imageAlt: "Mesh Multisig — vote on Cardano governance as a team.", description: "Browse Cardano governance proposals and vote as a team with multi-signature security. Register your organization as a DRep and take part in on-chain decisions.", }, "/governance/drep": { title: "DRep Explorer", + image: OG_CARD.drep, + imageAlt: "Mesh Multisig — find the DRep that votes like you do.", description: "Explore Cardano Delegated Representatives (DReps): voting records, delegated stake and metadata. Find a DRep to delegate your Cardano voting power to.", }, "/governance/drep/[id]": { title: "DRep Details", + image: OG_CARD.drep, + imageAlt: "Mesh Multisig — find the DRep that votes like you do.", description: "View a Cardano DRep's voting history, delegated stake and on-chain metadata on Mesh Multisig.", }, "/api-docs": { title: "API & Bot Documentation", + image: OG_CARD.apiDocs, + imageAlt: "Mesh Multisig — drive your treasury from code.", description: "Interactive OpenAPI / Swagger documentation for the Mesh Multisig REST API. Authenticate bots, query wallets and pending transactions, and build Cardano integrations.", }, "/dapps": { title: "DApps", + image: OG_CARD.dapps, + imageAlt: "Mesh Multisig — use any Cardano dApp in multisig mode.", description: "Use Mesh Multisig with your favourite Cardano DApps in multi-signature mode.", }, "/roadmap": { title: "Roadmap", + image: OG_CARD.roadmap, + imageAlt: "Mesh Multisig — twelve months of roadmap.", description: - "The twelve-month Mesh Multisig roadmap: what has shipped, what is in progress and what is planned for the Cardano multi-signature wallet, from May 2026 to April 2027.", + "The twelve-month Mesh Multisig roadmap: what has shipped, what is in progress and what is planned for the Cardano multi-signature wallet, from April 2026 to March 2027.", }, "/roadmap/graph": { title: "Feature Graph", + image: OG_CARD.roadmapGraph, + imageAlt: "Mesh Multisig — every feature, and what it connects to.", description: "An interactive knowledge graph of every Mesh Multisig feature and the state it is in — delivered, planned or blocked — linked to its area and the features it touches.", }, "/blog": { title: "Blog", + image: OG_CARD.blog, + imageAlt: "Mesh Multisig — notes on multisig, governance and agents.", description: "Guides and updates on Cardano multisig treasuries, governance and AI-agent automation from the team behind Mesh Multisig.", }, @@ -102,11 +157,29 @@ export const routeSeo: Record<string, RouteSeo> = { // Per-post title/description/Article JSON-LD are supplied at request time via // `pageProps.seo` (see buildPostSeo); these are only the fallback. title: "Blog", + image: OG_CARD.blog, + imageAlt: "Mesh Multisig — notes on multisig, governance and agents.", description: "Guides and updates on Cardano multisig treasuries, governance and AI-agent automation from the team behind Mesh Multisig.", }, + "/vault": { + title: "Shielded Sign-Off", + image: OG_CARD.vault, + imageAlt: "Mesh Multisig — prove one document, reveal nothing else.", + description: + "An Obsidian-style document vault where every trust edge commits to a hash. Disclose a single document and its path to the root without revealing the documents beside it.", + }, + "/verify": { + title: "Verify a Sign-Off Proof", + image: OG_CARD.verify, + imageAlt: "Mesh Multisig — check a document approval without an account.", + description: + "Check a Mesh Multisig document sign-off proof: re-hash the document, verify each signer's CIP-8 signature against the frozen signer set, and confirm the approval threshold was reached. No account needed.", + }, "/wallets/import-wallet": { title: "Import a Multisig Wallet", + image: OG_CARD.importWallet, + imageAlt: "Mesh Multisig — bring your multisig wallet with you.", description: "Import an existing Cardano multi-signature wallet into Mesh Multisig and continue collaborating with your signers.", }, @@ -135,6 +208,8 @@ export type ResolvedSeo = { title: string; description: string; keywords: string; + image: string; + imageAlt?: string; noindex: boolean; }; @@ -152,6 +227,8 @@ export function getRouteSeo(pathname: string): ResolvedSeo { title: composeTitle(pathname, entry?.title), description: entry?.description ?? DEFAULT_DESCRIPTION, keywords: entry?.keywords ?? DEFAULT_KEYWORDS, + image: entry?.image ?? OG_IMAGE_PATH, + imageAlt: entry?.imageAlt, noindex, }; } @@ -168,6 +245,8 @@ export const INDEXABLE_ROUTES: SitemapRoute[] = [ { path: "/features", changefreq: "monthly", priority: 0.8 }, { path: "/roadmap", changefreq: "monthly", priority: 0.6 }, { path: "/roadmap/graph", changefreq: "monthly", priority: 0.5 }, + { path: "/vault", changefreq: "monthly", priority: 0.5 }, + { path: "/verify", changefreq: "monthly", priority: 0.5 }, { path: "/blog", changefreq: "weekly", priority: 0.7 }, { path: "/governance", changefreq: "daily", priority: 0.8 }, { path: "/governance/drep", changefreq: "daily", priority: 0.7 }, @@ -235,6 +314,8 @@ export type PageSeo = { keywords?: string; /** Site-relative OG image path. */ image?: string; + /** Alt text for that image. */ + imageAlt?: string; /** Open Graph object type, e.g. "article". */ type?: string; noindex?: boolean; @@ -270,7 +351,7 @@ export function buildArticleJsonLd(meta: ArticleMeta): Record<string, any> { "@type": "Article", headline: meta.title, description: meta.description, - image: absoluteUrl(meta.image ?? OG_IMAGE_PATH), + image: absoluteUrl(meta.image ?? OG_CARD.blog), datePublished: meta.date, dateModified: meta.updated ?? meta.date, author: { @@ -297,7 +378,7 @@ export function buildPostSeo(meta: ArticleMeta): PageSeo { title: `${meta.title} · ${SITE_NAME}`, description: meta.description, keywords: meta.tags?.length ? meta.tags.join(", ") : DEFAULT_KEYWORDS, - image: meta.image ?? OG_IMAGE_PATH, + image: meta.image ?? OG_CARD.blog, type: "article", noindex: false, jsonLd: [buildArticleJsonLd(meta)], diff --git a/src/lib/server/createPendingMultisigTransaction.ts b/src/lib/server/createPendingMultisigTransaction.ts index 0df63f1c..76b84fa3 100644 --- a/src/lib/server/createPendingMultisigTransaction.ts +++ b/src/lib/server/createPendingMultisigTransaction.ts @@ -1,4 +1,4 @@ -import type { PrismaClient } from "@prisma/client"; +import type { Prisma, PrismaClient, Transaction } from "@prisma/client"; import { getProvider } from "@/utils/get-provider"; import { enqueueSignatureRequiredNotifications } from "@/lib/notifications/center"; @@ -7,6 +7,17 @@ export type WalletSubmitShape = { type: string; }; +/** + * Runs inside the same database transaction as the pending row's insert, so a + * failure here rolls the row back too. Used to link a task-board payout to the + * transaction it pays (src/lib/task-payout/hooks.ts) — the link and the row + * exist together or not at all. + */ +export type AfterCreateHook = ( + tx: Prisma.TransactionClient, + transaction: Transaction, +) => Promise<void>; + function getRequiredSignerCount(wallet: WalletSubmitShape): number { if (wallet.type === "any") return 1; if (wallet.type === "atLeast" || typeof wallet.numRequiredSigners === "number") { @@ -29,6 +40,14 @@ export async function createPendingMultisigTransaction( description: string; network: number; initialSignedAddresses?: string[]; + /** + * Who the signature-required notification treats as the creator (and + * therefore skips). Defaults to the proposer, who has normally signed + * already. Pass `null` when the proposer has NOT signed — an MCP draft — + * so they are notified like every other outstanding signer. + */ + notificationCreatorAddress?: string | null; + afterCreate?: AfterCreateHook; }, ) { const { @@ -40,6 +59,8 @@ export async function createPendingMultisigTransaction( description, network, initialSignedAddresses = [proposerAddress], + notificationCreatorAddress = proposerAddress, + afterCreate, } = args; const reqSigners = wallet.numRequiredSigners; const wtype = wallet.type; @@ -55,17 +76,22 @@ export async function createPendingMultisigTransaction( return await blockchainProvider.submitTx(txCbor); } - const transaction = await db.transaction.create({ - data: { - walletId, - txJson: txJsonStr, - txCbor, - signedAddresses: initialSignedAddresses, - rejectedAddresses: [], - description, - state: 0, - }, - }); + const data = { + walletId, + txJson: txJsonStr, + txCbor, + signedAddresses: initialSignedAddresses, + rejectedAddresses: [], + description, + state: 0, + }; + const transaction = afterCreate + ? await db.$transaction(async (tx) => { + const row = await tx.transaction.create({ data }); + await afterCreate(tx, row); + return row; + }) + : await db.transaction.create({ data }); try { const walletRow = await db.wallet.findUnique({ @@ -86,7 +112,7 @@ export async function createPendingMultisigTransaction( resourceId: transaction.id, signedAddresses: transaction.signedAddresses, rejectedAddresses: transaction.rejectedAddresses, - creatorAddress: proposerAddress, + creatorAddress: notificationCreatorAddress, description, txJson, }); diff --git a/src/lib/staking/stake-account-status.ts b/src/lib/staking/stake-account-status.ts new file mode 100644 index 00000000..9488ad61 --- /dev/null +++ b/src/lib/staking/stake-account-status.ts @@ -0,0 +1,55 @@ +export type StakeAccountStatus = { + /** Registered on chain right now (a registration not since undone). */ + active: boolean; + poolId: string | null; +}; + +/** The raw REST accessor (`BlockfrostProvider.get`), not the typed wrappers. */ +type AccountProvider = { + get: (url: string) => Promise<unknown>; +}; + +type BlockfrostAccount = { + active?: boolean; + active_epoch?: number | null; + pool_id?: string | null; +}; + +/** + * Whether the reward address is registered on chain, and where it delegates. + * + * Reads Blockfrost's `/accounts/{stake}` directly and trusts only its + * `active` flag — the same check the builder canvas makes. Mesh's + * `fetchAccountInfo` must not be used here: it reports + * `active || active_epoch !== null`, and `active_epoch` keeps the epoch of + * the last registration after a deregistration, so a deregistered account + * comes back "active" and a delegation built on that answer is rejected by + * the node (StakeKeyNotRegisteredDELEG) only after every signature is in. + * + * Blockfrost answers 404 for an account that has never appeared on chain, + * which means "not active" rather than a failure. Anything else is + * rethrown: a caller deciding whether to add a registration must not guess + * when the lookup itself failed. + */ +export async function fetchStakeAccountStatus( + provider: AccountProvider, + rewardAddress: string, +): Promise<StakeAccountStatus> { + try { + const data = (await provider.get(`/accounts/${rewardAddress.trim()}`)) as + | BlockfrostAccount + | null + | undefined; + return { active: data?.active === true, poolId: data?.pool_id ?? null }; + } catch (error) { + if (isNotFound(error)) return { active: false, poolId: null }; + throw error; + } +} + +function isNotFound(error: unknown): boolean { + return ( + (error as { status?: number })?.status === 404 || + (error instanceof Error && error.message.includes("404")) + ); +} diff --git a/src/lib/task-payout/deps.ts b/src/lib/task-payout/deps.ts new file mode 100644 index 00000000..e6776f10 --- /dev/null +++ b/src/lib/task-payout/deps.ts @@ -0,0 +1,57 @@ +import type { PrismaClient } from "@prisma/client"; + +import type { McpCaller } from "@/lib/mcp/auth"; +import { MCP_SCOPES } from "@/lib/mcp/scopes"; +import { freeUtxosFetcher, type ToolContext } from "@/lib/mcp/tools"; +import type { ReviewDeps } from "@/lib/tx-review/pipeline"; +import type { AuthCtx } from "@/server/api/trpc"; +import { getSessionAddresses, requireSessionAddress } from "@/server/api/auth"; + +/** + * How the web app enters the tx-review pipeline — the same pipeline the MCP + * tools use, with the caller derived from the tRPC session instead of a + * bearer token. + * + * `clientName` is a fixed marker rather than null: a draft token carries the + * client id and `verifyDraftToken` compares it, so a token previewed in the + * app cannot be confirmed through an MCP connection (whose v1-bearer client + * id is null) or vice versa. The same human, but two surfaces that each show + * their own review. It also lands in `txJson.mcp.client`, where "app" reads + * as "shared pipeline, confirmed in the web app". + */ +export const APP_CLIENT_ID = "app"; + +export function callerFromSession(ctx: AuthCtx): McpCaller { + const subject = requireSessionAddress(ctx); + const addresses = [...new Set([subject, ...getSessionAddresses(ctx)])]; + return { + subject, + addresses, + scopes: [...MCP_SCOPES], + clientName: APP_CLIENT_ID, + botId: null, + expiresAt: Math.floor(Date.now() / 1000) + 60 * 60, + }; +} + +export function toolContextFromSession(ctx: AuthCtx): ToolContext { + return { caller: callerFromSession(ctx), clientIp: ctx.ip }; +} + +/** + * The pipeline's dependencies outside MCP. Spendable UTxOs come from the v1 + * `freeUtxos` handler run in-process — the same `freeUtxosFetcher` the + * drafting tools use — so pending-transaction input locking stays defined + * once, in that handler. (The handler module is loaded lazily inside it.) + */ +export function buildReviewDeps( + db: PrismaClient, + toolCtx: ToolContext, + extra: Partial<ReviewDeps> = {}, +): ReviewDeps { + return { + db, + fetchFreeUtxos: freeUtxosFetcher(toolCtx), + ...extra, + }; +} diff --git a/src/lib/task-payout/hooks.ts b/src/lib/task-payout/hooks.ts new file mode 100644 index 00000000..d03ad227 --- /dev/null +++ b/src/lib/task-payout/hooks.ts @@ -0,0 +1,60 @@ +import type { ProposeDeps } from "@/lib/tx-review/propose"; +import { TxReviewError } from "@/lib/tx-review/context"; + +import { loadPayableTasks } from "./load"; +import { recipientsHash, TASKS_TXJSON_KEY, type TasksTxJsonProvenance } from "./spec"; + +/** + * The confirm side of a task payout, attached to `runTransactionPropose` + * through its deps — so the web app's `task.confirmPayout` and the MCP + * `transaction_propose` tool link tasks the same way, and a token with no + * task origin is proposed exactly as before. + * + * Inside the insert's database transaction the tasks are re-read and their + * recipient rows re-hashed: if anything the human reviewed has changed since + * the preview, or a payout was created for one of the tasks in between, the + * insert is rolled back and the caller gets a 409 telling them to preview + * again. + */ +export function withTaskPayoutHooks(deps: ProposeDeps): ProposeDeps { + return { + ...deps, + txJsonExtras: (claims) => { + const base = deps.txJsonExtras?.(claims) ?? {}; + if (claims.origin?.kind !== "tasks") return base; + const provenance: TasksTxJsonProvenance = { + taskIds: claims.origin.taskIds, + recipientsHash: claims.origin.recipientsHash, + preparedBy: claims.subject, + preparedAt: new Date().toISOString(), + }; + return { ...base, [TASKS_TXJSON_KEY]: provenance }; + }, + afterCreate: async (tx, created, claims) => { + await deps.afterCreate?.(tx, created, claims); + if (claims.origin?.kind !== "tasks") return; + const { taskIds } = claims.origin; + + // Also refuses a task moved out of Done or one whose payout appeared since preview. + const tasks = await loadPayableTasks(tx, claims.walletId, taskIds); + if (recipientsHash(tasks) !== claims.origin.recipientsHash) { + throw new TxReviewError( + 409, + "TASK_CHANGED", + "A task's recipients or amounts changed since the payout was previewed. Nothing was created — preview the payout again.", + { taskIds }, + ); + } + + await tx.taskPayout.createMany({ + data: taskIds.map((taskId) => ({ + walletId: claims.walletId, + taskId, + transactionId: created.id, + status: "Pending" as const, + createdBy: claims.subject, + })), + }); + }, + }; +} diff --git a/src/lib/task-payout/load.ts b/src/lib/task-payout/load.ts new file mode 100644 index 00000000..381d872b --- /dev/null +++ b/src/lib/task-payout/load.ts @@ -0,0 +1,122 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; + +import { TxReviewError } from "@/lib/tx-review/context"; + +import type { PayableTask } from "./spec"; +import { MAX_PAYOUT_TASKS } from "./state"; + +type Db = PrismaClient | Prisma.TransactionClient; + +export { MAX_PAYOUT_TASKS }; + +const ACTIVE_LINK = ["Pending", "Paid"] as const; + +const include = { + recipients: { orderBy: { position: "asc" } }, + payouts: { where: { status: { in: [...ACTIVE_LINK] } } }, +} satisfies Prisma.TaskInclude; + +type Row = Prisma.TaskGetPayload<{ include: typeof include }>; + +function toPayable(row: Row): PayableTask { + return { + id: row.id, + title: row.title, + recipients: row.recipients.map((r) => ({ + address: r.address, + unit: r.unit, + quantity: r.quantity, + })), + }; +} + +/** + * The tasks a payout may cover, in the order requested. Refuses unknown ids, + * ids belonging to another wallet (same 404 — the caller learns nothing about + * other wallets' tasks), tasks whose work has not reached Done, and tasks that + * already have a pending or paid link. A task with no recipients is refused by + * `buildPayoutSpec`. + */ +export async function loadPayableTasks( + db: Db, + walletId: string, + taskIds: string[], +): Promise<PayableTask[]> { + const unique = [...new Set(taskIds)]; + const rows = await db.task.findMany({ + where: { id: { in: unique }, walletId }, + include, + }); + + const byId = new Map(rows.map((row) => [row.id, row])); + const missing = unique.filter((id) => !byId.has(id)); + if (missing.length > 0) { + throw new TxReviewError( + 404, + "TASK_NOT_FOUND", + `${missing.length === 1 ? "Task" : "Tasks"} not found in this wallet: ${missing.join(", ")}`, + { taskIds: missing }, + ); + } + + const incomplete = rows.filter((row) => row.status !== "Done"); + if (incomplete.length > 0) { + throw new TxReviewError( + 409, + "TASK_NOT_DONE", + `${incomplete.length === 1 ? "A task is" : `${incomplete.length} tasks are`} not Done: ${incomplete + .map((t) => `"${t.title}"`) + .join(", ")}. Complete and review the work before preparing its payout.`, + { taskIds: incomplete.map((t) => t.id) }, + ); + } + + const blocked = rows.filter((row) => row.payouts.length > 0); + if (blocked.length > 0) { + throw new TxReviewError( + 409, + "TASK_NOT_PAYABLE", + `${blocked.length === 1 ? "A task already has" : `${blocked.length} tasks already have`} a payout that is pending or paid: ${blocked + .map((t) => `"${t.title}"`) + .join(", ")}. Delete the pending transaction first to pay it again.`, + { taskIds: blocked.map((t) => t.id) }, + ); + } + + return unique.map((id) => toPayable(byId.get(id)!)); +} + +/** + * Every task the wallet could pay right now — Done, with recipients, and no + * pending or paid link — in board order. This is what "Prepare payout" means + * when nothing was picked: pay all the finished work. The caller still sees + * exactly which tasks were included before anything is created. + */ +export async function loadAllPayableTasks(db: Db, walletId: string): Promise<PayableTask[]> { + const rows = await db.task.findMany({ + where: { + walletId, + status: "Done", + recipients: { some: {} }, + payouts: { none: { status: { in: [...ACTIVE_LINK] } } }, + }, + include, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + }); + if (rows.length === 0) { + throw new TxReviewError( + 409, + "NO_PAYABLE_TASKS", + "No Done task with recipients is waiting for payment. Move finished work to Done and add its recipients first.", + ); + } + if (rows.length > MAX_PAYOUT_TASKS) { + throw new TxReviewError( + 400, + "TOO_MANY_TASKS", + `${rows.length} tasks are payable, but one payout covers at most ${MAX_PAYOUT_TASKS}. Pass the taskIds to pay in this transaction.`, + { payableCount: rows.length, max: MAX_PAYOUT_TASKS }, + ); + } + return rows.map(toPayable); +} diff --git a/src/lib/task-payout/preview.ts b/src/lib/task-payout/preview.ts new file mode 100644 index 00000000..6a648664 --- /dev/null +++ b/src/lib/task-payout/preview.ts @@ -0,0 +1,58 @@ +import type { McpToolResult, ToolContext } from "@/lib/mcp/tools"; +import { loadReviewWalletContext, TxReviewError } from "@/lib/tx-review/context"; +import type { ReviewDeps } from "@/lib/tx-review/pipeline"; +import { runSpecPreview } from "@/lib/tx-review/preview"; + +import { loadAllPayableTasks, loadPayableTasks } from "./load"; +import { buildPayoutSpec, recipientsHash } from "./spec"; + +export type TaskPayoutPreviewInput = { + walletId: string; + /** Omitted or empty: every payable task in the wallet. */ + taskIds?: string[]; +}; + +/** + * Preview a payout for one or more Done tasks: the wallet is authorized first + * (a non-signer must not learn whether a task id exists), the tasks are loaded + * — the named ones, or every payable one when none were named — and turned + * into a canonical spec, and the shared preview pipeline builds, summarizes + * and mints the draft token — with the task ids and a hash of their recipient + * rows bound in, so `transaction_propose` / `confirmPayout` link exactly the + * tasks whose amounts were on the card. + * + * Persists nothing. + */ +export async function prepareTaskPayoutPreview( + input: TaskPayoutPreviewInput, + ctx: ToolContext, + deps: ReviewDeps, +): Promise<McpToolResult> { + try { + const wallet = await loadReviewWalletContext(deps.db, input.walletId, ctx.caller); + const tasks = + input.taskIds && input.taskIds.length > 0 + ? await loadPayableTasks(deps.db, wallet.walletRow.id, input.taskIds) + : await loadAllPayableTasks(deps.db, wallet.walletRow.id); + const spec = buildPayoutSpec(wallet.walletRow.id, tasks); + const result = await runSpecPreview(spec, ctx, deps, { + wallet, + draftId: "task-payout", + origin: { + kind: "tasks", + taskIds: tasks.map((t) => t.id), + recipientsHash: recipientsHash(tasks), + }, + extraBody: { + tasks: tasks.map((t) => ({ id: t.id, title: t.title })), + }, + }); + return { + ...result, + audit: { ...(result.audit ?? {}), taskCount: tasks.length }, + }; + } catch (error) { + if (error instanceof TxReviewError) return error.toResult(); + throw error; + } +} diff --git a/src/lib/task-payout/recipients.ts b/src/lib/task-payout/recipients.ts new file mode 100644 index 00000000..742aa68d --- /dev/null +++ b/src/lib/task-payout/recipients.ts @@ -0,0 +1,51 @@ +import { TxReviewError } from "@/lib/tx-review/context"; +import { resolveAssetMetadata } from "@/lib/tx-review/metadata"; +import { collectSpecUnits, hasSpecErrors, normalizeTxSpec } from "@/lib/tx-review/spec"; +import { getProvider } from "@/utils/get-provider"; + +/** + * Task recipients as a caller writes them: display units, the same item + * shape as `transaction_preview`'s outputs (`ada`, or a token with its + * registered decimals). + */ +export type DisplayRecipient = { + address: string; + ada?: string; + assets?: { unit: string; quantity: string }[]; +}; + +/** Task recipients as the board stores them: one row per (address, unit), base units. */ +export type BaseRecipient = { address: string; unit: string; quantity: string }; + +/** + * Display-unit recipients → base-unit rows, through the same normalization + * `transaction_preview` uses (registry decimals; a fractional amount for a + * token without registered decimals is refused, never guessed). Used by the + * v1 `taskUpsert` handler, so REST and MCP callers share one contract. + * + * Throws `TxReviewError("INVALID_SPEC")` with the issue list. + */ +export async function recipientsToBaseUnits( + network: 0 | 1, + walletId: string, + recipients: DisplayRecipient[], +): Promise<BaseRecipient[]> { + if (recipients.length === 0) return []; + const input = { walletId, outputs: recipients }; + const assets = await resolveAssetMetadata(getProvider(network), collectSpecUnits(input), network); + const { spec, issues } = normalizeTxSpec(input, { decimalsFor: assets.decimalsFor }); + if (hasSpecErrors(issues)) { + throw new TxReviewError( + 400, + "INVALID_SPEC", + `The recipients could not be understood: ${issues + .filter((i) => i.level === "error") + .map((i) => i.message) + .join(" ")}`, + { issues }, + ); + } + return spec.outputs.flatMap((output) => + output.assets.map((asset) => ({ address: output.address, unit: asset.unit, quantity: asset.quantity })), + ); +} diff --git a/src/lib/task-payout/serialize.ts b/src/lib/task-payout/serialize.ts new file mode 100644 index 00000000..a1458e6b --- /dev/null +++ b/src/lib/task-payout/serialize.ts @@ -0,0 +1,81 @@ +import { TRPCError } from "@trpc/server"; + +import type { AnnotatedTask } from "@/server/api/routers/tasks"; + +/** + * The task shape the v1 `tasks` / `taskUpsert` handlers return (and so the + * `task_list` / `task_upsert` MCP tools, which wrap them). Dates as ISO + * strings, recipients in base units, and the derived payout state. + */ +export function serializeTask(task: AnnotatedTask) { + return { + id: task.id, + walletId: task.walletId, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + assigneeAddress: task.assigneeAddress, + dueDate: task.dueDate ? task.dueDate.toISOString() : null, + position: task.position, + createdBy: task.createdBy, + createdAt: task.createdAt.toISOString(), + updatedAt: task.updatedAt.toISOString(), + recipients: task.recipients.map((r) => ({ + address: r.address, + unit: r.unit, + /** Base units: lovelace, or the token's raw quantity. */ + quantity: r.quantity, + label: r.label, + })), + payout: task.payout, + }; +} + +export type SerializedTask = ReturnType<typeof serializeTask>; + +/** + * A tRPC error from the in-process `task` router → the `{status, body}` a + * v1 handler answers with. The router owns authorization and validation, so + * its codes are the contract; this only maps them onto HTTP. + */ +export function trpcErrorToHttp(error: TRPCError): { + status: number; + body: { error: string; code: string }; +} { + const status = + error.code === "NOT_FOUND" + ? 404 + : error.code === "FORBIDDEN" + ? 403 + : error.code === "UNAUTHORIZED" + ? 401 + : error.code === "CONFLICT" + ? 409 + : error.code === "PRECONDITION_FAILED" + ? 412 + : error.code === "BAD_REQUEST" + ? 400 + : 500; + return { status, body: { error: readableMessage(error), code: error.code } }; +} + +/** + * A zod input failure reaches the caller as the ZodError message, which is + * the JSON-serialized issue list. Surface the first issue as a sentence a + * caller (or a model) can act on instead. + */ +function readableMessage(error: TRPCError): string { + if (error.code !== "BAD_REQUEST") return error.message; + try { + const issues = JSON.parse(error.message) as unknown; + const first = Array.isArray(issues) ? (issues[0] as { path?: unknown; message?: unknown }) : null; + if (first && typeof first.message === "string") { + const path = Array.isArray(first.path) && first.path.length > 0 ? `${first.path.join(".")}: ` : ""; + return `${path}${first.message}`; + } + } catch { + /* not a zod message */ + } + return error.message; +} diff --git a/src/lib/task-payout/spec.ts b/src/lib/task-payout/spec.ts new file mode 100644 index 00000000..3dc3f088 Binary files /dev/null and b/src/lib/task-payout/spec.ts differ diff --git a/src/lib/task-payout/state.ts b/src/lib/task-payout/state.ts new file mode 100644 index 00000000..94dbb417 --- /dev/null +++ b/src/lib/task-payout/state.ts @@ -0,0 +1,90 @@ +/** + * Payout state of a task, derived — never stored. The board shows it as a + * badge; the column is the task's own status and is never moved by a payment. + * + * Precedence: a Paid link wins over a Pending one, a Pending link over + * "ready", and a task with no recipients has nothing to pay. Cancelled links + * (the pending transaction was deleted or replaced) are ignored, so the task + * becomes payable again. + * + * "Payable" is the one question the board, the payout dialog and the MCP + * tools all ask — can this task be paid right now? — so it is answered here, + * once, together with the reason when the answer is no. + */ + +export type TaskPayoutState = "none" | "ready" | "pending" | "paid"; + +/** + * Upper bound on tasks in one payout; the router, the loader and the MCP + * schema quote it. It lives here, in a module with no imports, because the + * router must not pull the payout pipeline (and Mesh) into its graph. + */ +export const MAX_PAYOUT_TASKS = 20; + +/** Why a task cannot be paid right now; `null` on a payable task. */ +export type PayoutBlocker = "paid" | "pending" | "no_recipients" | "not_done"; + +export type PayoutLinkLike = { + status: "Pending" | "Paid" | "Cancelled"; + transactionId: string; + txHash?: string | null; +}; + +export type DerivedPayoutState = { + state: TaskPayoutState; + /** The transaction the state points at, when there is one. */ + transactionId: string | null; + txHash: string | null; + /** True when the task can go into a payout right now (Done, has recipients, no active link). */ + payable: boolean; + blocker: PayoutBlocker | null; +}; + +export function derivePayoutState(task: { + status: string; + recipients: { length: number }; + payouts: PayoutLinkLike[]; +}): DerivedPayoutState { + const paid = task.payouts.find((p) => p.status === "Paid"); + if (paid) { + return { + state: "paid", + transactionId: paid.transactionId, + txHash: paid.txHash ?? null, + payable: false, + blocker: "paid", + }; + } + const pending = task.payouts.find((p) => p.status === "Pending"); + if (pending) { + return { + state: "pending", + transactionId: pending.transactionId, + txHash: null, + payable: false, + blocker: "pending", + }; + } + if (task.recipients.length === 0) { + return { state: "none", transactionId: null, txHash: null, payable: false, blocker: "no_recipients" }; + } + if (task.status !== "Done") { + return { state: "ready", transactionId: null, txHash: null, payable: false, blocker: "not_done" }; + } + return { state: "ready", transactionId: null, txHash: null, payable: true, blocker: null }; +} + +export const PAYOUT_STATE_LABELS: Record<TaskPayoutState, string> = { + none: "", + ready: "Payout ready", + pending: "Awaiting signatures", + paid: "Paid", +}; + +/** One sentence per blocker, for tooltips and tool results. */ +export const PAYOUT_BLOCKER_LABELS: Record<PayoutBlocker, string> = { + paid: "This task has already been paid.", + pending: "A payout for this task is awaiting signatures.", + no_recipients: "Add payment recipients to this task first.", + not_done: "Move it to Done to pay it.", +}; diff --git a/src/lib/task-payout/sync.ts b/src/lib/task-payout/sync.ts new file mode 100644 index 00000000..51091901 --- /dev/null +++ b/src/lib/task-payout/sync.ts @@ -0,0 +1,33 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; + +type Db = PrismaClient | Prisma.TransactionClient; + +/** + * Keeps TaskPayout links in step with the transaction they point at. Called + * from every server site that moves a transaction to state 1 or removes a + * pending one; see the call sites in `src/server/api/routers/transactions.ts` + * and `src/pages/api/v1/signTransaction.ts`. Both are updateMany on the + * Pending rows only, so calling them twice is harmless. + */ + +export async function markPayoutsPaid( + db: Db, + args: { transactionId: string; txHash?: string | null }, +): Promise<number> { + const result = await db.taskPayout.updateMany({ + where: { transactionId: args.transactionId, status: "Pending" }, + data: { status: "Paid", paidAt: new Date(), txHash: args.txHash ?? undefined }, + }); + return result.count; +} + +export async function cancelPayoutsForTransaction( + db: Db, + transactionId: string, +): Promise<number> { + const result = await db.taskPayout.updateMany({ + where: { transactionId, status: "Pending" }, + data: { status: "Cancelled" }, + }); + return result.count; +} diff --git a/src/lib/tx-draft/build-draft-tx.ts b/src/lib/tx-draft/build-draft-tx.ts new file mode 100644 index 00000000..ecb9a38a --- /dev/null +++ b/src/lib/tx-draft/build-draft-tx.ts @@ -0,0 +1,63 @@ +import type { MeshTxBuilder } from "@meshsdk/core"; +import { resolveTxHash } from "@meshsdk/core-cst"; + +import type { TxDraft } from "@/types/tx-draft"; +import { applyMetadataMessage } from "./metadata"; +import { applyDraftToTxBuilder, type ApplyDraftContext } from "./to-tx-builder"; + +export type DraftBuildResult = { + /** Unsigned transaction hex returned by `complete()`. */ + unsignedTx: string; + /** Builder body after `complete()`: fee and change output(s) included. */ + body: MeshTxBuilder["meshTxBuilderBody"]; + /** Transaction hash the signed transaction will have (body hash). */ + txHash: string; + /** Fee in lovelace. */ + fee: string; + /** Size of the unsigned transaction; witnesses add to this on signing. */ + sizeBytes: number; + inputCount: number; + outputCount: number; +}; + +export type BuildDraftTxOptions = { + /** CIP-20 message written under metadata label 674. */ + metadataMessage?: string; + /** + * Finalizes the builder (fee, balancing, change) and returns the unsigned + * tx hex. Injected so callers pick the production `complete` (with fresh + * cost models) while tests avoid the network entirely. + */ + complete: (txBuilder: MeshTxBuilder) => Promise<string>; +}; + +/** + * Builds a draft into an unsigned transaction without signing, submitting or + * persisting anything: the shared pipeline behind both the builder's "Build" + * (test) button and the propose flow, up to the point where they diverge. + * + * The builder must be a fresh instance — `MeshTxBuilder` is stateful and a + * completed builder cannot be reused for another build. + */ +export async function buildDraftTx( + txBuilder: MeshTxBuilder, + draft: TxDraft, + ctx: ApplyDraftContext, + opts: BuildDraftTxOptions, +): Promise<DraftBuildResult> { + applyDraftToTxBuilder(txBuilder, draft, ctx); + applyMetadataMessage(txBuilder, "674", opts.metadataMessage); + + const unsignedTx = await opts.complete(txBuilder); + const body = txBuilder.meshTxBuilderBody; + + return { + unsignedTx, + body, + txHash: resolveTxHash(unsignedTx).toLowerCase(), + fee: String(body.fee ?? "0"), + sizeBytes: Math.ceil(unsignedTx.length / 2), + inputCount: body.inputs.length, + outputCount: body.outputs.length, + }; +} diff --git a/src/lib/tx-draft/metadata.ts b/src/lib/tx-draft/metadata.ts new file mode 100644 index 00000000..5c1a891d --- /dev/null +++ b/src/lib/tx-draft/metadata.ts @@ -0,0 +1,40 @@ +/** + * Structural view of `MeshTxBuilder.metadataValue` — the builder's method + * returns `this`, which a recording stub can't satisfy through `Pick`. + */ +export type MetadataBuilder = { + metadataValue: (label: string, metadata: object) => unknown; +}; + +/** + * Cardano metadata strings are capped at 64 bytes; the app has always split + * the CIP-20 message at 63 characters to stay under it. + */ +const METADATA_CHUNK_LENGTH = 63; + +/** + * Splits a message into the string / string-list shape written under a + * `{ msg }` metadata label. Exported for tests; callers use + * `applyMetadataMessage`. + */ +export function chunkMetadataMessage(value: string): string | string[] { + if (value.length <= METADATA_CHUNK_LENGTH) return value; + return value.match(new RegExp(`.{1,${METADATA_CHUNK_LENGTH}}`, "g")) ?? []; +} + +/** + * Attaches a CIP-20 style `{ msg }` message under `label` (normally "674"). + * A missing or empty message leaves the builder untouched. + * + * Both the proposal flow and the builder's test build go through this, so + * the transaction previewed by a test build is byte-identical (fee, size) + * to the one that gets proposed. + */ +export function applyMetadataMessage( + txBuilder: MetadataBuilder, + label: string, + value: string | undefined, +): void { + if (!value) return; + txBuilder.metadataValue(label, { msg: chunkMetadataMessage(value) }); +} diff --git a/src/lib/tx-draft/mutations.ts b/src/lib/tx-draft/mutations.ts index 3960353c..62f24cdc 100644 --- a/src/lib/tx-draft/mutations.ts +++ b/src/lib/tx-draft/mutations.ts @@ -1,6 +1,7 @@ import type { DraftCertificate, DraftOutput, + DraftSource, DraftUtxoSelection, DraftVote, DraftVoteKind, @@ -25,6 +26,7 @@ function generateId(): string { export function createDraft(id?: string): TxDraft { return { id: id ?? generateId(), + source: { kind: "multisig" }, outputs: [], utxoSelection: { mode: "auto" }, description: "", @@ -34,6 +36,37 @@ export function createDraft(id?: string): TxDraft { }; } +/** True when the draft holds actions only the multisig source can build. */ +export function hasMultisigOnlyActions(draft: TxDraft): boolean { + return draft.certificates.length > 0 || draft.votes.length > 0; +} + +export function sameSource(a: DraftSource, b: DraftSource): boolean { + if (a.kind !== b.kind) return false; + return a.kind === "address" && b.kind === "address" + ? a.address === b.address + : true; +} + +/** + * Sets the funding source. Manual UTxO picks belong to the previous address, + * so the selection always reverts to automatic. Leaving the multisig drops + * staking certificates and votes — they need the multisig's script + * credentials, which no other source can witness. + */ +export function setSource(draft: TxDraft, source: DraftSource): TxDraft { + const keepActions = source.kind === "multisig"; + return { + ...draft, + source, + utxoSelection: sameSource(draft.source, source) + ? draft.utxoSelection + : { mode: "auto" }, + certificates: keepActions ? draft.certificates : [], + votes: keepActions ? draft.votes : [], + }; +} + export function addOutput( draft: TxDraft, partial?: Partial<DraftOutput>, diff --git a/src/lib/tx-draft/source.ts b/src/lib/tx-draft/source.ts new file mode 100644 index 00000000..dafbe375 --- /dev/null +++ b/src/lib/tx-draft/source.ts @@ -0,0 +1,80 @@ +import type { AddressLabeler } from "@/types/token-flow"; +import type { DraftSource } from "@/types/tx-draft"; + +/** Runtime facts needed to turn a source into a concrete address. */ +export type SourceResolution = { + multisigAddress: string; + /** The connected signer wallet's payment address, when a wallet is connected. */ + connectedAddress?: string; +}; + +/** The source's payment address, or "" while it isn't known yet. */ +export function resolveSourceAddress( + source: DraftSource, + ctx: SourceResolution, +): string { + switch (source.kind) { + case "multisig": + return ctx.multisigAddress; + case "connected": + return ctx.connectedAddress ?? ""; + case "address": + return source.address.trim(); + } +} + +/** Human label for banners and toggles. */ +export function describeSource( + source: DraftSource, + opts: { walletName?: string } = {}, +): string { + switch (source.kind) { + case "multisig": + return "Multisig wallet"; + case "connected": + return opts.walletName + ? `Connected wallet (${opts.walletName})` + : "Connected wallet"; + case "address": + return "Other address"; + } +} + +/** + * What the builder's primary action is for a source: the multisig proposes, + * the connected wallet signs and submits, an arbitrary address can only be + * built and exported. + */ +export function sourcePrimaryAction( + source: DraftSource, +): "propose" | "sign" | "none" { + switch (source.kind) { + case "multisig": + return "propose"; + case "connected": + return "sign"; + case "address": + return "none"; + } +} + +/** + * Wraps an address labeler so a non-multisig source renders as the canvas's + * "self" card (blue, Landmark icon) under a source-specific label, while + * every other address — including the multisig's own — keeps its usual + * label. + */ +export function withSourceLabel( + base: AddressLabeler, + source: DraftSource, + sourceAddress: string, + walletName?: string, +): AddressLabeler { + if (source.kind === "multisig" || !sourceAddress) return base; + const label = + source.kind === "connected" + ? describeSource(source, { walletName }) + : "Source address"; + return (address) => + address === sourceAddress ? { label, type: "self" } : base(address); +} diff --git a/src/lib/tx-draft/to-tx-builder.ts b/src/lib/tx-draft/to-tx-builder.ts index 27ca761d..ce960a7a 100644 --- a/src/lib/tx-draft/to-tx-builder.ts +++ b/src/lib/tx-draft/to-tx-builder.ts @@ -18,10 +18,18 @@ const ACTION_FEE_FLOOR_LOVELACE = 5_000_000n; */ const STAKE_KEY_DEPOSIT_LOVELACE = 2_000_000n; +/** + * How the source's inputs are witnessed: the multisig spends script inputs + * (each `txIn` gets `txInScript`); a connected or arbitrary key-based wallet + * spends plain pubkey inputs. + */ +export type ApplyDraftInputs = + | { kind: "script"; scriptCbor: string } + | { kind: "pubkey" }; + export type ApplyDraftContext = { - /** The multisig payment script; every input is a script input. */ - scriptCbor: string; - /** The multisig wallet address; default change target. */ + inputs: ApplyDraftInputs; + /** The source address: owner of the inputs and the change target. */ walletAddress: string; /** Spendable UTxOs (pending-blocked ones excluded); used in auto mode. */ availableUtxos: UTxO[]; @@ -55,6 +63,14 @@ export function applyDraftToTxBuilder( ) { throw new Error("Draft has no outputs, votes or certificates"); } + if ( + ctx.inputs.kind === "pubkey" && + (draft.votes.length > 0 || draft.certificates.length > 0) + ) { + throw new Error( + "Staking certificates and votes can only be built from the multisig wallet", + ); + } if (draft.votes.length > 0 && (!ctx.drepId || !ctx.drepScriptCbor)) { throw new Error("Draft has votes but no DRep context"); } @@ -100,14 +116,15 @@ export function applyDraftToTxBuilder( } for (const utxo of selectedUtxos) { - txBuilder - .txIn( - utxo.input.txHash, - utxo.input.outputIndex, - utxo.output.amount, - utxo.output.address, - ) - .txInScript(ctx.scriptCbor); + txBuilder.txIn( + utxo.input.txHash, + utxo.input.outputIndex, + utxo.output.amount, + utxo.output.address, + ); + if (ctx.inputs.kind === "script") { + txBuilder.txInScript(ctx.inputs.scriptCbor); + } } for (const output of draft.outputs) { @@ -160,7 +177,7 @@ export function applyDraftToTxBuilder( .voteScript(ctx.drepScriptCbor!); } - // Change always returns to the multisig wallet itself — a configurable + // Change always returns to the source wallet itself — a configurable // change address would let a draft quietly drain the wallet's remaining // funds to another address. txBuilder.changeAddress(ctx.walletAddress); diff --git a/src/lib/tx-draft/validate.ts b/src/lib/tx-draft/validate.ts index 6a6c5934..0450aa36 100644 --- a/src/lib/tx-draft/validate.ts +++ b/src/lib/tx-draft/validate.ts @@ -7,6 +7,7 @@ import { requiredAssetTotals, safeBigInt, } from "./assets"; +import { hasMultisigOnlyActions } from "./mutations"; export type DraftIssueCode = | "no-outputs" @@ -21,7 +22,22 @@ export type DraftIssueCode = | "cert-stake-missing" | "cert-pool-missing" | "duplicate-vote" - | "cert-duplicate"; + | "cert-duplicate" + | "cert-delegate-unregistered" + | "cert-already-registered" + | "cert-deregister-unregistered" + | "vote-drep-unregistered" + | "source-address-missing" + | "source-address-invalid" + | "source-address-wrong-network" + | "source-address-script" + | "source-address-is-connected" + | "source-actions-unsupported"; + +/** Issue codes about the funding source (rendered next to the source picker). */ +export function isSourceIssue(code: DraftIssueCode): boolean { + return code.startsWith("source-"); +} export type DraftIssue = { level: "error" | "warning"; @@ -51,9 +67,32 @@ export type ValidateDraftContext = { * no certificates) to skip the check. */ hasStakeContext?: boolean; + /** + * Whether the wallet's stake credential is registered on chain. Omit when + * unknown (not fetched, or the draft has no certificates) to skip the + * registration-state checks — the builder canvas gates its dialog on the + * same state instead, so only headless callers (MCP) pass it. + */ + stakeAccountActive?: boolean; + /** + * Whether the wallet's DRep credential is registered on chain. Omit when + * unknown (not fetched, or the draft has no votes) to skip the check — the + * app's vote UI gates on the same state itself, so only headless callers + * (MCP) pass it. + */ + drepRegistered?: boolean; + /** The multisig's own address; lets the source check name it. */ + multisigAddress?: string; + /** The connected wallet's address; absent when no wallet is connected. */ + connectedAddress?: string; }; -function isValidPaymentAddress(address: string): boolean { +export type ValidateSourceContext = Pick< + ValidateDraftContext, + "network" | "multisigAddress" | "connectedAddress" +>; + +export function isValidPaymentAddress(address: string): boolean { if (!address.startsWith("addr")) return false; // excludes stake/DRep ids try { deserializeAddress(address); @@ -63,6 +102,95 @@ function isValidPaymentAddress(address: string): boolean { } } +/** Payment credential is a script (multisig / contract), not a key. */ +function isScriptAddress(address: string): boolean { + try { + return deserializeAddress(address).scriptHash !== ""; + } catch { + return false; + } +} + +function isMainnetAddress(address: string): boolean { + return !address.startsWith("addr_test"); +} + +function wrongNetworkMessage(address: string, network: number): string { + return `Address belongs to ${isMainnetAddress(address) ? "mainnet" : "a testnet"}, but the wallet is on ${network === 1 ? "mainnet" : "a testnet"}.`; +} + +/** + * Validates the funding source alone. Split out from `validateDraft` so the + * page can decide whether to fetch the source's UTxOs before the full + * validation (which needs those UTxOs for the sufficiency check) runs. + */ +export function validateSource( + draft: TxDraft, + ctx: ValidateSourceContext, +): DraftIssue[] { + const issues: DraftIssue[] = []; + const { source } = draft; + + if (source.kind === "connected" && !ctx.connectedAddress) { + issues.push({ + level: "error", + code: "source-address-missing", + message: "Connect a wallet to build a transaction from it.", + }); + } + + if (source.kind === "address") { + const address = source.address.trim(); + if (!address) { + issues.push({ + level: "error", + code: "source-address-missing", + message: "Enter the address of the wallet to build from.", + }); + } else if (!isValidPaymentAddress(address)) { + issues.push({ + level: "error", + code: "source-address-invalid", + message: "Source is not a valid Cardano payment address.", + }); + } else if (isScriptAddress(address)) { + issues.push({ + level: "error", + code: "source-address-script", + message: + address === ctx.multisigAddress + ? "This is the multisig's own address — choose the Multisig source instead." + : "This is a script address — only regular key-based wallet addresses can be a source.", + }); + } else if (isMainnetAddress(address) !== (ctx.network === 1)) { + issues.push({ + level: "error", + code: "source-address-wrong-network", + message: `Source: ${wrongNetworkMessage(address, ctx.network)}`, + }); + } else if (address === ctx.connectedAddress) { + issues.push({ + level: "warning", + code: "source-address-is-connected", + message: + "This is your connected wallet — choose the Connected wallet source to sign and submit here.", + }); + } + } + + // Backstop: setSource clears these when leaving the multisig. + if (source.kind !== "multisig" && hasMultisigOnlyActions(draft)) { + issues.push({ + level: "error", + code: "source-actions-unsupported", + message: + "Staking actions and votes can only be built from the multisig wallet.", + }); + } + + return issues; +} + /** * Pure validation over a draft. Errors block building; warnings only inform. * The Build button is gated on the absence of errors. @@ -71,7 +199,7 @@ export function validateDraft( draft: TxDraft, ctx: ValidateDraftContext, ): DraftIssue[] { - const issues: DraftIssue[] = []; + const issues: DraftIssue[] = [...validateSource(draft, ctx)]; if ( draft.outputs.length === 0 && @@ -92,6 +220,15 @@ export function validateDraft( message: "This wallet has no DRep identity — governance votes can't be rebuilt.", }); + } else if (draft.votes.length > 0 && ctx.drepRegistered === false) { + // A vote from an unregistered DRep builds fine and is rejected by the + // node only at submit — after every signature has been collected. + issues.push({ + level: "error", + code: "vote-drep-unregistered", + message: + "This wallet cannot vote: it is not registered as a DRep on chain. Register the wallet as a DRep in the app (Governance → DRep) before drafting votes.", + }); } if (draft.certificates.length > 0 && ctx.hasStakeContext === false) { @@ -154,6 +291,40 @@ export function validateDraft( seenKinds.add(cert.kind); } + // A certificate the ledger is guaranteed to reject given the account's + // current registration state. The node only reports this at submit — + // after every signature has been collected — so it must be caught here. + if (ctx.stakeAccountActive !== undefined && draft.certificates.length > 0) { + const registersHere = seenKinds.has("RegisterStake"); + if (ctx.stakeAccountActive) { + if (registersHere) { + issues.push({ + level: "error", + code: "cert-already-registered", + message: + "The wallet's stake credential is already registered on chain — drop the RegisterStake certificate.", + }); + } + } else { + if (seenKinds.has("DelegateStake") && !registersHere) { + issues.push({ + level: "error", + code: "cert-delegate-unregistered", + message: + "The wallet's stake credential is not registered on chain — include a RegisterStake certificate (2 ADA deposit) before DelegateStake.", + }); + } + if (seenKinds.has("DeregisterStake") && !registersHere) { + issues.push({ + level: "error", + code: "cert-deregister-unregistered", + message: + "The wallet's stake credential is not registered on chain — there is nothing to deregister.", + }); + } + } + } + const seenAddresses = new Set<string>(); for (const output of draft.outputs) { if (!output.address) { @@ -171,12 +342,11 @@ export function validateDraft( outputId: output.id, }); } else { - const isMainnetAddress = !output.address.startsWith("addr_test"); - if (isMainnetAddress !== (ctx.network === 1)) { + if (isMainnetAddress(output.address) !== (ctx.network === 1)) { issues.push({ level: "error", code: "wrong-network-address", - message: `Address belongs to ${isMainnetAddress ? "mainnet" : "a testnet"}, but the wallet is on ${ctx.network === 1 ? "mainnet" : "a testnet"}.`, + message: wrongNetworkMessage(output.address, ctx.network), outputId: output.id, }); } diff --git a/src/lib/tx-review/card.ts b/src/lib/tx-review/card.ts new file mode 100644 index 00000000..b5508499 --- /dev/null +++ b/src/lib/tx-review/card.ts @@ -0,0 +1,340 @@ +import { getFirstAndLast } from "@/utils/strings"; +import type { TxReviewSummary } from "./summary"; + +/** + * The review card as a plain element tree for satori (via `next/og`). + * + * Kept free of React so the CJS test project can assert on the layout + * without loading the WASM renderer. Satori rules observed throughout: a + * div with more than one child must be `display: flex`; the bundled font is + * Latin-only (so "ADA", never "₳"; no emoji); there is a single regular + * weight, so hierarchy comes from size and colour, not boldness. + * + * The card is a statement of fact for a human about to sign, so it repeats + * the boundary in its own pixels: nothing here has been signed or broadcast. + */ + +export const CARD_WIDTH = 1200; +export const CARD_MIN_HEIGHT = 420; +export const CARD_MAX_HEIGHT = 2400; + +export const CARD_FOOTER_PREVIEW = + "Nothing has been signed or broadcast. Signers approve in the Mesh Multisig app."; + +export type CardElement = { + type: string; + props: Record<string, unknown> & { children?: CardElement | string | (CardElement | string)[] }; +}; + +const COLORS = { + bg: "#0b1220", + panel: "#111a2e", + border: "#1f2a44", + text: "#f1f5f9", + muted: "#94a3b8", + faint: "#64748b", + amber: "#f59e0b", + amberInk: "#1f1300", + green: "#22c55e", + red: "#ef4444", + blue: "#60a5fa", +}; + +const h = ( + type: string, + props: Record<string, unknown>, + ...children: (CardElement | string | null | undefined | false)[] +): CardElement => { + const kids = children.filter((c): c is CardElement | string => !!c || c === ""); + return { type, props: { ...props, children: kids.length === 1 ? kids[0] : kids } }; +}; + +// `flexShrink: 0` everywhere: the canvas height is an estimate, and if the +// content ever exceeds it, yoga would otherwise squash the text-only boxes to +// zero height (lines overlap, boxes lose their text) rather than overflow. +// Overflowing the bottom edge is the honest failure; squashing is not. +const row = (style: Record<string, unknown>, ...children: (CardElement | string | null | false)[]) => + h("div", { style: { display: "flex", flexDirection: "row", flexShrink: 0, ...style } }, ...children); +const col = (style: Record<string, unknown>, ...children: (CardElement | string | null | false)[]) => + h("div", { style: { display: "flex", flexDirection: "column", flexShrink: 0, ...style } }, ...children); +const text = (value: string, style: Record<string, unknown> = {}) => + h("div", { style: { display: "flex", flexShrink: 0, ...style } }, value); + +function pill(label: string, bg: string, ink: string): CardElement { + return text(label, { + background: bg, + color: ink, + fontSize: 20, + letterSpacing: 1, + padding: "8px 16px", + borderRadius: 999, + }); +} + +function sectionTitle(label: string): CardElement { + return text(label.toUpperCase(), { + fontSize: 18, + letterSpacing: 2, + color: COLORS.faint, + marginBottom: 10, + }); +} + +/** + * Per-section heights, measured from rendered cards and rounded UP: the + * boxes never shrink (see the helpers), so an under-estimate clips the + * footer while an over-estimate only adds dark space above it. + */ +export function estimateCardHeight(summary: TxReviewSummary): number { + let height = 88 + 104; // canvas padding + header + if (summary.description) { + height += 80 + 32 * Math.floor(summary.description.length / 70); + } + if (summary.recipients.length > 0) { + height += 58; // section title + top margin + for (const recipient of summary.recipients) { + height += 52 + Math.max(1, recipient.amounts.length) * 36; + } + } + if (summary.actions.length > 0) { + height += 58; + for (const action of summary.actions) { + height += 58 + (action.rationale?.status === "will-publish-on-confirm" ? 34 : 0); + } + } + const stats = 3 + (summary.deposit ? 1 : 0) + (summary.sizeBytes ? 1 : 0); + height += 122 + (stats > 5 ? 90 : 0); // totals panel (+ a wrapped row) + if (summary.metadataMessage) height += 42; + if (summary.kind === "pending") height += 42; + if (summary.warnings.length > 0) height += 16 + summary.warnings.length * 34; + height += 112; // hash + footer banner + height += 20; // slack + return Math.min(CARD_MAX_HEIGHT, Math.max(CARD_MIN_HEIGHT, height)); +} + +export function reviewCardTree(summary: TxReviewSummary): { + element: CardElement; + width: number; + height: number; +} { + const height = estimateCardHeight(summary); + const isPreview = summary.kind === "preview"; + const signedCount = summary.signatures.signed.length; + + const header = row( + { justifyContent: "space-between", alignItems: "flex-start" }, + col( + { gap: 8 }, + text(summary.wallet.name || "Multisig wallet", { fontSize: 38, color: COLORS.text }), + text( + `${summary.wallet.network === "mainnet" ? "Mainnet" : "Preprod"} · ${summary.threshold.required} of ${summary.threshold.total} signatures required · ${getFirstAndLast(summary.wallet.address, 14, 8)}`, + { fontSize: 20, color: COLORS.muted }, + ), + ), + col( + { alignItems: "flex-end", gap: 8 }, + isPreview + ? pill("UNSIGNED PREVIEW", COLORS.amber, COLORS.amberInk) + : pill( + `PENDING · ${signedCount} OF ${summary.threshold.required} SIGNED`, + signedCount >= summary.threshold.required ? COLORS.green : COLORS.amber, + COLORS.amberInk, + ), + ), + ); + + const description = summary.description + ? text(`"${summary.description}"`, { + fontSize: 24, + color: COLORS.text, + marginTop: 26, + padding: "12px 18px", + background: COLORS.panel, + borderRadius: 10, + border: `1px solid ${COLORS.border}`, + }) + : null; + + const recipients = + summary.recipients.length > 0 + ? col( + { marginTop: 28 }, + sectionTitle(summary.recipients.length === 1 ? "Recipient" : "Recipients"), + ...summary.recipients.map((recipient) => + row( + { + justifyContent: "space-between", + alignItems: "flex-start", + padding: "12px 0", + borderBottom: `1px solid ${COLORS.border}`, + }, + col( + { gap: 4, maxWidth: 640 }, + text(recipient.label || "Unknown address", { + fontSize: 26, + color: recipient.label ? COLORS.text : COLORS.amber, + }), + text(getFirstAndLast(recipient.address, 22, 12), { + fontSize: 18, + color: COLORS.faint, + }), + ), + col( + { alignItems: "flex-end", gap: 2 }, + ...(recipient.amounts.length > 0 + ? recipient.amounts.map((amount) => + text(amount.display, { fontSize: 28, color: COLORS.text }), + ) + : [text("no amount", { fontSize: 22, color: COLORS.red })]), + ), + ), + ), + ) + : null; + + const actions = + summary.actions.length > 0 + ? col( + { marginTop: 28 }, + sectionTitle("Actions"), + ...summary.actions.map((action) => + col( + { padding: "10px 0", borderBottom: `1px solid ${COLORS.border}`, gap: 4 }, + row( + { gap: 14, alignItems: "baseline" }, + text(action.label, { + fontSize: 26, + color: action.kind === "vote" ? COLORS.blue : COLORS.green, + }), + action.title ? text(action.title.slice(0, 80), { fontSize: 24, color: COLORS.text }) : null, + action.detail ? text(action.detail, { fontSize: 18, color: COLORS.faint }) : null, + ), + action.rationale?.status === "will-publish-on-confirm" + ? text(`Rationale (published to IPFS on confirm): "${action.rationale.excerpt}"`, { + fontSize: 18, + color: COLORS.muted, + }) + : action.rationale?.status === "anchored" + ? text(`Rationale: ${action.rationale.url.slice(0, 90)}`, { + fontSize: 18, + color: COLORS.muted, + }) + : null, + ), + ), + ) + : null; + + const stat = (label: string, value: string) => + col( + { gap: 4, minWidth: 160 }, + text(label.toUpperCase(), { fontSize: 15, letterSpacing: 2, color: COLORS.faint }), + text(value, { fontSize: 24, color: COLORS.text }), + ); + + const totals = row( + { + marginTop: 28, + gap: 28, + padding: "16px 20px", + background: COLORS.panel, + borderRadius: 10, + border: `1px solid ${COLORS.border}`, + flexWrap: "wrap", + }, + stat("Fee", summary.fee?.display ?? "—"), + summary.deposit ? stat("Deposit", summary.deposit.display) : null, + stat( + "Change to this wallet", + summary.change.length > 0 ? summary.change.map((a) => a.display).join(" + ") : "none", + ), + stat( + "Inputs", + `${summary.inputs.count}${summary.inputs.unresolved > 0 ? ` (${summary.inputs.unresolved} unresolved)` : ""}`, + ), + summary.sizeBytes ? stat("Size", `${(summary.sizeBytes / 1024).toFixed(1)} KB`) : null, + ); + + const metadata = summary.metadataMessage + ? text(`On-chain message: "${summary.metadataMessage}"`, { + fontSize: 20, + color: COLORS.muted, + marginTop: 16, + }) + : null; + + const signatures = + summary.kind === "pending" + ? row( + { marginTop: 16, gap: 24 }, + text( + `Signed: ${summary.signatures.signed.length > 0 ? summary.signatures.signed.map((s) => s.label).join(", ") : "nobody yet"}`, + { fontSize: 20, color: COLORS.green }, + ), + summary.signatures.rejected.length > 0 + ? text(`Rejected: ${summary.signatures.rejected.map((s) => s.label).join(", ")}`, { + fontSize: 20, + color: COLORS.red, + }) + : null, + ) + : null; + + const warnings = + summary.warnings.length > 0 + ? col( + { marginTop: 16, gap: 6 }, + ...summary.warnings.map((warning) => + text(`Warning: ${warning}`, { fontSize: 19, color: COLORS.amber }), + ), + ) + : null; + + const footer = col( + { marginTop: "auto", gap: 10, paddingTop: 20 }, + text(`Tx hash ${summary.txHash}`, { fontSize: 16, color: COLORS.faint }), + text( + isPreview + ? CARD_FOOTER_PREVIEW + : `Awaiting ${summary.signatures.remaining} more signature${summary.signatures.remaining === 1 ? "" : "s"} in the Mesh Multisig app. Nothing is broadcast until the threshold is met.`, + { + fontSize: 20, + color: COLORS.amberInk, + background: COLORS.amber, + padding: "12px 18px", + borderRadius: 10, + }, + ), + ); + + const element = col( + { + width: "100%", + height: "100%", + padding: "44px 56px", + background: COLORS.bg, + color: COLORS.text, + fontFamily: "Geist, sans-serif", + }, + header, + description, + recipients, + actions, + totals, + metadata, + signatures, + warnings, + footer, + ); + + return { element, width: CARD_WIDTH, height }; +} + +/** Every string the card will paint, for tests that assert on glyph safety. */ +export function collectCardText(element: CardElement | string): string[] { + if (typeof element === "string") return [element]; + const children = element.props.children; + if (children === undefined) return []; + const list = Array.isArray(children) ? children : [children]; + return list.flatMap((child) => collectCardText(child)); +} diff --git a/src/lib/tx-review/context.ts b/src/lib/tx-review/context.ts new file mode 100644 index 00000000..89b8dcae --- /dev/null +++ b/src/lib/tx-review/context.ts @@ -0,0 +1,180 @@ +import type { PrismaClient, Wallet as DbWallet } from "@prisma/client"; +import type { UTxO } from "@meshsdk/core"; + +import type { McpCaller } from "@/lib/mcp/auth"; +import type { ApplyDraftContext } from "@/lib/tx-draft/to-tx-builder"; +import { deriveDrepVoteContext, type DrepVoteContext } from "@/lib/governance/drep-context"; +import { deriveStakeCertContext, type StakeCertContext } from "@/lib/staking/stake-context"; +import { getRequiredSignerCount } from "@/lib/notifications/center"; +import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; +import type { DbWalletWithLegacy, Wallet } from "@/types/wallet"; +import { buildMultisigWallet, buildWallet } from "@/utils/common"; +import type { MultisigWallet } from "@/utils/multisigSDK"; +import { resolveExpectedPaymentScriptCbor } from "@/utils/txScriptRecovery"; + +/** + * Everything the review pipeline needs to know about a wallet, resolved once + * per tool call on the server — the same derivations the canvas builder does + * client-side in `draftBuildContext` (`src/components/pages/wallet/build`), + * and the bot certificate handlers do in `src/pages/api/v1/bot*Certificate.ts`. + */ + +export type TxReviewErrorCode = + | "NOT_SIGNER" + | "NOT_FOUND" + | "NO_SCRIPT" + | "BOT_NOT_ALLOWED" + | "INVALID_ADDRESS" + | "INVALID_SPEC" + | "INVALID_DRAFT" + | "UTXO_LOOKUP_FAILED" + | "STAKE_LOOKUP_FAILED" + | "DREP_LOOKUP_FAILED" + | "BUILD_FAILED" + | "PIN_FAILED" + | "TOKEN_INVALID" + | "TOKEN_EXPIRED" + // Task-board payouts (src/lib/task-payout). + | "TASK_NOT_FOUND" + | "TASK_NOT_DONE" + | "TASK_NOT_PAYABLE" + | "TASK_CHANGED" + | "NO_PAYABLE_TASKS" + | "TOO_MANY_TASKS"; + +/** + * A failure the model can read and act on. `status` maps to the tool + * result's `isError`; `details` (issue lists and the like) travels in the + * body so the model can tell the human exactly what to fix. + */ +export class TxReviewError extends Error { + constructor( + readonly status: number, + readonly code: TxReviewErrorCode, + message: string, + readonly details?: Record<string, unknown>, + ) { + super(message); + this.name = "TxReviewError"; + } + + toResult(): { status: number; body: Record<string, unknown> } { + return { + status: this.status, + body: { error: this.message, code: this.code, ...(this.details ?? {}) }, + }; + } +} + +export type ReviewWalletContext = { + walletRow: DbWallet; + appWallet: Wallet; + multisigWallet: MultisigWallet | undefined; + network: 0 | 1; + /** The wallet's script address: owner of the inputs and change target. */ + walletAddress: string; + /** Payment script CBOR whose hash matches the address. */ + scriptCbor: string; + drep: DrepVoteContext | undefined; + stake: StakeCertContext | undefined; + threshold: { required: number; total: number; type: string }; +}; + +export function networkFromAddress(address: string): 0 | 1 { + // bech32 HRP, not a substring test: `addr1`/`stake1` = mainnet, + // `addr_test1`/`stake_test1` = testnet (see addTransaction.ts). + if (/^(addr_test1|stake_test1)/.test(address)) return 0; + if (/^(addr1|stake1)/.test(address)) return 1; + throw new TxReviewError(400, "INVALID_ADDRESS", "Caller address is not a Cardano address"); +} + +/** + * Load the wallet and authorize the caller as one of its signers. + * + * Only humans: the scope is not projected onto bot keys, and this is the + * belt to that brace. A bot with `multisig:sign` already has the REST route + * (`addTransaction`) and needs no chat-review flow. + */ +export async function loadReviewWalletContext( + db: PrismaClient, + walletId: string, + caller: McpCaller, +): Promise<ReviewWalletContext> { + if (caller.botId !== null) { + throw new TxReviewError( + 403, + "BOT_NOT_ALLOWED", + "Transaction drafting through MCP is available to human wallet connections only", + ); + } + + const walletRow = await db.wallet.findUnique({ where: { id: walletId } }); + if (!walletRow) { + throw new TxReviewError(404, "NOT_FOUND", "Wallet not found"); + } + if (!walletRow.signersAddresses.includes(caller.subject)) { + // Same answer as v1WalletAuth for a non-signer: known wallet, not yours. + throw new TxReviewError(403, "NOT_SIGNER", "Not authorized for this wallet"); + } + + const network = networkFromAddress(caller.subject); + const wallet = walletRow as DbWalletWithLegacy; + + let walletAddress: string; + try { + walletAddress = resolveWalletScriptAddress(wallet, caller.subject); + } catch (error) { + throw new TxReviewError( + 400, + "NO_SCRIPT", + `This wallet's script address cannot be derived: ${error instanceof Error ? error.message : "unknown error"}`, + ); + } + + const appWallet = buildWallet(wallet, network); + const multisigWallet = buildMultisigWallet(wallet, network); + + // Prefer the script whose hash matches the address — an imported wallet's + // stored scriptCbor can be a differently encoded variant that the node + // rejects (MissingScriptWitnessesUTXOW). + const scriptCbor = + resolveExpectedPaymentScriptCbor(appWallet, network) ?? appWallet.scriptCbor; + if (!scriptCbor) { + throw new TxReviewError( + 400, + "NO_SCRIPT", + "This wallet has no payment script to witness inputs with", + ); + } + + return { + walletRow, + appWallet, + multisigWallet, + network, + walletAddress, + scriptCbor, + drep: deriveDrepVoteContext(multisigWallet, appWallet), + stake: deriveStakeCertContext(multisigWallet, appWallet), + threshold: { + required: getRequiredSignerCount(walletRow), + total: walletRow.signersAddresses.length, + type: walletRow.type, + }, + }; +} + +export function toApplyDraftContext( + ctx: ReviewWalletContext, + availableUtxos: UTxO[], +): ApplyDraftContext { + return { + inputs: { kind: "script", scriptCbor: ctx.scriptCbor }, + walletAddress: ctx.walletAddress, + availableUtxos, + drepId: ctx.drep?.dRepId, + drepScriptCbor: ctx.drep?.drepScriptCbor, + stakeRewardAddress: ctx.stake?.rewardAddress, + stakeScriptCbor: ctx.stake?.stakeScriptCbor, + }; +} diff --git a/src/lib/tx-review/draft-token.ts b/src/lib/tx-review/draft-token.ts new file mode 100644 index 00000000..daf6ec60 --- /dev/null +++ b/src/lib/tx-review/draft-token.ts @@ -0,0 +1,217 @@ +import { randomUUID } from "crypto"; +import jwt from "jsonwebtoken"; + +import type { TxSpec } from "./spec"; + +const { sign, verify } = jwt; + +/** + * The draft token — what `transaction_preview` hands back and the ONLY input + * `transaction_propose` accepts. + * + * It binds the transaction the human reviewed to the one that gets created: + * the normalized spec (base units, canonical ids), the acting address, the + * wallet, the OAuth client that asked, and the hash of the previewed + * transaction. Because propose takes nothing else, a model cannot "helpfully" + * change a recipient or an amount between the review and the creation — it + * would have to preview again, which shows a new card. + * + * Third token family sharing `JWT_SECRET`, distinguished by `typ` exactly as + * `mcp_at` is distinguished from v1 bearers (`src/lib/oauth/accessToken.ts`). + * A draft token verifies as neither: `verifyJwt` rejects it (no `address`), + * `verifyAccessToken` rejects it (wrong `typ`, no audience). + * + * Short-lived on purpose: UTxOs move, and the review is only meaningful for as + * long as the human's attention is on it. + */ + +export const DRAFT_TOKEN_TYPE = "mcp_draft"; +export const DRAFT_TOKEN_TTL_SECONDS = 15 * 60; + +/** + * Where a draft came from, when it was not composed by hand. A task-board + * payout binds the task ids and a hash of their recipient rows into the token, + * so confirming links exactly the tasks whose amounts the human reviewed — + * neither the model nor a later edit of the task can re-point it. + */ +export type DraftOrigin = { + kind: "tasks"; + taskIds: string[]; + /** sha256 over the canonical (taskId, address, unit, quantity) rows. */ + recipientsHash: string; +}; + +/** + * How the review card reached the human: `html` (the default) is drawn by the + * client's inline card view from the structured summary; `image` also attaches + * the server-rendered PNG. Propose takes only the token, so the mode the + * preview was delivered in is recorded here and the propose result follows it. + */ +export type ReviewCardMode = "html" | "image"; + +export type DraftTokenClaims = { + typ: typeof DRAFT_TOKEN_TYPE; + /** Acting address — must equal the caller's subject on propose. */ + sub: string; + /** Wallet UUID. */ + wid: string; + /** OAuth client id, or null for a v1 bearer caller. */ + cid: string | null; + /** The normalized spec, base units. */ + spec: TxSpec; + /** Hash of the previewed unsigned transaction. */ + ph: string; + /** Present only for drafts derived from another record (task payouts). */ + origin?: DraftOrigin; + /** Present only when the preview attached the PNG card. */ + card?: "image"; + jti: string; + iat: number; + exp: number; +}; + +export type VerifiedDraftToken = { + jti: string; + subject: string; + walletId: string; + clientId: string | null; + spec: TxSpec; + previewTxHash: string; + origin: DraftOrigin | null; + card: ReviewCardMode; + expiresAt: number; +}; + +export type DraftTokenFailure = + | "malformed" + | "expired" + | "wrong_type" + | "subject_mismatch" + | "client_mismatch"; + +function secret(): string { + const value = process.env.JWT_SECRET; + if (!value) throw new Error("JWT_SECRET is not defined"); + return value; +} + +export function mintDraftToken(args: { + subject: string; + walletId: string; + clientId: string | null; + spec: TxSpec; + previewTxHash: string; + origin?: DraftOrigin; + /** Omit (or pass "html") unless the preview attached the PNG card. */ + card?: ReviewCardMode; +}): { token: string; jti: string; expiresAt: number } { + const jti = randomUUID(); + const token = sign( + { + typ: DRAFT_TOKEN_TYPE, + sub: args.subject, + wid: args.walletId, + cid: args.clientId, + spec: args.spec, + ph: args.previewTxHash, + ...(args.origin ? { origin: args.origin } : {}), + ...(args.card === "image" ? { card: "image" } : {}), + jti, + }, + secret(), + { expiresIn: DRAFT_TOKEN_TTL_SECONDS }, + ); + const decoded = jwt.decode(token) as { exp?: number } | null; + const expiresAt = + decoded?.exp ?? Math.floor(Date.now() / 1000) + DRAFT_TOKEN_TTL_SECONDS; + return { token, jti, expiresAt }; +} + +/** + * Verify a draft token for the caller about to propose it. + * + * Subject and client are checked against the caller, not just the signature: + * a token minted for one grant must not be redeemable through another, even + * for the same human. + */ +export function verifyDraftToken( + token: string, + caller: { subject: string; clientId: string | null }, +): { ok: true; claims: VerifiedDraftToken } | { ok: false; reason: DraftTokenFailure } { + let claims: DraftTokenClaims; + try { + claims = verify(token, secret()) as DraftTokenClaims; + } catch (error) { + const name = (error as { name?: string })?.name; + return { ok: false, reason: name === "TokenExpiredError" ? "expired" : "malformed" }; + } + + if (claims.typ !== DRAFT_TOKEN_TYPE) return { ok: false, reason: "wrong_type" }; + if ( + typeof claims.sub !== "string" || + typeof claims.wid !== "string" || + typeof claims.ph !== "string" || + typeof claims.jti !== "string" || + typeof claims.exp !== "number" || + !claims.spec || + typeof claims.spec !== "object" + ) { + return { ok: false, reason: "malformed" }; + } + if (claims.origin !== undefined && !isDraftOrigin(claims.origin)) { + return { ok: false, reason: "malformed" }; + } + if (claims.card !== undefined && claims.card !== "image") { + return { ok: false, reason: "malformed" }; + } + if (claims.sub !== caller.subject) { + return { ok: false, reason: "subject_mismatch" }; + } + if ((claims.cid ?? null) !== (caller.clientId ?? null)) { + return { ok: false, reason: "client_mismatch" }; + } + + return { + ok: true, + claims: { + jti: claims.jti, + subject: claims.sub, + walletId: claims.wid, + clientId: claims.cid ?? null, + spec: claims.spec, + previewTxHash: claims.ph, + origin: claims.origin ?? null, + card: claims.card === "image" ? "image" : "html", + expiresAt: claims.exp, + }, + }; +} + +function isDraftOrigin(value: unknown): value is DraftOrigin { + if (!value || typeof value !== "object") return false; + const origin = value as Record<string, unknown>; + return ( + origin.kind === "tasks" && + Array.isArray(origin.taskIds) && + origin.taskIds.length > 0 && + origin.taskIds.every((id) => typeof id === "string" && id.length > 0) && + typeof origin.recipientsHash === "string" && + origin.recipientsHash.length > 0 + ); +} + +/** Human-readable failure for the tool error body. */ +export function describeDraftTokenFailure(reason: DraftTokenFailure): string { + switch (reason) { + case "expired": + return "The draft token has expired. Run transaction_preview again and confirm the new card."; + case "subject_mismatch": + return "The draft token was issued to a different address. Run transaction_preview again from this connection."; + case "client_mismatch": + return "The draft token was issued to a different MCP connection. Run transaction_preview again from this connection."; + case "wrong_type": + return "This is not a draft token. Pass the draftToken returned by transaction_preview."; + default: + return "The draft token could not be verified. Run transaction_preview again."; + } +} diff --git a/src/lib/tx-review/labels.ts b/src/lib/tx-review/labels.ts new file mode 100644 index 00000000..2e3731a6 --- /dev/null +++ b/src/lib/tx-review/labels.ts @@ -0,0 +1,55 @@ +import type { PrismaClient } from "@prisma/client"; + +import type { AddressLabeler } from "@/types/token-flow"; + +/** + * Server-side address labelling for the review card. + * + * Same resolution order as `src/hooks/useAddressLabels.ts` (which cannot run + * here — it is a React hook over tRPC queries): the wallet itself, then a + * named signer, then a saved contact, else unknown. The label is what the + * human reads next to an amount, so it must come from data the wallet's own + * signers control, never from the transaction being reviewed. + */ +export function createServerAddressLabeler(args: { + walletAddress: string; + signersAddresses: string[]; + signersDescriptions: string[]; + contacts: { address: string; name: string }[]; +}): AddressLabeler { + const contactNames = new Map( + args.contacts.map((contact) => [contact.address, contact.name]), + ); + return (address: string) => { + if (address && address === args.walletAddress) { + return { label: "This wallet", type: "self" }; + } + const signerIndex = args.signersAddresses.indexOf(address); + if (signerIndex >= 0) { + return { + label: + args.signersDescriptions[signerIndex]?.trim() || + `Signer ${signerIndex + 1}`, + type: "signer", + }; + } + const contact = contactNames.get(address); + if (contact) return { label: contact, type: "contact" }; + return { label: "", type: "unknown" }; + }; +} + +export async function loadContacts( + db: PrismaClient, + walletId: string, +): Promise<{ address: string; name: string }[]> { + try { + return await db.contact.findMany({ + where: { walletId }, + select: { address: true, name: true }, + }); + } catch { + // Labels are a courtesy; a contacts lookup failure must not block a review. + return []; + } +} diff --git a/src/lib/tx-review/metadata.ts b/src/lib/tx-review/metadata.ts new file mode 100644 index 00000000..415f763a --- /dev/null +++ b/src/lib/tx-review/metadata.ts @@ -0,0 +1,185 @@ +import type { PrismaClient } from "@prisma/client"; +import type { BlockfrostProvider } from "@meshsdk/core"; + +import type { AssetMetadataMap } from "@/components/common/token-flow/format"; +import { parseProposalId } from "@/lib/governance"; +import { ballotTitleMap } from "@/lib/governance/proposal-titles"; +import { fetchProposalMetadataWithFallback } from "@/lib/governance/proposalMetadata"; +import type { ProposalDetails } from "@/types/governance"; +import { cachedGetAssetMetadata } from "@/utils/blockchain-cache"; + +/** + * Lookups that turn on-chain identifiers into the words a human recognises: + * asset tickers and decimals, pool names, proposal titles. Every one is + * best-effort with a cap and a budget — a slow registry must degrade the card + * to raw identifiers, never fail the review. + */ + +const MAX_ASSET_LOOKUPS = 20; +const MAX_POOL_LOOKUPS = 5; +const MAX_TITLE_LOOKUPS = 10; +const GOV_BALLOT_TYPE = 1; + +type BlockfrostAsset = { + asset_name?: string | null; + policy_id?: string; + metadata?: { ticker?: string | null; name?: string | null; decimals?: number | null } | null; + onchain_metadata?: { name?: string | null } | null; +}; + +function withBudget<T>(promise: Promise<T>, budgetMs: number, fallback: T): Promise<T> { + return new Promise<T>((resolve) => { + const timer = setTimeout(() => resolve(fallback), budgetMs); + timer.unref?.(); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + () => { + clearTimeout(timer); + resolve(fallback); + }, + ); + }); +} + +function hexToAscii(hex: string): string { + try { + const text = Buffer.from(hex, "hex").toString("utf8"); + return /^[\x20-\x7e]+$/.test(text) ? text : ""; + } catch { + return ""; + } +} + +export type ResolvedAssetMetadata = { + /** Display metadata; unknown decimals default to 0 here. */ + metadata: AssetMetadataMap; + /** + * Registered decimals, or undefined when the token registry has none — + * kept apart from the display default so a display→base conversion can + * refuse to guess a token's scale. + */ + decimalsFor: (unit: string) => number | undefined; +}; + +export async function resolveAssetMetadata( + provider: BlockfrostProvider, + units: string[], + network: number, + opts: { budgetMs?: number } = {}, +): Promise<ResolvedAssetMetadata> { + const metadata: AssetMetadataMap = {}; + const known = new Map<string, number>(); + const targets = [...new Set(units)].slice(0, MAX_ASSET_LOOKUPS); + await Promise.all( + targets.map(async (unit) => { + const info = await withBudget( + cachedGetAssetMetadata(provider, unit, network) as Promise<BlockfrostAsset | null>, + opts.budgetMs ?? 4000, + null, + ); + const policyId = unit.slice(0, 56); + const assetNameHex = info?.asset_name ?? unit.slice(56); + const ascii = hexToAscii(assetNameHex ?? ""); + const decimals = info?.metadata?.decimals; + if (typeof decimals === "number") known.set(unit, decimals); + metadata[unit] = { + policyId, + assetName: + info?.onchain_metadata?.name || info?.metadata?.name || ascii || assetNameHex || unit, + ticker: info?.metadata?.ticker || ascii || "", + decimals: typeof decimals === "number" ? decimals : 0, + image: "", + }; + }), + ); + return { metadata, decimalsFor: (unit) => known.get(unit) }; +} + +/** Bech32 pool id → "[TICKER] Name", mirroring `usePoolNames`. */ +export async function resolvePoolNames( + provider: BlockfrostProvider, + poolIds: string[], + opts: { budgetMs?: number } = {}, +): Promise<(poolId: string) => string | undefined> { + const names = new Map<string, string>(); + await Promise.all( + [...new Set(poolIds)].slice(0, MAX_POOL_LOOKUPS).map(async (poolId) => { + const metadata = await withBudget( + provider.get(`/pools/${poolId}/metadata`) as Promise< + { name?: string | null; ticker?: string | null } | null + >, + opts.budgetMs ?? 4000, + null, + ); + const name = metadata?.name?.trim(); + const ticker = metadata?.ticker?.trim(); + const label = ticker && name ? `[${ticker}] ${name}` : name || (ticker ? `[${ticker}]` : ""); + if (label) names.set(poolId, label); + }), + ); + return (poolId) => names.get(poolId); +} + +/** + * "txHash#index" → proposal title. The wallet's own ballots first (they + * store titles next to each item, zero network cost), then Blockfrost with + * the IPFS gateway fallback under a shared budget. + */ +export async function resolveProposalTitles( + db: PrismaClient, + provider: BlockfrostProvider, + walletId: string, + proposalIds: string[], + opts: { budgetMs?: number } = {}, +): Promise<(proposalId: string) => string | undefined> { + const ids = [...new Set(proposalIds)]; + if (ids.length === 0) return () => undefined; + + let titles = new Map<string, string>(); + try { + const ballots = await db.ballot.findMany({ + where: { walletId, type: GOV_BALLOT_TYPE }, + orderBy: { createdAt: "desc" }, + select: { items: true, itemDescriptions: true }, + }); + titles = ballotTitleMap(ballots); + } catch { + // Ballot rows are a courtesy source; fall through to the chain. + } + + const missing = ids.filter((id) => !titles.has(id)).slice(0, MAX_TITLE_LOOKUPS); + const budgetMs = opts.budgetMs ?? 5000; + await Promise.all( + missing.map(async (id) => { + let parsed: { txHash: string; certIndex: number }; + try { + parsed = parseProposalId(id); + } catch { + return; + } + const metadata = await withBudget( + fetchProposalMetadataWithFallback({ + provider, + proposal: { + tx_hash: parsed.txHash, + cert_index: parsed.certIndex, + governance_type: "", + }, + fetchDetails: async () => + (await provider + .get(`/governance/proposals/${parsed.txHash}/${parsed.certIndex}`) + .catch(() => null)) as ProposalDetails | null, + }), + budgetMs, + null, + ); + const title = metadata?.json_metadata?.body?.title?.trim(); + if (title && title !== "Metadata could not be loaded.") titles.set(id, title); + }), + ); + + return (proposalId) => titles.get(proposalId); +} diff --git a/src/lib/tx-review/pipeline.ts b/src/lib/tx-review/pipeline.ts new file mode 100644 index 00000000..1fd53b2e --- /dev/null +++ b/src/lib/tx-review/pipeline.ts @@ -0,0 +1,341 @@ +import type { PrismaClient } from "@prisma/client"; +import type { UTxO } from "@meshsdk/core"; + +import type { V1Result } from "@/lib/mcp/invokeV1"; +import { completeTxWithFreshCostModels } from "@/lib/completeTxWithFreshCostModels"; +import { fetchDrepStatus } from "@/lib/governance/drep-status"; +import { fetchStakeAccountStatus } from "@/lib/staking/stake-account-status"; +import { utxoFunds } from "@/lib/tx-draft/assets"; +import { buildDraftTx, type DraftBuildResult } from "@/lib/tx-draft/build-draft-tx"; +import { validateDraft, type DraftIssue } from "@/lib/tx-draft/validate"; +import type { TxDraft } from "@/types/tx-draft"; +import { getProvider } from "@/utils/get-provider"; +import { getTxBuilder } from "@/utils/get-tx-builder"; + +import { loadContacts, createServerAddressLabeler } from "./labels"; +import { + resolveAssetMetadata, + resolvePoolNames, + resolveProposalTitles, + type ResolvedAssetMetadata, +} from "./metadata"; +import { renderReviewPng } from "./render-png"; +import { collectSpecUnits, type TxSpec } from "./spec"; +import { + summarizeMeshBody, + type SummarizeOptions, + type TxReviewSummary, +} from "./summary"; +import { TxReviewError, toApplyDraftContext, type ReviewWalletContext } from "./context"; + +/** + * The steps preview and propose share, in the order they must happen: + * spendable UTxOs → asset metadata → validation → build → summary → card. + * Both callers hand in the same spec (propose gets it from the token), so + * the only way the two transactions differ is what changed on chain in + * between — which the propose result reports. + */ + +export type ReviewDeps = { + db: PrismaClient; + /** `multisig_list_free_utxos`'s v1 handler, run in-process for the caller. */ + fetchFreeUtxos: (walletId: string) => Promise<V1Result>; + /** Injectable for tests; defaults to the real renderer. */ + renderPng?: (summary: TxReviewSummary) => Promise<Buffer>; + /** + * Skip the PNG card entirely (no `images` in the result). The web app's + * task-payout dialog renders the summary as React and runs under the tRPC + * route, which has none of the `next/og` asset tracing `/api/mcp` has. + */ + omitCard?: boolean; +}; + +/** UTxOs not locked by another pending transaction, fresh from chain. */ +export async function loadSpendableUtxos( + deps: ReviewDeps, + walletId: string, +): Promise<UTxO[]> { + const result = await deps.fetchFreeUtxos(walletId); + if (result.status >= 400) { + const error = (result.body as { error?: string } | null)?.error; + throw new TxReviewError( + result.status, + "UTXO_LOOKUP_FAILED", + error ?? "Could not load the wallet's spendable UTxOs", + ); + } + return Array.isArray(result.body) ? (result.body as UTxO[]) : []; +} + +export async function loadSpecAssetMetadata( + spec: TxSpec, + network: 0 | 1, +): Promise<ResolvedAssetMetadata> { + const units = collectSpecUnits({ walletId: spec.walletId, outputs: spec.outputs.map((o) => ({ + address: o.address, + assets: o.assets.filter((a) => a.unit !== "lovelace").map((a) => ({ unit: a.unit, quantity: a.quantity })), + })) }); + return resolveAssetMetadata(getProvider(network), units, network); +} + +/** Validation errors become a 400 with the issue list; warnings are returned. */ +export function validateOrThrow( + draft: TxDraft, + ctx: ReviewWalletContext, + availableUtxos: UTxO[], + stakeAccountActive?: boolean, + drepRegistered?: boolean, +): DraftIssue[] { + const issues = validateDraft(draft, { + network: ctx.network, + selectedFunds: utxoFunds(availableUtxos), + hasDrepContext: !!ctx.drep, + hasStakeContext: !!ctx.stake, + stakeAccountActive, + drepRegistered, + multisigAddress: ctx.walletAddress, + }); + const errors = issues.filter((issue) => issue.level === "error"); + if (errors.length > 0) { + throw new TxReviewError( + 400, + "INVALID_DRAFT", + `The transaction cannot be built: ${errors.map((e) => e.message).join(" ")}`, + { issues }, + ); + } + return issues.filter((issue) => issue.level === "warning"); +} + +/** + * Registration state of the wallet's stake credential, fetched only when the + * spec carries certificates. A delegation for an unregistered credential + * (or a registration for a registered one) builds fine and is rejected by + * the node at submit — after the signatures are in — so the state must be + * known before validation. Returns undefined when there is nothing to check. + */ +export async function loadStakeAccountActive( + ctx: ReviewWalletContext, + spec: { certificates: readonly unknown[] }, +): Promise<boolean | undefined> { + if (spec.certificates.length === 0 || !ctx.stake) return undefined; + try { + const status = await fetchStakeAccountStatus( + getProvider(ctx.network), + ctx.stake.rewardAddress, + ); + return status.active; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new TxReviewError( + 502, + "STAKE_LOOKUP_FAILED", + `Could not check whether the wallet's stake credential is registered: ${message.slice(0, 200)}`, + ); + } +} + +/** + * Registration state of the wallet's DRep credential, fetched only when the + * spec carries votes. A vote from an unregistered DRep builds fine and is + * rejected by the node at submit — after the signatures are in — so the + * state must be known before validation, which then refuses the draft + * (`vote-drep-unregistered`) and tells the user to register first. Returns + * undefined when there is nothing to check; a wallet with no DRep identity + * at all is `vote-drep-missing`'s job. + */ +export async function loadDrepRegistered( + ctx: ReviewWalletContext, + spec: { votes: readonly unknown[] }, +): Promise<boolean | undefined> { + if (spec.votes.length === 0 || !ctx.drep) return undefined; + try { + const status = await fetchDrepStatus(getProvider(ctx.network), ctx.drep.dRepId); + return status.active; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new TxReviewError( + 502, + "DREP_LOOKUP_FAILED", + `Could not check whether the wallet is registered as a DRep: ${message.slice(0, 200)}`, + ); + } +} + +export const STAKE_REGISTRATION_ADDED_WARNING = + "The wallet's stake credential is not registered on chain, so a stake registration (2 ADA deposit, refundable on deregistration) was added ahead of the delegation."; + +/** + * A delegation for an unregistered credential gets its registration added, + * the way the builder canvas offers only register+delegate for an inactive + * account. Done on the spec, not the draft, so the token minted from it + * records the registration and its deposit exactly as the card showed them: + * propose rebuilds from that spec, and if the account was registered in the + * meantime, validation refuses (`cert-already-registered`) rather than + * quietly dropping a deposit the human confirmed. + */ +export function ensureStakeRegistration( + spec: TxSpec, + stakeAccountActive: boolean | undefined, +): { spec: TxSpec; added: boolean } { + if (stakeAccountActive !== false) return { spec, added: false }; + const kinds = new Set(spec.certificates.map((cert) => cert.kind)); + if (!kinds.has("DelegateStake") || kinds.has("RegisterStake")) { + return { spec, added: false }; + } + return { + spec: { + ...spec, + certificates: [{ kind: "RegisterStake" }, ...spec.certificates], + }, + added: true, + }; +} + +export async function buildUnsigned( + draft: TxDraft, + ctx: ReviewWalletContext, + availableUtxos: UTxO[], +): Promise<DraftBuildResult> { + try { + // Always a fresh builder: MeshTxBuilder is stateful and single-use. + const txBuilder = await getTxBuilder(ctx.network); + return await buildDraftTx(txBuilder, draft, toApplyDraftContext(ctx, availableUtxos), { + metadataMessage: draft.metadata || undefined, + complete: (builder) => completeTxWithFreshCostModels(builder, ctx.network), + }); + } catch (error) { + if (error instanceof TxReviewError) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new TxReviewError( + 400, + "BUILD_FAILED", + `Building the transaction failed: ${message.slice(0, 400)}`, + ); + } +} + +export type SummaryInputs = Pick< + SummarizeOptions, + | "kind" + | "signedAddresses" + | "rejectedAddresses" + | "description" + | "metadataMessage" + | "pendingRationales" + | "paymentCount" + | "txHash" + | "transactionId" + | "sizeBytes" + | "warnings" +>; + +/** Resolve labels, names and titles, then summarize one builder body. */ +export async function summarizeForWallet( + deps: Pick<ReviewDeps, "db">, + body: any, + wallet: { + id: string; + name: string; + address: string; + network: 0 | 1; + signersAddresses: string[]; + signersDescriptions: string[]; + threshold: SummarizeOptions["threshold"]; + }, + assets: ResolvedAssetMetadata | undefined, + inputs: SummaryInputs, +): Promise<TxReviewSummary> { + const provider = getProvider(wallet.network); + + const outputUnits = new Set<string>(); + for (const output of Array.isArray(body?.outputs) ? body.outputs : []) { + for (const asset of Array.isArray(output?.amount) ? output.amount : []) { + if (asset?.unit && asset.unit !== "lovelace") outputUnits.add(asset.unit); + } + } + const missingUnits = [...outputUnits].filter((unit) => !assets?.metadata[unit]); + const extra = + missingUnits.length > 0 + ? await resolveAssetMetadata(provider, missingUnits, wallet.network) + : undefined; + const assetMetadata = { ...(assets?.metadata ?? {}), ...(extra?.metadata ?? {}) }; + + const poolIds: string[] = []; + for (const cert of Array.isArray(body?.certificates) ? body.certificates : []) { + const poolId = cert?.certType?.poolId; + if (cert?.certType?.type === "DelegateStake" && typeof poolId === "string") { + poolIds.push(poolId); + } + } + const proposalIds: string[] = []; + for (const vote of Array.isArray(body?.votes) ? body.votes : []) { + const id = vote?.vote?.govActionId; + if (id?.txHash !== undefined && id?.txIndex !== undefined) { + proposalIds.push(`${id.txHash}#${id.txIndex}`); + } + } + + const [contacts, resolvePoolName, resolveProposalTitle] = await Promise.all([ + loadContacts(deps.db, wallet.id), + poolIds.length > 0 ? resolvePoolNames(provider, poolIds) : Promise.resolve(undefined), + proposalIds.length > 0 + ? resolveProposalTitles(deps.db, provider, wallet.id, proposalIds) + : Promise.resolve(undefined), + ]); + + return summarizeMeshBody(body, { + ...inputs, + wallet: { + id: wallet.id, + name: wallet.name, + address: wallet.address, + network: wallet.network, + }, + threshold: wallet.threshold, + labelAddress: createServerAddressLabeler({ + walletAddress: wallet.address, + signersAddresses: wallet.signersAddresses, + signersDescriptions: wallet.signersDescriptions, + contacts, + }), + assetMetadata, + resolvePoolName, + resolveProposalTitle, + }); +} + +export async function renderCard( + deps: Pick<ReviewDeps, "renderPng">, + summary: TxReviewSummary, +): Promise<{ data: string; mimeType: "image/png" }> { + const png = await (deps.renderPng ?? renderReviewPng)(summary); + return { data: png.toString("base64"), mimeType: "image/png" }; +} + +export function walletSummaryShape(ctx: ReviewWalletContext) { + return { + id: ctx.walletRow.id, + name: ctx.walletRow.name, + address: ctx.walletAddress, + network: ctx.network, + signersAddresses: ctx.walletRow.signersAddresses, + signersDescriptions: ctx.walletRow.signersDescriptions, + threshold: ctx.threshold, + }; +} + +/** Rationale text per proposal id, for the preview's "will publish" note. */ +export function pendingRationalesOf(spec: TxSpec): Map<string, string> { + const map = new Map<string, string>(); + for (const vote of spec.votes) { + if (vote.rationale) { + map.set(`${vote.govActionTxHash}#${vote.govActionIndex}`, vote.rationale); + } + } + return map; +} + +export function issueMessages(issues: DraftIssue[]): string[] { + return issues.map((issue) => issue.message); +} diff --git a/src/lib/tx-review/preview.ts b/src/lib/tx-review/preview.ts new file mode 100644 index 00000000..2d6f7552 --- /dev/null +++ b/src/lib/tx-review/preview.ts @@ -0,0 +1,175 @@ +import type { McpToolResult, ToolContext } from "@/lib/mcp/tools"; + +import { loadReviewWalletContext, type ReviewWalletContext, TxReviewError } from "./context"; +import { DRAFT_TOKEN_TTL_SECONDS, type DraftOrigin, mintDraftToken } from "./draft-token"; +import type { ResolvedAssetMetadata } from "./metadata"; +import { + buildUnsigned, + ensureStakeRegistration, + issueMessages, + loadDrepRegistered, + loadSpecAssetMetadata, + loadSpendableUtxos, + loadStakeAccountActive, + pendingRationalesOf, + renderCard, + STAKE_REGISTRATION_ADDED_WARNING, + summarizeForWallet, + validateOrThrow, + walletSummaryShape, + type ReviewDeps, +} from "./pipeline"; +import { hasSpecErrors, normalizeTxSpec, specToDraft, type TxSpec, type TxSpecInput } from "./spec"; +import { REVIEW_CARD_HINT, REVIEW_CARD_INLINE_HINT, summaryToText } from "./summary"; + +/** + * `transaction_preview`: build the unsigned transaction and show it. + * + * Persists nothing. What comes back is the review card, a readable summary, + * and a draft token that is the only thing `transaction_propose` will + * accept — see `draft-token.ts` for why that shape matters. + */ +export async function runTransactionPreview( + input: TxSpecInput, + ctx: ToolContext, + deps: ReviewDeps, +): Promise<McpToolResult> { + try { + const wallet = await loadReviewWalletContext(deps.db, input.walletId, ctx.caller); + + // Units first: the display→base conversion needs each token's decimals. + const probe = normalizeTxSpec(input, { decimalsFor: () => 0 }); + const assets = await loadSpecAssetMetadata(probe.spec, wallet.network); + const { spec: requested, issues: specIssues } = normalizeTxSpec(input, { + decimalsFor: assets.decimalsFor, + }); + if (hasSpecErrors(specIssues)) { + throw new TxReviewError( + 400, + "INVALID_SPEC", + `The request could not be understood: ${specIssues + .filter((i) => i.level === "error") + .map((i) => i.message) + .join(" ")}`, + { issues: specIssues }, + ); + } + + return await runSpecPreview(requested, ctx, deps, { + wallet, + assets, + specWarnings: specIssues.filter((i) => i.level === "warning").map((i) => i.message), + }); + } catch (error) { + if (error instanceof TxReviewError) return error.toResult(); + throw error; + } +} + +export type SpecPreviewOptions = { + /** Already-loaded wallet context; loaded from `spec.walletId` when absent. */ + wallet?: ReviewWalletContext; + /** Already-resolved asset metadata for the spec's units. */ + assets?: ResolvedAssetMetadata; + /** Normalization warnings to prepend to the draft's own. */ + specWarnings?: string[]; + /** Draft id prefix; shows up nowhere the human sees, but keeps logs readable. */ + draftId?: string; + /** Bound into the draft token — see `DraftOrigin`. */ + origin?: DraftOrigin; + /** Merged into the result body (e.g. the tasks a payout covers). */ + extraBody?: Record<string, unknown>; +}; + +/** + * The preview from a canonical spec (base units) onward: UTxOs, on-chain + * probes, validate, build, summarize, card, token. `runTransactionPreview` + * is the tool-input front end; the task-board payout builds its spec + * directly from stored recipient rows and enters here. + * + * Throws `TxReviewError`; callers map it to a result. + */ +export async function runSpecPreview( + requested: TxSpec, + ctx: ToolContext, + deps: ReviewDeps, + opts: SpecPreviewOptions = {}, +): Promise<McpToolResult> { + const wallet = + opts.wallet ?? (await loadReviewWalletContext(deps.db, requested.walletId, ctx.caller)); + const assets = opts.assets ?? (await loadSpecAssetMetadata(requested, wallet.network)); + + const availableUtxos = await loadSpendableUtxos(deps, wallet.walletRow.id); + // Both on-chain registration probes at once; each is skipped when the + // spec has nothing for it to check. + const [stakeAccountActive, drepRegistered] = await Promise.all([ + loadStakeAccountActive(wallet, requested), + loadDrepRegistered(wallet, requested), + ]); + // The token is minted from this spec, registration included, so the + // human confirms the deposit they saw on the card. + const registration = ensureStakeRegistration(requested, stakeAccountActive); + const spec = registration.spec; + const draft = specToDraft(spec, opts.draftId ?? "mcp-preview"); + const draftWarnings = validateOrThrow( + draft, + wallet, + availableUtxos, + stakeAccountActive, + drepRegistered, + ); + + const built = await buildUnsigned(draft, wallet, availableUtxos); + + const warnings = [ + ...(opts.specWarnings ?? []), + ...(registration.added ? [STAKE_REGISTRATION_ADDED_WARNING] : []), + ...issueMessages(draftWarnings), + ]; + const summary = await summarizeForWallet(deps, built.body, walletSummaryShape(wallet), assets, { + kind: "preview", + signedAddresses: [], + rejectedAddresses: [], + description: spec.description, + metadataMessage: spec.metadataMessage, + pendingRationales: pendingRationalesOf(spec), + paymentCount: spec.outputs.length, + txHash: built.txHash, + sizeBytes: built.sizeBytes, + warnings, + }); + + // The token records how the card reached the human, so propose (which takes + // only the token) answers in the same mode. + const image = deps.omitCard ? undefined : await renderCard(deps, summary); + const token = mintDraftToken({ + subject: ctx.caller.subject, + walletId: wallet.walletRow.id, + clientId: ctx.caller.clientName, + spec, + previewTxHash: built.txHash, + origin: opts.origin, + card: image ? "image" : "html", + }); + + return { + status: 200, + body: { + draftToken: token.token, + expiresAt: new Date(token.expiresAt * 1000).toISOString(), + expiresInSeconds: DRAFT_TOKEN_TTL_SECONDS, + txHash: built.txHash, + fee: built.fee, + summary, + warnings, + reviewCard: image ? REVIEW_CARD_HINT : REVIEW_CARD_INLINE_HINT, + persisted: false, + signed: false, + broadcast: false, + ...(opts.extraBody ?? {}), + }, + text: summaryToText(summary, { card: image ? "image" : "html" }), + ...(image ? { images: [image] } : {}), + audit: { walletId: wallet.walletRow.id, previewTxHash: built.txHash }, + }; +} diff --git a/src/lib/tx-review/propose.ts b/src/lib/tx-review/propose.ts new file mode 100644 index 00000000..36990131 --- /dev/null +++ b/src/lib/tx-review/propose.ts @@ -0,0 +1,381 @@ +import type { Prisma } from "@prisma/client"; + +import type { McpToolResult, ToolContext } from "@/lib/mcp/tools"; +import { audit } from "@/lib/observability/audit"; +import { issuerOrigin } from "@/lib/oauth/config"; +import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; +import { pinJsonLd } from "@/lib/server/pinataUpload"; +import { buildRationaleAnchor } from "@/lib/server/rationaleAnchor"; +import { withVoteAnchor } from "@/lib/tx-draft/mutations"; +import type { TxDraft } from "@/types/tx-draft"; + +import { loadReviewWalletContext, TxReviewError } from "./context"; +import { describeDraftTokenFailure, verifyDraftToken, type VerifiedDraftToken } from "./draft-token"; +import { + buildUnsigned, + ensureStakeRegistration, + issueMessages, + loadDrepRegistered, + loadSpecAssetMetadata, + loadSpendableUtxos, + loadStakeAccountActive, + renderCard, + STAKE_REGISTRATION_ADDED_WARNING, + summarizeForWallet, + validateOrThrow, + walletSummaryShape, + type ReviewDeps, +} from "./pipeline"; +import { specToDraft, type TxSpec } from "./spec"; +import { REVIEW_CARD_HINT, REVIEW_CARD_INLINE_HINT, summaryToText } from "./summary"; + +/** + * `transaction_propose`: turn a reviewed draft into a pending transaction. + * + * Accepts only the draft token. Rebuilds from the spec inside it against the + * wallet's current UTxOs, publishes any vote rationale to IPFS (the one + * public side effect, and the reason it waits for the human's confirmation), + * and stores the result with zero signatures: `signedAddresses: []`. The + * helper that persists it would broadcast a single-signer transaction only + * when the initial signer set already meets the threshold, which an empty + * set never does — and the result is checked anyway. + * + * Idempotent on the token's `jti`: a replay returns the transaction the first + * call created instead of a second one competing for the same UTxOs. + */ + +export type ProposeDeps = ReviewDeps & { + /** Injectable pinner; defaults to Pinata. */ + pin?: (filename: string, json: string) => Promise<{ url: string }>; + /** Injectable anchor hash (blake2b-256 over the pretty JSON). */ + hashAnchor?: (doc: Record<string, unknown>) => string; + createPending?: typeof createPendingMultisigTransaction; + clientIp?: string; + /** + * Which surface confirmed the draft, for the audit row. The web app's + * task-payout dialog runs this same function and passes "app". + */ + via?: "mcp" | "app"; + /** + * Extra top-level txJson namespaces, stamped next to `mcp`. Never under + * `multisig` (rewritten on every signature). Task payouts add `tasks`. + */ + txJsonExtras?: (claims: VerifiedDraftToken) => Record<string, unknown>; + /** + * Runs inside the database transaction that inserts the pending row; a + * throw aborts the insert. A thrown `TxReviewError` becomes the tool result. + */ + afterCreate?: ( + tx: Prisma.TransactionClient, + created: { id: string }, + claims: VerifiedDraftToken, + ) => Promise<void>; +}; + +/** Top-level txJson namespace for MCP provenance. Never under `multisig`, which signTransaction.ts rewrites. */ +export const MCP_TXJSON_KEY = "mcp"; + +export type McpTxJsonProvenance = { + draftId: string; + client: string | null; + proposer: string; + previewTxHash: string; + proposedAt: string; +}; + +/** + * The PNG is rendered only when the preview the human confirmed attached one + * (recorded in the token); the app path (`omitCard`) never renders it. + */ +function wantsPng(deps: ProposeDeps, claims: VerifiedDraftToken): boolean { + return !deps.omitCard && claims.card === "image"; +} + +export async function runTransactionPropose( + input: { draftToken: string }, + ctx: ToolContext, + deps: ProposeDeps, +): Promise<McpToolResult> { + try { + const verified = verifyDraftToken(String(input.draftToken ?? ""), { + subject: ctx.caller.subject, + clientId: ctx.caller.clientName, + }); + if (!verified.ok) { + throw new TxReviewError( + verified.reason === "subject_mismatch" || verified.reason === "client_mismatch" ? 403 : 401, + verified.reason === "expired" ? "TOKEN_EXPIRED" : "TOKEN_INVALID", + describeDraftTokenFailure(verified.reason), + ); + } + const { claims } = verified; + let spec = claims.spec; + + const wallet = await loadReviewWalletContext(deps.db, claims.walletId, ctx.caller); + const walletShape = walletSummaryShape(wallet); + const link = `${issuerOrigin()}/wallets/${wallet.walletRow.id}/transactions`; + + // Replay: the same reviewed draft must never become two transactions. + const existing = await findByDraftId(deps, wallet.walletRow.id, claims.jti); + if (existing) { + const body = safeParse(existing.txJson); + const summary = await summarizeForWallet(deps, body, walletShape, undefined, { + kind: "pending", + signedAddresses: existing.signedAddresses, + rejectedAddresses: existing.rejectedAddresses, + description: existing.description, + txHash: existing.txHash ?? claims.previewTxHash, + transactionId: existing.id, + warnings: [], + }); + const image = wantsPng(deps, claims) ? await renderCard(deps, summary) : undefined; + return { + status: 200, + body: { + transactionId: existing.id, + alreadyExisted: true, + txHash: summary.txHash, + previewTxHash: claims.previewTxHash, + signaturesRequired: wallet.threshold.required, + link, + summary, + reviewCard: image ? REVIEW_CARD_HINT : REVIEW_CARD_INLINE_HINT, + persisted: true, + signed: false, + broadcast: false, + }, + text: `This draft was already proposed as transaction ${existing.id}; nothing new was created.\n${summaryToText(summary, { card: image ? "image" : "html" })}\nSign it at ${link}`, + ...(image ? { images: [image] } : {}), + audit: { walletId: wallet.walletRow.id, transactionId: existing.id, draftId: claims.jti, replay: true }, + }; + } + + const assets = await loadSpecAssetMetadata(spec, wallet.network); + const availableUtxos = await loadSpendableUtxos(deps, wallet.walletRow.id); + // Re-checked, not carried in the token: the account may have been + // registered (validation then refuses the token's registration) or + // deregistered (a registration is added, and the hash change reported) + // since the preview. The DRep registration is re-checked for the same + // reason: a DRep retired after the preview can no longer vote. + const [stakeAccountActive, drepRegistered] = await Promise.all([ + loadStakeAccountActive(wallet, spec), + loadDrepRegistered(wallet, spec), + ]); + const registration = ensureStakeRegistration(spec, stakeAccountActive); + spec = registration.spec; + let draft = specToDraft(spec, "mcp-propose"); + const draftWarnings = validateOrThrow( + draft, + wallet, + availableUtxos, + stakeAccountActive, + drepRegistered, + ); + + // Rationales are pinned only now — public and permanent, so only after + // the human said yes and only for a draft that still validates. + const anchored = await anchorRationales(draft, spec, deps); + draft = anchored.draft; + + const built = await buildUnsigned(draft, wallet, availableUtxos); + + const provenance: McpTxJsonProvenance = { + draftId: claims.jti, + client: ctx.caller.clientName, + proposer: ctx.caller.subject, + previewTxHash: claims.previewTxHash, + proposedAt: new Date().toISOString(), + }; + const txJson = { + ...(built.body as object), + [MCP_TXJSON_KEY]: provenance, + ...(deps.txJsonExtras?.(claims) ?? {}), + }; + + const createPending = deps.createPending ?? createPendingMultisigTransaction; + const afterCreate = deps.afterCreate; + const created = await createPending(deps.db, { + walletId: wallet.walletRow.id, + wallet: { + numRequiredSigners: wallet.walletRow.numRequiredSigners, + type: wallet.walletRow.type, + }, + proposerAddress: ctx.caller.subject, + txCbor: built.unsignedTx, + txJson, + description: spec.description || "MCP draft", + network: wallet.network, + initialSignedAddresses: [], + notificationCreatorAddress: null, + ...(afterCreate + ? { afterCreate: (tx, row) => afterCreate(tx, { id: row.id }, claims) } + : {}), + }); + if (typeof created === "string" || !created || typeof created !== "object" || !("id" in created)) { + // A string would be a submitted tx hash — a broadcast. It cannot + // happen with an empty signer set, and it must never pass silently. + throw new Error("Unexpected persistence result for an unsigned draft"); + } + + void audit(deps.db, { + actorAddress: ctx.caller.subject, + actorType: "user", + action: "transaction.create", + resourceType: "transaction", + resourceId: created.id, + ip: deps.clientIp ?? ctx.clientIp, + outcome: "success", + metadata: { + walletId: wallet.walletRow.id, + via: deps.via ?? "mcp", + client: ctx.caller.clientName, + txHash: built.txHash, + mcpDraftId: claims.jti, + initialSigners: 0, + rationalesPinned: anchored.pinned, + }, + }); + + const txHashChanged = built.txHash !== claims.previewTxHash; + const txHashChangeReasons: string[] = []; + if (txHashChanged) { + if (anchored.pinned > 0) txHashChangeReasons.push("rationale-anchors"); + if (registration.added) txHashChangeReasons.push("stake-registration"); + if (txHashChangeReasons.length === 0) txHashChangeReasons.push("utxo-set"); + } + + const warnings = [ + ...(registration.added ? [STAKE_REGISTRATION_ADDED_WARNING] : []), + ...issueMessages(draftWarnings), + ...(txHashChangeReasons.includes("utxo-set") + ? [ + "The wallet's spendable UTxOs changed since the preview, so different inputs were selected. Recipients and amounts are unchanged.", + ] + : []), + ]; + + const summary = await summarizeForWallet(deps, built.body, walletShape, assets, { + kind: "pending", + signedAddresses: [], + rejectedAddresses: [], + description: spec.description, + metadataMessage: spec.metadataMessage, + paymentCount: spec.outputs.length, + txHash: built.txHash, + transactionId: created.id, + sizeBytes: built.sizeBytes, + warnings, + }); + const image = wantsPng(deps, claims) ? await renderCard(deps, summary) : undefined; + + return { + status: 201, + body: { + transactionId: created.id, + alreadyExisted: false, + txHash: built.txHash, + previewTxHash: claims.previewTxHash, + txHashChanged, + txHashChangeReasons, + signaturesRequired: wallet.threshold.required, + signaturesCollected: 0, + rationalesPublished: anchored.pinned, + link, + summary, + reviewCard: image ? REVIEW_CARD_HINT : REVIEW_CARD_INLINE_HINT, + persisted: true, + signed: false, + broadcast: false, + }, + text: `Created pending transaction ${created.id}. It has no signatures yet; the wallet's signers have been notified and can sign it at ${link}\n${summaryToText(summary, { card: image ? "image" : "html" })}`, + ...(image ? { images: [image] } : {}), + audit: { + walletId: wallet.walletRow.id, + transactionId: created.id, + txHash: built.txHash, + draftId: claims.jti, + }, + }; + } catch (error) { + if (error instanceof TxReviewError) return error.toResult(); + throw error; + } +} + +type PendingRow = { + id: string; + txJson: string; + txHash: string | null; + description: string | null; + signedAddresses: string[]; + rejectedAddresses: string[]; +}; + +async function findByDraftId( + deps: ReviewDeps, + walletId: string, + draftId: string, +): Promise<PendingRow | null> { + const rows = (await deps.db.transaction.findMany({ + where: { walletId, state: 0 }, + select: { + id: true, + txJson: true, + txHash: true, + description: true, + signedAddresses: true, + rejectedAddresses: true, + }, + })) as PendingRow[]; + for (const row of rows) { + const parsed = safeParse(row.txJson); + if (parsed?.[MCP_TXJSON_KEY]?.draftId === draftId) return row; + } + return null; +} + +function safeParse(txJson: string): any { + try { + return JSON.parse(txJson); + } catch { + return {}; + } +} + +async function anchorRationales( + draft: TxDraft, + spec: TxSpec, + deps: ProposeDeps, +): Promise<{ draft: TxDraft; pinned: number }> { + let next = draft; + let pinned = 0; + for (let index = 0; index < spec.votes.length; index++) { + const vote = spec.votes[index]!; + const text = vote.rationale?.trim(); + if (!text) continue; + try { + const hashAnchor = + deps.hashAnchor ?? + (await import("@meshsdk/core")).hashDrepAnchor; + const anchor = buildRationaleAnchor( + { summary: text.slice(0, 300), rationaleStatement: text }, + (doc) => hashAnchor(doc), + `rationale-${vote.govActionTxHash.slice(0, 16)}-${vote.govActionIndex}`, + ); + const pin = deps.pin ?? ((filename, json) => pinJsonLd(filename, json)); + const { url } = await pin(anchor.filename, anchor.json); + next = withVoteAnchor(next, `vote-${index}`, { + anchorUrl: url, + anchorDataHash: anchor.hash, + }); + pinned++; + } catch (error) { + throw new TxReviewError( + 502, + "PIN_FAILED", + `Publishing the rationale for vote ${index + 1} failed: ${error instanceof Error ? error.message : String(error)}. Nothing was created.`, + ); + } + } + return { draft: next, pinned }; +} diff --git a/src/lib/tx-review/render-png.ts b/src/lib/tx-review/render-png.ts new file mode 100644 index 00000000..438dd262 --- /dev/null +++ b/src/lib/tx-review/render-png.ts @@ -0,0 +1,24 @@ +import { reviewCardTree } from "./card"; +import type { TxReviewSummary } from "./summary"; + +/** + * Rasterize the review card to a PNG. + * + * Uses the `ImageResponse` that ships inside Next (`next/og`: satori for + * layout, resvg for rasterization, a bundled Geist face) — no new dependency, + * no system fonts, works in the plain Node runtime. The import MUST stay + * inside the function: the module loads two WASM blobs, and nothing that + * imports this file (the MCP registry included) may pay that at module scope. + * + * `next/og` is an ESM bundle that the CJS jest project cannot load; the + * rendering test lives in the ESM project for that reason, and every other + * test mocks this module. + */ +export async function renderReviewPng(summary: TxReviewSummary): Promise<Buffer> { + const { element, width, height } = reviewCardTree(summary); + const { ImageResponse } = await import("next/og"); + // satori accepts a plain {type, props} tree; the React element type is only + // nominal here. + const response = new ImageResponse(element as never, { width, height }); + return Buffer.from(await response.arrayBuffer()); +} diff --git a/src/lib/tx-review/review.ts b/src/lib/tx-review/review.ts new file mode 100644 index 00000000..fb2ae58e --- /dev/null +++ b/src/lib/tx-review/review.ts @@ -0,0 +1,138 @@ +import type { V1Result } from "@/lib/mcp/invokeV1"; +import type { McpToolResult, ToolContext } from "@/lib/mcp/tools"; +import { getRequiredSignerCount } from "@/lib/notifications/center"; +import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; +import type { DbWalletWithLegacy } from "@/types/wallet"; + +import { networkFromAddress, TxReviewError } from "./context"; +import { renderCard, summarizeForWallet, type ReviewDeps } from "./pipeline"; +import { REVIEW_CARD_HINT, REVIEW_CARD_INLINE_HINT, summaryToText } from "./summary"; + +/** + * `multisig_review_pending_transaction`: the review card for a transaction + * that already exists, whoever created it. + * + * Authorization is the pending-transactions v1 handler's: it returns rows + * only for a signer (human JWT) or a bot with access, and observers may look. + * The wallet row is read afterwards purely for names and the threshold. + */ +/** + * What the pending-transaction review needs: no UTxO source, since it builds + * nothing — the transaction already exists. + */ +export type PendingReviewDeps = Pick<ReviewDeps, "db" | "renderPng" | "omitCard"> & { + /** `multisig_list_pending_transactions`'s v1 handler, run in-process for the caller. */ + fetchPendingTransactions: (walletId: string) => Promise<V1Result>; +}; + +export async function runPendingTransactionReview( + input: { walletId: string; transactionId: string }, + ctx: ToolContext, + deps: PendingReviewDeps, +): Promise<McpToolResult> { + try { + const listed = await deps.fetchPendingTransactions(input.walletId); + if (listed.status >= 400) { + return listed; + } + const rows = Array.isArray(listed.body) ? (listed.body as PendingRow[]) : []; + const row = rows.find((r) => r?.id === input.transactionId); + if (!row) { + throw new TxReviewError( + 404, + "NOT_FOUND", + "No pending transaction with that id in this wallet (it may have been submitted or deleted).", + ); + } + + const walletRow = await deps.db.wallet.findUnique({ where: { id: input.walletId } }); + if (!walletRow) throw new TxReviewError(404, "NOT_FOUND", "Wallet not found"); + + const body = safeParse(row.txJson); + const network = networkFromAddress(ctx.caller.subject); + let address: string; + try { + address = resolveWalletScriptAddress(walletRow as DbWalletWithLegacy, ctx.caller.subject); + } catch { + address = typeof body?.changeAddress === "string" ? body.changeAddress : ""; + } + + const txHash = row.txHash ?? (await bodyHashOf(row.txCbor)) ?? ""; + + const summary = await summarizeForWallet( + deps, + body, + { + id: walletRow.id, + name: walletRow.name, + address, + network, + signersAddresses: walletRow.signersAddresses, + signersDescriptions: walletRow.signersDescriptions, + threshold: { + required: getRequiredSignerCount(walletRow), + total: walletRow.signersAddresses.length, + type: walletRow.type, + }, + }, + undefined, + { + kind: "pending", + signedAddresses: row.signedAddresses ?? [], + rejectedAddresses: row.rejectedAddresses ?? [], + description: row.description, + txHash, + transactionId: row.id, + sizeBytes: typeof row.txCbor === "string" ? Math.ceil(row.txCbor.length / 2) : undefined, + warnings: [], + }, + ); + const image = deps.omitCard ? undefined : await renderCard(deps, summary); + + return { + status: 200, + body: { + transactionId: row.id, + txHash, + summary, + reviewCard: image ? REVIEW_CARD_HINT : REVIEW_CARD_INLINE_HINT, + createdAt: row.createdAt ?? null, + }, + text: summaryToText(summary, { card: image ? "image" : "html" }), + ...(image ? { images: [image] } : {}), + audit: { walletId: input.walletId, transactionId: row.id }, + }; + } catch (error) { + if (error instanceof TxReviewError) return error.toResult(); + throw error; + } +} + +type PendingRow = { + id: string; + txJson: string; + txCbor: string; + txHash: string | null; + description: string | null; + signedAddresses: string[]; + rejectedAddresses: string[]; + createdAt?: string | Date; +}; + +function safeParse(txJson: string): any { + try { + return JSON.parse(txJson); + } catch { + return {}; + } +} + +async function bodyHashOf(txCbor: unknown): Promise<string | undefined> { + if (typeof txCbor !== "string" || !txCbor) return undefined; + try { + const { resolveTxHash } = await import("@meshsdk/core-cst"); + return resolveTxHash(txCbor).toLowerCase(); + } catch { + return undefined; + } +} diff --git a/src/lib/tx-review/spec.ts b/src/lib/tx-review/spec.ts new file mode 100644 index 00000000..1948235c --- /dev/null +++ b/src/lib/tx-review/spec.ts @@ -0,0 +1,374 @@ +import type { AssetQuantity } from "@/types/token-flow"; +import type { + DraftCertificateKind, + DraftVoteKind, + TxDraft, +} from "@/types/tx-draft"; +import { displayToBase } from "@/lib/tx-draft/decimal"; +import { + addCertificate, + addOutput, + addVote, + createDraft, +} from "@/lib/tx-draft/mutations"; +import { parseProposalId } from "@/lib/governance"; +import { normalizePoolIdForDelegation } from "@/utils/normalizePoolId"; + +/** + * The transaction spec: what the model asks for, and what the draft token + * carries. + * + * Two shapes on purpose. `TxSpecInput` is the tool-facing one — amounts in + * display units ("12.5" ADA, "100" of a token with its registered decimals), + * proposals as "txHash#index", pool ids in either encoding — because that is + * how a human phrases a payment in chat and how the model will relay it. + * `TxSpec` is the canonical one — base units, parsed ids, defaults applied — + * so that the token binds an unambiguous transaction and `specToDraft` is a + * pure, deterministic projection onto the builder's `TxDraft`. + */ + +export const CERTIFICATE_KINDS: readonly DraftCertificateKind[] = [ + "RegisterStake", + "DelegateStake", + "DeregisterStake", +]; +/** Ledger-valid emission order: register → delegate → deregister. */ +const CERTIFICATE_ORDER: Record<DraftCertificateKind, number> = { + RegisterStake: 0, + DelegateStake: 1, + DeregisterStake: 2, +}; +export const VOTE_KINDS: readonly DraftVoteKind[] = ["Yes", "No", "Abstain"]; + +export const MAX_DESCRIPTION_LENGTH = 128; +export const MAX_METADATA_MESSAGE_LENGTH = 64; +export const MAX_RATIONALE_LENGTH = 10_000; + +export type TxSpecInput = { + walletId: string; + outputs?: { + address: string; + /** ADA in display units, e.g. "12.5". */ + ada?: string; + /** Native assets: unit = policyId + hex asset name; quantity in display units. */ + assets?: { unit: string; quantity: string }[]; + }[]; + certificates?: { kind: string; poolId?: string }[]; + votes?: { proposalId: string; vote: string; rationale?: string }[]; + description?: string; + metadataMessage?: string; +}; + +export type TxSpec = { + v: 1; + walletId: string; + outputs: { address: string; assets: AssetQuantity[] }[]; + certificates: { kind: DraftCertificateKind; poolId?: string }[]; + votes: { + govActionTxHash: string; + govActionIndex: number; + voteKind: DraftVoteKind; + /** Rationale text; pinned to IPFS and anchored only on propose. */ + rationale?: string; + }[]; + description: string; + metadataMessage: string; +}; + +export type SpecIssue = { + level: "error" | "warning"; + code: + | "no-actions" + | "invalid-amount" + | "unknown-decimals" + | "invalid-unit" + | "invalid-certificate" + | "invalid-pool-id" + | "invalid-proposal-id" + | "invalid-vote" + | "too-long"; + message: string; + /** 0-based index into the offending input list, when there is one. */ + outputIndex?: number; + voteIndex?: number; + certificateIndex?: number; +}; + +const UNIT_PATTERN = /^[0-9a-f]{56,120}$/; + +/** Distinct native-asset units named by the input, for a metadata lookup. */ +export function collectSpecUnits(input: TxSpecInput): string[] { + const units = new Set<string>(); + for (const output of input.outputs ?? []) { + for (const asset of output.assets ?? []) { + if (asset.unit && asset.unit !== "lovelace") units.add(asset.unit.toLowerCase()); + } + } + return [...units]; +} + +/** + * Normalize the tool input into a canonical spec. + * + * `decimalsFor` answers from asset metadata already fetched for + * `collectSpecUnits`. When decimals are unknown the amount is only accepted + * if it is an integer, and then as base units with a warning — a guess at + * the scale of a token is exactly the kind of mistake a review card exists + * to prevent, so it is never made silently. + */ +export function normalizeTxSpec( + input: TxSpecInput, + opts: { decimalsFor: (unit: string) => number | undefined }, +): { spec: TxSpec; issues: SpecIssue[] } { + const issues: SpecIssue[] = []; + + const outputs: TxSpec["outputs"] = []; + (input.outputs ?? []).forEach((output, outputIndex) => { + const assets: AssetQuantity[] = []; + if (output.ada !== undefined && String(output.ada).trim() !== "") { + const lovelace = displayToBase(String(output.ada), 6); + if (lovelace === undefined || BigInt(lovelace) <= 0n) { + issues.push({ + level: "error", + code: "invalid-amount", + message: `Recipient ${outputIndex + 1}: "${output.ada}" is not a valid ADA amount.`, + outputIndex, + }); + } else { + assets.push({ unit: "lovelace", quantity: lovelace }); + } + } + for (const asset of output.assets ?? []) { + const unit = String(asset.unit ?? "").toLowerCase(); + if (unit === "lovelace") { + issues.push({ + level: "error", + code: "invalid-unit", + message: `Recipient ${outputIndex + 1}: use the "ada" field for ADA rather than an asset entry.`, + outputIndex, + }); + continue; + } + if (!UNIT_PATTERN.test(unit)) { + issues.push({ + level: "error", + code: "invalid-unit", + message: `Recipient ${outputIndex + 1}: "${asset.unit}" is not a valid asset unit (policy id + hex asset name).`, + outputIndex, + }); + continue; + } + const quantityRaw = String(asset.quantity ?? "").trim(); + const decimals = opts.decimalsFor(unit); + let base: string | undefined; + if (decimals === undefined) { + if (/^\d+$/.test(quantityRaw)) { + base = BigInt(quantityRaw).toString(); + issues.push({ + level: "warning", + code: "unknown-decimals", + message: `Recipient ${outputIndex + 1}: no decimals are registered for ${shortUnit(unit)}, so ${quantityRaw} is treated as the raw on-chain quantity.`, + outputIndex, + }); + } else { + issues.push({ + level: "error", + code: "unknown-decimals", + message: `Recipient ${outputIndex + 1}: no decimals are registered for ${shortUnit(unit)}; give a whole-number quantity in raw on-chain units.`, + outputIndex, + }); + continue; + } + } else { + base = displayToBase(quantityRaw, decimals); + } + if (base === undefined || BigInt(base) <= 0n) { + issues.push({ + level: "error", + code: "invalid-amount", + message: `Recipient ${outputIndex + 1}: "${asset.quantity}" is not a valid quantity for ${shortUnit(unit)}.`, + outputIndex, + }); + continue; + } + const existing = assets.find((a) => a.unit === unit); + if (existing) { + existing.quantity = (BigInt(existing.quantity) + BigInt(base)).toString(); + } else { + assets.push({ unit, quantity: base }); + } + } + outputs.push({ address: String(output.address ?? "").trim(), assets }); + }); + + const certificates: TxSpec["certificates"] = []; + (input.certificates ?? []).forEach((cert, certificateIndex) => { + const kind = cert.kind as DraftCertificateKind; + if (!CERTIFICATE_KINDS.includes(kind)) { + issues.push({ + level: "error", + code: "invalid-certificate", + message: `Certificate ${certificateIndex + 1}: unknown kind "${cert.kind}".`, + certificateIndex, + }); + return; + } + if (kind === "DelegateStake") { + let poolId: string | undefined; + try { + poolId = normalizePoolIdForDelegation(String(cert.poolId ?? "")); + } catch (error) { + issues.push({ + level: "error", + code: "invalid-pool-id", + message: `Certificate ${certificateIndex + 1}: ${error instanceof Error ? error.message : "invalid pool id"}.`, + certificateIndex, + }); + return; + } + certificates.push({ kind, poolId }); + } else { + certificates.push({ kind }); + } + }); + + const votes: TxSpec["votes"] = []; + (input.votes ?? []).forEach((vote, voteIndex) => { + let parsed: { txHash: string; certIndex: number }; + try { + parsed = parseProposalId(String(vote.proposalId ?? "").trim()); + } catch { + issues.push({ + level: "error", + code: "invalid-proposal-id", + message: `Vote ${voteIndex + 1}: "${vote.proposalId}" is not a proposal id (expected <txHash>#<index>).`, + voteIndex, + }); + return; + } + if (!/^[0-9a-f]{64}$/i.test(parsed.txHash)) { + issues.push({ + level: "error", + code: "invalid-proposal-id", + message: `Vote ${voteIndex + 1}: proposal tx hash must be 64 hex characters.`, + voteIndex, + }); + return; + } + const voteKind = vote.vote as DraftVoteKind; + if (!VOTE_KINDS.includes(voteKind)) { + issues.push({ + level: "error", + code: "invalid-vote", + message: `Vote ${voteIndex + 1}: choice must be Yes, No or Abstain.`, + voteIndex, + }); + return; + } + const rationale = vote.rationale?.trim(); + if (rationale && rationale.length > MAX_RATIONALE_LENGTH) { + issues.push({ + level: "error", + code: "too-long", + message: `Vote ${voteIndex + 1}: rationale exceeds ${MAX_RATIONALE_LENGTH} characters.`, + voteIndex, + }); + return; + } + votes.push({ + govActionTxHash: parsed.txHash.toLowerCase(), + govActionIndex: parsed.certIndex, + voteKind, + ...(rationale ? { rationale } : {}), + }); + }); + + const description = (input.description ?? "").trim(); + if (description.length > MAX_DESCRIPTION_LENGTH) { + issues.push({ + level: "error", + code: "too-long", + message: `Description exceeds ${MAX_DESCRIPTION_LENGTH} characters.`, + }); + } + const metadataMessage = (input.metadataMessage ?? "").trim(); + if (metadataMessage.length > MAX_METADATA_MESSAGE_LENGTH) { + issues.push({ + level: "error", + code: "too-long", + message: `Metadata message exceeds ${MAX_METADATA_MESSAGE_LENGTH} characters.`, + }); + } + + if (outputs.length === 0 && certificates.length === 0 && votes.length === 0) { + issues.push({ + level: "error", + code: "no-actions", + message: "Nothing to build: give at least one recipient, certificate or vote.", + }); + } + + return { + spec: { + v: 1, + walletId: input.walletId, + outputs, + certificates, + votes, + description, + metadataMessage, + }, + issues, + }; +} + +/** + * Project a spec onto the builder's draft model with deterministic ids, so + * the draft built at propose time is structurally identical to the one + * previewed (the ids are only used for issue anchoring and node identity). + */ +export function specToDraft(spec: TxSpec, id: string): TxDraft { + let draft = createDraft(id); + spec.outputs.forEach((output, index) => { + draft = addOutput(draft, { + id: `out-${index}`, + address: output.address, + assets: output.assets.map((asset) => ({ ...asset })), + }).draft; + }); + // Ledger order: a registration must precede the delegation it enables, + // and a deregistration must come last. The model may list them any way. + const orderedCertificates = [...spec.certificates].sort( + (a, b) => CERTIFICATE_ORDER[a.kind] - CERTIFICATE_ORDER[b.kind], + ); + orderedCertificates.forEach((cert, index) => { + draft = addCertificate(draft, { + id: `cert-${index}`, + kind: cert.kind, + ...(cert.poolId ? { poolId: cert.poolId } : {}), + origin: "user", + }).draft; + }); + spec.votes.forEach((vote, index) => { + draft = addVote(draft, { + id: `vote-${index}`, + govActionTxHash: vote.govActionTxHash, + govActionIndex: vote.govActionIndex, + voteKind: vote.voteKind, + ...(vote.rationale ? { rationaleEdit: vote.rationale } : {}), + }).draft; + }); + return { + ...draft, + description: spec.description, + metadata: spec.metadataMessage, + }; +} + +export function hasSpecErrors(issues: SpecIssue[]): boolean { + return issues.some((issue) => issue.level === "error"); +} + +function shortUnit(unit: string): string { + return `${unit.slice(0, 8)}…${unit.slice(-6)}`; +} diff --git a/src/lib/tx-review/summary.ts b/src/lib/tx-review/summary.ts new file mode 100644 index 00000000..1af106cf --- /dev/null +++ b/src/lib/tx-review/summary.ts @@ -0,0 +1,415 @@ +import type { AssetMetadataMap } from "@/components/common/token-flow/format"; +import { baseToDisplay } from "@/lib/tx-draft/decimal"; +import type { + AddressLabeler, + AddressPartyType, + AssetQuantity, +} from "@/types/token-flow"; +import { + meshCertificateToBadge, + meshVoteToBadge, + type PoolNameResolver, + type ProposalTitleResolver, +} from "@/utils/token-flow/certificates"; +import { splitTrailingChange } from "@/utils/token-flow/change"; +import { getFirstAndLast } from "@/utils/strings"; + +/** + * The review summary: one plain object describing a transaction the way a + * signer needs to read it. It is computed once per tool call from the + * completed builder body and feeds all three outputs — the text block, the + * `structuredContent`, and the PNG card — so a number on the picture can + * never disagree with the number in the JSON. + * + * Amounts are formatted with decimal-string math (`baseToDisplay`) and the + * word "ADA": the card's bundled font has no "₳" glyph, and a review card is + * the wrong place for rounding. + */ + +export type ReviewAmount = { + unit: string; + quantity: string; + /** e.g. "12.5 ADA", "1,000 HOSKY", "3 f4c1…9a2b" */ + display: string; +}; + +export type ReviewRecipient = { + address: string; + /** Resolved label ("Alice", "Signer 2", "This wallet"), or "" when unknown. */ + label: string; + partyType: AddressPartyType; + amounts: ReviewAmount[]; +}; + +export type ReviewAction = { + kind: "certificate" | "vote"; + /** "Vote: Yes", "Stake Delegation" */ + label: string; + /** Resolved proposal title or pool name, when known. */ + title?: string; + /** Truncated identifier, e.g. "8f3a…c21b#0" */ + detail?: string; + /** Votes only: what happens to the rationale text. */ + rationale?: + | { status: "will-publish-on-confirm"; excerpt: string } + | { status: "anchored"; url: string } + | { status: "none" }; +}; + +export type TxReviewSummary = { + kind: "preview" | "pending"; + wallet: { id: string; name: string; address: string; network: "mainnet" | "preprod" }; + threshold: { required: number; total: number; type: string }; + signatures: { + signed: { address: string; label: string }[]; + rejected: { address: string; label: string }[]; + /** Signatures still needed to reach the threshold. */ + remaining: number; + }; + description: string; + metadataMessage: string; + recipients: ReviewRecipient[]; + /** Change returning to the wallet, aggregated across change outputs. */ + change: ReviewAmount[]; + inputs: { count: number; total: ReviewAmount[]; unresolved: number }; + fee: ReviewAmount | null; + /** Net protocol deposit implied by certificates (positive = paid). */ + deposit: ReviewAmount | null; + actions: ReviewAction[]; + txHash: string; + transactionId?: string; + sizeBytes?: number; + warnings: string[]; + /** ISO timestamp of when this summary was produced. */ + generatedAt: string; +}; + +export type SummarizeOptions = { + kind: TxReviewSummary["kind"]; + wallet: { id: string; name: string; address: string; network: 0 | 1 }; + threshold: TxReviewSummary["threshold"]; + signedAddresses: string[]; + rejectedAddresses: string[]; + description: string | null | undefined; + metadataMessage?: string; + labelAddress: AddressLabeler; + assetMetadata: AssetMetadataMap; + resolvePoolName?: PoolNameResolver; + resolveProposalTitle?: ProposalTitleResolver; + /** Rationale text per "txHash#index", for votes not yet anchored. */ + pendingRationales?: Map<string, string>; + /** + * Number of outputs the caller intended as payments (the spec's output + * count), when known. Every trailing self-addressed output beyond it is + * change — so a certificate-only transaction's single output is change, + * not a "recipient". + */ + paymentCount?: number; + txHash: string; + transactionId?: string; + sizeBytes?: number; + warnings?: string[]; + now?: Date; +}; + +const STAKE_KEY_DEPOSIT = 2_000_000n; + +export function formatReviewAmount( + asset: AssetQuantity, + metadata: AssetMetadataMap, +): ReviewAmount { + if (asset.unit === "lovelace") { + return { + unit: asset.unit, + quantity: asset.quantity, + display: `${withThousands(baseToDisplay(asset.quantity, 6))} ADA`, + }; + } + const meta = metadata[asset.unit]; + const decimals = meta?.decimals ?? 0; + const name = + meta?.ticker?.trim() || + meta?.assetName?.trim() || + `${asset.unit.slice(0, 4)}…${asset.unit.slice(-4)}`; + return { + unit: asset.unit, + quantity: asset.quantity, + display: `${withThousands(baseToDisplay(asset.quantity, decimals))} ${name.slice(0, 24)}`, + }; +} + +function withThousands(display: string): string { + const [whole = "0", fraction] = display.split("."); + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return fraction ? `${grouped}.${fraction}` : grouped; +} + +function sumAssets(lists: AssetQuantity[][]): AssetQuantity[] { + const totals = new Map<string, bigint>(); + for (const list of lists) { + for (const asset of list) { + let quantity: bigint; + try { + quantity = BigInt(asset.quantity); + } catch { + continue; + } + totals.set(asset.unit, (totals.get(asset.unit) ?? 0n) + quantity); + } + } + const out: AssetQuantity[] = []; + // Lovelace first, then by unit for a stable order. + for (const [unit, quantity] of [...totals.entries()].sort(([a], [b]) => + a === "lovelace" ? -1 : b === "lovelace" ? 1 : a.localeCompare(b), + )) { + if (quantity !== 0n) out.push({ unit, quantity: quantity.toString() }); + } + return out; +} + +/** + * Summarize a MeshTxBuilderBody (the shape stored in `Transaction.txJson` + * and returned by `buildDraftTx`). Parsed defensively like the token-flow + * adapters: a malformed body degrades to fewer facts, never to a throw. + */ +export function summarizeMeshBody( + body: any, + opts: SummarizeOptions, +): TxReviewSummary { + const metadata = opts.assetMetadata; + const format = (asset: AssetQuantity) => formatReviewAmount(asset, metadata); + const labelOf = (address: string) => { + const { label } = opts.labelAddress(address); + return label || getFirstAndLast(address, 12, 6); + }; + + // Outputs: trailing run at the change address is change. When the caller + // does not say how many payments it intended, a body carrying certificates, + // votes or withdrawals is assumed to pay nobody unless an output goes + // elsewhere: its self-addressed outputs are change, not a recipient. + const outputs = (Array.isArray(body?.outputs) ? body.outputs : []).filter( + (output: any) => typeof output?.address === "string", + ); + const changeAddress = + typeof body?.changeAddress === "string" ? body.changeAddress : opts.wallet.address; + const hasActions = ["certificates", "votes", "withdrawals"].some( + (key) => Array.isArray(body?.[key]) && body[key].length > 0, + ); + const paymentCount = opts.paymentCount ?? (hasActions ? 0 : undefined); + const { payments, change } = splitTrailingChange(outputs, changeAddress, paymentCount); + + const recipients: ReviewRecipient[] = payments.map((output: any) => { + const { label, type } = opts.labelAddress(output.address); + return { + address: output.address, + label, + partyType: type, + amounts: (Array.isArray(output.amount) ? output.amount : []).map(format), + }; + }); + + const changeAmounts = sumAssets( + change.map((output: any) => (Array.isArray(output.amount) ? output.amount : [])), + ).map(format); + + // Inputs: the builder body carries amounts for inputs it selected itself. + const inputs = Array.isArray(body?.inputs) ? body.inputs : []; + let unresolved = 0; + const inputAmounts: AssetQuantity[][] = []; + for (const input of inputs) { + const amount = input?.txIn?.amount; + if (Array.isArray(amount)) inputAmounts.push(amount); + else unresolved++; + } + + const feeRaw = typeof body?.fee === "string" ? body.fee : undefined; + let fee: ReviewAmount | null = null; + try { + if (feeRaw && BigInt(feeRaw) > 0n) fee = format({ unit: "lovelace", quantity: feeRaw }); + } catch { + fee = null; + } + + // Certificates and votes as badges, with deposits implied by cert kinds. + const actions: ReviewAction[] = []; + let deposit = 0n; + for (const cert of Array.isArray(body?.certificates) ? body.certificates : []) { + const badge = meshCertificateToBadge(cert); + const certType = cert?.certType?.type; + const poolId = cert?.certType?.poolId; + const poolName = + certType === "DelegateStake" && typeof poolId === "string" + ? opts.resolvePoolName?.(poolId) + : undefined; + actions.push({ + kind: "certificate", + label: badge.label, + ...(badge.detail ? { detail: badge.detail } : {}), + ...(poolName ? { title: poolName } : {}), + }); + if (badge.deposit) deposit += BigInt(badge.deposit); + if (badge.refund) deposit -= BigInt(badge.refund); + if (certType === "RegisterStake") deposit += STAKE_KEY_DEPOSIT; + if (certType === "DeregisterStake") deposit -= STAKE_KEY_DEPOSIT; + } + for (const vote of Array.isArray(body?.votes) ? body.votes : []) { + const badge = meshVoteToBadge(vote, opts.resolveProposalTitle); + const govActionId = vote?.vote?.govActionId; + const proposalId = + govActionId?.txHash !== undefined && govActionId?.txIndex !== undefined + ? `${govActionId.txHash}#${govActionId.txIndex}` + : undefined; + const anchorUrl = vote?.vote?.votingProcedure?.anchor?.anchorUrl; + const pendingText = proposalId ? opts.pendingRationales?.get(proposalId) : undefined; + actions.push({ + kind: "vote", + label: badge.label, + ...(badge.detail ? { detail: badge.detail } : {}), + ...(badge.title ? { title: badge.title } : {}), + rationale: + typeof anchorUrl === "string" && anchorUrl + ? { status: "anchored", url: anchorUrl } + : pendingText + ? { status: "will-publish-on-confirm", excerpt: excerpt(pendingText) } + : { status: "none" }, + }); + } + + const signed = opts.signedAddresses.map((address) => ({ address, label: labelOf(address) })); + const rejected = opts.rejectedAddresses.map((address) => ({ address, label: labelOf(address) })); + + return { + kind: opts.kind, + wallet: { + id: opts.wallet.id, + name: opts.wallet.name, + address: opts.wallet.address, + network: opts.wallet.network === 1 ? "mainnet" : "preprod", + }, + threshold: opts.threshold, + signatures: { + signed, + rejected, + remaining: Math.max(0, opts.threshold.required - signed.length), + }, + description: (opts.description ?? "").trim(), + metadataMessage: (opts.metadataMessage ?? readMetadataMessage(body)).trim(), + recipients, + change: changeAmounts, + inputs: { + count: inputs.length, + total: sumAssets(inputAmounts).map(format), + unresolved, + }, + fee, + deposit: + deposit !== 0n + ? format({ unit: "lovelace", quantity: deposit.toString() }) + : null, + actions, + txHash: opts.txHash, + ...(opts.transactionId ? { transactionId: opts.transactionId } : {}), + ...(opts.sizeBytes !== undefined ? { sizeBytes: opts.sizeBytes } : {}), + warnings: opts.warnings ?? [], + generatedAt: (opts.now ?? new Date()).toISOString(), + }; +} + +/** CIP-20 message under label 674, if the body carries one. */ +function readMetadataMessage(body: any): string { + const metadata = body?.metadata; + const entry = + metadata instanceof Map + ? metadata.get("674") + : Array.isArray(metadata) + ? metadata.find((m: any) => String(m?.tag ?? m?.label) === "674")?.metadata + : metadata?.["674"]; + const msg = entry?.msg ?? entry?.metadata?.msg; + if (Array.isArray(msg)) return msg.join(""); + return typeof msg === "string" ? msg : ""; +} + +function excerpt(text: string, max = 140): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +/** + * Structured-content hint that a card image accompanies the result, for a + * model that reads `structuredContent` rather than the content list. + */ +export const REVIEW_CARD_HINT = { attached: true, mimeType: "image/png" } as const; + +/** + * The html-mode counterpart: no image block; a client with the inline card + * view draws the card from `structuredContent.summary`. + */ +export const REVIEW_CARD_INLINE_HINT = { attached: false, inline: true } as const; + +/** How the card was delivered; decides the text opener and the hint. */ +export type CardDelivery = "html" | "image"; + +/** Opening line of a review text when the PNG is attached. */ +export const CARD_ATTACHED_LINE = + "Review card attached as an image in this result — show it to the user now."; + +/** Opening line when the card is left to the client's inline card view. */ +export const CARD_INLINE_LINE = + 'Review card: clients with the inline card view draw it from this result, next to this tool call. If the user cannot see a card, relay the summary below; if they want a picture of it, call the tool again with card: "image".'; + +/** The readable text block that accompanies the card. */ +export function summaryToText( + summary: TxReviewSummary, + opts: { card?: CardDelivery } = {}, +): string { + const lines: string[] = [opts.card === "image" ? CARD_ATTACHED_LINE : CARD_INLINE_LINE]; + const state = + summary.kind === "preview" + ? "UNSIGNED PREVIEW — nothing has been saved, signed or broadcast." + : `PENDING — ${summary.signatures.signed.length} of ${summary.threshold.required} signatures collected, ${summary.signatures.remaining} still needed.`; + lines.push(`${summary.wallet.name} (${summary.wallet.network}) — ${state}`); + if (summary.description) lines.push(`Description: ${summary.description}`); + + if (summary.recipients.length > 0) { + lines.push("Recipients:"); + for (const recipient of summary.recipients) { + const who = recipient.label + ? `${recipient.label} (${getFirstAndLast(recipient.address, 12, 6)})` + : getFirstAndLast(recipient.address, 16, 8); + lines.push(` - ${who}: ${recipient.amounts.map((a) => a.display).join(" + ")}`); + } + } + if (summary.actions.length > 0) { + lines.push("Actions:"); + for (const action of summary.actions) { + const parts = [action.label]; + if (action.title) parts.push(action.title); + if (action.detail) parts.push(`(${action.detail})`); + let line = ` - ${parts.join(" — ")}`; + if (action.rationale?.status === "will-publish-on-confirm") { + line += ` · rationale will be published to IPFS on confirm: "${action.rationale.excerpt}"`; + } else if (action.rationale?.status === "anchored") { + line += ` · rationale: ${action.rationale.url}`; + } + lines.push(line); + } + } + const facts: string[] = []; + if (summary.fee) facts.push(`fee ${summary.fee.display}`); + if (summary.deposit) facts.push(`deposit ${summary.deposit.display}`); + if (summary.change.length > 0) { + facts.push(`change back to the wallet ${summary.change.map((a) => a.display).join(" + ")}`); + } + facts.push(`${summary.inputs.count} input${summary.inputs.count === 1 ? "" : "s"}`); + lines.push(`Totals: ${facts.join(", ")}.`); + if (summary.metadataMessage) lines.push(`On-chain message: "${summary.metadataMessage}"`); + lines.push(`Tx hash: ${summary.txHash}`); + if (summary.transactionId) lines.push(`Transaction id: ${summary.transactionId}`); + for (const warning of summary.warnings) lines.push(`Warning: ${warning}`); + if (summary.kind === "preview") { + lines.push( + "Review the card above. If it is correct, confirm and I will create it for the signers with transaction_propose; it will still need their signatures in the app.", + ); + } + return lines.join("\n"); +} diff --git a/src/lib/vault-proof/disclosure.ts b/src/lib/vault-proof/disclosure.ts new file mode 100644 index 00000000..a2bec43a --- /dev/null +++ b/src/lib/vault-proof/disclosure.ts @@ -0,0 +1,186 @@ +import { hashNode, hashRoot, type TrustGraph, type VaultDoc } from "./trust-graph"; + +/** + * Selective disclosure over the trust graph. + * + * To prove one document belongs to the signed vault, reveal it, reveal every + * document on the trust path up to a root, and hand over the bare hashes of the + * withheld siblings. The verifier re-hashes upward and checks the result against + * the signed root. + * + * What this proves and what it does not, stated plainly because the boundary is + * the whole product: it proves MEMBERSHIP - that these exact bytes sit under + * that exact signed root. It does not prove a PREDICATE. Revealing a document + * reveals all of it, so "the limit is at least X, without showing X" is out of + * scope here and belongs to BBS+ or SD-JWT if it is ever needed. + */ + +export type DisclosedNode = { + id: string; + salt: string; + content: string; + /** + * Hashes of this node's trusted children, in declaration order, each marked + * disclosed or withheld. A withheld child contributes its hash and nothing + * else - no title, no size, no content. + */ + children: readonly { hash: string; disclosed: boolean }[]; +}; + +export type Disclosure = { + /** The document the disclosure is about. */ + targetId: string; + /** Root the chain must reconcile to. Compare against the signed value. */ + rootHash: string; + /** Hashes of the roots not on this path. A count, and nothing more. */ + siblingRootHashes: readonly string[]; + /** Target first, then each ancestor up to and including the root. */ + path: readonly DisclosedNode[]; +}; + +export type DiscloseResult = + | { ok: true; disclosure: Disclosure } + | { ok: false; error: string }; + +function parentsOf(graph: TrustGraph, id: string): string[] { + const out: string[] = []; + for (const node of graph.nodes.values()) { + if (node.trusts.includes(id)) out.push(node.id); + } + return out; +} + +/** + * Builds a disclosure for one document under one root. + * + * A document may sit under several roots - a spending limit belongs to both the + * banking facet and the audit facet - so the caller chooses which root to + * disclose under. That choice is a privacy decision, not a technical one: + * picking the narrowest facet that contains the target reveals the least. + */ +export function disclose( + graph: TrustGraph, + docs: ReadonlyMap<string, VaultDoc>, + targetId: string, + underRoot: string, +): DiscloseResult { + if (!graph.nodes.has(targetId)) { + return { ok: false, error: `Unknown document "${targetId}"` }; + } + if (!graph.roots.includes(underRoot)) { + return { ok: false, error: `"${underRoot}" is not a root of this vault` }; + } + + // Walk up from the target to the chosen root. Breadth-first so the disclosure + // is the SHORTEST path available, which is also the one revealing fewest + // intermediate documents. + const cameFrom = new Map<string, string | null>([[targetId, null]]); + const queue = [targetId]; + let found = false; + while (queue.length > 0 && !found) { + const id = queue.shift()!; + if (id === underRoot) { + found = true; + break; + } + for (const parent of parentsOf(graph, id)) { + if (!cameFrom.has(parent)) { + cameFrom.set(parent, id); + queue.push(parent); + } + } + } + if (!found) { + return { ok: false, error: `"${targetId}" is not reachable from "${underRoot}"` }; + } + + const chain: string[] = []; + for (let id: string | undefined = underRoot; id; id = cameFrom.get(id) ?? undefined) { + chain.push(id); + if (id === targetId) break; + } + chain.reverse(); + const onPath = new Set(chain); + + const path: DisclosedNode[] = chain.map((id) => { + const node = graph.nodes.get(id)!; + const doc = docs.get(id)!; + return { + id, + salt: doc.salt, + content: doc.content, + children: node.trusts.map((childId) => ({ + hash: graph.nodes.get(childId)!.hash, + disclosed: onPath.has(childId), + })), + }; + }); + + return { + ok: true, + disclosure: { + targetId, + rootHash: graph.rootHash, + siblingRootHashes: graph.roots + .filter((id) => id !== underRoot) + .map((id) => graph.nodes.get(id)!.hash) + .sort(), + path, + }, + }; +} + +export type VerifyResult = + | { ok: true; targetId: string; rootHash: string } + | { ok: false; error: string }; + +/** + * Verifies a disclosure offline. + * + * Needs nothing but SHA-256 and the expected root - no database, no network, no + * client from us. That is the property that makes the artefact worth anything to + * a third party: they can re-implement this function in an afternoon and check + * our work without trusting us. + */ +export function verifyDisclosure( + disclosure: Disclosure, + expectedRootHash: string, +): VerifyResult { + const { path } = disclosure; + if (path.length === 0) return { ok: false, error: "Empty disclosure path" }; + if (path[0]!.id !== disclosure.targetId) { + return { ok: false, error: "Path does not begin at the stated target" }; + } + + // Recompute each disclosed node's hash from its own bytes plus its children's + // hashes, then check that the child hash the parent claims for the node we + // just verified is the hash we actually computed. That link is the proof. + let childHash: string | null = null; + for (const node of path) { + const recomputed = hashNode( + { id: node.id, salt: node.salt, content: node.content, trusts: [] }, + node.children.map((c) => c.hash), + ); + + if (childHash !== null) { + const disclosedChildren = node.children.filter((c) => c.disclosed); + if (!disclosedChildren.some((c) => c.hash === childHash)) { + return { + ok: false, + error: `"${node.id}" does not commit to the hash of the node below it`, + }; + } + } + childHash = recomputed; + } + + const rootHash = hashRoot([childHash!, ...disclosure.siblingRootHashes]); + + if (rootHash !== expectedRootHash) { + return { + ok: false, + error: "Recomputed root does not match the signed root", + }; + } + return { ok: true, targetId: disclosure.targetId, rootHash }; +} diff --git a/src/lib/vault-proof/trust-graph.ts b/src/lib/vault-proof/trust-graph.ts new file mode 100644 index 00000000..88afb886 --- /dev/null +++ b/src/lib/vault-proof/trust-graph.ts @@ -0,0 +1,236 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** + * The trust graph over a Markdown vault. + * + * A vault carries two different relations that happen to be written with the + * same brackets, and separating them is what makes the whole construction work: + * + * LOGICAL - a `[[wikilink]]` in the body. Carries a name and nothing else, so + * it creates no hash dependency and may point anywhere, including back up. + * Cycles here are normal and harmless. Not tamper-evident. + * + * TRUST - declared in frontmatter as `trusts: [Other Note]`. Carries the + * target's hash, so it creates a real dependency: a document's hash covers + * everything it trusts, transitively. These must form a DAG, and the build + * fails loudly if they do not. + * + * The repo's own vault already distinguishes frontmatter-declared edges from + * body wikilinks for its roadmap graph, so this is an existing convention made + * load-bearing rather than a new one. + */ + +/** Domain separators, so a node hash can never be confused with a root hash. */ +const NODE_DOMAIN = "mesh-vault-proof/node/v1"; +const ROOT_DOMAIN = "mesh-vault-proof/root/v1"; + +export type VaultDoc = { + /** Stable identifier - the note title, which is how trust edges name targets. */ + id: string; + /** + * Per-document nonce. Without it, a short low-entropy document's hash can be + * brute-forced from a guessed title, which would defeat withholding it. + */ + salt: string; + /** The exact bytes under trust - frontmatter and body as authored. */ + content: string; + /** Ids this document commits to. Must be acyclic across the vault. */ + trusts: readonly string[]; +}; + +export type TrustNode = { + id: string; + hash: string; + trusts: readonly string[]; + /** Depth from the deepest leaf, used only to make ordering deterministic. */ + height: number; +}; + +export type TrustGraph = { + nodes: ReadonlyMap<string, TrustNode>; + /** Ids with no parent - the vault's top level, usually the proxy hubs. */ + roots: readonly string[]; + /** + * Commitment over the whole vault. Built from the root ids' hashes ONLY - + * never their titles - so disclosing it reveals how many hubs exist and + * nothing about what they are called. + */ + rootHash: string; +}; + +export type BuildResult = + | { ok: true; graph: TrustGraph } + | { ok: false; errors: string[] }; + +/** A fresh 16-byte salt as lowercase hex. One per document, stored with it. */ +export function newSalt(): string { + return randomBytes(16).toString("hex"); +} + +function sha256(parts: readonly string[]): string { + const h = createHash("sha256"); + // Length-prefix every part. Without it, ("ab","c") and ("a","bc") hash + // identically and a document could be substituted for a pair of others. + for (const part of parts) { + h.update(String(Buffer.byteLength(part, "utf8"))); + h.update(" "); + h.update(part, "utf8"); + } + return h.digest("hex"); +} + +/** + * The commitment over a set of top-level hashes. + * + * Exported because the verifier must recompute exactly this, and a second + * implementation of it in the verifier is a bug waiting to happen the first time + * either side changes. + */ +export function hashRoot(topLevelHashes: readonly string[]): string { + return sha256([ROOT_DOMAIN, [...topLevelHashes].sort().join("")]); +} + +/** + * A document's hash covers its id, its salt, its exact bytes, and the hashes of + * everything it trusts. Children are sorted by hash rather than by declaration + * order so that reordering the frontmatter list does not change the commitment. + * + * The id is part of the hash because this library models identity BY id: trust + * edges name their targets by id, and both Disclosure and VerifyResult hand ids + * back to a relying party. Leaving it out let an honest disclosure be + * relabelled — same bytes, different `targetId` — and still verify, and let two + * vaults whose documents differ only in name share one root hash. + * + * This does not weaken the blinded root. Blinding means the root does not + * REVEAL titles: it is a hash over salted node hashes, so there is nothing to + * read out of it. It never meant the root should be invariant to titles, which + * is precisely what made relabelling possible. + */ +export function hashNode( + doc: VaultDoc, + childHashes: readonly string[], +): string { + return sha256([ + NODE_DOMAIN, + doc.id, + doc.salt, + doc.content, + [...childHashes].sort().join(""), + ]); +} + +/** + * Builds the graph, or refuses. + * + * Refusal cases are deliberate and loud: a cycle among trust edges (named with + * the offending path), a trust edge to a document that does not exist, and + * duplicate ids. A partially-built graph is never returned - a commitment over + * a vault the builder did not fully understand is worse than no commitment. + */ +export function buildTrustGraph(docs: readonly VaultDoc[]): BuildResult { + const errors: string[] = []; + const byId = new Map<string, VaultDoc>(); + + for (const doc of docs) { + if (byId.has(doc.id)) { + errors.push(`Duplicate document id "${doc.id}"`); + continue; + } + if (!doc.salt) errors.push(`Document "${doc.id}" has no salt`); + byId.set(doc.id, doc); + } + + for (const doc of byId.values()) { + for (const target of doc.trusts) { + if (!byId.has(target)) { + errors.push(`"${doc.id}" trusts "${target}", which does not exist`); + } + } + } + if (errors.length > 0) return { ok: false, errors }; + + // Depth-first with an explicit path, so a cycle can be reported as the actual + // chain a human has to go and break rather than as "a cycle was detected". + const state = new Map<string, "visiting" | "done" | "failed">(); + const nodes = new Map<string, TrustNode>(); + const path: string[] = []; + + const visit = (id: string): number | null => { + const seen = state.get(id); + if (seen === "done") return nodes.get(id)!.height; + // Already known-bad. Stay quiet: the cycle it belongs to has been reported + // once, and re-reporting it from every other branch that reaches it buries + // the real chain under invented ones. + if (seen === "failed") return null; + if (seen === "visiting") { + const from = path.indexOf(id); + errors.push( + `Trust cycle: ${[...path.slice(from), id].join(" -> ")}. ` + + `Trust edges must form a DAG - make one of these a plain [[wikilink]] instead.`, + ); + return null; + } + + state.set(id, "visiting"); + path.push(id); + + const doc = byId.get(id)!; + const childHashes: string[] = []; + let height = 0; + for (const target of doc.trusts) { + const childHeight = visit(target); + if (childHeight === null) { + // "failed", not "done": no node was built for this id, so a later + // branch reaching it must not look one up. And not left as "visiting" + // either, which would make every later branch report it as a fresh + // cycle. + path.pop(); + state.set(id, "failed"); + return null; + } + childHashes.push(nodes.get(target)!.hash); + height = Math.max(height, childHeight + 1); + } + + path.pop(); + state.set(id, "done"); + nodes.set(id, { + id, + hash: hashNode(doc, childHashes), + trusts: doc.trusts, + height, + }); + return height; + }; + + for (const id of byId.keys()) { + if (state.get(id) !== "done") visit(id); + } + if (errors.length > 0) return { ok: false, errors }; + + const trusted = new Set<string>(); + for (const doc of byId.values()) for (const t of doc.trusts) trusted.add(t); + const roots = [...byId.keys()].filter((id) => !trusted.has(id)).sort(); + + // Blinded: the root commits to its children's HASHES, never their ids. Without + // this the disclosure leak simply moves up a level, and revealing the root to + // prove one facet would name every other facet. + const rootHash = hashRoot(roots.map((id) => nodes.get(id)!.hash)); + + return { ok: true, graph: { nodes, roots, rootHash } }; +} + +/** + * Parses `trusts:` out of already-split frontmatter. Accepts a YAML flow list + * or a single value, matching the subset the vault loader already supports. + */ +export function parseTrusts( + frontmatter: Record<string, string | string[]>, +): string[] { + const raw = frontmatter.trusts; + if (!raw) return []; + const list = Array.isArray(raw) ? raw : [raw]; + return list + .map((v) => v.replace(/^\[\[|\]\]$/g, "").trim()) + .filter((v) => v.length > 0); +} diff --git a/src/lib/vault-trust-types.ts b/src/lib/vault-trust-types.ts new file mode 100644 index 00000000..3f14dd0e --- /dev/null +++ b/src/lib/vault-trust-types.ts @@ -0,0 +1,83 @@ +/** + * Serialisable shape of the vault trust view, shared by the build-time loader + * and the browser. + * + * Kept apart from `@/lib/vault-trust` for the same reason `vault-types` is kept + * apart from `vault`: that module reads the filesystem, so importing any *value* + * from it into a client component would pull `fs` into the bundle and fail the + * build. + */ + +export type VaultTrustNote = { + id: string; + kind: "area" | "feature"; + /** The hub this note hangs under, for features. */ + area: string | null; + state: string | null; + owner: string | null; + /** Body with frontmatter stripped. */ + body: string; + /** `[[wikilinks]]` — the LOGICAL relation. May cycle; not part of any hash. */ + links: string[]; + /** This note's position in the trust commitment. */ + hash: string; +}; + +export type VaultTrustView = { + /** Commitment over the hub hashes only — never their titles. */ + rootHash: string; + /** Area notes, which act as proxy hubs. */ + hubs: string[]; + notes: VaultTrustNote[]; + /** Hub -> feature. The TRUST relation: downward, acyclic, hash-bearing. */ + trustEdges: { from: string; to: string }[]; + /** Features whose `area:` matches no hub — outside the commitment's spine. */ + orphans: string[]; +}; + +/** + * What a disclosure of `noteId` under its hub would reveal, and what it would + * withhold. Derived in the browser from the view above so the UI can show the + * consequence of drilling down, not just the content. + */ +export function disclosureFor( + view: VaultTrustView, + noteId: string, +): { path: string[]; withheld: string[] } | null { + const note = view.notes.find((n) => n.id === noteId); + if (!note) return null; + + // Everything the root commits to directly: the hubs, plus any feature whose + // `area:` matched no hub and therefore hangs off the root on its own. + const otherRoots = (keep: string) => + [...view.hubs, ...view.orphans].filter((id) => id !== keep); + + if (note.kind === "area") { + // Disclosing a hub reveals the hub document and the HASHES of the documents + // under it. Those children are withheld too — leaving them out made the + // first thing every visitor sees under-report what a hub disclosure costs. + const children = view.trustEdges + .filter((e) => e.from === note.id) + .map((e) => e.to); + return { path: [note.id], withheld: [...children, ...otherRoots(note.id)] }; + } + + const edge = view.trustEdges.find((e) => e.to === noteId); + if (!edge) { + // An orphan is its own root: nothing sits between it and the commitment, + // but every other root is still sealed. Reporting "nothing withheld" here + // claimed a disclosure was free when it is not. + return { path: [noteId], withheld: otherRoots(noteId) }; + } + + // Siblings under the same hub stay sealed: the disclosure carries their + // hashes and nothing else. + const siblings = view.trustEdges + .filter((e) => e.from === edge.from && e.to !== noteId) + .map((e) => e.to); + + return { + path: [noteId, edge.from], + withheld: [...siblings, ...otherRoots(edge.from)], + }; +} diff --git a/src/lib/vault-trust.ts b/src/lib/vault-trust.ts new file mode 100644 index 00000000..83a2ad0a --- /dev/null +++ b/src/lib/vault-trust.ts @@ -0,0 +1,162 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import { buildTrustGraph, type VaultDoc } from "@/lib/vault-proof/trust-graph"; +import { extractWikilinks, splitFrontmatter } from "@/lib/vault"; +import type { VaultTrustView } from "@/lib/vault-trust-types"; + +/** + * Derives the trust graph over this repo's own feature vault, at build time. + * + * Reads the filesystem, so it must never be imported from a client component — + * same rule as `@/lib/vault`. The browser gets the serialised + * {@link VaultTrustView} through getStaticProps instead. + * + * WHERE THE TRUST EDGES COME FROM + * + * The vault already carries the two relations the construction needs, without + * anyone having to author `trusts:` frontmatter: + * + * - `area:` in a feature's frontmatter is a DOWNWARD edge from a workstream to + * the work in it. Areas never point back at features, so these are acyclic by + * construction — which makes each area note a proxy hub, exactly the shape + * the shielded sign-off design calls for. + * - `[[wikilinks]]` in the body are the LOGICAL relation. They cycle freely + * between features and are deliberately excluded from the commitment. + * + * So the trust spine is: blinded root -> area hubs -> features. Disclosing the + * Governance hub proves a governance feature belongs to the vault without naming + * the other workstreams. + */ + +const VAULT_DIR = path.join(process.cwd(), "vault"); + +/** + * Salts are derived rather than random. + * + * A production vault stores a per-document nonce so a short note cannot be + * brute-forced from a guessed title. This view has no store, and a random salt + * would change every hash on every build, so it derives one deterministically. + * That is fine HERE — the vault is public, there is nothing to withhold — and it + * is not fine anywhere real. Stated because the difference is easy to miss. + */ +function derivedSalt(id: string): string { + return createHash("sha256") + .update(`mesh-vault-view/demo-salt/v1:${id}`, "utf8") + .digest("hex") + .slice(0, 32); +} + +function readDir(dir: string): { title: string; raw: string }[] { + const full = path.join(VAULT_DIR, dir); + if (!fs.existsSync(full)) return []; + return fs + .readdirSync(full) + .filter((name) => name.endsWith(".md") && !name.startsWith("_")) + .map((name) => ({ + title: name.replace(/\.md$/, ""), + raw: fs.readFileSync(path.join(full, name), "utf8"), + })); +} + +let cached: VaultTrustView | null = null; + +/** + * Memoised accessor, matching `loadVaultGraph` in @/lib/vault. + * + * `/vault` is public, sitemap-indexed and server-rendered per request, so + * without this every crawler hit re-reads ~67 files and re-hashes the whole + * vault. The vault only changes on deploy, so one build per process is enough. + * Skipped outside production so an edit shows up without a restart. + */ +export function loadVaultTrustView(): VaultTrustView { + if (process.env.NODE_ENV !== "production") return buildVaultTrustView(); + cached ??= buildVaultTrustView(); + return cached; +} + +export function buildVaultTrustView(): VaultTrustView { + const areas = readDir("areas"); + const features = readDir("features"); + + const childrenOfArea = new Map<string, string[]>( + areas.map((a) => [a.title, []]), + ); + const orphans: string[] = []; + + const docs: VaultDoc[] = []; + const notes: VaultTrustView["notes"] = []; + + for (const f of features) { + const { frontmatter, body } = splitFrontmatter(f.raw); + const area = typeof frontmatter.area === "string" ? frontmatter.area : ""; + if (area && childrenOfArea.has(area)) + childrenOfArea.get(area)!.push(f.title); + else orphans.push(f.title); + + docs.push({ + id: f.title, + salt: derivedSalt(f.title), + content: f.raw, + trusts: [], + }); + notes.push({ + id: f.title, + kind: "feature", + area: area || null, + state: typeof frontmatter.state === "string" ? frontmatter.state : null, + owner: typeof frontmatter.owner === "string" ? frontmatter.owner : null, + body, + links: extractWikilinks(body), + hash: "", + }); + } + + for (const a of areas) { + const { body } = splitFrontmatter(a.raw); + docs.push({ + id: a.title, + salt: derivedSalt(a.title), + content: a.raw, + trusts: childrenOfArea.get(a.title) ?? [], + }); + notes.push({ + id: a.title, + kind: "area", + area: null, + state: null, + owner: null, + body, + links: extractWikilinks(body), + hash: "", + }); + } + + const built = buildTrustGraph(docs); + if (!built.ok) { + // Loud rather than degraded: a vault whose trust edges do not form a DAG has + // no commitment, and rendering a graph that claims otherwise would be worse + // than failing the build. + throw new Error(`vault trust graph: ${built.errors.join("; ")}`); + } + + for (const note of notes) { + note.hash = built.graph.nodes.get(note.id)?.hash ?? ""; + } + + return { + rootHash: built.graph.rootHash, + // The AREA notes, not `graph.roots`. A feature whose `area:` matches no hub + // also has no parent, so it lands in roots too — listing that as a hub + // showed it twice (once as a hub, once under "outside the spine") and + // inflated the hub count. The root hash still commits to every root, + // orphans included; this is only what the vault presents as a hub. + hubs: areas.map((a) => a.title).sort((a, b) => a.localeCompare(b)), + notes: notes.sort((a, b) => a.id.localeCompare(b.id)), + trustEdges: [...childrenOfArea.entries()].flatMap(([hub, kids]) => + kids.map((child) => ({ from: hub, to: child })), + ), + orphans: orphans.sort(), + }; +} diff --git a/src/lib/zustand/tx-builder.ts b/src/lib/zustand/tx-builder.ts index 820f6d77..9e24da78 100644 --- a/src/lib/zustand/tx-builder.ts +++ b/src/lib/zustand/tx-builder.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; import type { BuilderSelection, DraftOutput, + DraftSource, DraftUtxoSelection, DraftVote, DraftVoteKind, @@ -21,6 +22,7 @@ import { setDescription, setMetadata, setOutputAsset, + setSource, setUtxoSelection, setVoteRationale, updateCertificatePool, @@ -65,6 +67,11 @@ interface TxBuilderState { removeOutput: (outputId: string) => void; setOutputAsset: (outputId: string, unit: string, quantity: string) => void; setUtxoSelection: (selection: DraftUtxoSelection) => void; + /** + * Changes the funding source (see `setSource` in mutations). Ignored while + * editing a pending multisig transaction — its source is the multisig. + */ + setSource: (source: DraftSource) => void; setDescription: (description: string) => void; setMetadata: (metadata: string) => void; updateVoteKind: (voteId: string, voteKind: DraftVoteKind) => void; @@ -159,6 +166,11 @@ export const useTxBuilderStore = create<TxBuilderState>()((set, get) => ({ }, setUtxoSelection: (selection) => set({ draft: setUtxoSelection(get().draft, selection) }), + setSource: (source) => { + const state = get(); + if (state.editingTxId) return; + set({ draft: setSource(state.draft, source) }); + }, setDescription: (description) => set({ draft: setDescription(get().draft, description) }), setMetadata: (metadata) => diff --git a/src/pages/README.md b/src/pages/README.md index 849a066d..33e98c98 100644 --- a/src/pages/README.md +++ b/src/pages/README.md @@ -228,6 +228,14 @@ A comprehensive Next.js pages directory containing all application routes, API e - Metadata filtering and validation - CORS support for cross-origin requests +#### `api/v1/resolveRegistrationScript.ts` +- **Purpose**: Resolve the native script(s) of a CIP-0146 registration transaction +- **Features**: Script-credential address discovery from tx UTxOs, timelock JSON per script hash + +#### `api/v1/resolveScript.ts` +- **Purpose**: Resolve a native script by hash (policy) or multisig address to its signer key hashes +- **Features**: Backs lookup-by-policy on the Discover tab and the MCP wallet lookup tool + #### `api/v1/addTransaction.ts` - **Purpose**: Add new transactions to the system - **Features**: Transaction creation and validation diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 27783b8c..6bc0cd0f 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -71,7 +71,8 @@ const MyApp: AppType<{ session: Session | null }> = ({ description: pageSeo?.description ?? routeSeo.description, keywords: pageSeo?.keywords ?? routeSeo.keywords, noindex: pageSeo?.noindex ?? routeSeo.noindex, - image: pageSeo?.image, + image: pageSeo?.image ?? routeSeo.image, + imageAlt: pageSeo?.imageAlt ?? routeSeo.imageAlt, type: pageSeo?.type, jsonLd: pageSeo?.jsonLd, }; @@ -92,6 +93,7 @@ const MyApp: AppType<{ session: Session | null }> = ({ path={canonicalPath} noindex={seo.noindex} image={seo.image} + imageAlt={seo.imageAlt} type={seo.type} extraJsonLd={seo.jsonLd} /> diff --git a/src/pages/api/mcp/README.md b/src/pages/api/mcp/README.md index 168ff43a..3b799889 100644 --- a/src/pages/api/mcp/README.md +++ b/src/pages/api/mcp/README.md @@ -2,7 +2,8 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server, so an LLM agent (Claude Code, claude.ai, or any MCP client) can read multisig wallets, UTxOs, pending -transactions, proxies and governance proposals, and draft ballot rationales. +transactions, proxies, governance proposals and sign-off documents, draft ballot +rationales, and draft unsigned transactions for the wallet's signers to review. Built on `@modelcontextprotocol/server` v2, which implements the 2026-07-28 spec and also serves 2025-era clients through its legacy path — both from the same tool registry. @@ -17,7 +18,7 @@ factory on **every request**. Nothing may be hoisted to module scope: a reused t throws on the *second* request while the first still looks healthy — which is why `src/__tests__/mcpRoute.test.ts` asserts two sequential POSTs explicitly. -## Tool surface — read-only plus ballot drafts +## Tool surface | Tool | Scope | Wraps | |---|---|---| @@ -27,26 +28,229 @@ throws on the *second* request while the first still looks healthy — which is | `multisig_list_free_utxos` | `wallets:read` | `freeUtxos.ts` | | `multisig_list_proxies` | `wallets:read` | `proxies.ts` | | `multisig_proxy_drep_info` | `wallets:read` | `proxyDRepInfo.ts` | -| `multisig_lookup_wallet` | `wallets:read` | `lookupMultisigWallet.ts` | +| `multisig_lookup_wallet` | `wallets:read` | `lookupMultisigWallet.ts` (plus `resolveScript.ts` when called with `scriptHash` / `address`) | | `governance_list_active_proposals` | `governance:read` | `governanceActiveProposals.ts` | +| `governance_list_ballots` | `governance:read` | `botBallots.ts` | +| `governance_vote_history` | `governance:read` | `drepInfo.ts` → `governance/drepVotes.ts` | +| `governance_open_proposals` | `governance:read` | `governanceActiveProposals.ts` × vote history | | `ballot_upsert` | `ballots:write` | `botBallotsUpsert.ts` | +| `ballot_publish_rationale` | `ballots:write` | `ballotRationaleAnchor.ts` | +| `document_list` | `documents:read` | `documents.ts` | +| `document_get` | `documents:read` | `documentDetail.ts` | +| `transaction_preview` | `transactions:write` | `src/lib/tx-review/preview.ts` (uses `freeUtxos.ts` for inputs) | +| `transaction_propose` | `transactions:write` | `src/lib/tx-review/propose.ts` (uses `freeUtxos.ts` for inputs) | +| `multisig_review_pending_transaction` | `wallets:read` | `pendingTransactions.ts` → `src/lib/tx-review/review.ts` | +| `task_list` | `wallets:read` | `tasks.ts` | +| `task_upsert` | `tasks:write` | `taskUpsert.ts` | +| `task_prepare_payout` | `transactions:write` | `src/lib/task-payout/preview.ts` → `src/lib/tx-review/preview.ts` (`runSpecPreview`) | **Nothing here can sign, spend or broadcast.** That is a deliberate boundary. Tool results carry user-authored strings — wallet names, transaction descriptions, ballot rationales — so anything an attacker can write into a wallet the caller can read is text that reaches the model. Pairing that with a signing tool turns prompt injection into a funds-movement -path, so a write surface needs its own design pass, not a new row in -`src/lib/mcp/tools.ts`. `src/__tests__/mcpTools.test.ts` fails if one is added quietly. +path. `src/__tests__/mcpTools.test.ts` pins the exact set of write tools and fails if one +is added quietly. `tools/list` is filtered by the caller's scopes, so a client never sees a tool that would come back 403. +## Transaction drafts: preview → confirm + +The two `transactions:write` tools let an agent prepare a transaction that humans then +sign in the app. The design is built around the human seeing exactly what gets created: + +1. **`transaction_preview`** takes recipients (ADA and native assets in display units), + staking certificates and DRep votes, builds the unsigned transaction against the + wallet's spendable UTxOs, and returns three things: a readable summary, the **review + card** (recipients with resolved labels, amounts, fee, change, actions, and the + statement that nothing is signed), and a **draft token**. The card is delivered + according to the tool's `card` option: `"html"` (default) leaves it to the client's + inline card view, which draws it from `structuredContent.summary`; `"image"` also + attaches it as a PNG `image` content block, for a user who wants a picture or a + client that shows images but not inline views. Nothing is stored. The tool is annotated read-only. Staking certificates are checked + against the account's on-chain registration state (the same Blockfrost probe the + builder canvas uses): a `DelegateStake` for an unregistered credential gets a + `RegisterStake` added ahead of it (2 ADA deposit, reported as a warning and shown on + the card), and the token is minted from that completed spec. A `RegisterStake` for an + already-registered credential is a 400 — including at propose time, if the account was + registered after the preview — rather than a transaction the node would reject only + after the signatures are in. Votes are checked the same way against the DRep's + on-chain registration (`/governance/dreps/{id}`): a wallet that is not registered as a + DRep cannot vote, so the draft is a 400 whose message says so and tells the user to + register in the app first. Both probes are repeated at propose time. +2. The agent shows the card and asks the user to confirm. +3. **`transaction_propose`** accepts *only* the draft token. It rebuilds from the spec + inside the token, creates the pending transaction with `signedAddresses: []`, notifies + every signer (the proposer included — they have not signed), and returns the final + card, delivered the way the preview was: the token records whether the preview + attached the PNG (`card: "image"` claim), so propose needs no option of its own. + Because propose takes no recipients or amounts, a model cannot change the + transaction between the review and the creation; it would have to preview again, + which shows a new card. + +The draft token is a JWT (`typ: "mcp_draft"`, 15 minutes) signed with `JWT_SECRET`, +bound to the acting address, the OAuth client, the wallet, the normalized spec in base +units, the previewed tx hash and the card mode (`src/lib/tx-review/draft-token.ts`). It verifies as +neither an access token nor a v1 bearer. Replaying a token returns the transaction the +first call created (the draft id is stored under a top-level `mcp` key in `txJson`), so a +retried confirmation never spends the same UTxOs twice. + +Properties worth not regressing: + +- Every transaction created through MCP starts with **zero signatures**. The persistence + helper would broadcast a single-signer transaction only when the initial signer set + already meets the threshold, which an empty set never does — and propose refuses a + broadcast result anyway. +- Change always returns to the wallet itself (enforced in `src/lib/tx-draft`). +- Vote rationales are pinned to IPFS **only on propose** — public and permanent, so only + after the human said yes and only for a draft that still validates. This is why the + final tx hash can differ from the previewed one; the result says so + (`txHashChanged`, `txHashChangeReasons`). +- Unknown token decimals are never guessed: a fractional quantity for a token without + registry metadata is rejected; a whole number is treated as raw units with a warning. +- The scope is human-only. `mcpScopesForBot` never projects a bot key onto + `transactions:write`; a bot with `multisig:sign` already has `POST /api/v1/addTransaction`. +- Every call is audited (`mcp.tool.called`), and a propose additionally writes a + `transaction.create` row with `via: "mcp"`. + +`multisig_review_pending_transaction` renders the same card for any pending transaction, +however it was created — the in-chat review for transactions proposed from the app. + +### Task payouts + +The project task board (`/wallets/[wallet]/tasks`) stores payment recipients per task, in +base units. Recipients can be configured while work progresses, but a task must reach +`Done` before payout; that rule is checked both when previewing and when confirming. +An agent never has to derive that itself: every task `task_list` returns carries +`payout.payable` (true when it can be paid right now) and `payout.blocker` +(`not_done`, `no_recipients`, `pending`, `paid`, or `null`), the tool takes +`payable: true` to list only those, and the body's `payableCount` is the wallet-wide +total. `task_prepare_payout` takes the ids to pay, or none at all to pay every payable +task (`src/lib/task-payout/load.ts`, `loadAllPayableTasks`; more than 20 payable tasks +is refused with `TOO_MANY_TASKS` and asks for explicit ids). Either way the result's +`tasks` list names exactly what the draft covers, so the human confirms a known set. +`task_prepare_payout` turns those Done tasks into a draft through the same +pipeline: the recipient rows become a canonical spec (`src/lib/task-payout/spec.ts`, +outputs merged per address), `runSpecPreview` builds and summarizes it, and the draft +token is minted with an extra `origin` claim — the task ids and a sha256 over their +`(taskId, address, unit, quantity)` rows. There is no separate confirm tool: +`transaction_propose` accepts the token, and `withTaskPayoutHooks` +(`src/lib/task-payout/hooks.ts`) does the task-specific work inside the database +transaction that inserts the pending row — re-reads the tasks, refuses with a 409 if any +row changed since the preview (`TASK_CHANGED`) or a payout appeared for one of them +(`TASK_NOT_PAYABLE`), then writes one `TaskPayout` link per task. Row and links exist +together or not at all. The txJson carries a top-level `tasks` namespace next to `mcp`. +Once linked, the task is read-only while the payout is pending and remains read-only +after payment; cancelling the pending transaction unlocks an unpaid task. + +The web app's payout dialog is the same two calls (`task.preparePayout`, +`task.confirmPayout` in `src/server/api/routers/tasks.ts`) with the caller derived from +the session and `clientName: "app"`, so a token previewed in the app is not redeemable +through an MCP connection or vice versa. Links flip to `Paid` when the transaction +reaches state 1 and to `Cancelled` when a pending one is deleted or replaced +(`src/lib/task-payout/sync.ts`); the board derives the badge from those rows. + +### Getting the card in front of the user + +By default the card is drawn by the inline card view (next section) and the model has +nothing to show. But whether a client renders that view — or a tool's image block — is +the client's decision, not the server's: claude.ai and Claude Desktop show tool images +only inside the collapsed tool-call panel ([claude-ai-mcp #238](https://github.com/anthropics/claude-ai-mcp/issues/238), +[claude-code #53256](https://github.com/anthropics/claude-code/issues/53256)), and the +model decides whether to surface anything in its reply. The server therefore tells the +model, three ways, what the deliverable is and how to get the picture: + +- **Server `instructions`** (`MCP_SERVER_INSTRUCTIONS` in `src/lib/mcp/server.ts`), + returned at initialize and placed in the model's context by the client: the inline + view draws the card; if the user cannot see one, relay the summary in the same turn; + pass `card: "image"` when the user wants a picture, then present the image; ask for + confirmation before proposing. Kept short — it rides on every conversation. +- **Tool descriptions** repeat the contract per tool, including the `card` option. +- **The result itself**: the text block opens with a line that names the delivery + (`CARD_INLINE_LINE`, or `CARD_ATTACHED_LINE` — "Review card attached as an image in + this result — show it to the user now." — with the PNG), and + `structuredContent.reviewCard` says whether an image is attached. + +Content blocks deliberately carry **no** spec `annotations` (`audience`, `priority`): the +Claude app rejected a result whose image block had them ("Unexpected response type"), +and `src/__tests__/mcpRoute.test.ts` pins the minimal wire shape. + +### The inline card view (MCP App) + +Even when the model surfaces the image, the Claude app shows a tool's image block only +inside the collapsed tool-call panel. The protocol's answer is an +[MCP App](https://modelcontextprotocol.io/extensions/apps/build): the three review tools +carry `_meta.ui.resourceUri = "ui://mesh-multisig/review-card"`, and the server exposes +that resource as `text/html;profile=mcp-app`. A supporting host (claude.ai, Claude +Desktop) reads it, renders it inline in a sandboxed iframe where the tool call appears, +and hands it the tool result. + +The view (`src/lib/mcp/apps/review-card.ts`) is one self-contained HTML document that +hand-rolls the ext-apps bridge (JSON-RPC over `postMessage`): `ui/initialize` → +`ui/notifications/initialized`, then on `ui/notifications/tool-result` it shows the +card. In the default `card: "html"` mode there is no image block and the view **draws +the card itself** from `structuredContent.summary` (`cardMarkup()`: the same sections +and strings as the PNG's `card.ts`), so it reflows to whatever width the host gives it +and takes the host's theme — no raster to fit, hence none of the sizing trouble below. +With `card: "image"` it shows the PNG from the image block instead. For a preview it +offers a **Confirm** button: the click sends `tools/call transaction_propose +{ draftToken }` through the host — so the human confirms on the card itself, under the +same token binding as a typed confirmation — then shows the final card, an "Open in the +app to sign" link (`ui/open-link`), and tells the model what happened via +`ui/update-model-context`. It declares no network or external-asset origins and runs +under the extension's default CSP; a PNG travels as a `data:` URL. + +**Sizing and theming** follow the spec's host-context contract and +[Anthropic's design guidelines for inline apps](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines) +(auto-fit height, no nested or horizontal scrolling, generous padding, safe-area insets, +host style tokens). claude.ai does not act on `ui/notifications/size-changed`: it reads +the iframe document's height itself and can snapshot it early +([claude-ai-mcp #69](https://github.com/anthropics/claude-ai-mcp/issues/69)). So the view +measures the way the ext-apps SDK does (`max-content` bounding height, `innerWidth`, +never `scrollWidth`), pins `documentElement.style.height` synchronously after every DOM +mutation (so a DOM-reading host never sees the previous state's height, and never a +`0px` from a hidden frame) and again on the next frame. Fixed `containerDimensions` +(`height`/`width`) are honoured as `100vh`/`100vw`. An advertised `maxHeight` is never +applied to the document (inline cards are meant to auto-fit, and clamping is what made +the frame short and scrolling in claude.ai). In image mode the view additionally +reserves the PNG's box from its IHDR dimensions before the image decodes, and scales the +PNG down via a `--img-max-w` cap derived from its aspect ratio so the auto-fit card stays +within the host's cap without a scrollbar (ChatGPT sizes the inline iframe to its +`maxHeight`). It is +borderless (`prefersBorder: false`, Claude web's default) and transparent, pads itself +16px plus `safeAreaInsets` on every side, and styles with Claude's `--color-*`, +`--border-radius-*` and `--font-text-*` tokens over a built-in light/dark fallback. Host +fonts are intentionally not loaded — Anthropic Sans needs `assets.claude.ai` in the CSP, +which stays at zero origins — so the system font stack remains; the amber Confirm button +is brand colour. + +Things learned from Anthropic's client (tracked in +[claude-ai-mcp #61](https://github.com/anthropics/claude-ai-mcp/issues/61) and +[#69](https://github.com/anthropics/claude-ai-mcp/issues/69)): the frame +stays hidden until the handshake completes; claude.ai does not advertise the UI extension +at initialize, so the resource is never gated on it; and the host may read the resource +from a different session than the tool call, which this stateless server handles +trivially. Text-only clients (Claude Code included) see the text block, and the image +block when `card: "image"` was asked for. `src/__tests__/mcpReviewCardApp.test.ts` pins the bridge method names and the +sandbox constraints; `mcpRoute.test.ts` pins `_meta` and `resources/read`. + +Expanding the tool call in the client always shows the card, whatever the model did. +Reconnect after changing the instructions — clients read them once, at initialize. + +The PNG (`card: "image"` only) is rasterized with the `ImageResponse` that ships inside +Next (`next/og`) in the Node runtime: no new dependency, no system fonts. `next.config.js` adds its WASM and +font files to the `/api/mcp` output file trace, because they are loaded through +`import.meta.url` and tracing cannot see them. + ## How tools reach the API -Tools invoke the existing `/api/v1/*` handlers **in-process** through a synthetic +Most tools invoke the existing `/api/v1/*` handlers **in-process** through a synthetic request/response pair (`src/lib/mcp/invokeV1.ts`) — not a loopback `fetch`, and not reimplemented logic. Every authorization check, validation branch and error code stays -defined exactly once, in the v1 handler. +defined exactly once, in the v1 handler. The transaction review tools are the exception: +no REST route builds an arbitrary transaction for a caller, so they live in +`src/lib/tx-review/` and reuse the canvas builder's `src/lib/tx-draft/` pipeline +server-side, taking their inputs from the `freeUtxos` handler. Handler imports in `src/lib/mcp/tools.ts` are **lazy on purpose**. Several v1 handlers import `@meshsdk/core` / `@meshsdk/core-csl` at module top level, which pulls the whisky @@ -57,17 +261,18 @@ this route's cold path for every request, including a bare `tools/list`. Defined in `src/lib/mcp/scopes.ts` — the MCP spec deliberately defines no vocabulary. -- `wallets:read` — wallets, pending transactions, spendable UTxOs, proxies -- `governance:read` — active governance proposals -- `ballots:write` — ballot drafts, including rationale text (no on-chain vote) +- `wallets:read` — wallets, pending transactions (and their review cards), spendable UTxOs, proxies +- `governance:read` — governance proposals, ballots, DRep vote history +- `ballots:write` — ballot drafts and rationale publication to IPFS (no on-chain vote) +- `documents:read` — sign-off documents (no approval or signature) +- `transactions:write` — unsigned transaction drafts via preview → confirm (no signing, no broadcast); includes task payout drafts +- `tasks:write` — create, edit and move project tasks and their payment recipients (records only; reading the board is `wallets:read`) -**What a client gets by default.** The `WWW-Authenticate` challenge advertises -`wallets:read governance:read`. That is what clients actually request — Claude -Code uses the challenge's `scope` rather than `scopes_supported` from the -metadata document — so a scope omitted there is unreachable in practice, however -well documented. `ballots:write` is deliberately excluded: it is the only scope -that writes anything, so a client must ask for it explicitly (in Claude Code, -pin `oauth.scopes` for the server). Re-authorize after changing it. +**What a client gets by default.** The `WWW-Authenticate` challenge advertises **every** +scope. Clients request exactly the challenge's `scope` — Claude Code uses it rather than +`scopes_supported` from the metadata document — so a scope omitted there is unreachable +in practice, however well documented. Withholding a scope is the user's decision on the +consent screen, where each one is a separate checkbox. These are intentionally *not* the `BOT_SCOPES` strings from `src/lib/auth/botKey.ts`. When a bot key authenticates, `mcpScopesForBot` projects one onto the other so a bot never @@ -87,12 +292,13 @@ gains MCP reach it lacks over REST — notably, `multisig:sign` maps to nothing. The two cannot be confused for one another despite sharing `JWT_SECRET`: OAuth tokens carry the subject in `sub` (so `verifyJwt` rejects them outright) and are typed `mcp_at` (so a v1 token fails OAuth verification). `src/__tests__/oauthTokens.test.ts` -asserts both directions. +asserts both directions; `src/__tests__/txReviewDraftToken.test.ts` adds the draft token +as a third, equally non-interchangeable family. An unauthenticated request answers `401` with the RFC 9728 challenge: ``` -WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource/api/mcp", scope="wallets:read" +WWW-Authenticate: Bearer resource_metadata="https://<host>/.well-known/oauth-protected-resource/api/mcp", scope="wallets:read governance:read ballots:write documents:read transactions:write tasks:write" ``` That header is what lets an MCP client discover the authorization server and begin an @@ -121,12 +327,24 @@ claude mcp add --transport http mesh-multisig https://multisig.meshjs.dev/api/mc |---|---| | `src/pages/api/mcp/index.ts` | The route: guards, auth, bridge, handler | | `src/lib/mcp/bridge.ts` | Node `req`/`res` ↔ web `Request`/`Response` | -| `src/lib/mcp/server.ts` | Per-request `McpServer` factory, result mapping | +| `src/lib/mcp/server.ts` | Per-request `McpServer` factory, result mapping (text + image blocks), audit | | `src/lib/mcp/tools.ts` | Tool registry — the source of truth for the surface | | `src/lib/mcp/schemas.ts` | Hand-written JSON Schemas for tool inputs | | `src/lib/mcp/invokeV1.ts` | In-process v1 handler invocation | | `src/lib/mcp/auth.ts` | Caller resolution + internal token minting | | `src/lib/mcp/scopes.ts` | Scope vocabulary | +| `src/lib/tx-review/spec.ts` | Tool input → canonical spec (base units) → `TxDraft` | +| `src/lib/tx-review/context.ts` | Wallet, script, DRep and staking context, signer authorization | +| `src/lib/tx-review/pipeline.ts` | UTxOs → validate → build → summarize → card, shared by preview and propose | +| `src/lib/tx-review/summary.ts` | The one summary model behind text, JSON and PNG | +| `src/lib/tx-review/card.ts`, `render-png.ts` | Card layout tree and `next/og` rasterization | +| `src/lib/tx-review/draft-token.ts` | The preview → confirm binding | +| `src/lib/tx-review/preview.ts`, `propose.ts`, `review.ts` | The three tool bodies | +| `src/lib/mcp/apps/review-card.ts` | The inline review-card view (MCP App resource + bridge) | +| `src/lib/task-payout/spec.ts`, `load.ts` | Task rows → canonical payout spec; recipients hash | +| `src/lib/task-payout/preview.ts`, `hooks.ts` | `task_prepare_payout` body; the task link hooks `transaction_propose` and the app share | +| `src/lib/task-payout/recipients.ts`, `serialize.ts` | Display-unit recipients → base-unit rows; the task shape and tRPC→HTTP error map the `tasks.ts` / `taskUpsert.ts` v1 handlers share | +| `src/lib/task-payout/deps.ts`, `sync.ts` | Pipeline deps from a tRPC session; Paid/Cancelled link sync | Tool inputs are hand-written JSON Schema rather than generated from `src/utils/swagger.ts`: that file is a hand-maintained literal with `apis: []` that has @@ -135,8 +353,10 @@ already drifted from the handlers it documents. ## Tests ```bash -npx jest src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts +npx jest src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/txReview*.test.ts +npm run test:esm -- src/__tests__/txReviewRenderPng.test.ts ``` `mcpRoute.test.ts` drives the **real** SDK — mocking it would prove nothing about the -transport wiring or the bridge, which are the parts most likely to break. +transport wiring or the bridge, which are the parts most likely to break. The render test +lives in the ESM project because `next/og` is an ESM bundle the CJS project cannot load. diff --git a/src/pages/api/notifications/ballot-deadlines.ts b/src/pages/api/notifications/ballot-deadlines.ts new file mode 100644 index 00000000..b68b5b0e --- /dev/null +++ b/src/pages/api/notifications/ballot-deadlines.ts @@ -0,0 +1,55 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { env } from "@/env"; +import { db } from "@/server/db"; +import { enqueueBallotDeadlineReminders } from "@/lib/notifications/ballotDeadlines"; +import { notificationsEmailEnabled } from "@/lib/notifications/center"; +import { drainNotificationOutbox } from "@/lib/notifications/worker"; + +/** + * Scheduled entry point (see .github/workflows/ballot-deadline-reminders.yml): + * scans ballots for proposals whose voting deadline is within 48h, enqueues + * reminder emails, then drains the outbox. Shares the drain secret. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + if (!env.NOTIFICATION_DRAIN_SECRET) { + return res.status(503).json({ error: "Notification drain is not configured" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : null; + + if (token !== env.NOTIFICATION_DRAIN_SECRET) { + return res.status(401).json({ error: "Unauthorized" }); + } + + // Skip the chain lookups entirely when nothing could be sent anyway. + if (!notificationsEmailEnabled()) { + return res.status(200).json({ emailEnabled: false, remindersEnqueued: 0, drained: 0 }); + } + + try { + const scan = await enqueueBallotDeadlineReminders(db); + const deliveries = + scan.remindersEnqueued > 0 + ? await drainNotificationOutbox(db, { limit: 100 }) + : []; + return res.status(200).json({ + emailEnabled: true, + ...scan, + drained: deliveries.length, + }); + } catch (error) { + console.error("Ballot deadline reminder scan failed", error); + return res.status(500).json({ error: "Ballot deadline reminder scan failed" }); + } +} diff --git a/src/pages/api/v1/README.md b/src/pages/api/v1/README.md index b94ce78a..11b25ddc 100644 --- a/src/pages/api/v1/README.md +++ b/src/pages/api/v1/README.md @@ -294,6 +294,34 @@ Endpoints: - **Response**: Array of matching metadata items - **Error Handling**: 400 (validation), 500 (server) +#### `resolveRegistrationScript.ts` - GET `/api/v1/resolveRegistrationScript` + +- **Purpose**: Resolve the native script(s) behind a CIP-0146 registration transaction +- **Authentication**: Not required (public endpoint, rate-limited 30/min) +- **Features**: + - Reads the transaction's UTxO addresses and resolves each script-credential address + - Returns timelock JSON per script hash (Plutus/unknown scripts are skipped) +- **Query Parameters**: + - `txHash`: Registration transaction hash (64 hex) + - `network`: Network identifier (optional, defaults to mainnet) +- **Response**: `{ txHash, candidates: [{ address, scriptHash, stakeCredentialHash, scriptJson }] }` +- **Error Handling**: 400 (validation), 500 (server) + +#### `resolveScript.ts` - GET `/api/v1/resolveScript` + +- **Purpose**: Resolve a native script by hash (policy) or multisig wallet address to its signer key hashes — backs "lookup by policy" on the Discover tab and the MCP `multisig_lookup_wallet` tool +- **Authentication**: Not required (public endpoint, rate-limited 30/min) +- **Features**: + - Accepts exactly one of `scriptHash` or `address` (script payment credential) + - Returns the timelock JSON and sig key hashes in script order + - Unknown / Plutus scripts return 200 with `scriptJson: null` and `sigHashes: []` +- **Query Parameters**: + - `scriptHash`: Native-script hash / policy id (56 hex) + - `address`: Bech32 multisig wallet address + - `network`: Network identifier (optional, defaults to mainnet) +- **Response**: `{ scriptHash, stakeCredentialHash, scriptJson, sigHashes }` +- **Error Handling**: 400 (validation), 500 (server) + ### UTxO Management #### `freeUtxos.ts` - GET `/api/v1/freeUtxos` @@ -662,3 +690,30 @@ is unchanged in both cases: - **`botBallotsUpsert`** — was bot-cosigner-only. A human caller is now authorized by the same signer-or-owner check every ballot procedure in the tRPC router already applies, so the REST path is no more permissive than the app's own UI. + +### Task Board + +#### `tasks.ts` - GET `/api/v1/tasks` + +- **Purpose**: A wallet's project task board with each task's derived payout state (backs the `task_list` MCP tool) +- **Authentication**: Required (JWT Bearer token). Human signer/owner, or a bot key with any granted wallet access (observer is enough) +- **Query Parameters**: + - `walletId`: Wallet identifier + - `address`: Requester address (must match JWT payload) + - `status`: Optional column filter (`Backlog`, `InProgress`, `InReview`, `Done`) + - `payable`: Optional `true` to return only tasks that can be paid right now +- **Response**: `{ tasks, count, payableCount }` — recipients in base units; `payout` carries `state`, `payable`, `blocker` and the pending `transactionId`; `payableCount` is wallet-wide before filters +- **Error Handling**: 400 (validation), 401 (auth), 403 (address mismatch / not a signer / bot not granted), 404 (wallet), 500 (server) + +#### `taskUpsert.ts` - POST `/api/v1/taskUpsert` + +- **Purpose**: Create a task, or update and/or move an existing one (backs the `task_upsert` MCP tool). Records a task only; creates no transaction +- **Authentication**: Required (JWT Bearer token). Human wallet JWTs only — bot keys receive 403 +- **Request Body**: + - `walletId`: Wallet identifier + - `taskId`: Omit to create (then `title` is required) + - `title`, `description`, `priority`, `assigneeAddress`, `dueDate` (ISO or `null`) + - `status`, `position`: Column and index; on an existing task these move it + - `recipients`: Display units (`{ address, ada?, assets?: [{ unit, quantity }] }`), converted to base units with the token's registered decimals; replaces the task's recipient list +- **Response**: `201 { task, created: true }` or `200 { task, created: false }` +- **Error Handling**: 400 (validation, `INVALID_SPEC` recipients), 401 (auth), 403 (not a signer / bot key), 404 (wallet or task), 409 (task locked by a pending or paid payout), 413 (body), 500 (server) diff --git a/src/pages/api/v1/botSetupGuide.ts b/src/pages/api/v1/botSetupGuide.ts new file mode 100644 index 00000000..52df9879 --- /dev/null +++ b/src/pages/api/v1/botSetupGuide.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { buildBotSetupGuide } from "@/lib/botSetupGuide"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + res.setHeader("Allow", "GET"); + return res.status(405).end(); + } + const guide = buildBotSetupGuide(); + res.setHeader("Content-Type", "text/markdown; charset=utf-8"); + res.setHeader("Cache-Control", "public, max-age=300"); + return res.status(200).send(guide); +} diff --git a/src/pages/api/v1/documentDetail.ts b/src/pages/api/v1/documentDetail.ts new file mode 100644 index 00000000..7247a726 --- /dev/null +++ b/src/pages/api/v1/documentDetail.ts @@ -0,0 +1,103 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { summariseDocument } from "@/lib/documents/summary"; +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { + applyBotRateLimit, + applyRateLimit, +} from "@/lib/security/requestGuards"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; + +/** + * GET /api/v1/documentDetail?documentId=&address= — one document with its + * version history and audit events. + * + * Same rules as `documents.ts`: authorization goes through + * `caller.document.getById` rather than being re-implemented, the response is a + * projection, and bot keys are excluded. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/documentDetail" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer <token>)", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload) && !applyBotRateLimit(req, res, payload.botId)) return; + + const { documentId, address } = req.query; + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof documentId !== "string") { + return res.status(400).json({ error: "Invalid documentId parameter" }); + } + + if (isBotJwt(payload)) { + // See documents.ts: sign-off is a human accountability record. + return res + .status(403) + .json({ error: "Document sign-off is not available to bot keys" }); + } + + try { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + + const document = await caller.document.getById({ documentId }); + if (!document) { + return res.status(404).json({ error: "Document not found" }); + } + + return res.status(200).json({ + ...summariseDocument(document), + events: (document.events ?? []).map((event) => ({ + type: event.type, + actorAddress: event.actorAddress, + createdAt: new Date(event.createdAt).toISOString(), + })), + }); + } catch (error) { + console.error("Error in documentDetail handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} diff --git a/src/pages/api/v1/documents.ts b/src/pages/api/v1/documents.ts new file mode 100644 index 00000000..c2dba689 --- /dev/null +++ b/src/pages/api/v1/documents.ts @@ -0,0 +1,102 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { + applyBotRateLimit, + applyRateLimit, +} from "@/lib/security/requestGuards"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; +import { summariseDocument } from "@/lib/documents/summary"; + +/** + * GET /api/v1/documents?walletId=&address= — sign-off documents for a wallet. + * + * Authorization is not re-implemented here: the request goes through + * `caller.document.listByWallet`, so the same signer-or-owner rule the UI uses + * applies, and there is one place to change it. + * + * The response is a deliberate PROJECTION, not the raw rows — see + * `summariseDocument`. This endpoint backs an MCP tool, so its output reaches a + * model, and the raw rows carry up to 512KB of inline base64 per version. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/documents" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer <token>)", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload)) { + if (!applyBotRateLimit(req, res, payload.botId)) return; + // Bot keys are deliberately excluded. Sign-off is a human accountability + // record: every approval is a CIP-8 signature from a named wallet signer, + // and an automated identity has no standing in it. Reading alone is + // harmless, but it is the first step of a surface that only makes sense for + // people, so it waits for a reason to exist. + return res + .status(403) + .json({ error: "Document sign-off is not available to bot keys" }); + } + + const { walletId, address, includeArchived } = req.query; + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof walletId !== "string") { + return res.status(400).json({ error: "Invalid walletId parameter" }); + } + + try { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + + const documents = await caller.document.listByWallet({ + walletId, + includeArchived: includeArchived === "true", + }); + + return res.status(200).json(documents.map(summariseDocument)); + } catch (error) { + console.error("Error in documents handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} diff --git a/src/pages/api/v1/drepInfo.ts b/src/pages/api/v1/drepInfo.ts index 4d60ed56..dba6e5e8 100644 --- a/src/pages/api/v1/drepInfo.ts +++ b/src/pages/api/v1/drepInfo.ts @@ -3,22 +3,12 @@ import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { authorizeProxyReadForV1 } from "@/lib/server/proxyAccess"; import { applyRateLimit, applyBotRateLimit } from "@/lib/security/requestGuards"; +import { fetchDrepStatus } from "@/lib/governance/drep-status"; import { db } from "@/server/db"; import { buildMultisigWallet, buildWallet, getWalletType } from "@/utils/common"; -import { env } from "@/env"; +import { getProvider } from "@/utils/get-provider"; import type { DbWalletWithLegacy } from "@/types/wallet"; -function getBlockfrostConfig(network: 0 | 1): { key: string; baseUrl: string } | null { - if (network === 0) { - const key = env.BLOCKFROST_API_KEY_PREPROD ?? env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD; - if (!key) return null; - return { key, baseUrl: "https://cardano-preprod.blockfrost.io/api/v0" }; - } - const key = env.BLOCKFROST_API_KEY_MAINNET ?? env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET; - if (!key) return null; - return { key, baseUrl: "https://cardano-mainnet.blockfrost.io/api/v0" }; -} - export default async function handler(req: NextApiRequest, res: NextApiResponse) { addCorsCacheBustingHeaders(res); if (!applyRateLimit(req, res, { keySuffix: "v1/drepInfo" })) return; @@ -85,27 +75,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(400).json({ error: "DRep is not configured for this wallet" }); } - const config = getBlockfrostConfig(network); - if (!config) { - return res.status(500).json({ error: `Missing Blockfrost API key for network ${network}` }); - } - try { - const response = await fetch(`${config.baseUrl}/governance/dreps/${encodeURIComponent(dRepId)}`, { - headers: { project_id: config.key }, - }); - - if (response.status === 404) { - return res.status(200).json({ active: false, dRepId }); - } - if (!response.ok) { - const body = await response.text(); - console.error(`drepInfo Blockfrost error ${response.status}:`, body); - return res.status(500).json({ error: `Blockfrost returned ${response.status}` }); - } - - const data = (await response.json()) as { active?: boolean }; - return res.status(200).json({ active: data.active === true, dRepId }); + // The same probe the transaction review pipeline runs before it lets a + // draft vote (`src/lib/governance/drep-status.ts`); a never-registered + // DRep (Blockfrost 404) comes back inactive rather than as a failure. + const status = await fetchDrepStatus(getProvider(network), dRepId); + return res.status(200).json({ active: status.active, dRepId }); } catch (e) { console.error("drepInfo error:", e); return res.status(500).json({ error: "Failed to fetch DRep info" }); diff --git a/src/pages/api/v1/governanceActiveProposals.ts b/src/pages/api/v1/governanceActiveProposals.ts index 0bb8503b..f732ccdd 100644 --- a/src/pages/api/v1/governanceActiveProposals.ts +++ b/src/pages/api/v1/governanceActiveProposals.ts @@ -4,13 +4,13 @@ import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import { applyRateLimit, applyBotRateLimit, applyAddressRateLimit } from "@/lib/security/requestGuards"; import { parseScope, scopeIncludes, type BotScope } from "@/lib/auth/botKey"; -import { getProvider } from "@/utils/get-provider"; import { getProposalStatus } from "@/lib/governance"; import { getProviderErrorStatus } from "@/lib/server/providerErrors"; import { fetchProposalMetadataWithFallback, type ProposalMetadataProvider, } from "@/lib/governance/proposalMetadata"; +import { getGovernanceProvider, providerGet } from "@/lib/governance/provider"; import type { ProposalMetadata } from "@/types/governance"; const REQUIRED_SCOPE = "governance:read"; @@ -39,89 +39,6 @@ type BlockfrostProposalDetailsItem = { expired_epoch?: number | null; }; -const getBlockfrostConfig = (network: string): { key: string; baseUrl: string } | null => { - const key = - network === "0" - ? process.env.BLOCKFROST_API_KEY_PREPROD || process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD - : process.env.BLOCKFROST_API_KEY_MAINNET || process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET; - if (!key?.trim()) return null; - return { - key, - baseUrl: - network === "0" - ? "https://cardano-preprod.blockfrost.io/api/v0" - : "https://cardano-mainnet.blockfrost.io/api/v0", - }; -}; - -const blockfrostGet = async <T,>(network: string, path: string): Promise<T> => { - const config = getBlockfrostConfig(network); - if (!config) { - throw new Error(`Missing Blockfrost API key for network ${network}`); - } - const response = await fetch(`${config.baseUrl}${path.startsWith("/") ? path : `/${path}`}`, { - headers: { - project_id: config.key, - accept: "application/json", - }, - }); - const text = await response.text(); - const body = text - ? (() => { - try { - return JSON.parse(text) as unknown; - } catch { - return text; - } - })() - : null; - - if (!response.ok) { - throw { - status: response.status, - data: typeof body === "object" && body !== null ? body : { message: String(body ?? "") }, - }; - } - - return body as T; -}; - -const providerGet = async <T,>(args: { - provider: { get: (path: string) => Promise<unknown> } | null; - network: string; - path: string; -}): Promise<T> => { - if (!args.provider) { - return blockfrostGet<T>(args.network, args.path); - } - - try { - return (await args.provider.get(args.path)) as T; - } catch (error) { - const status = getProviderErrorStatus(error); - if (status !== undefined) { - throw error; - } - console.warn("governanceActiveProposals provider.get failed; retrying via Blockfrost REST", { - path: args.path, - message: error instanceof Error ? error.message : String(error), - }); - return blockfrostGet<T>(args.network, args.path); - } -}; - -const getGovernanceProvider = (network: string): { get: (path: string) => Promise<unknown> } | null => { - try { - return getProvider(Number(network)); - } catch (error) { - console.warn("governanceActiveProposals getProvider failed; using Blockfrost REST", { - network, - message: error instanceof Error ? error.message : String(error), - }); - return null; - } -}; - const getErrorStatus = getProviderErrorStatus; /** Strict integer query param: undefined → fallback, anything else must be an in-range integer. */ diff --git a/src/pages/api/v1/resolveScript.ts b/src/pages/api/v1/resolveScript.ts new file mode 100644 index 00000000..b4d59727 --- /dev/null +++ b/src/pages/api/v1/resolveScript.ts @@ -0,0 +1,149 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { deserializeAddress } from "@meshsdk/core"; +import { getProvider } from "@/utils/get-provider"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { applyRateLimit } from "@/lib/security/requestGuards"; +import { isProviderNotFoundError } from "@/lib/server/providerErrors"; +import { + collectNativeScriptSigHashes, + providerScriptJsonToNativeScript, +} from "@/utils/nativeScriptJson"; + +export type ResolveScriptResponse = { + scriptHash: string; + /** Stake credential of the queried address (null when queried by hash). */ + stakeCredentialHash: string | null; + /** Provider timelock JSON, or null when unknown / not a native script. */ + scriptJson: unknown | null; + /** Sig key hashes in script order (empty when unresolved or non-timelock). */ + sigHashes: string[]; +}; + +const SCRIPT_HASH_RE = /^[0-9a-f]{56}$/i; + +/** + * Resolves a native script by its hash ("policy") or by a multisig + * (script-credential) address, returning its sig key hashes. + * + * Backs "lookup by policy" on the Discover tab: the label-1854 + * registration metadata carries participants but not the script hash, + * so a policy query is answered by resolving the script to its signer + * hashes and then matching those against registrations. Public chain + * data, unauthenticated, rate-limited like its sibling routes. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if ( + !applyRateLimit(req, res, { + keySuffix: "v1/resolveScript", + maxRequests: 30, + }) + ) { + return; + } + + await cors(req, res); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const { scriptHash: rawScriptHash, address, network = "1" } = req.query; + + const hasHash = typeof rawScriptHash === "string" && rawScriptHash.length > 0; + const hasAddress = typeof address === "string" && address.length > 0; + if (hasHash === hasAddress) { + return res + .status(400) + .json({ error: "Provide exactly one of scriptHash or address" }); + } + + const networkId = parseInt(network as string, 10); + if (networkId !== 0 && networkId !== 1) { + return res.status(400).json({ error: "Invalid network (expected 0 or 1)" }); + } + + let scriptHash: string; + let stakeCredentialHash: string | null = null; + if (hasHash) { + if (!SCRIPT_HASH_RE.test(rawScriptHash as string)) { + return res.status(400).json({ error: "Invalid scriptHash parameter" }); + } + scriptHash = (rawScriptHash as string).toLowerCase(); + } else { + let parts: ReturnType<typeof deserializeAddress>; + try { + parts = deserializeAddress(address as string); + } catch { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (!parts.scriptHash) { + return res + .status(400) + .json({ error: "Address is not a multisig (script) address" }); + } + scriptHash = parts.scriptHash.toLowerCase(); + stakeCredentialHash = + parts.stakeScriptCredentialHash || parts.stakeCredentialHash || null; + } + + const provider = getProvider(networkId); + const empty: ResolveScriptResponse = { + scriptHash, + stakeCredentialHash, + scriptJson: null, + sigHashes: [], + }; + + try { + const scriptInfo = await provider.get(`/scripts/${scriptHash}/json`); + if (!scriptInfo?.json) { + // Plutus script or provider without timelock JSON. + res.setHeader( + "Cache-Control", + "public, max-age=60, stale-while-revalidate=120", + ); + return res.status(200).json(empty); + } + + let sigHashes: string[] = []; + try { + sigHashes = collectNativeScriptSigHashes( + providerScriptJsonToNativeScript(scriptInfo.json), + ); + } catch { + // Unsupported script shape — still surface the JSON, no signers. + sigHashes = []; + } + + // Script content is immutable per hash — cache aggressively. + res.setHeader( + "Cache-Control", + "public, max-age=300, stale-while-revalidate=600", + ); + const response: ResolveScriptResponse = { + scriptHash, + stakeCredentialHash, + scriptJson: scriptInfo.json, + sigHashes, + }; + return res.status(200).json(response); + } catch (error) { + if (isProviderNotFoundError(error)) { + res.setHeader( + "Cache-Control", + "public, max-age=60, stale-while-revalidate=120", + ); + return res.status(200).json(empty); + } + console.error("resolveScript error:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +} diff --git a/src/pages/api/v1/signTransaction.ts b/src/pages/api/v1/signTransaction.ts index 9e3fc08a..51fd426b 100644 --- a/src/pages/api/v1/signTransaction.ts +++ b/src/pages/api/v1/signTransaction.ts @@ -4,6 +4,7 @@ import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { createCaller } from "@/server/api/root"; import { db } from "@/server/db"; +import { markPayoutsPaid } from "@/lib/task-payout/sync"; import { getProvider } from "@/utils/get-provider"; import { addressToNetwork } from "@/utils/multisigSDK"; import { @@ -17,6 +18,7 @@ import { calculateTxHash } from "@meshsdk/core-csl"; import { applyRateLimit, applyBotRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; import { getClientIP } from "@/lib/security/rateLimit"; import { getBotWalletAccess } from "@/lib/auth/botAccess"; +import { enqueueThresholdReachedNotifications } from "@/lib/notifications/center"; function coerceBoolean(value: unknown, fallback = false): boolean { if (typeof value === "boolean") return value; @@ -519,6 +521,38 @@ export default async function handler( return res.status(500).json({ error: "Failed to load updated transaction state" }); } + // A task-board payout that just reached the chain: flip its task links + // to Paid. Best-effort like the notifications below. + if (nextState === 1 && transaction.state !== 1) { + try { + await markPayoutsPaid(db, { + transactionId, + txHash: updatedTransaction.txHash ?? finalTxHash ?? null, + }); + } catch (error: unknown) { + console.error("Failed to mark task payouts paid", toError(error)); + } + } + + // The witness is persisted on both the 200 and 502 paths below, so the + // threshold crossing (if any) is real either way. Never let a + // notification failure turn a recorded signature into a 500. + try { + await enqueueThresholdReachedNotifications(db, { + wallet, + resourceType: "transaction", + resourceId: transactionId, + previousSignedAddresses: transaction.signedAddresses, + signedAddresses: updatedTransaction.signedAddresses, + actorAddress: address, + description: updatedTransaction.description, + txJson: transaction.txJson, + txHash: updatedTransaction.txHash ?? null, + }); + } catch (error: unknown) { + console.error("Failed to enqueue threshold notifications", toError(error)); + } + if (submissionError) { return res.status(502).json({ error: "Transaction witness recorded, but submission to network failed", diff --git a/src/pages/api/v1/stakeAccountInfo.ts b/src/pages/api/v1/stakeAccountInfo.ts index 499cb545..76f229f6 100644 --- a/src/pages/api/v1/stakeAccountInfo.ts +++ b/src/pages/api/v1/stakeAccountInfo.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { applyRateLimit, applyBotRateLimit } from "@/lib/security/requestGuards"; +import { fetchStakeAccountStatus } from "@/lib/staking/stake-account-status"; import { getProvider } from "@/utils/get-provider"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -28,16 +29,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const provider = getProvider(network); try { - const info = await provider.fetchAccountInfo(stakeAddress.trim()); - return res.status(200).json({ active: info.active, poolId: info.poolId ?? null }); + // Never-registered accounts (Blockfrost 404) come back as inactive. + const info = await fetchStakeAccountStatus(provider, stakeAddress); + return res.status(200).json(info); } catch (e) { - // Blockfrost returns 404 for accounts that have never been registered — treat as inactive - const is404 = - (e as { status?: number })?.status === 404 || - (e instanceof Error && e.message.includes("404")); - if (is404) { - return res.status(200).json({ active: false, poolId: null }); - } console.error("stakeAccountInfo error:", e); return res.status(500).json({ error: "Failed to fetch stake account info" }); } diff --git a/src/pages/api/v1/stats/run-snapshots-batch.ts b/src/pages/api/v1/stats/run-snapshots-batch.ts index b7660d84..bac1acc2 100644 --- a/src/pages/api/v1/stats/run-snapshots-batch.ts +++ b/src/pages/api/v1/stats/run-snapshots-batch.ts @@ -2,9 +2,8 @@ import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import type { NextApiRequest, NextApiResponse } from "next"; import { db } from "@/server/db"; import { buildWallet } from "@/utils/common"; -import { MultisigWallet, type MultisigKey } from "@/utils/multisigSDK"; import { getProvider } from "@/utils/get-provider"; -import { resolvePaymentKeyHash, resolveStakeKeyHash, type UTxO } from "@meshsdk/core"; +import { type UTxO } from "@meshsdk/core"; import { getBalance } from "@/utils/getBalance"; import { addressToNetwork } from "@/utils/multisigSDK"; import { Prisma, type Wallet as DbWallet } from "@prisma/client"; @@ -271,43 +270,15 @@ export default async function handler( } } - // Build wallet conditionally: use MultisigSDK ordering if signersStakeKeys exist + // This used to branch on `signersStakeKeys` and hand-assemble a + // MultisigWallet with ordered keys for the stake-key case, falling back + // to buildWallet() otherwise. buildWallet() now does that branch itself + // (getWalletType -> sdk/legacy/summon) and reports the resolved address + // on `capabilities`, so the conditional here is fully subsumed. let walletAddress: string; try { - const hasStakeKeys = !!(wallet.signersStakeKeys && wallet.signersStakeKeys.length > 0); - if (hasStakeKeys) { - // Build MultisigSDK wallet with ordered keys - const keys: MultisigKey[] = []; - wallet.signersAddresses.forEach((addr: string, i: number) => { - if (!addr) return; - try { - keys.push({ keyHash: resolvePaymentKeyHash(addr), role: 0, name: wallet.signersDescriptions[i] || "" }); - } catch {} - }); - wallet.signersStakeKeys?.forEach((stakeKey: string, i: number) => { - if (!stakeKey) return; - try { - keys.push({ keyHash: resolveStakeKeyHash(stakeKey), role: 2, name: wallet.signersDescriptions[i] || "" }); - } catch {} - }); - if (keys.length === 0 && !wallet.stakeCredentialHash) { - throw new Error("No valid keys or stakeCredentialHash provided"); - } - const mWallet = new MultisigWallet( - wallet.name, - keys, - wallet.description ?? "", - wallet.numRequiredSigners ?? 1, - network, - wallet.stakeCredentialHash as undefined | string, - (wallet.type as any) || "atLeast" - ); - walletAddress = mWallet.getScript().address; - } else { - // Fallback: build the wallet without enforcing key ordering (legacy payment-script build) - const builtWallet = buildWallet(wallet as DbWalletWithLegacy, network); - walletAddress = builtWallet.address; - } + const builtWallet = buildWallet(wallet as DbWalletWithLegacy, network); + walletAddress = builtWallet.capabilities?.address ?? builtWallet.address; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown wallet build error'; console.error(`Failed to build wallet for ${wallet.id.slice(0, 8)}...:`, errorMessage); diff --git a/src/pages/api/v1/taskUpsert.ts b/src/pages/api/v1/taskUpsert.ts new file mode 100644 index 00000000..782d592b --- /dev/null +++ b/src/pages/api/v1/taskUpsert.ts @@ -0,0 +1,197 @@ +import { TRPCError } from "@trpc/server"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { + applyAddressRateLimit, + applyBotRateLimit, + applyRateLimit, + enforceBodySize, +} from "@/lib/security/requestGuards"; +import { + recipientsToBaseUnits, + type DisplayRecipient, +} from "@/lib/task-payout/recipients"; +import { serializeTask, trpcErrorToHttp } from "@/lib/task-payout/serialize"; +import { networkFromAddress, TxReviewError } from "@/lib/tx-review/context"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { db } from "@/server/db"; + +/** + * POST /api/v1/taskUpsert — create a task on a wallet's board, or update + * and/or move an existing one. + * + * Backs the `task_upsert` MCP tool. Body: + * { walletId, taskId?, title?, description?, priority?, assigneeAddress?, + * dueDate? (ISO | null), status?, position?, recipients? } + * `taskId` absent = create (title required); present = update, and a + * `status` / `position` also moves it. `recipients` are in display units + * (`ada`, or a token with its registered decimals — the same item shape as + * `transaction_preview`'s outputs) and are converted here, so REST and MCP + * share one contract; the board stores base units. + * + * Human wallet JWTs only. There is no bot scope for the board, and the MCP + * layer never projects a bot key onto `tasks:write`. Authorization and + * validation are the `task` router's: this handler runs it in-process. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/taskUpsert" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "POST") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + if (!enforceBodySize(req, res, 100 * 1024)) return; + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer <token>)", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload)) { + if (!applyBotRateLimit(req, res, payload.botId)) return; + return res + .status(403) + .json({ error: "Task board writes are not available to bot keys" }); + } + if (!applyAddressRateLimit(req, res, payload.address)) return; + + const body = (req.body ?? {}) as TaskUpsertBody; + if (typeof body.walletId !== "string" || !body.walletId.trim()) { + return res.status(400).json({ error: "Missing required field walletId!" }); + } + if (body.taskId !== undefined && typeof body.taskId !== "string") { + return res.status(400).json({ error: "Invalid taskId: must be a string" }); + } + if (body.recipients !== undefined && !Array.isArray(body.recipients)) { + return res + .status(400) + .json({ error: "Invalid recipients: must be an array" }); + } + + try { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + + const recipients = + body.recipients !== undefined + ? await recipientsToBaseUnits( + networkFromAddress(payload.address), + body.walletId, + body.recipients, + ) + : undefined; + const scalars = { + ...(body.title !== undefined ? { title: body.title } : {}), + ...(body.description !== undefined + ? { description: body.description } + : {}), + ...(body.priority !== undefined + ? { priority: body.priority as never } + : {}), + ...(body.assigneeAddress !== undefined + ? { assigneeAddress: body.assigneeAddress } + : {}), + ...(body.dueDate !== undefined + ? { dueDate: body.dueDate === null ? null : new Date(body.dueDate) } + : {}), + }; + + let task; + let created = false; + if (body.taskId) { + task = await caller.task.update({ + id: body.taskId, + ...scalars, + ...(recipients ? { recipients } : {}), + }); + if (body.status !== undefined || body.position !== undefined) { + task = await caller.task.move({ + id: task.id, + status: (body.status ?? task.status) as never, + position: body.position ?? task.position, + }); + } + } else { + if (!body.title) { + return res.status(400).json({ + error: "title is required to create a task", + code: "BAD_REQUEST", + }); + } + task = await caller.task.create({ + walletId: body.walletId, + title: body.title, + ...scalars, + ...(body.status !== undefined ? { status: body.status as never } : {}), + recipients: recipients ?? [], + }); + created = true; + if (body.position !== undefined) { + task = await caller.task.move({ + id: task.id, + status: task.status, + position: body.position, + }); + } + } + + return res + .status(created ? 201 : 200) + .json({ task: serializeTask(task), created }); + } catch (error) { + if (error instanceof TxReviewError) { + const result = error.toResult(); + return res.status(result.status).json(result.body); + } + if (error instanceof TRPCError) { + const mapped = trpcErrorToHttp(error); + return res.status(mapped.status).json(mapped.body); + } + console.error("Error in taskUpsert handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} + +type TaskUpsertBody = { + walletId?: unknown; + taskId?: unknown; + title?: string; + description?: string | null; + status?: string; + priority?: string | null; + assigneeAddress?: string | null; + dueDate?: string | null; + position?: number; + recipients?: DisplayRecipient[]; +}; diff --git a/src/pages/api/v1/tasks.ts b/src/pages/api/v1/tasks.ts new file mode 100644 index 00000000..b2a0e06a --- /dev/null +++ b/src/pages/api/v1/tasks.ts @@ -0,0 +1,127 @@ +import { TRPCError } from "@trpc/server"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { getBotWalletAccess } from "@/lib/auth/botAccess"; +import { addCorsCacheBustingHeaders, cors } from "@/lib/cors"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { applyBotRateLimit, applyRateLimit } from "@/lib/security/requestGuards"; +import { serializeTask, trpcErrorToHttp } from "@/lib/task-payout/serialize"; +import { isBotJwt, verifyJwt } from "@/lib/verifyJwt"; +import { createCaller } from "@/server/api/root"; +import { listWalletTasks, TASK_STATUSES, type AnnotatedTask } from "@/server/api/routers/tasks"; +import { db } from "@/server/db"; + +/** + * GET /api/v1/tasks?walletId=&address=[&status=][&payable=true] — a wallet's + * project task board, with each task's payout state. + * + * Backs the `task_list` MCP tool. Two identities, resolved the way every + * wallet read here is: + * - a human wallet JWT goes through `caller.task.list`, so the router's + * signer-or-owner rule applies and there is one place to change it; + * - a bot JWT is authorized by its WalletBotAccess grant (observer is + * enough — the board is a read), then reads the same query the router + * uses. A bot's payment address is not a signer, so the router's session + * check cannot be what admits it. + * + * `payableCount` is the wallet-wide total before any filter: what + * `task_prepare_payout` would pay when called without task ids. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/tasks" })) return; + + await cors(req, res); + if (req.method === "OPTIONS") return res.status(200).end(); + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer <token>)", + }); + } + + const payload = verifyJwt(token); + if (!payload) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + if (isBotJwt(payload) && !applyBotRateLimit(req, res, payload.botId)) return; + + const { walletId, address, status, payable } = req.query; + if (typeof address !== "string") { + return res.status(400).json({ error: "Invalid address parameter" }); + } + if (payload.address !== address) { + return res.status(403).json({ error: "Address mismatch" }); + } + if (typeof walletId !== "string" || !walletId.trim()) { + return res.status(400).json({ error: "Invalid walletId parameter" }); + } + if ( + status !== undefined && + !(TASK_STATUSES as readonly string[]).includes(String(status)) + ) { + return res.status(400).json({ + error: `Invalid status parameter (expected one of ${TASK_STATUSES.join(", ")})`, + }); + } + const onlyPayable = payable === "true"; + + try { + let tasks: AnnotatedTask[]; + if (isBotJwt(payload)) { + const access = await getBotWalletAccess(db, walletId, payload.botId); + if (!access.allowed) { + // Convention: 404 = unknown wallet, 403 = known but not permitted. + return access.reason === "wallet_not_found" + ? res.status(404).json({ error: "Wallet not found" }) + : res.status(403).json({ error: "Not authorized for this wallet" }); + } + tasks = await listWalletTasks(db, walletId); + } else { + const caller = createCaller({ + db, + session: { + user: { id: payload.address }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }, + sessionAddress: payload.address, + sessionWallets: [payload.address], + primaryWallet: payload.address, + ip: getClientIP(req), + }); + tasks = await caller.task.list({ walletId }); + } + + const payableCount = tasks.filter((t) => t.payout.payable).length; + const filtered = tasks + .filter((t) => status === undefined || t.status === status) + .filter((t) => !onlyPayable || t.payout.payable); + + return res.status(200).json({ + tasks: filtered.map(serializeTask), + count: filtered.length, + payableCount, + }); + } catch (error) { + if (error instanceof TRPCError) { + const mapped = trpcErrorToHttp(error); + return res.status(mapped.status).json(mapped.body); + } + console.error("Error in tasks handler", { + message: (error as Error)?.message, + stack: (error as Error)?.stack, + }); + return res.status(500).json({ error: "Internal Server Error" }); + } +} diff --git a/src/pages/api/v1/wallet/transfer/export.ts b/src/pages/api/v1/wallet/transfer/export.ts new file mode 100644 index 00000000..1424b349 --- /dev/null +++ b/src/pages/api/v1/wallet/transfer/export.ts @@ -0,0 +1,155 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { db } from "@/server/db"; +import { verifyJwt } from "@/lib/verifyJwt"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { applyRateLimit } from "@/lib/security/requestGuards"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { audit } from "@/lib/observability/audit"; +import { + WALLET_TRANSFER_FORMAT, + WALLET_TRANSFER_VERSION, + type WalletTransferBallot, + type WalletTransferContact, + type WalletTransferPayloadV1, + type WalletTransferType, +} from "@/types/walletTransfer"; + +const ALLOWED_TYPES: WalletTransferType[] = ["atLeast", "all", "any"]; + +function parseIncludeFlags(raw: unknown): { contacts: boolean; ballots: boolean } { + const values = typeof raw === "string" ? raw.split(",") : Array.isArray(raw) ? raw : []; + const set = new Set(values.map((v) => (typeof v === "string" ? v.trim() : ""))); + return { contacts: set.has("contacts"), ballots: set.has("ballots") }; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/wallet/transfer/export" })) { + return; + } + + await cors(req, res); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + if (!token) { + return res.status(401).json({ error: "Unauthorized - Missing token" }); + } + const jwt = verifyJwt(token); + if (!jwt) { + return res.status(401).json({ error: "Invalid or expired token" }); + } + + const walletId = typeof req.query.walletId === "string" ? req.query.walletId : null; + if (!walletId) { + return res.status(400).json({ error: "walletId query parameter is required" }); + } + + const include = parseIncludeFlags(req.query.include); + + const wallet = await db.wallet.findUnique({ where: { id: walletId } }); + if (!wallet) { + return res.status(404).json({ error: "Wallet not found" }); + } + + const requester = jwt.address; + const isOwner = wallet.ownerAddress === requester; + if (!isOwner) { + void audit(db, { + actorAddress: requester, + actorType: "user", + action: "wallet.transfer.export", + resourceType: "wallet", + resourceId: walletId, + ip: getClientIP(req), + outcome: "denied", + reason: "not_owner", + }); + return res.status(403).json({ error: "Only the wallet owner can export this wallet" }); + } + + const type: WalletTransferType = ALLOWED_TYPES.includes(wallet.type as WalletTransferType) + ? (wallet.type as WalletTransferType) + : "atLeast"; + + const payload: WalletTransferPayloadV1 = { + format: WALLET_TRANSFER_FORMAT, + version: WALLET_TRANSFER_VERSION, + exportedAt: new Date().toISOString(), + exportedFromOrigin: + (req.headers["x-forwarded-proto"] && req.headers.host + ? `${req.headers["x-forwarded-proto"]}://${req.headers.host}` + : `https://${req.headers.host ?? "multisig.meshjs.dev"}`), + exporterAddress: requester, + wallet: { + name: wallet.name, + description: wallet.description ?? "", + type, + signersAddresses: wallet.signersAddresses ?? [], + signersStakeKeys: wallet.signersStakeKeys ?? [], + signersDRepKeys: wallet.signersDRepKeys ?? [], + signersDescriptions: wallet.signersDescriptions ?? [], + numRequiredSigners: wallet.numRequiredSigners ?? null, + scriptCbor: wallet.scriptCbor, + stakeCredentialHash: wallet.stakeCredentialHash ?? null, + profileImageIpfsUrl: wallet.profileImageIpfsUrl ?? null, + }, + }; + + if (include.contacts) { + const contacts = await db.contact.findMany({ + where: { walletId }, + orderBy: { createdAt: "asc" }, + take: 500, + }); + payload.contacts = contacts.map<WalletTransferContact>((c) => ({ + name: c.name, + address: c.address, + description: c.description ?? null, + })); + } + + if (include.ballots) { + const ballots = await db.ballot.findMany({ + where: { walletId }, + orderBy: { createdAt: "asc" }, + take: 200, + }); + payload.ballots = ballots.map<WalletTransferBallot>((b) => ({ + description: b.description ?? null, + items: b.items ?? [], + itemDescriptions: b.itemDescriptions ?? [], + choices: b.choices ?? [], + anchorUrls: b.anchorUrls ?? [], + anchorHashes: b.anchorHashes ?? [], + rationaleComments: b.rationaleComments ?? [], + type: b.type, + })); + } + + void audit(db, { + actorAddress: requester, + actorType: "user", + action: "wallet.transfer.export", + resourceType: "wallet", + resourceId: walletId, + ip: getClientIP(req), + outcome: "success", + metadata: { + includeContacts: include.contacts, + includeBallots: include.ballots, + signerCount: payload.wallet.signersAddresses.length, + }, + }); + + res.setHeader("Cache-Control", "no-store"); + return res.status(200).json(payload); +} diff --git a/src/pages/api/v1/wallet/transfer/import.ts b/src/pages/api/v1/wallet/transfer/import.ts new file mode 100644 index 00000000..73fa66f2 --- /dev/null +++ b/src/pages/api/v1/wallet/transfer/import.ts @@ -0,0 +1,286 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { db } from "@/server/db"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; +import { getClientIP } from "@/lib/security/rateLimit"; +import { audit } from "@/lib/observability/audit"; +import { + WALLET_TRANSFER_FORMAT, + WALLET_TRANSFER_VERSION, + type WalletTransferBallot, + type WalletTransferContact, + type WalletTransferDefinition, + type WalletTransferPayloadV1, + type WalletTransferType, +} from "@/types/walletTransfer"; + +const ALLOWED_TYPES: WalletTransferType[] = ["atLeast", "all", "any"]; +const MAX_SIGNERS = 200; +const MAX_CONTACTS = 500; +const MAX_BALLOTS = 200; + +function stripHtml(input: string): string { + let out = ""; + let inTag = false; + for (let i = 0; i < input.length; i++) { + const ch = input[i]!; + if (ch === "<") { + inTag = true; + continue; + } + if (ch === ">") { + inTag = false; + continue; + } + if (!inTag) out += ch; + } + return out; +} + +function sanitizeText(value: unknown, maxLen: number): string { + if (typeof value !== "string") return ""; + return stripHtml(value).slice(0, maxLen).trim(); +} + +function asStringArray(value: unknown, maxItems: number, maxLen: number): string[] { + if (!Array.isArray(value)) return []; + return value + .slice(0, maxItems) + .map((v) => (typeof v === "string" ? sanitizeText(v, maxLen) : "")); +} + +function validateDefinition(input: unknown): { ok: true; value: WalletTransferDefinition } | { ok: false; error: string } { + if (typeof input !== "object" || input === null) { + return { ok: false, error: "wallet field must be an object" }; + } + const w = input as Record<string, unknown>; + const name = sanitizeText(w.name, 256); + if (!name) return { ok: false, error: "wallet.name is required" }; + const type = w.type as WalletTransferType; + if (!ALLOWED_TYPES.includes(type)) { + return { ok: false, error: "wallet.type must be 'atLeast', 'all', or 'any'" }; + } + const scriptCbor = typeof w.scriptCbor === "string" ? w.scriptCbor.trim() : ""; + if (!scriptCbor) { + return { ok: false, error: "wallet.scriptCbor is required" }; + } + const signersAddresses = asStringArray(w.signersAddresses, MAX_SIGNERS, 512); + if (signersAddresses.length === 0) { + return { ok: false, error: "wallet.signersAddresses must be a non-empty array" }; + } + const signersStakeKeys = asStringArray(w.signersStakeKeys, MAX_SIGNERS, 512); + const signersDRepKeys = asStringArray(w.signersDRepKeys, MAX_SIGNERS, 512); + const signersDescriptions = asStringArray(w.signersDescriptions, MAX_SIGNERS, 256); + const description = sanitizeText(w.description, 2000); + + let numRequiredSigners: number | null = null; + if (type === "atLeast") { + const n = w.numRequiredSigners; + if (typeof n !== "number" || !Number.isFinite(n) || n < 1) { + return { ok: false, error: "wallet.numRequiredSigners must be a positive number when type is 'atLeast'" }; + } + numRequiredSigners = Math.min(Math.floor(n), signersAddresses.length); + } + + const stakeCredentialHash = + typeof w.stakeCredentialHash === "string" && w.stakeCredentialHash.length > 0 + ? w.stakeCredentialHash + : null; + const profileImageIpfsUrl = + typeof w.profileImageIpfsUrl === "string" && w.profileImageIpfsUrl.length > 0 + ? w.profileImageIpfsUrl.slice(0, 1024) + : null; + + return { + ok: true, + value: { + name, + description, + type, + signersAddresses, + signersStakeKeys: signersStakeKeys.length ? signersStakeKeys : signersAddresses.map(() => ""), + signersDRepKeys: signersDRepKeys.length ? signersDRepKeys : signersAddresses.map(() => ""), + signersDescriptions: signersDescriptions.length ? signersDescriptions : signersAddresses.map(() => ""), + numRequiredSigners, + scriptCbor, + stakeCredentialHash, + profileImageIpfsUrl, + }, + }; +} + +function validateContacts(input: unknown): WalletTransferContact[] { + if (!Array.isArray(input)) return []; + const result: WalletTransferContact[] = []; + for (const c of input.slice(0, MAX_CONTACTS)) { + if (typeof c !== "object" || c === null) continue; + const obj = c as Record<string, unknown>; + const name = sanitizeText(obj.name, 128); + const address = sanitizeText(obj.address, 512); + if (!name || !address) continue; + result.push({ + name, + address, + description: sanitizeText(obj.description, 1000) || null, + }); + } + return result; +} + +function validateBallots(input: unknown): WalletTransferBallot[] { + if (!Array.isArray(input)) return []; + const result: WalletTransferBallot[] = []; + for (const b of input.slice(0, MAX_BALLOTS)) { + if (typeof b !== "object" || b === null) continue; + const obj = b as Record<string, unknown>; + const items = asStringArray(obj.items, 256, 256); + if (items.length === 0) continue; + result.push({ + description: sanitizeText(obj.description, 1000) || null, + items, + itemDescriptions: asStringArray(obj.itemDescriptions, 256, 1000), + choices: asStringArray(obj.choices, 256, 32), + anchorUrls: asStringArray(obj.anchorUrls, 256, 1024), + anchorHashes: asStringArray(obj.anchorHashes, 256, 256), + rationaleComments: asStringArray(obj.rationaleComments, 256, 4000), + type: + typeof obj.type === "number" && Number.isFinite(obj.type) + ? Math.floor(obj.type) + : 0, + }); + } + return result; +} + +function buildInviteUrl(req: NextApiRequest, newWalletId: string): string { + const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? "https"; + const host = req.headers.host ?? "multisig.meshjs.dev"; + return `${proto}://${host}/wallets/invite/${newWalletId}`; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/wallet/transfer/import", maxRequests: 5 })) { + return; + } + + await cors(req, res); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + + if (req.method !== "POST") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + if (!enforceBodySize(req, res, 200 * 1024)) { + return; + } + + if (typeof req.body !== "object" || req.body === null) { + return res.status(400).json({ error: "Invalid request body" }); + } + + const body = req.body as Partial<WalletTransferPayloadV1>; + + if (body.format !== WALLET_TRANSFER_FORMAT) { + return res.status(400).json({ error: "Invalid payload format" }); + } + if (body.version !== WALLET_TRANSFER_VERSION) { + return res.status(400).json({ error: `Unsupported payload version (expected ${WALLET_TRANSFER_VERSION})` }); + } + + const validation = validateDefinition(body.wallet); + if (!validation.ok) { + return res.status(400).json({ error: validation.error }); + } + const def = validation.value; + + const contacts = validateContacts(body.contacts); + const ballots = validateBallots(body.ballots); + + try { + const exporterAddress = sanitizeText(body.exporterAddress, 512) || null; + const exportedFromOrigin = sanitizeText(body.exportedFromOrigin, 512) || null; + + const newWallet = await db.newWallet.create({ + data: { + name: def.name, + description: def.description, + signersAddresses: def.signersAddresses, + signersStakeKeys: def.signersStakeKeys, + signersDRepKeys: def.signersDRepKeys, + signersDescriptions: def.signersDescriptions, + numRequiredSigners: def.numRequiredSigners ?? null, + ownerAddress: "all", + stakeCredentialHash: def.stakeCredentialHash, + scriptType: def.type, + paymentCbor: def.scriptCbor, + stakeCbor: "", + usesStored: false, + rawImportBodies: { + source: "wallet-transfer", + exporterAddress, + exportedFromOrigin, + exportedAt: typeof body.exportedAt === "string" ? body.exportedAt : null, + profileImageIpfsUrl: def.profileImageIpfsUrl ?? null, + }, + }, + }); + + if (contacts.length > 0) { + await db.contact.createMany({ + data: contacts.map((c) => ({ + walletId: newWallet.id, + name: c.name, + address: c.address, + description: c.description ?? null, + })), + skipDuplicates: true, + }); + } + + if (ballots.length > 0) { + await db.ballot.createMany({ + data: ballots.map((b) => ({ + walletId: newWallet.id, + description: b.description ?? null, + items: b.items, + itemDescriptions: b.itemDescriptions, + choices: b.choices, + anchorUrls: b.anchorUrls, + anchorHashes: b.anchorHashes, + rationaleComments: b.rationaleComments, + type: b.type, + })), + }); + } + + const inviteUrl = buildInviteUrl(req, newWallet.id); + + void audit(db, { + actorAddress: exporterAddress, + actorType: "user", + action: "wallet.transfer.import", + resourceType: "wallet", + resourceId: newWallet.id, + ip: getClientIP(req), + outcome: "success", + metadata: { + exportedFromOrigin, + signerCount: def.signersAddresses.length, + contactCount: contacts.length, + ballotCount: ballots.length, + }, + }); + + return res.status(200).json({ + newWalletId: newWallet.id, + inviteUrl, + }); + } catch (err) { + console.error("[api/v1/wallet/transfer/import] failed:", err); + return res.status(500).json({ error: "Failed to import wallet" }); + } +} diff --git a/src/pages/api/v1/walletIds.ts b/src/pages/api/v1/walletIds.ts index bcf2e59b..3dd10fb3 100644 --- a/src/pages/api/v1/walletIds.ts +++ b/src/pages/api/v1/walletIds.ts @@ -3,7 +3,10 @@ import { createCaller } from "@/server/api/root"; import { db } from "@/server/db"; import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; -import { applyRateLimit, applyBotRateLimit } from "@/lib/security/requestGuards"; +import { + applyRateLimit, + applyBotRateLimit, +} from "@/lib/security/requestGuards"; import { getClientIP } from "@/lib/security/rateLimit"; import { getWalletIdsForBot } from "@/lib/auth/botAccess"; @@ -13,7 +16,7 @@ export default async function handler( ) { // Add cache-busting headers for CORS addCorsCacheBustingHeaders(res); - + if (!applyRateLimit(req, res, { keySuffix: "v1/walletIds" })) { return; } @@ -31,7 +34,12 @@ export default async function handler( const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!token) { - return res.status(401).json({ error: "Unauthorized - Missing or malformed Authorization header (expected: Bearer <token>)" }); + return res + .status(401) + .json({ + error: + "Unauthorized - Missing or malformed Authorization header (expected: Bearer <token>)", + }); } const payload = verifyJwt(token); @@ -67,6 +75,8 @@ export default async function handler( try { let walletIds: { walletId: string; walletName: string }[]; + // Unaccepted invitations, surfaced as a number rather than by name. + let pendingInvitations = 0; if (isBotJwt(payload)) { walletIds = await getWalletIdsForBot(db, payload.botId); } else { @@ -79,12 +89,40 @@ export default async function handler( ip: getClientIP(req), }); const wallets = await caller.wallet.getUserWallets({ address }); - walletIds = (wallets ?? []).map((w) => ({ walletId: w.id, walletName: w.name })); + // Only wallets this address has actually ACCEPTED are named. + // + // `createWallet` takes an arbitrary 256-character name and an arbitrary + // list of signer addresses, with no consent from the addresses it names, + // and `getUserWallets` returns every wallet that merely lists you. So any + // authenticated stranger can put text of their choosing in front of you + // by naming you as a signer — and this endpoint feeds an MCP tool, whose + // results are JSON.stringify'd straight into a model's context. That is a + // prompt-injection channel from an unrelated attacker. + // + // Acceptance is ownership, or having proved control of the address by + // signing the verification nonce. Everything else is an unaccepted + // invitation and is reported as a COUNT only: a number carries no + // attacker-chosen text. + const accepted = (wallets ?? []).filter( + (w) => w.ownerAddress === address || w.verified.includes(address), + ); + pendingInvitations = (wallets?.length ?? 0) - accepted.length; + walletIds = accepted.map((w) => ({ walletId: w.id, walletName: w.name })); } // No memberships is a valid answer, not an error — return an empty list // so clients can tell "not in any wallets yet" from a bad request. - res.setHeader("Cache-Control", "private, max-age=120, stale-while-revalidate=300"); + res.setHeader( + "Cache-Control", + "private, max-age=120, stale-while-revalidate=300", + ); + + // The documented contract (see src/utils/swagger.ts and the dApps example) + // is a bare array, and bots depend on it. `includePending=true` opts into + // the richer object instead of breaking every existing caller. + if (req.query.includePending === "true") { + return res.status(200).json({ wallets: walletIds, pendingInvitations }); + } res.status(200).json(walletIds); } catch (error) { console.error("Error fetching wallet IDs:", error); diff --git a/src/pages/bot-setup.tsx b/src/pages/bot-setup.tsx new file mode 100644 index 00000000..7cdbf096 --- /dev/null +++ b/src/pages/bot-setup.tsx @@ -0,0 +1,46 @@ +import type { GetServerSideProps } from "next"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import { buildBotSetupGuide } from "@/lib/botSetupGuide"; + +// Match the blog's Markdown styling; this project has no typography plugin. +const GUIDE_STYLES = + "max-w-none break-words text-sm leading-relaxed text-foreground " + + "[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:tracking-tight " + + "[&_h2]:mt-8 [&_h2]:text-xl [&_h2]:font-semibold " + + "[&_h3]:mt-6 [&_h3]:text-lg [&_h3]:font-semibold " + + "[&_p]:mt-4 [&_ul]:mt-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:mt-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_li]:mt-1.5 " + + "[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_strong]:font-semibold " + + "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-sm " + + "[&_pre]:mt-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-zinc-950 [&_pre]:p-4 [&_pre]:text-zinc-100 [&_pre_code]:bg-transparent [&_pre_code]:p-0 " + + "[&_table]:mt-4 [&_table]:block [&_table]:w-full [&_table]:overflow-x-auto " + + "[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left " + + "[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2"; + +type Props = { + markdown: string; +}; + +export const getServerSideProps: GetServerSideProps<Props> = async () => { + return { props: { markdown: buildBotSetupGuide() } }; +}; + +export default function BotSetupPage({ markdown }: Props) { + return ( + <main className="mx-auto w-full min-w-0 max-w-3xl px-4 py-8"> + <div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> + <h1 className="text-2xl font-semibold">Bot Setup</h1> + <a + className="text-sm underline text-muted-foreground" + href="/api/v1/botSetupGuide" + > + Raw markdown (/api/v1/botSetupGuide) + </a> + </div> + <article className={GUIDE_STYLES}> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown> + </article> + </main> + ); +} diff --git a/src/pages/llms.txt.tsx b/src/pages/llms.txt.tsx index f171ac0b..2221b690 100644 --- a/src/pages/llms.txt.tsx +++ b/src/pages/llms.txt.tsx @@ -16,6 +16,8 @@ export function buildLlmsTxt(): string { const spec = absoluteUrl("/api/swagger"); const skill = absoluteUrl("/api/skill"); const docs = absoluteUrl("/api-docs"); + const botSetupGuide = absoluteUrl("/api/v1/botSetupGuide"); + const botSetup = absoluteUrl("/bot-setup"); return `# ${SITE_NAME} @@ -34,6 +36,8 @@ but funds move only once the required quorum of signatures is collected. - [Agent skill (Markdown)](${skill}): a ready-to-load skill describing the full bot workflow end to end — download and give it to your agent. - [Interactive API docs](${docs}): human Swagger UI with a wallet-based bearer-token generator. +- [Bot setup guide (Markdown)](${botSetupGuide}): the five-phase onboarding flow, agent-readable. +- [Bot setup page](${botSetup}): the same guide rendered for a human operator. ## Authentication @@ -74,6 +78,10 @@ Write (required scope in parentheses): Plutus proxy endpoints (\`/api/v1/proxy*\`) follow the same request pattern; see the OpenAPI spec. +Cross-instance wallet transfer: +- GET /api/v1/wallet/transfer/export?walletId=... — export a wallet definition (owner JWT). +- POST /api/v1/wallet/transfer/import — receive a wallet definition exported from another instance. + ## Notes - Status: alpha — endpoints may change. The OpenAPI spec at ${spec} is the source of truth. diff --git a/src/pages/user/index.tsx b/src/pages/user/index.tsx index 972794cc..4c75009e 100644 --- a/src/pages/user/index.tsx +++ b/src/pages/user/index.tsx @@ -19,6 +19,7 @@ import useUTXOS from "@/hooks/useUTXOS"; import { Badge } from "@/components/ui/badge"; import BotManagementCard from "@/components/pages/user/BotManagementCard"; import McpConnectionsCard from "@/components/pages/user/McpConnectionsCard"; +import SharedProxiesCard from "@/components/pages/user/SharedProxiesCard"; export const getServerSideProps = () => ({ props: {} }); @@ -361,6 +362,8 @@ export default function UserInfoPage() { <McpConnectionsCard /> + <SharedProxiesCard /> + <CardUI title="Account Details" description="Additional account information" diff --git a/src/pages/vault.tsx b/src/pages/vault.tsx new file mode 100644 index 00000000..1b247ba6 --- /dev/null +++ b/src/pages/vault.tsx @@ -0,0 +1,54 @@ +import type { InferGetServerSidePropsType } from "next"; + +import VaultBrowser from "@/components/pages/vault/browser"; +import { loadVaultTrustView } from "@/lib/vault-trust"; + +/** + * The vault, browsable, with its trust graph over the top. + * + * `getServerSideProps` rather than static for the same reason /roadmap/graph + * uses it: prerendering this SPA dies with "NextRouter was not mounted", and it + * survives a local build while breaking the deployed one. + */ +export const getServerSideProps = () => ({ + props: { view: loadVaultTrustView() }, +}); + +export default function Page({ + view, +}: InferGetServerSidePropsType<typeof getServerSideProps>) { + return ( + <div className="relative z-20 mx-auto w-full min-w-0 max-w-[1400px] py-10 lg:py-8"> + <div className="px-8"> + <h1 className="text-3xl font-medium tracking-tight text-black dark:text-white lg:text-4xl"> + Vault + </h1> + <p className="my-4 max-w-3xl text-sm font-normal text-neutral-500 dark:text-neutral-300 lg:text-base"> + Linked Markdown, hashed so every trust edge commits to what it points + at. Areas act as proxy hubs; the root commits to their hashes and + never their titles. Selecting a note shows the path a proof of it + would reveal, and the siblings it would keep sealed. + </p> + <p className="my-4 max-w-3xl text-sm text-neutral-500 dark:text-neutral-300 lg:text-base"> + New to this?{" "} + <a + href="/blog/how-to-use-shielded-sign-off" + className="underline underline-offset-2" + > + How to use shielded sign-off + </a>{" "} + explains the two kinds of link, why one hub is not enough, and exactly + what a disclosure gives away. + </p> + <p className="max-w-3xl font-mono text-xs text-neutral-500 dark:text-neutral-400"> + root {view.rootHash.slice(0, 32)}… · {view.hubs.length} hubs ·{" "} + {view.notes.length} notes + </p> + </div> + + <div className="mt-8 px-4 sm:px-8"> + <VaultBrowser view={view} /> + </div> + </div> + ); +} diff --git a/src/pages/verify.tsx b/src/pages/verify.tsx new file mode 100644 index 00000000..10c6b412 --- /dev/null +++ b/src/pages/verify.tsx @@ -0,0 +1,12 @@ +import PageVerifyProof from "@/components/pages/verify"; + +/** + * Public proof verifier. `getServerSideProps` for the same reason the other + * client-only pages use it: prerendering a page that mounts the wallet + * providers dies with "NextRouter was not mounted" on the deployed build. + */ +export const getServerSideProps = () => ({ props: {} }); + +export default function Page() { + return <PageVerifyProof />; +} diff --git a/src/pages/wallets/[wallet]/documents/[documentId]/edit.tsx b/src/pages/wallets/[wallet]/documents/[documentId]/edit.tsx new file mode 100644 index 00000000..f4a84eeb --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/[documentId]/edit.tsx @@ -0,0 +1,7 @@ +import PageDocumentEditor from "@/components/pages/wallet/documents/editor"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function PageWalletDocumentEdit() { + return <PageDocumentEditor />; +} diff --git a/src/pages/wallets/[wallet]/documents/[documentId]/index.tsx b/src/pages/wallets/[wallet]/documents/[documentId]/index.tsx new file mode 100644 index 00000000..53bf7c1d --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/[documentId]/index.tsx @@ -0,0 +1,7 @@ +import PageDocumentDetail from "@/components/pages/wallet/documents/detail"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function PageWalletDocumentDetail() { + return <PageDocumentDetail />; +} diff --git a/src/pages/wallets/[wallet]/documents/[documentId]/review/[versionId].tsx b/src/pages/wallets/[wallet]/documents/[documentId]/review/[versionId].tsx new file mode 100644 index 00000000..36054132 --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/[documentId]/review/[versionId].tsx @@ -0,0 +1,7 @@ +import PageDocumentReview from "@/components/pages/wallet/documents/review"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function PageWalletDocumentReview() { + return <PageDocumentReview />; +} diff --git a/src/pages/wallets/[wallet]/documents/index.tsx b/src/pages/wallets/[wallet]/documents/index.tsx new file mode 100644 index 00000000..24817a94 --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/index.tsx @@ -0,0 +1,7 @@ +import PageDocuments from "@/components/pages/wallet/documents"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function PageWalletDocuments() { + return <PageDocuments />; +} diff --git a/src/pages/wallets/[wallet]/documents/new.tsx b/src/pages/wallets/[wallet]/documents/new.tsx new file mode 100644 index 00000000..6d6e221f --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/new.tsx @@ -0,0 +1,7 @@ +import PageDocumentNew from "@/components/pages/wallet/documents/new"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function PageWalletDocumentNew() { + return <PageDocumentNew />; +} diff --git a/src/pages/wallets/[wallet]/documents/preview.tsx b/src/pages/wallets/[wallet]/documents/preview.tsx new file mode 100644 index 00000000..25b02806 --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/preview.tsx @@ -0,0 +1,19 @@ +import type { InferGetServerSidePropsType } from "next"; + +import PageDocumentsVaultPreview from "@/components/pages/wallet/documents/vault-preview"; +import { loadVaultPreviewProps } from "@/lib/documents/vault-preview"; + +/** + * Shielded sign-off, previewed inside the wallet's Documents section. + * + * `getServerSideProps` rather than static, for the reason /vault and + * /roadmap/graph both use it: prerendering this SPA dies with "NextRouter was + * not mounted", which passes locally and breaks the deployed build. + */ +export const getServerSideProps = () => ({ props: loadVaultPreviewProps() }); + +export default function PageWalletDocumentsPreview({ + view, +}: InferGetServerSidePropsType<typeof getServerSideProps>) { + return <PageDocumentsVaultPreview view={view} />; +} diff --git a/src/pages/wallets/[wallet]/documents/vault.tsx b/src/pages/wallets/[wallet]/documents/vault.tsx new file mode 100644 index 00000000..ff13b871 --- /dev/null +++ b/src/pages/wallets/[wallet]/documents/vault.tsx @@ -0,0 +1,7 @@ +import PageWalletVault from "@/components/pages/wallet/documents/vault"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function PageWalletDocumentsVault() { + return <PageWalletVault />; +} diff --git a/src/pages/wallets/[wallet]/tasks/index.tsx b/src/pages/wallets/[wallet]/tasks/index.tsx new file mode 100644 index 00000000..5cc61a90 --- /dev/null +++ b/src/pages/wallets/[wallet]/tasks/index.tsx @@ -0,0 +1,7 @@ +import PageTasks from "@/components/pages/wallet/tasks"; + +export const getServerSideProps = () => ({ props: {} }); + +export default function Page() { + return <PageTasks />; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 5f93ab7d..b5178e75 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -10,8 +10,10 @@ import { authRouter } from "./routers/auth"; import { contactRouter } from "./routers/contacts"; import { botRouter } from "./routers/bot"; import { governanceRouter } from "./routers/governance"; +import { documentRouter } from "./routers/documents"; import { notificationRouter } from "./routers/notifications"; import { mcpRouter } from "./routers/mcp"; +import { taskRouter } from "./routers/tasks"; /** * This is the primary router for your server. @@ -32,6 +34,8 @@ export const appRouter = createTRPCRouter({ bot: botRouter, governance: governanceRouter, notification: notificationRouter, + document: documentRouter, + task: taskRouter, }); // export type definition of API diff --git a/src/server/api/routers/documents.ts b/src/server/api/routers/documents.ts new file mode 100644 index 00000000..6ab3117c --- /dev/null +++ b/src/server/api/routers/documents.ts @@ -0,0 +1,1715 @@ +/** + * Document Sign-Off (PRD-001) — tRPC router. + * + * Six operations: createDocument, uploadVersion, startReview, + * submitSignerAction, exportProof, verifyProof — plus the reads the four + * Documents pages need. + * + * Two rules carry the whole feature and are enforced here, not in the UI: + * + * 1. **Version-hash binding.** A review is attached to a DocumentVersion's + * content hash. The server rebuilds the signed payload from its own + * records and rejects the submission unless it is byte-identical to what + * the client signed, so a signature collected against one version can + * never be replayed onto another. + * + * 2. **Threshold inheritance from a frozen snapshot.** Required approvals + * come from the DocumentSignerSnapshot captured when the round started, + * never from the live wallet — changing the wallet's signers must not + * rewrite a decision that has already been made. + */ + +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { checkSignature } from "@meshsdk/core"; +import { Prisma, type Wallet } from "@prisma/client"; + +import { + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "@/server/api/trpc"; +import type { AuthCtx } from "@/server/api/trpc"; +import { randomBytes } from "crypto"; + +import { audit } from "@/lib/observability/audit"; +import { buildWalletVaultView } from "@/lib/documents/vault-db"; +import { + ATTESTATION_DOMAIN, + ATTESTATION_STATEMENT, + GENESIS_PREV, + attestationHash, + buildAttestationPayload, + canonicalizeAttestation, + verifyAttestationChain, + type AttestationRecord, +} from "@/lib/documents/attestation"; +import { + getAttestationPublicKeys, + getAttestationSigner, +} from "@/lib/documents/attestation-key"; +import { + buildSignOffPayload, + canonicalizeSignOffPayload, + evaluateThreshold, + isSha256Hex, + isSignedAtWithinTolerance, + normalizeContentHash, + sha256Hex, + walletPolicyHash, +} from "@/lib/documents/payload"; +import { + PROOF_FORMAT, + VERIFICATION_INSTRUCTIONS, + proofPackageProblem, + verifyProofPackage, + type ProofPackage, +} from "@/lib/documents/proof"; +import { SIGNOFF_DOMAIN } from "@/lib/documents/payload"; + +/** Inline content is a convenience for small files, not a document store. */ +const MAX_INLINE_BYTES = 512 * 1024; + +const contentHashSchema = z + .string() + .trim() + .transform(normalizeContentHash) + .refine(isSha256Hex, { message: "contentHash must be a sha256 hex digest" }); + +// --------------------------------------------------------------------------- +// Access helpers +// --------------------------------------------------------------------------- + +const getSessionAddresses = (ctx: AuthCtx): string[] => { + const sessionWallets: string[] = ctx.sessionWallets ?? []; + if (Array.isArray(sessionWallets) && sessionWallets.length > 0) { + return sessionWallets; + } + const single = ctx.session?.user?.id ?? ctx.sessionAddress; + return single ? [single] : []; +}; + +/** Wallet membership: a signer or the owner. Mirrors the ballot router. */ +const assertWalletAccess = async ( + ctx: AuthCtx, + walletId: string, +): Promise<{ wallet: Wallet; addresses: string[] }> => { + const wallet = await ctx.db.wallet.findUnique({ where: { id: walletId } }); + if (!wallet) { + throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); + } + + const addresses = getSessionAddresses(ctx); + if (addresses.length === 0) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const authorized = addresses.some( + (addr) => + (Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.includes(addr)) || + wallet.ownerAddress === addr, + ); + if (!authorized) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized for this wallet", + }); + } + + return { wallet, addresses }; +}; + +const assertDocumentAccess = async (ctx: AuthCtx, documentId: string) => { + const document = await ctx.db.document.findUnique({ + where: { id: documentId }, + }); + if (!document) { + throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" }); + } + const { wallet, addresses } = await assertWalletAccess( + ctx, + document.walletId, + ); + return { document, wallet, addresses }; +}; + +/** + * Document access for a caller who may be a wallet member, a named contract + * party, or both. + * + * `assertDocumentAccess` authorizes only wallet members, which is correct for + * sign-off and fatal for contracts: a counterparty is in neither + * signersAddresses nor ownerAddress, so every document procedure rejects them + * with FORBIDDEN before any party logic runs. This is the gate that lets them + * in — and only to the document they were named on, never to the wallet. + * + * Returns EVERY party the caller holds, not one. One human may hold two roles, + * so picking a single party here would silently choose a capacity on their + * behalf; anything that acts as a party must be told which one. + */ +const resolveDocumentAccess = async (ctx: AuthCtx, documentId: string) => { + const document = await ctx.db.document.findUnique({ + where: { id: documentId }, + }); + if (!document) { + throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" }); + } + + const addresses = getSessionAddresses(ctx); + if (addresses.length === 0) throw new TRPCError({ code: "UNAUTHORIZED" }); + + const wallet = await ctx.db.wallet.findUnique({ + where: { id: document.walletId }, + }); + if (!wallet) { + throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); + } + + const isMember = addresses.some( + (addr) => + (Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.includes(addr)) || + wallet.ownerAddress === addr, + ); + + // Only a REDEEMED party counts. An unredeemed row carries an address nobody + // has proved control of — it is an intention to invite, not an identity. + const parties = await ctx.db.contractParty.findMany({ + where: { + documentId: document.id, + address: { in: addresses }, + inviteConsumedAt: { not: null }, + }, + orderBy: [{ signingOrder: "asc" }, { id: "asc" }], + }); + + if (!isMember && parties.length === 0) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized for this document", + }); + } + + return { document, wallet, addresses, parties, isMember }; +}; + +/** + * The party roster is frozen once a round starts. + * + * `startReview` copies the parties into DocumentSignerSnapshot, and the whole + * feature rests on that snapshot being immutable — adding or removing a party + * afterwards would leave the frozen list describing a set that no longer + * exists, exactly the drift the snapshot was introduced to prevent. Mirrors the + * existing "A review round has already started" guard. + */ +const assertPartyRosterOpen = async (ctx: AuthCtx, documentId: string) => { + const latest = await ctx.db.documentVersion.findFirst({ + where: { documentId }, + orderBy: { versionNumber: "desc" }, + include: { signerSnapshot: true }, + }); + if (latest?.signerSnapshot) { + throw new TRPCError({ + code: "CONFLICT", + message: + "A review round has already started for this document. The party " + + "list is frozen until a new version is published.", + }); + } +}; + +/** The acting address for a write — one of the session addresses. */ +const actingAddress = (addresses: string[], claimed?: string): string => { + if (claimed) { + if (!addresses.includes(claimed)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "signerAddress is not one of your session addresses", + }); + } + return claimed; + } + const first = addresses[0]; + if (!first) throw new TRPCError({ code: "UNAUTHORIZED" }); + return first; +}; + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +export const documentRouter = createTRPCRouter({ + /** Document list for a wallet — the list page. */ + listByWallet: protectedProcedure + .input( + z.object({ + walletId: z.string().min(1), + includeArchived: z.boolean().default(false), + }), + ) + .query(async ({ ctx, input }) => { + await assertWalletAccess(ctx, input.walletId); + return ctx.db.document.findMany({ + where: { + walletId: input.walletId, + ...(input.includeArchived ? {} : { status: { not: "Archived" } }), + }, + orderBy: { updatedAt: "desc" }, + include: { + versions: { + orderBy: { versionNumber: "desc" }, + include: { + signerSnapshot: true, + reviews: { + select: { signerAddress: true, action: true, signedAt: true }, + }, + }, + }, + }, + }); + }), + + /** Full document with history — the detail page. */ + getById: protectedProcedure + .input(z.object({ documentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + // Party-aware: a counterparty must be able to read the contract they were + // named on. Scoped to that one document — being a party grants nothing + // anywhere else in the wallet. + const { document } = await resolveDocumentAccess(ctx, input.documentId); + return ctx.db.document.findUnique({ + where: { id: document.id }, + include: { + versions: { + orderBy: { versionNumber: "desc" }, + include: { signerSnapshot: true, reviews: true }, + }, + events: { orderBy: { createdAt: "asc" } }, + }, + }); + }), + + /** + * Everything the review page needs, including the exact payload the signer + * is about to sign. Handing the payload back from the server (rather than + * letting the client compose it) is what makes "what you see is what you + * sign" true — the same builder runs again on submit. + */ + getVersionForReview: protectedProcedure + .input( + z.object({ + versionId: z.string().min(1), + action: z.enum(["approve", "reject"]).default("approve"), + comment: z.string().max(2000).optional(), + signerAddress: z.string().min(1).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const version = await ctx.db.documentVersion.findUnique({ + where: { id: input.versionId }, + include: { document: true, signerSnapshot: true, reviews: true }, + }); + if (!version) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Version not found", + }); + } + const { addresses } = await assertWalletAccess( + ctx, + version.document.walletId, + ); + const signerAddress = actingAddress(addresses, input.signerAddress); + + const snapshot = version.signerSnapshot; + const alreadyActed = version.reviews.find( + (r) => r.signerAddress === signerAddress, + ); + + const payload = snapshot + ? buildSignOffPayload({ + action: input.action, + comment: input.comment, + contentHash: version.contentHash, + documentId: version.documentId, + signedAt: new Date(), + signerAddress, + versionId: version.id, + versionNumber: version.versionNumber, + walletId: version.document.walletId, + walletPolicyHash: snapshot.walletPolicyHash, + }) + : null; + + return { + version, + document: version.document, + snapshot, + signerAddress, + canSign: + !!snapshot && + version.status === "InReview" && + snapshot.signersAddresses.includes(signerAddress) && + !alreadyActed, + alreadyActed: alreadyActed ?? null, + payload, + payloadToSign: payload ? canonicalizeSignOffPayload(payload) : null, + }; + }), + + /** 1. Create a document, optionally with its first version. */ + createDocument: protectedProcedure + .input( + z.object({ + walletId: z.string().min(1), + title: z.string().trim().min(1).max(200), + description: z.string().max(5000).optional(), + documentType: z.string().max(100).optional(), + firstVersion: z + .object({ + contentHash: contentHashSchema, + fileName: z.string().max(300).optional(), + mimeType: z.string().max(200).optional(), + fileSize: z.number().int().nonnegative().optional(), + storageMode: z + .enum(["hashOnly", "inline", "external"]) + .default("hashOnly"), + contentRef: z.string().max(2000).optional(), + contentInline: z.string().optional(), + reviewInstructions: z.string().max(5000).optional(), + }) + .optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { addresses } = await assertWalletAccess(ctx, input.walletId); + const creator = actingAddress(addresses); + + const document = await ctx.db.$transaction(async (tx) => { + const created = await tx.document.create({ + data: { + walletId: input.walletId, + title: input.title, + description: input.description ?? null, + documentType: input.documentType ?? null, + createdBy: creator, + status: "Draft", + }, + }); + + await tx.documentEvent.create({ + data: { + documentId: created.id, + type: "document.created", + actorAddress: creator, + metadata: { title: created.title }, + }, + }); + + if (input.firstVersion) { + const version = await createVersionRow(tx, { + documentId: created.id, + versionNumber: 1, + createdBy: creator, + ...input.firstVersion, + }); + await tx.documentEvent.create({ + data: { + documentId: created.id, + versionId: version.id, + type: "version.uploaded", + actorAddress: creator, + metadata: { + versionNumber: version.versionNumber, + contentHash: version.contentHash, + }, + }, + }); + await attestVersion(tx, version, input.walletId); + } + + return created; + }); + + void audit(ctx.db, { + actorAddress: creator, + actorType: "user", + action: "document.create", + resourceType: "document", + resourceId: document.id, + outcome: "success", + metadata: { walletId: input.walletId }, + }); + + return document; + }), + + /** + * 2. Upload a new version. Approvals never carry forward: the previous + * version is superseded and the new one starts at zero approvals, because + * approval is bound to the content hash, not to the title. + */ + uploadVersion: protectedProcedure + .input( + z.object({ + documentId: z.string().min(1), + contentHash: contentHashSchema, + fileName: z.string().max(300).optional(), + mimeType: z.string().max(200).optional(), + fileSize: z.number().int().nonnegative().optional(), + storageMode: z + .enum(["hashOnly", "inline", "external"]) + .default("hashOnly"), + contentRef: z.string().max(2000).optional(), + contentInline: z.string().optional(), + reviewInstructions: z.string().max(5000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { document, addresses } = await assertDocumentAccess( + ctx, + input.documentId, + ); + if (document.status === "Archived") { + throw new TRPCError({ + code: "CONFLICT", + message: "Document is archived", + }); + } + const actor = actingAddress(addresses); + + const version = await ctx.db.$transaction(async (tx) => { + const latest = await tx.documentVersion.findFirst({ + where: { documentId: document.id }, + orderBy: { versionNumber: "desc" }, + }); + + if ( + latest && + latest.status !== "Superseded" && + latest.status !== "Archived" + ) { + await tx.documentVersion.update({ + where: { id: latest.id }, + data: { status: "Superseded", supersededAt: new Date() }, + }); + await tx.documentEvent.create({ + data: { + documentId: document.id, + versionId: latest.id, + type: "version.superseded", + actorAddress: actor, + metadata: { versionNumber: latest.versionNumber }, + }, + }); + } + + const created = await createVersionRow(tx, { + documentId: document.id, + versionNumber: (latest?.versionNumber ?? 0) + 1, + createdBy: actor, + contentHash: input.contentHash, + fileName: input.fileName, + mimeType: input.mimeType, + fileSize: input.fileSize, + storageMode: input.storageMode, + contentRef: input.contentRef, + contentInline: input.contentInline, + reviewInstructions: input.reviewInstructions, + }); + + await tx.documentEvent.create({ + data: { + documentId: document.id, + versionId: created.id, + type: "version.uploaded", + actorAddress: actor, + metadata: { + versionNumber: created.versionNumber, + contentHash: created.contentHash, + approvalsReset: true, + }, + }, + }); + + await attestVersion(tx, created, document.walletId); + + await tx.document.update({ + where: { id: document.id }, + data: { status: "Draft" }, + }); + + return created; + }); + + return version; + }), + + /** + * 3. Start a review round: freeze the wallet's signer set and threshold onto + * the version. Everything downstream reads the snapshot, not the wallet. + */ + startReview: protectedProcedure + .input(z.object({ versionId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const version = await ctx.db.documentVersion.findUnique({ + where: { id: input.versionId }, + include: { document: true, signerSnapshot: true }, + }); + if (!version) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Version not found", + }); + } + const { wallet, addresses } = await assertWalletAccess( + ctx, + version.document.walletId, + ); + const actor = actingAddress(addresses); + + if (version.signerSnapshot) { + throw new TRPCError({ + code: "CONFLICT", + message: "A review round has already started for this version", + }); + } + if (version.status !== "Draft") { + throw new TRPCError({ + code: "CONFLICT", + message: `Cannot start a review on a ${version.status} version`, + }); + } + + const signers = wallet.signersAddresses ?? []; + const required = wallet.numRequiredSigners ?? signers.length; + if (signers.length === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Wallet has no signers", + }); + } + if (required < 1 || required > signers.length) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Wallet threshold is not usable", + }); + } + + const result = await ctx.db.$transaction(async (tx) => { + const snapshot = await tx.documentSignerSnapshot.create({ + data: { + versionId: version.id, + walletId: wallet.id, + signersAddresses: signers, + signersDescriptions: wallet.signersDescriptions ?? [], + requiredSigners: required, + walletPolicyHash: walletPolicyHash(wallet.scriptCbor), + }, + }); + await tx.documentVersion.update({ + where: { id: version.id }, + data: { status: "InReview", reviewStartedAt: new Date() }, + }); + await tx.document.update({ + where: { id: version.documentId }, + data: { status: "InReview" }, + }); + await tx.documentEvent.create({ + data: { + documentId: version.documentId, + versionId: version.id, + type: "review.started", + actorAddress: actor, + metadata: { + requiredSigners: required, + signerCount: signers.length, + walletPolicyHash: snapshot.walletPolicyHash, + }, + }, + }); + return snapshot; + }); + + void audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "document.review.start", + resourceType: "document", + resourceId: version.documentId, + outcome: "success", + metadata: { versionId: version.id, requiredSigners: required }, + }); + + return result; + }), + + /** + * 4. Submit a signer's approve/reject with its CIP-8 signature. + * + * The submitted payload is never trusted. The server rebuilds it from its + * own records and requires a byte-identical match before the signature is + * even checked — a signature harvested for version 2 cannot be recorded + * against version 3, and a tampered comment invalidates the whole thing. + */ + submitSignerAction: protectedProcedure + .input( + z.object({ + versionId: z.string().min(1), + action: z.enum(["approve", "reject"]), + comment: z.string().max(2000).optional(), + signerAddress: z.string().min(1), + signedAt: z.string().min(1), + payload: z.string().min(1), + signature: z.string().min(1), + signatureKey: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const version = await ctx.db.documentVersion.findUnique({ + where: { id: input.versionId }, + include: { document: true, signerSnapshot: true, reviews: true }, + }); + if (!version) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Version not found", + }); + } + const { addresses } = await assertWalletAccess( + ctx, + version.document.walletId, + ); + const signerAddress = actingAddress(addresses, input.signerAddress); + + const deny = ( + reason: string, + code: "CONFLICT" | "FORBIDDEN" | "BAD_REQUEST" = "BAD_REQUEST", + ): never => { + void audit(ctx.db, { + actorAddress: signerAddress, + actorType: "user", + action: "document.review.submit", + resourceType: "document", + resourceId: version.documentId, + outcome: "denied", + reason, + metadata: { versionId: version.id }, + }); + throw new TRPCError({ code, message: reason }); + }; + + const snapshot = version.signerSnapshot; + if (!snapshot) + deny("No review round has been started for this version", "CONFLICT"); + if (version.status !== "InReview") { + deny(`Version is ${version.status}, not open for review`, "CONFLICT"); + } + if (!snapshot!.signersAddresses.includes(signerAddress)) { + deny("Signer is not in this round's signer snapshot", "FORBIDDEN"); + } + if (version.reviews.some((r) => r.signerAddress === signerAddress)) { + deny("This signer has already acted on this version", "CONFLICT"); + } + + const signedAt = new Date(input.signedAt); + if (Number.isNaN(signedAt.getTime())) + deny("signedAt is not a valid date"); + if (!isSignedAtWithinTolerance(signedAt)) { + deny("signedAt is outside the accepted time window"); + } + + // --- version-hash binding: rebuild, then compare byte for byte -------- + const expected = canonicalizeSignOffPayload( + buildSignOffPayload({ + action: input.action, + comment: input.comment, + contentHash: version.contentHash, + documentId: version.documentId, + signedAt, + signerAddress, + versionId: version.id, + versionNumber: version.versionNumber, + walletId: version.document.walletId, + walletPolicyHash: snapshot!.walletPolicyHash, + }), + ); + if (expected !== input.payload) { + deny( + "Signed payload does not match this document version — refusing to record the signature", + ); + } + + // --- CIP-8: this address's key signed exactly that payload ------------ + let signatureValid = false; + try { + signatureValid = await checkSignature( + input.payload, + { key: input.signatureKey, signature: input.signature }, + signerAddress, + ); + } catch (error) { + deny( + `Signature verification failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!signatureValid) + deny("Invalid signature for this signer", "FORBIDDEN"); + + const result = await ctx.db.$transaction(async (tx) => { + const review = await tx.documentReview.create({ + data: { + versionId: version.id, + signerAddress, + action: input.action, + comment: input.comment ?? null, + payload: input.payload, + signature: input.signature, + signatureKey: input.signatureKey, + signedAt, + }, + }); + + await tx.documentEvent.create({ + data: { + documentId: version.documentId, + versionId: version.id, + type: + input.action === "approve" + ? "review.approved" + : "review.rejected", + actorAddress: signerAddress, + metadata: { versionNumber: version.versionNumber }, + }, + }); + + const reviews = await tx.documentReview.findMany({ + where: { versionId: version.id }, + }); + const approvals = reviews.filter((r) => r.action === "approve").length; + const rejections = reviews.filter((r) => r.action === "reject").length; + const outcome = evaluateThreshold({ + approvals, + rejections, + signerCount: snapshot!.signersAddresses.length, + requiredSigners: snapshot!.requiredSigners, + }); + + if (outcome !== "InReview") { + await tx.documentVersion.update({ + where: { id: version.id }, + data: { status: outcome, decidedAt: new Date() }, + }); + await tx.document.update({ + where: { id: version.documentId }, + data: { status: outcome }, + }); + await tx.documentEvent.create({ + data: { + documentId: version.documentId, + versionId: version.id, + type: + outcome === "Approved" + ? "threshold.reached" + : "version.rejected", + actorAddress: signerAddress, + metadata: { + approvals, + rejections, + requiredSigners: snapshot!.requiredSigners, + }, + }, + }); + } + + return { review, approvals, rejections, outcome }; + }); + + void audit(ctx.db, { + actorAddress: signerAddress, + actorType: "user", + action: "document.review.submit", + resourceType: "document", + resourceId: version.documentId, + outcome: "success", + metadata: { + versionId: version.id, + action: input.action, + result: result.outcome, + }, + }); + + return result; + }), + + /** 5. Export the proof package for a version. */ + exportProof: protectedProcedure + .input(z.object({ versionId: z.string().min(1) })) + .mutation(async ({ ctx, input }): Promise<ProofPackage> => { + const version = await ctx.db.documentVersion.findUnique({ + where: { id: input.versionId }, + include: { + document: { include: { events: { orderBy: { createdAt: "asc" } } } }, + signerSnapshot: true, + reviews: { orderBy: { signedAt: "asc" } }, + }, + }); + if (!version) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Version not found", + }); + } + const { addresses } = await assertWalletAccess( + ctx, + version.document.walletId, + ); + const actor = actingAddress(addresses); + + const snapshot = version.signerSnapshot; + if (!snapshot) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "This version has no review round to export", + }); + } + + const descriptionFor = (address: string): string | null => { + const idx = snapshot.signersAddresses.indexOf(address); + return idx >= 0 ? (snapshot.signersDescriptions[idx] ?? null) : null; + }; + + const pkg: ProofPackage = { + format: PROOF_FORMAT, + exportedAt: new Date().toISOString(), + document: { + id: version.document.id, + walletId: version.document.walletId, + title: version.document.title, + description: version.document.description, + documentType: version.document.documentType, + createdBy: version.document.createdBy, + createdAt: version.document.createdAt.toISOString(), + }, + version: { + id: version.id, + versionNumber: version.versionNumber, + contentHash: version.contentHash, + hashAlgorithm: version.hashAlgorithm, + fileName: version.fileName, + mimeType: version.mimeType, + fileSize: version.fileSize, + status: version.status, + createdBy: version.createdBy, + createdAt: version.createdAt.toISOString(), + reviewStartedAt: version.reviewStartedAt?.toISOString() ?? null, + decidedAt: version.decidedAt?.toISOString() ?? null, + }, + policy: { + walletId: snapshot.walletId, + walletPolicyHash: snapshot.walletPolicyHash, + requiredSigners: snapshot.requiredSigners, + signersAddresses: snapshot.signersAddresses, + signersDescriptions: snapshot.signersDescriptions, + capturedAt: snapshot.capturedAt.toISOString(), + }, + reviews: version.reviews.map((r) => ({ + signerAddress: r.signerAddress, + signerDescription: descriptionFor(r.signerAddress), + action: r.action, + comment: r.comment, + payload: r.payload, + signature: r.signature, + signatureKey: r.signatureKey, + signedAt: r.signedAt.toISOString(), + })), + events: version.document.events + .filter((e) => e.versionId === null || e.versionId === version.id) + .map((e) => ({ + type: e.type, + actorAddress: e.actorAddress, + createdAt: e.createdAt.toISOString(), + metadata: e.metadata, + })), + verification: { + domain: SIGNOFF_DOMAIN, + instructions: VERIFICATION_INSTRUCTIONS, + }, + }; + + await ctx.db.documentEvent.create({ + data: { + documentId: version.documentId, + versionId: version.id, + type: "proof.exported", + actorAddress: actor, + metadata: { reviewCount: pkg.reviews.length }, + }, + }); + + return pkg; + }), + + /** + * 6. Verify a proof package. Public on purpose: a counterparty holding the + * JSON and the file must be able to check it without an account, and this + * endpoint reads nothing from the database — it only re-runs the maths. + */ + verifyProof: publicProcedure + .input( + z.object({ + proof: z.unknown(), + /** sha256 of the bytes the verifier holds, if they have the file. */ + expectedContentHash: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const problem = proofPackageProblem(input.proof); + if (problem !== null) { + throw new TRPCError({ code: "BAD_REQUEST", message: problem }); + } + const pkg = input.proof as ProofPackage; + return verifyProofPackage(pkg, { + expectedContentHash: input.expectedContentHash, + checkSignature, + }); + }), + + /** + * Export a document's attestation chain, with the public keys needed to check + * it. Deliberately separate from `exportProof`: that package is a versioned + * format (`...proof.v1`) whose field names are a contract, and quietly adding + * to it would change what every existing verifier is parsing. + */ + exportAttestationChain: protectedProcedure + .input(z.object({ documentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const { document } = await assertDocumentAccess(ctx, input.documentId); + + const rows = await ctx.db.documentAttestation.findMany({ + where: { documentId: document.id }, + orderBy: { sequence: "asc" }, + }); + + return { + format: ATTESTATION_DOMAIN, + documentId: document.id, + title: document.title, + // What the signature does and does not mean, carried with the export so + // it does not depend on the reader having found the docs. + statement: ATTESTATION_STATEMENT, + publicKeys: getAttestationPublicKeys(), + chain: rows.map((row) => ({ + payload: JSON.parse(row.payload) as unknown, + signature: row.signature, + publicKeyId: row.publicKeyId, + })), + }; + }), + + /** + * Check an attestation chain. Public, like `verifyProof`, so a recipient can + * verify one without an account — though they do not need us at all: the + * algorithm is in src/lib/documents/attestation.ts and depends on nothing but + * node crypto. + */ + verifyAttestationChain: publicProcedure + .input( + z.object({ + chain: z.array( + z.object({ + payload: z.unknown(), + signature: z.string(), + publicKeyId: z.string(), + }), + ), + /** Omit to check against the keys this deployment publishes. */ + publicKeys: z.record(z.string(), z.string()).optional(), + }), + ) + .mutation(({ input }) => + verifyAttestationChain( + input.chain as AttestationRecord[], + input.publicKeys ?? getAttestationPublicKeys(), + ), + ), + + /** + * The draft body, if there is one. Returns null rather than throwing when a + * document has never been edited, so the editor can open on an empty page. + */ + getDraft: protectedProcedure + .input(z.object({ documentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const { document } = await assertDocumentAccess(ctx, input.documentId); + return ctx.db.documentDraft.findUnique({ + where: { documentId: document.id }, + }); + }), + + /** + * Save the draft body. + * + * Never touches DocumentVersion. That is the whole reason the draft exists: + * `uploadVersion` supersedes the previous version and resets approvals to + * zero, so autosaving through it would destroy in-flight approvals every few + * keystrokes and append an attestation link each time. + * + * Concurrency is optimistic on `revision`. A save presents the revision it + * read; if someone else saved in the meantime this is a conflict rather than + * a silent overwrite. That is what makes more than one person editing safe + * here — a CRDT would need a broker and a socket server, and this deployment + * has neither. + */ + saveDraft: protectedProcedure + .input( + z.object({ + documentId: z.string().min(1), + body: z.string().max(MAX_INLINE_BYTES), + /** The revision the editor last read. Omit only when creating. */ + expectedRevision: z.number().int().nonnegative().optional(), + /** Opt in to storing the body server-side. */ + storeBody: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { document, addresses } = await assertDocumentAccess( + ctx, + input.documentId, + ); + const actor = actingAddress(addresses); + + const existing = await ctx.db.documentDraft.findUnique({ + where: { documentId: document.id }, + }); + + if (!existing) { + return ctx.db.documentDraft.create({ + data: { + documentId: document.id, + body: input.storeBody ? input.body : null, + storeBody: input.storeBody ?? false, + revision: 1, + updatedBy: actor, + }, + }); + } + + if ( + input.expectedRevision !== undefined && + input.expectedRevision !== existing.revision + ) { + throw new TRPCError({ + code: "CONFLICT", + message: + `This draft moved on while you were editing (you had revision ` + + `${input.expectedRevision}, it is now ${existing.revision}). ` + + `Reload before saving so nobody's work is overwritten.`, + }); + } + + const storeBody = input.storeBody ?? existing.storeBody; + return ctx.db.documentDraft.update({ + where: { documentId: document.id }, + data: { + body: storeBody ? input.body : null, + storeBody, + revision: { increment: 1 }, + updatedBy: actor, + }, + }); + }), + + /** + * Promote the draft to a signable version — the one-way door. + * + * The server serialises and hashes; the client never supplies the hash. That + * mirrors the rule the rest of this router already follows, and it is what + * makes "what you sign is what was published" true rather than asserted. + * + * Only from here does content enter the immutable world: a DocumentVersion, + * an attestation link, and eventually signatures. + */ + publishDraft: protectedProcedure + .input( + z.object({ + documentId: z.string().min(1), + fileName: z.string().max(300).optional(), + reviewInstructions: z.string().max(5000).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { document, addresses } = await assertDocumentAccess( + ctx, + input.documentId, + ); + if (document.status === "Archived") { + throw new TRPCError({ + code: "CONFLICT", + message: "Document is archived", + }); + } + const actor = actingAddress(addresses); + + const draft = await ctx.db.documentDraft.findUnique({ + where: { documentId: document.id }, + }); + if (!draft?.storeBody || draft.body === null) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "This draft is not stored on the server, so there is nothing here " + + "to publish. Upload the file instead, or turn on server storage.", + }); + } + + const bytes = Buffer.from(draft.body, "utf8"); + if (bytes.length > MAX_INLINE_BYTES) { + throw new TRPCError({ + code: "PAYLOAD_TOO_LARGE", + message: `Draft exceeds ${MAX_INLINE_BYTES} bytes`, + }); + } + + return ctx.db.$transaction(async (tx) => { + const latest = await tx.documentVersion.findFirst({ + where: { documentId: document.id }, + orderBy: { versionNumber: "desc" }, + }); + + if ( + latest && + latest.status !== "Superseded" && + latest.status !== "Archived" + ) { + await tx.documentVersion.update({ + where: { id: latest.id }, + data: { status: "Superseded", supersededAt: new Date() }, + }); + await tx.documentEvent.create({ + data: { + documentId: document.id, + versionId: latest.id, + type: "version.superseded", + actorAddress: actor, + metadata: { versionNumber: latest.versionNumber }, + }, + }); + } + + const created = await createVersionRow(tx, { + documentId: document.id, + versionNumber: (latest?.versionNumber ?? 0) + 1, + createdBy: actor, + contentHash: sha256Hex(bytes), + fileName: input.fileName ?? `${document.title}.md`, + mimeType: "text/markdown", + fileSize: bytes.length, + storageMode: "inline", + contentInline: bytes.toString("base64"), + reviewInstructions: input.reviewInstructions, + }); + + await tx.documentEvent.create({ + data: { + documentId: document.id, + versionId: created.id, + type: "version.published", + actorAddress: actor, + metadata: { + versionNumber: created.versionNumber, + contentHash: created.contentHash, + fromDraftRevision: draft.revision, + approvalsReset: true, + }, + }, + }); + + await attestVersion(tx, created, document.walletId); + + await tx.document.update({ + where: { id: document.id }, + data: { status: "Draft" }, + }); + + return created; + }); + }), + + /** + * This wallet's documents as a vault: the same trust graph the /vault demo + * shows, built from real rows instead of this repo's `vault/` directory. + * + * Grouped by documentType, which becomes the proxy hub, so the spine is + * blinded root -> type -> document and is acyclic by construction. + */ + vaultView: protectedProcedure + .input(z.object({ walletId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + await assertWalletAccess(ctx, input.walletId); + return buildWalletVaultView(ctx.db, input.walletId); + }), + + /** + * Permanently delete a document. + * + * Everything cascades: versions, signatures, signer snapshots, attestations + * and the document's own event log. There is no tombstone and no undo, which + * is why this is not the default — `archiveDocument` keeps all of it and + * merely removes the document from active use. + * + * Two guards, because this is an accountability product: + * + * - A document anyone has SIGNED requires the caller to retype its title. + * Deleting it destroys the evidence that those people approved something, + * and that must not be one stray click. + * - An AuditLog row is written BEFORE the delete, recording who removed what + * and how much went with it. AuditLog holds no foreign key to Document, so + * unlike DocumentEvent it survives the cascade: the document goes, the + * fact that someone deleted it does not. + */ + deleteDocument: protectedProcedure + .input( + z.object({ + documentId: z.string().min(1), + /** Must equal the document's title once any signature exists. */ + confirmTitle: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { document, addresses } = await assertDocumentAccess( + ctx, + input.documentId, + ); + const actor = actingAddress(addresses); + + const [signatureCount, versionCount, attestationCount] = + await Promise.all([ + ctx.db.documentReview.count({ + where: { version: { documentId: document.id } }, + }), + ctx.db.documentVersion.count({ where: { documentId: document.id } }), + ctx.db.documentAttestation.count({ + where: { documentId: document.id }, + }), + ]); + + if (signatureCount > 0 && input.confirmTitle !== document.title) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + `This document carries ${signatureCount} signature` + + `${signatureCount === 1 ? "" : "s"}. Retype the title to confirm.`, + }); + } + + // Awaited, and before the delete: once the row is gone the cascade has + // taken every DocumentEvent with it, so this is the only surviving trace. + await audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "document.delete", + resourceType: "document", + resourceId: document.id, + outcome: "success", + metadata: { + walletId: document.walletId, + title: document.title, + versionCount, + signatureCount, + attestationCount, + }, + }); + + await ctx.db.document.delete({ where: { id: document.id } }); + return { deleted: true, signatureCount, versionCount }; + }), + + /** + * The contract parties on a document. Visible to wallet members and to the + * parties themselves — a counterparty needs to see who else is signing. + */ + listContractParties: protectedProcedure + .input(z.object({ documentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + await resolveDocumentAccess(ctx, input.documentId); + const parties = await ctx.db.contractParty.findMany({ + where: { documentId: input.documentId }, + orderBy: [{ signingOrder: "asc" }, { createdAt: "asc" }], + include: { fields: true }, + }); + // The invite hash never leaves the server: it is the credential. + return parties.map(({ inviteTokenHash: _hash, ...party }) => ({ + ...party, + invited: !!party.invitedAt, + redeemed: !!party.inviteConsumedAt, + })); + }), + + /** + * Add or edit a party. Wallet members only — a counterparty may sign a + * contract, never rewrite who else is on it. + */ + upsertContractParty: protectedProcedure + .input( + z.object({ + documentId: z.string().min(1), + partyId: z.string().min(1).optional(), + role: z.string().trim().min(1).max(100), + displayName: z.string().trim().min(1).max(200), + email: z.string().email().max(320).optional(), + signingOrder: z.number().int().min(0).max(999).default(0), + required: z.boolean().default(true), + }), + ) + .mutation(async ({ ctx, input }) => { + const { document } = await assertDocumentAccess(ctx, input.documentId); + await assertPartyRosterOpen(ctx, document.id); + + const data = { + role: input.role, + displayName: input.displayName, + email: input.email ?? null, + signingOrder: input.signingOrder, + required: input.required, + }; + + if (input.partyId) { + const existing = await ctx.db.contractParty.findUnique({ + where: { id: input.partyId }, + }); + if (!existing || existing.documentId !== document.id) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Party not found", + }); + } + return ctx.db.contractParty.update({ + where: { id: input.partyId }, + data, + }); + } + + return ctx.db.contractParty.create({ + data: { ...data, documentId: document.id }, + }); + }), + + /** Remove a party. Refused once they have signed — see the NoAction FK. */ + deleteContractParty: protectedProcedure + .input(z.object({ partyId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const party = await ctx.db.contractParty.findUnique({ + where: { id: input.partyId }, + }); + if (!party) { + throw new TRPCError({ code: "NOT_FOUND", message: "Party not found" }); + } + await assertDocumentAccess(ctx, party.documentId); + await assertPartyRosterOpen(ctx, party.documentId); + + const signed = await ctx.db.documentReview.count({ + where: { partyId: party.id }, + }); + if (signed > 0) { + // The database would refuse this anyway (NoAction), but a foreign key + // error is not an explanation. + throw new TRPCError({ + code: "CONFLICT", + message: + "This party has already signed. Their signature is evidence and " + + "cannot be removed by deleting them.", + }); + } + + await ctx.db.contractParty.delete({ where: { id: party.id } }); + return { deleted: true }; + }), + + /** + * Mint a single-use invite for a party and return the raw token ONCE. + * + * Only the sha256 is stored, so this response is the only time the token + * exists anywhere retrievable — the same shape as every other credential in + * this schema. Re-issuing replaces the previous token, which is what makes a + * mistyped email recoverable. + */ + issuePartyInvite: protectedProcedure + .input( + z.object({ + partyId: z.string().min(1), + expiresInHours: z + .number() + .int() + .min(1) + .max(24 * 30) + .default(24 * 14), + }), + ) + .mutation(async ({ ctx, input }) => { + const party = await ctx.db.contractParty.findUnique({ + where: { id: input.partyId }, + }); + if (!party) { + throw new TRPCError({ code: "NOT_FOUND", message: "Party not found" }); + } + const { document, addresses } = await assertDocumentAccess( + ctx, + party.documentId, + ); + + if (party.inviteConsumedAt) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This party has already redeemed an invite and is bound to an " + + "address. Remove and re-add them to invite someone else.", + }); + } + + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date( + Date.now() + input.expiresInHours * 60 * 60 * 1000, + ); + + await ctx.db.contractParty.update({ + where: { id: party.id }, + data: { + inviteTokenHash: sha256Hex(token), + invitedAt: new Date(), + inviteExpiresAt: expiresAt, + }, + }); + + await audit(ctx.db, { + actorAddress: actingAddress(addresses), + actorType: "user", + action: "contract.party.invited", + resourceType: "document", + resourceId: document.id, + outcome: "success", + metadata: { partyId: party.id, role: party.role, expiresAt }, + }); + + // The only time this value is returnable. + return { token, expiresAt }; + }), + + /** + * Redeem an invite: bind the caller's wallet address to the party. + * + * The caller authenticates with their OWN wallet through the ordinary session + * flow — they are a real, signed-in user who simply is not a member of this + * wallet. That is the whole point: it is what lets an outside counterparty + * reach a contract without being given access to a treasury. + */ + redeemPartyInvite: protectedProcedure + .input(z.object({ token: z.string().min(16).max(500) })) + .mutation(async ({ ctx, input }) => { + const addresses = getSessionAddresses(ctx); + if (addresses.length === 0) throw new TRPCError({ code: "UNAUTHORIZED" }); + const address = actingAddress(addresses); + + const party = await ctx.db.contractParty.findUnique({ + where: { inviteTokenHash: sha256Hex(input.token) }, + }); + + // One message for every failure. Distinguishing "no such invite" from + // "already used" turns this into an oracle for guessing tokens. + const reject = () => { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This invite is not valid. Ask for a fresh link.", + }); + }; + if (!party || party.inviteConsumedAt) reject(); + if (party!.inviteExpiresAt && party!.inviteExpiresAt < new Date()) + reject(); + + // Conditional update, not read-then-write: two clicks on the same link + // must not both bind, and the second must lose rather than overwrite. + const claimed = await ctx.db.contractParty.updateMany({ + where: { id: party!.id, inviteConsumedAt: null }, + data: { address, inviteConsumedAt: new Date() }, + }); + if (claimed.count !== 1) reject(); + + await audit(ctx.db, { + actorAddress: address, + actorType: "user", + action: "contract.party.redeemed", + resourceType: "document", + resourceId: party!.documentId, + outcome: "success", + metadata: { partyId: party!.id, role: party!.role }, + }); + + return { documentId: party!.documentId, role: party!.role }; + }), + + /** Archive a document — history is retained, it just leaves active use. */ + archiveDocument: protectedProcedure + .input(z.object({ documentId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const { document, addresses } = await assertDocumentAccess( + ctx, + input.documentId, + ); + const actor = actingAddress(addresses); + return ctx.db.$transaction(async (tx) => { + const updated = await tx.document.update({ + where: { id: document.id }, + data: { status: "Archived", archivedAt: new Date() }, + }); + await tx.documentEvent.create({ + data: { + documentId: document.id, + type: "document.archived", + actorAddress: actor, + }, + }); + return updated; + }); + }), +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type VersionRowInput = { + documentId: string; + versionNumber: number; + createdBy: string; + contentHash: string; + fileName?: string; + mimeType?: string; + fileSize?: number; + storageMode: "hashOnly" | "inline" | "external"; + contentRef?: string; + contentInline?: string; + reviewInstructions?: string; +}; + +/** + * Creates the version row, and — when the bytes are actually supplied — + * re-hashes them server-side. A client-declared hash that does not match the + * bytes it shipped is a version-drift bug or an attack; either way it must + * never reach the signers. + */ +/** + * Attest a newly created version: a signed timestamp-and-ordering record, + * linked to the previous attestation for this document. + * + * Explicitly NOT an approval — see src/lib/documents/attestation.ts. The + * platform key witnesses that a version existed at a time and in a position; + * enactment still requires CIP-8 signatures from the wallet's own signers. + * + * Runs inside the caller's transaction, on purpose. When a key is configured, + * every version gets an attestation or the version is not created at all: a + * silently unattested version would make the audit trail lie by omission, which + * is worse than a failed upload. When no key is configured this is a no-op and + * the rest of the feature behaves exactly as before. + * + * Concurrency is handled by @@unique([documentId, sequence]): two simultaneous + * uploads cannot both claim the same link, so one rolls back rather than + * forking the chain — the same way @@unique([documentId, versionNumber]) + * already guards version numbers. + */ +async function attestVersion( + tx: Prisma.TransactionClient, + version: { + id: string; + documentId: string; + versionNumber: number; + contentHash: string; + }, + walletId: string, +) { + const signer = getAttestationSigner(); + if (!signer) return; + + const previous = await tx.documentAttestation.findFirst({ + where: { documentId: version.documentId }, + orderBy: { sequence: "desc" }, + }); + + const payload = buildAttestationPayload({ + attestedAt: new Date(), + contentHash: version.contentHash, + documentId: version.documentId, + prevAttestationHash: previous?.attestationHash ?? GENESIS_PREV, + sequence: (previous?.sequence ?? 0) + 1, + versionId: version.id, + versionNumber: version.versionNumber, + walletId, + }); + + await tx.documentAttestation.create({ + data: { + versionId: version.id, + documentId: version.documentId, + sequence: payload.sequence, + contentHash: payload.contentHash, + prevAttestationHash: payload.prevAttestationHash, + attestationHash: attestationHash(payload), + payload: canonicalizeAttestation(payload), + signature: signer.sign(payload), + publicKeyId: signer.keyId, + attestedAt: new Date(payload.attestedAt), + }, + }); +} + +async function createVersionRow( + tx: Prisma.TransactionClient, + input: VersionRowInput, +) { + if (input.storageMode === "inline") { + if (!input.contentInline) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "storageMode 'inline' requires contentInline", + }); + } + const bytes = Buffer.from(input.contentInline, "base64"); + if (bytes.length > MAX_INLINE_BYTES) { + throw new TRPCError({ + code: "PAYLOAD_TOO_LARGE", + message: `Inline content exceeds ${MAX_INLINE_BYTES} bytes`, + }); + } + if (sha256Hex(bytes) !== input.contentHash) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "contentHash does not match the supplied bytes", + }); + } + } + if (input.storageMode === "external" && !input.contentRef) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "storageMode 'external' requires contentRef", + }); + } + + return tx.documentVersion.create({ + data: { + documentId: input.documentId, + versionNumber: input.versionNumber, + contentHash: input.contentHash, + hashAlgorithm: "sha256", + fileName: input.fileName ?? null, + mimeType: input.mimeType ?? null, + fileSize: input.fileSize ?? null, + storageMode: input.storageMode, + contentRef: input.contentRef ?? null, + contentInline: + input.storageMode === "inline" ? input.contentInline : null, + reviewInstructions: input.reviewInstructions ?? null, + status: "Draft", + createdBy: input.createdBy, + }, + }); +} diff --git a/src/server/api/routers/notifications.ts b/src/server/api/routers/notifications.ts index 183da064..09fa7a82 100644 --- a/src/server/api/routers/notifications.ts +++ b/src/server/api/routers/notifications.ts @@ -144,6 +144,8 @@ export const notificationRouter = createTRPCRouter({ emailOptIn: true, notifyTransactionSignatures: true, notifySignableSignatures: true, + notifyThresholdReached: true, + notifyBallotDeadlines: true, createdAt: null, updatedAt: null, } @@ -159,6 +161,8 @@ export const notificationRouter = createTRPCRouter({ emailOptIn: z.boolean().optional(), notifyTransactionSignatures: z.boolean().optional(), notifySignableSignatures: z.boolean().optional(), + notifyThresholdReached: z.boolean().optional(), + notifyBallotDeadlines: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -202,6 +206,12 @@ export const notificationRouter = createTRPCRouter({ ...(typeof input.notifySignableSignatures === "boolean" ? { notifySignableSignatures: input.notifySignableSignatures } : {}), + ...(typeof input.notifyThresholdReached === "boolean" + ? { notifyThresholdReached: input.notifyThresholdReached } + : {}), + ...(typeof input.notifyBallotDeadlines === "boolean" + ? { notifyBallotDeadlines: input.notifyBallotDeadlines } + : {}), }; const setting = await ctx.db.walletSignerNotificationSetting.upsert({ @@ -222,6 +232,8 @@ export const notificationRouter = createTRPCRouter({ notifyTransactionSignatures: input.notifyTransactionSignatures ?? true, notifySignableSignatures: input.notifySignableSignatures ?? true, + notifyThresholdReached: input.notifyThresholdReached ?? true, + notifyBallotDeadlines: input.notifyBallotDeadlines ?? true, }, }); diff --git a/src/server/api/routers/proxy.ts b/src/server/api/routers/proxy.ts index c53b0f56..2d187b38 100644 --- a/src/server/api/routers/proxy.ts +++ b/src/server/api/routers/proxy.ts @@ -81,6 +81,106 @@ const getRequesterAddresses = (ctx: AuthCtx): string[] => { return sessionWallets.length ? sessionWallets : [requireSessionAddress(ctx)]; }; +/** + * Every address the caller can act as: connected wallets plus the session + * address. Membership is keyed by address, so a member who signs in with a + * different connected wallet than the one they were invited with still matches. + */ +const getAllRequesterAddresses = (ctx: AuthCtx): string[] => { + const addresses = new Set<string>(ctx.sessionWallets ?? []); + const sessionAddress = ctx.session?.user?.id ?? ctx.sessionAddress; + if (sessionAddress) { + addresses.add(sessionAddress); + } + if (addresses.size === 0) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return [...addresses]; +}; + +export const PROXY_MEMBER_ROLES = ["manager", "viewer"] as const; +type ProxyMemberRole = (typeof PROXY_MEMBER_ROLES)[number]; + +/** + * Loose bech32 shape check. The full check needs the Mesh serialization stack, + * which we keep out of the server bundle; the client validates properly before + * submitting and a malformed address here only ever creates a dead grant. + */ +const CARDANO_ADDRESS_PATTERN = /^addr(_test)?1[02-9ac-hj-np-z]{20,150}$/; + +const proxyAddressSchema = z + .string() + .trim() + .refine((value) => CARDANO_ADDRESS_PATTERN.test(value), { + message: "Not a valid Cardano payment address", + }); + +const hasOwnerProxyAccess = async ( + ctx: AuthCtx, + proxy: { walletId: string | null; userId: string | null }, + addresses: string[], +) => { + try { + await assertProxyAccess(ctx, proxy, addresses); + return true; + } catch { + return false; + } +}; + +type ProxyPermissions = { + canRead: boolean; + canManage: boolean; + role: "owner" | ProxyMemberRole | null; +}; + +/** + * Resolves what the caller may do with a proxy. + * + * Owners (signers of the controlling multisig, or the proxy's own user) always + * get full access. Everyone else gets whatever their ProxyMember row grants: + * `manager` can also change the member list, `viewer` is read-only. + */ +const resolveProxyPermissions = async ( + ctx: AuthCtx, + proxy: { walletId: string | null; userId: string | null }, + proxyId: string, + addresses: string[], +): Promise<ProxyPermissions> => { + if (await hasOwnerProxyAccess(ctx, proxy, addresses)) { + return { canRead: true, canManage: true, role: "owner" }; + } + + const memberships = await ctx.db.proxyMember.findMany({ + where: { proxyId, address: { in: addresses } }, + }); + if (memberships.length === 0) { + return { canRead: false, canManage: false, role: null }; + } + + const canManage = memberships.some((member) => member.role === "manager"); + return { canRead: true, canManage, role: canManage ? "manager" : "viewer" }; +}; + +const requireManageableProxy = async ( + ctx: AuthCtx, + proxyId: string, + addresses: string[], +) => { + const proxy = await ctx.db.proxy.findUnique({ where: { id: proxyId } }); + if (!proxy) { + throw new TRPCError({ code: "NOT_FOUND", message: "Proxy not found" }); + } + const permissions = await resolveProxyPermissions(ctx, proxy, proxyId, addresses); + if (!permissions.canManage) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized to manage access for this proxy", + }); + } + return { proxy, permissions }; +}; + const assertWalletAccessForAnyAddress = async ( ctx: AuthCtx, walletId: string, @@ -259,8 +359,19 @@ export const proxyRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND", message: "Proxy not found" }); } - const addresses = getRequesterAddresses(ctx); - await assertProxyAccess(ctx, proxy, addresses); + const addresses = getAllRequesterAddresses(ctx); + const permissions = await resolveProxyPermissions( + ctx, + proxy, + proxy.id, + addresses, + ); + if (!permissions.canRead) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized for this proxy", + }); + } return proxy; }), @@ -368,9 +479,186 @@ export const proxyRouter = createTRPCRouter({ await Promise.all(updatePromises); - return { - transferred: proxies.length, - message: `Successfully transferred ${proxies.length} proxy${proxies.length !== 1 ? 'ies' : ''}` + return { + transferred: proxies.length, + message: `Successfully transferred ${proxies.length} proxy${proxies.length !== 1 ? 'ies' : ''}` }; }), + + /** + * Batched member lookup for a set of proxies. Proxies the caller cannot read + * are omitted rather than throwing, so a single call can back a whole list of + * proxy cards. + */ + listProxyMembers: protectedProcedure + .input(z.object({ proxyIds: z.array(z.string()).min(1).max(50) })) + .query(async ({ ctx, input }) => { + const addresses = getAllRequesterAddresses(ctx); + const proxyIds = [...new Set(input.proxyIds)]; + const proxies = await ctx.db.proxy.findMany({ + where: { id: { in: proxyIds } }, + }); + + const permissionsByProxy = new Map<string, ProxyPermissions>(); + for (const proxy of proxies) { + permissionsByProxy.set( + proxy.id, + await resolveProxyPermissions(ctx, proxy, proxy.id, addresses), + ); + } + + const readableIds = proxies + .filter((proxy) => permissionsByProxy.get(proxy.id)?.canRead) + .map((proxy) => proxy.id); + + const members = readableIds.length + ? await ctx.db.proxyMember.findMany({ + where: { proxyId: { in: readableIds } }, + orderBy: { createdAt: "asc" }, + }) + : []; + + return readableIds.map((proxyId) => ({ + proxyId, + canManage: permissionsByProxy.get(proxyId)?.canManage ?? false, + role: permissionsByProxy.get(proxyId)?.role ?? null, + members: members + .filter((member) => member.proxyId === proxyId) + .map((member) => ({ + id: member.id, + address: member.address, + role: member.role, + label: member.label, + invitedBy: member.invitedBy, + createdAt: member.createdAt, + isSelf: addresses.includes(member.address), + })), + })); + }), + + addProxyMembers: protectedProcedure + .input( + z.object({ + proxyId: z.string(), + members: z + .array( + z.object({ + address: proxyAddressSchema, + role: z.enum(PROXY_MEMBER_ROLES).default("viewer"), + label: z.string().trim().max(120).optional(), + }), + ) + .min(1) + .max(20), + }), + ) + .mutation(async ({ ctx, input }) => { + const addresses = getAllRequesterAddresses(ctx); + await requireManageableProxy(ctx, input.proxyId, addresses); + + const invitedBy = addresses[0]!; + const seen = new Set<string>(); + const rows = input.members + .filter((member) => { + if (seen.has(member.address)) return false; + seen.add(member.address); + return true; + }) + .map((member) => ({ + proxyId: input.proxyId, + address: member.address, + role: member.role, + label: member.label && member.label.length > 0 ? member.label : null, + invitedBy, + })); + + const result = await ctx.db.proxyMember.createMany({ + data: rows, + skipDuplicates: true, + }); + + return { added: result.count, requested: rows.length }; + }), + + updateProxyMember: protectedProcedure + .input( + z.object({ + proxyId: z.string(), + address: proxyAddressSchema, + role: z.enum(PROXY_MEMBER_ROLES).optional(), + label: z.string().trim().max(120).nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const addresses = getAllRequesterAddresses(ctx); + await requireManageableProxy(ctx, input.proxyId, addresses); + + if (input.role === undefined && input.label === undefined) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Nothing to update", + }); + } + + return ctx.db.proxyMember.update({ + where: { + proxyId_address: { proxyId: input.proxyId, address: input.address }, + }, + data: { + ...(input.role !== undefined ? { role: input.role } : {}), + ...(input.label !== undefined + ? { label: input.label && input.label.length > 0 ? input.label : null } + : {}), + }, + }); + }), + + removeProxyMember: protectedProcedure + .input(z.object({ proxyId: z.string(), address: proxyAddressSchema })) + .mutation(async ({ ctx, input }) => { + const addresses = getAllRequesterAddresses(ctx); + + // Members can always remove themselves ("leave"), even as viewers. + if (!addresses.includes(input.address)) { + await requireManageableProxy(ctx, input.proxyId, addresses); + } + + await ctx.db.proxyMember.deleteMany({ + where: { proxyId: input.proxyId, address: input.address }, + }); + + return { removed: true }; + }), + + /** Active proxies shared with the caller through a ProxyMember grant. */ + getSharedProxies: protectedProcedure.query(async ({ ctx }) => { + const addresses = getAllRequesterAddresses(ctx); + const memberships = await ctx.db.proxyMember.findMany({ + where: { address: { in: addresses } }, + orderBy: { createdAt: "desc" }, + }); + if (memberships.length === 0) return []; + + const proxies = await ctx.db.proxy.findMany({ + where: { + id: { in: memberships.map((member) => member.proxyId) }, + isActive: true, + }, + orderBy: { createdAt: "desc" }, + }); + + const membershipByProxy = new Map( + memberships.map((member) => [member.proxyId, member]), + ); + + return proxies.map((proxy) => ({ + ...proxy, + // The address the grant was issued to, so the member can revoke it. + memberAddress: membershipByProxy.get(proxy.id)?.address ?? addresses[0]!, + role: membershipByProxy.get(proxy.id)?.role ?? "viewer", + label: membershipByProxy.get(proxy.id)?.label ?? null, + invitedBy: membershipByProxy.get(proxy.id)?.invitedBy ?? null, + sharedAt: membershipByProxy.get(proxy.id)?.createdAt ?? proxy.createdAt, + })); + }), }); \ No newline at end of file diff --git a/src/server/api/routers/signable.ts b/src/server/api/routers/signable.ts index 19e33441..6bfc4fb2 100644 --- a/src/server/api/routers/signable.ts +++ b/src/server/api/routers/signable.ts @@ -4,7 +4,10 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/lib/observability/audit"; import { requireSessionAddress, assertWalletAccess } from "@/server/api/auth"; -import { enqueueSignatureRequiredNotifications } from "@/lib/notifications/center"; +import { + enqueueSignatureRequiredNotifications, + enqueueThresholdReachedNotifications, +} from "@/lib/notifications/center"; import { summarizeSignableSignatureContext } from "@/lib/notifications/signatureContext"; export const signableRouter = createTRPCRouter({ @@ -73,7 +76,7 @@ export const signableRouter = createTRPCRouter({ if (!signable) { throw new TRPCError({ code: "NOT_FOUND", message: "Signable not found" }); } - await assertWalletAccess(ctx, signable.walletId, sessionAddress); + const wallet = await assertWalletAccess(ctx, signable.walletId, sessionAddress); const updated = await ctx.db.signable.update({ where: { id: input.signableId, @@ -85,6 +88,23 @@ export const signableRouter = createTRPCRouter({ state: input.state, }, }); + try { + await enqueueThresholdReachedNotifications(ctx.db, { + wallet, + resourceType: "signable", + resourceId: signable.id, + previousSignedAddresses: signable.signedAddresses, + signedAddresses: updated.signedAddresses, + actorAddress: sessionAddress, + description: signable.description, + signatureContext: summarizeSignableSignatureContext({ + method: signable.method, + description: signable.description, + }), + }); + } catch (error) { + console.error("Failed to enqueue signable threshold notifications", error); + } const justSigned = input.signedAddresses.includes(sessionAddress) && !signable.signedAddresses.includes(sessionAddress); const justRejected = input.rejectedAddresses.includes(sessionAddress) && diff --git a/src/server/api/routers/tasks.ts b/src/server/api/routers/tasks.ts new file mode 100644 index 00000000..fd666954 --- /dev/null +++ b/src/server/api/routers/tasks.ts @@ -0,0 +1,449 @@ +import { TRPCError } from "@trpc/server"; +import type { Prisma, TaskStatus } from "@prisma/client"; +import { z } from "zod"; + +import { audit } from "@/lib/observability/audit"; +import { derivePayoutState, MAX_PAYOUT_TASKS } from "@/lib/task-payout/state"; +import { assertWalletAccess, requireSessionAddress } from "@/server/api/auth"; +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import type { AuthCtx } from "@/server/api/trpc"; + +/** + * Project task board: tasks per wallet in four fixed columns, optional + * payment recipients per task (base units), and payouts prepared through + * the same server-side pipeline the MCP tools use — preview, then confirm + * with the draft token; the pending transaction starts with zero signatures + * and the tasks are linked in the same database transaction. + * + * Authorization is signer-or-owner via `assertWalletAccess` for every + * procedure; child rows are reached through their task's walletId. + */ + +export const TASK_STATUSES = ["Backlog", "InProgress", "InReview", "Done"] as const; +export const TASK_PRIORITIES = ["Low", "Medium", "High"] as const; +export const MAX_TASK_RECIPIENTS = 20; +export { MAX_PAYOUT_TASKS }; + +const statusEnum = z.enum(TASK_STATUSES); +const priorityEnum = z.enum(TASK_PRIORITIES); + +const ADDRESS_PATTERN = /^addr(_test)?1[0-9a-z]+$/; +const UNIT_PATTERN = /^[0-9a-f]{56,120}$/; + +const recipientInput = z.object({ + address: z.string().regex(ADDRESS_PATTERN, "Recipient must be a bech32 payment address"), + unit: z + .string() + .transform((u) => (u === "lovelace" ? u : u.toLowerCase())) + .refine((u) => u === "lovelace" || UNIT_PATTERN.test(u), "Unit must be lovelace or policyId+assetName hex"), + /** Base units (lovelace / raw token quantity), integer string. */ + quantity: z + .string() + .regex(/^\d+$/, "Amount must be an integer in base units") + .refine((q) => BigInt(q) > 0n, "Amount must be greater than zero"), + label: z.string().trim().max(64).optional(), +}); + +const taskScalars = { + title: z.string().trim().min(1, "Title is required").max(200), + description: z.string().trim().max(4000).nullable().optional(), + priority: priorityEnum.nullable().optional(), + assigneeAddress: z.string().trim().max(120).nullable().optional(), + dueDate: z.coerce.date().nullable().optional(), +}; + +const taskInclude = { + recipients: { orderBy: { position: "asc" as const } }, + payouts: { where: { status: { in: ["Pending", "Paid"] as const } } }, +} satisfies Prisma.TaskInclude; + +type TaskWithRelations = Prisma.TaskGetPayload<{ include: typeof taskInclude }>; + +function annotate(task: TaskWithRelations) { + return { ...task, payout: derivePayoutState(task) }; +} + +export type AnnotatedTask = ReturnType<typeof annotate>; + +/** + * Every task on a wallet's board with its payout state, in board order. The + * `list` procedure and the v1 `tasks` handler share it: a bot key is + * authorized through its WalletBotAccess grant and has no tRPC session, so + * that handler reads the board here after its own access check. + */ +export async function listWalletTasks( + db: AuthCtx["db"], + walletId: string, +): Promise<AnnotatedTask[]> { + const tasks = await db.task.findMany({ + where: { walletId }, + include: taskInclude, + orderBy: [{ status: "asc" }, { position: "asc" }, { createdAt: "asc" }], + }); + return tasks.map(annotate); +} + +async function loadTaskForWrite(ctx: AuthCtx, id: string) { + const task = await ctx.db.task.findUnique({ where: { id }, include: taskInclude }); + if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "Task not found" }); + await assertWalletAccess(ctx, task.walletId); + return task; +} + +function assertTaskMutable(task: { payouts: { status: string }[] }) { + if (task.payouts.some((p) => p.status === "Paid")) { + throw new TRPCError({ + code: "CONFLICT", + message: "This task is read-only because its payout has been paid.", + }); + } + if (task.payouts.some((p) => p.status === "Pending")) { + throw new TRPCError({ + code: "CONFLICT", + message: "This task is locked while its payout is awaiting signatures. Delete the pending transaction first.", + }); + } +} + +/** + * Writes dense 0..n positions for one column. Columns are small (a board, + * not a backlog database), so sequential updates are fine and keep the SQL + * portable. + */ +async function renumberColumn( + db: Prisma.TransactionClient, + walletId: string, + status: TaskStatus, + orderedIds: string[], +) { + for (let position = 0; position < orderedIds.length; position++) { + await db.task.update({ where: { id: orderedIds[position]! }, data: { status, position } }); + } +} + +async function nextPosition(db: Prisma.TransactionClient | AuthCtx["db"], walletId: string, status: TaskStatus) { + const max = await db.task.aggregate({ where: { walletId, status }, _max: { position: true } }); + return (max._max.position ?? -1) + 1; +} + +function mapReviewError(result: { status: number; body: unknown }): TRPCError { + const body = (result.body ?? {}) as { error?: string; code?: string }; + const code = + result.status === 404 + ? "NOT_FOUND" + : result.status === 403 + ? "FORBIDDEN" + : result.status === 401 + ? "UNAUTHORIZED" + : result.status === 409 + ? "CONFLICT" + : result.status >= 500 + ? "INTERNAL_SERVER_ERROR" + : "BAD_REQUEST"; + return new TRPCError({ + code, + message: body.error ?? "The payout could not be prepared", + cause: result.body, + }); +} + +export const taskRouter = createTRPCRouter({ + list: protectedProcedure + .input(z.object({ walletId: z.string() })) + .query(async ({ ctx, input }) => { + await assertWalletAccess(ctx, input.walletId); + return listWalletTasks(ctx.db, input.walletId); + }), + + create: protectedProcedure + .input( + z.object({ + walletId: z.string(), + status: statusEnum.optional(), + recipients: z.array(recipientInput).max(MAX_TASK_RECIPIENTS).default([]), + ...taskScalars, + }), + ) + .mutation(async ({ ctx, input }) => { + await assertWalletAccess(ctx, input.walletId); + const createdBy = requireSessionAddress(ctx); + const status = input.status ?? "Backlog"; + const position = await nextPosition(ctx.db, input.walletId, status); + const task = await ctx.db.task.create({ + data: { + walletId: input.walletId, + title: input.title, + description: input.description ?? null, + status, + priority: input.priority ?? null, + assigneeAddress: input.assigneeAddress || null, + dueDate: input.dueDate ?? null, + position, + createdBy, + recipients: { + create: input.recipients.map((r, index) => ({ ...r, label: r.label || null, position: index })), + }, + }, + include: taskInclude, + }); + void audit(ctx.db, { + actorAddress: createdBy, + actorType: "user", + action: "task.create", + resourceType: "task", + resourceId: task.id, + ip: ctx.ip ?? null, + outcome: "success", + metadata: { walletId: input.walletId, status, recipients: input.recipients.length }, + }); + return annotate(task); + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string(), + recipients: z.array(recipientInput).max(MAX_TASK_RECIPIENTS).optional(), + ...taskScalars, + title: taskScalars.title.optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const existing = await loadTaskForWrite(ctx, input.id); + const actor = requireSessionAddress(ctx); + assertTaskMutable(existing); + const data: Prisma.TaskUpdateInput = {}; + if (input.title !== undefined) data.title = input.title; + if (input.description !== undefined) data.description = input.description; + if (input.priority !== undefined) data.priority = input.priority; + if (input.assigneeAddress !== undefined) data.assigneeAddress = input.assigneeAddress || null; + if (input.dueDate !== undefined) data.dueDate = input.dueDate; + + const task = await ctx.db.$transaction(async (db) => { + if (input.recipients !== undefined) { + await db.taskRecipient.deleteMany({ where: { taskId: input.id } }); + if (input.recipients.length > 0) { + await db.taskRecipient.createMany({ + data: input.recipients.map((r, index) => ({ + taskId: input.id, + ...r, + label: r.label || null, + position: index, + })), + }); + } + } + return db.task.update({ where: { id: input.id }, data, include: taskInclude }); + }); + void audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "task.update", + resourceType: "task", + resourceId: task.id, + ip: ctx.ip ?? null, + outcome: "success", + metadata: { + walletId: task.walletId, + fields: Object.keys(data), + recipients: input.recipients?.length ?? null, + }, + }); + return annotate(task); + }), + + /** + * Drag-and-drop target: place the task at `position` within `status`, + * then renumber the destination column (and the source, if it changed) so + * positions stay dense. A same-column drop to the same index is a no-op. + */ + move: protectedProcedure + .input(z.object({ id: z.string(), status: statusEnum, position: z.number().int().nonnegative() })) + .mutation(async ({ ctx, input }) => { + const existing = await loadTaskForWrite(ctx, input.id); + const actor = requireSessionAddress(ctx); + assertTaskMutable(existing); + const task = await ctx.db.$transaction(async (db) => { + const destination = await db.task.findMany({ + where: { walletId: existing.walletId, status: input.status, id: { not: input.id } }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + select: { id: true }, + }); + const ids = destination.map((t) => t.id); + ids.splice(Math.min(input.position, ids.length), 0, input.id); + await renumberColumn(db, existing.walletId, input.status, ids); + if (existing.status !== input.status) { + const source = await db.task.findMany({ + where: { walletId: existing.walletId, status: existing.status }, + orderBy: [{ position: "asc" }, { createdAt: "asc" }], + select: { id: true }, + }); + await renumberColumn(db, existing.walletId, existing.status, source.map((t) => t.id)); + } + return db.task.findUniqueOrThrow({ where: { id: input.id }, include: taskInclude }); + }); + void audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "task.move", + resourceType: "task", + resourceId: task.id, + ip: ctx.ip ?? null, + outcome: "success", + metadata: { walletId: task.walletId, from: existing.status, to: input.status, position: task.position }, + }); + return annotate(task); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const existing = await loadTaskForWrite(ctx, input.id); + const actor = requireSessionAddress(ctx); + assertTaskMutable(existing); + await ctx.db.task.delete({ where: { id: input.id } }); + void audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "task.delete", + resourceType: "task", + resourceId: input.id, + ip: ctx.ip ?? null, + outcome: "success", + metadata: { walletId: existing.walletId }, + }); + return { id: input.id }; + }), + + /** + * Build the payout for the selected tasks and return what the human must + * review: the summary, the fee, warnings, and the draft token that + * `confirmPayout` accepts. Nothing is stored. A mutation rather than a + * query: it reads chain state and mints a token. + */ + preparePayout: protectedProcedure + .input(z.object({ walletId: z.string(), taskIds: z.array(z.string()).min(1).max(MAX_PAYOUT_TASKS) })) + .mutation(async ({ ctx, input }) => { + await assertWalletAccess(ctx, input.walletId); + const actor = requireSessionAddress(ctx); + // Lazy: the pipeline pulls Mesh into the module graph. + const [{ prepareTaskPayoutPreview }, { buildReviewDeps, toolContextFromSession }] = await Promise.all([ + import("@/lib/task-payout/preview"), + import("@/lib/task-payout/deps"), + ]); + const toolCtx = toolContextFromSession(ctx); + const result = await prepareTaskPayoutPreview( + input, + toolCtx, + buildReviewDeps(ctx.db, toolCtx, { omitCard: true }), + ); + void audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "task.payout.prepare", + resourceType: "wallet", + resourceId: input.walletId, + ip: ctx.ip ?? null, + outcome: result.status >= 400 ? "error" : "success", + reason: result.status >= 400 ? String((result.body as { code?: string })?.code ?? result.status) : null, + metadata: { taskIds: input.taskIds, ...(result.audit ?? {}) }, + }); + if (result.status >= 400) throw mapReviewError(result); + const body = result.body as { + draftToken: string; + expiresAt: string; + expiresInSeconds: number; + txHash: string; + fee: string; + summary: unknown; + warnings: string[]; + tasks: { id: string; title: string }[]; + }; + return { + draftToken: body.draftToken, + expiresAt: body.expiresAt, + expiresInSeconds: body.expiresInSeconds, + txHash: body.txHash, + fee: body.fee, + summary: body.summary as import("@/lib/tx-review/summary").TxReviewSummary, + warnings: body.warnings, + tasks: body.tasks, + text: result.text ?? "", + }; + }), + + /** + * Create the previewed payout as a pending transaction with zero + * signatures and link the tasks, all in one database transaction. Takes + * only the draft token, so nothing can change between review and creation. + */ + confirmPayout: protectedProcedure + .input(z.object({ draftToken: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const actor = requireSessionAddress(ctx); + const [ + { verifyDraftToken, describeDraftTokenFailure }, + { runTransactionPropose }, + { withTaskPayoutHooks }, + { APP_CLIENT_ID, buildReviewDeps, toolContextFromSession }, + ] = await Promise.all([ + import("@/lib/tx-review/draft-token"), + import("@/lib/tx-review/propose"), + import("@/lib/task-payout/hooks"), + import("@/lib/task-payout/deps"), + ]); + // Wallet access is asserted at the tRPC layer before the pipeline + // re-checks signer membership: same answer, but the audit and error + // shape match every other procedure here. + const verified = verifyDraftToken(input.draftToken, { subject: actor, clientId: APP_CLIENT_ID }); + if (!verified.ok) { + throw new TRPCError({ + code: verified.reason === "expired" ? "PRECONDITION_FAILED" : "BAD_REQUEST", + message: describeDraftTokenFailure(verified.reason), + }); + } + await assertWalletAccess(ctx, verified.claims.walletId); + + const toolCtx = toolContextFromSession(ctx); + const result = await runTransactionPropose( + { draftToken: input.draftToken }, + toolCtx, + withTaskPayoutHooks({ + ...buildReviewDeps(ctx.db, toolCtx, { omitCard: true }), + via: "app", + clientIp: ctx.ip, + }), + ); + void audit(ctx.db, { + actorAddress: actor, + actorType: "user", + action: "task.payout.confirm", + resourceType: "wallet", + resourceId: verified.claims.walletId, + ip: ctx.ip ?? null, + outcome: result.status >= 400 ? "error" : "success", + reason: result.status >= 400 ? String((result.body as { code?: string })?.code ?? result.status) : null, + metadata: { taskIds: verified.claims.origin?.taskIds ?? [], ...(result.audit ?? {}) }, + }); + if (result.status >= 400) throw mapReviewError(result); + const body = result.body as { + transactionId: string; + alreadyExisted: boolean; + txHash: string; + txHashChanged?: boolean; + txHashChangeReasons?: string[]; + link: string; + summary: unknown; + }; + return { + transactionId: body.transactionId, + alreadyExisted: body.alreadyExisted, + txHash: body.txHash, + txHashChanged: body.txHashChanged ?? false, + txHashChangeReasons: body.txHashChangeReasons ?? [], + link: body.link, + summary: body.summary as import("@/lib/tx-review/summary").TxReviewSummary, + taskIds: verified.claims.origin?.taskIds ?? [], + }; + }), +}); diff --git a/src/server/api/routers/transactions.ts b/src/server/api/routers/transactions.ts index f6c685fc..3821b4e4 100644 --- a/src/server/api/routers/transactions.ts +++ b/src/server/api/routers/transactions.ts @@ -3,14 +3,18 @@ import { z } from "zod"; import { csl } from "@meshsdk/core-csl"; import { resolveTxHash } from "@meshsdk/core-cst"; import { resolvePaymentKeyHash } from "@meshsdk/core"; -import { buildMultisigWallet } from "@/utils/common"; +import { buildWallet } from "@/utils/common"; import { getProvider } from "@/utils/get-provider"; import { addressToNetwork } from "@/utils/multisigSDK"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/lib/observability/audit"; import { assertWalletAccess } from "@/server/api/auth"; -import { enqueueSignatureRequiredNotifications } from "@/lib/notifications/center"; +import { + enqueueSignatureRequiredNotifications, + enqueueThresholdReachedNotifications, +} from "@/lib/notifications/center"; +import { cancelPayoutsForTransaction, markPayoutsPaid } from "@/lib/task-payout/sync"; function toHex(bytes: Uint8Array): string { return Buffer.from(bytes).toString("hex"); @@ -87,6 +91,8 @@ export const transactionRouter = createTRPCRouter({ "New signatures were collected while you were editing", }); } + // The replacement carries no task link; the tasks become payable again. + await cancelPayoutsForTransaction(db, old.id); await db.transaction.delete({ where: { id: old.id } }); return db.transaction.create({ data }); }) @@ -154,7 +160,7 @@ export const transactionRouter = createTRPCRouter({ if (!tx) { throw new TRPCError({ code: "NOT_FOUND", message: "Transaction not found" }); } - await assertWalletAccess(ctx, tx.walletId); + const wallet = await assertWalletAccess(ctx, tx.walletId); const sessionAddress = ctx.session?.user?.id ?? ctx.sessionAddress ?? null; const updated = await ctx.db.transaction.update({ where: { @@ -168,6 +174,28 @@ export const transactionRouter = createTRPCRouter({ txHash: input.txHash, }, }); + if (input.state === 1 && tx.state !== 1) { + try { + await markPayoutsPaid(ctx.db, { transactionId: tx.id, txHash: updated.txHash }); + } catch (error) { + console.error("Failed to mark task payouts paid", error); + } + } + try { + await enqueueThresholdReachedNotifications(ctx.db, { + wallet, + resourceType: "transaction", + resourceId: tx.id, + previousSignedAddresses: tx.signedAddresses, + signedAddresses: updated.signedAddresses, + actorAddress: sessionAddress, + description: tx.description, + txJson: tx.txJson, + txHash: updated.txHash ?? tx.txHash, + }); + } catch (error) { + console.error("Failed to enqueue threshold notifications", error); + } const justSigned = sessionAddress !== null && input.signedAddresses.includes(sessionAddress) && @@ -211,10 +239,15 @@ export const transactionRouter = createTRPCRouter({ } await assertWalletAccess(ctx, tx.walletId); const sessionAddress = ctx.session?.user?.id ?? ctx.sessionAddress ?? null; - const deleted = await ctx.db.transaction.delete({ - where: { - id: input.transactionId, - }, + const deleted = await ctx.db.$transaction(async (db) => { + // Task payouts pointing at this transaction are cancelled with it, so + // the tasks become payable again. + await cancelPayoutsForTransaction(db, input.transactionId); + return db.transaction.delete({ + where: { + id: input.transactionId, + }, + }); }); void audit(ctx.db, { actorAddress: sessionAddress, @@ -366,15 +399,15 @@ export const transactionRouter = createTRPCRouter({ ? addressToNetwork(wallet.signersAddresses[0]!) : 0; // Default to preprod/testnet - const mWallet = buildMultisigWallet(wallet as any, network); - if (!mWallet) { + const walletInfo = buildWallet(wallet as any, network); + if (!walletInfo || (!walletInfo.capabilities?.address && !walletInfo.address)) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to build wallet script", }); } - const walletScriptAddress = mWallet.getScript().address; + const walletScriptAddress = walletInfo.capabilities?.address ?? walletInfo.address; const blockchainProvider = getProvider(network); // Convert transaction body to txJson format diff --git a/src/server/api/routers/wallets.ts b/src/server/api/routers/wallets.ts index ab7a6c87..d3714ae0 100644 --- a/src/server/api/routers/wallets.ts +++ b/src/server/api/routers/wallets.ts @@ -1,7 +1,11 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; +import { + createTRPCRouter, + protectedProcedure, + publicProcedure, +} from "@/server/api/trpc"; import type { AuthCtx } from "@/server/api/trpc"; import type { RawImportBodies } from "@/types/wallet"; import { Prisma } from "@prisma/client"; @@ -9,6 +13,17 @@ import { audit } from "@/lib/observability/audit"; import { paymentKeyHash, stakeKeyHash } from "@/utils/multisigSDK"; import { recoverRoleKeySets } from "@/utils/cip146Discovery"; +import { + WALLET_TRANSFER_FORMAT, + WALLET_TRANSFER_VERSION, + type WalletTransferBallot, + type WalletTransferContact, + type WalletTransferPayloadV1, + type WalletTransferType, +} from "@/types/walletTransfer"; + +const TRANSFER_ALLOWED_TYPES: WalletTransferType[] = ["atLeast", "all", "any"]; + const requireSessionAddress = (ctx: AuthCtx) => { const address = ctx.session?.user?.id ?? ctx.sessionAddress; if (!address) { @@ -17,7 +32,11 @@ const requireSessionAddress = (ctx: AuthCtx) => { return address; }; -const assertWalletAccess = async (ctx: AuthCtx, walletId: string, requester: string | string[]) => { +const assertWalletAccess = async ( + ctx: AuthCtx, + walletId: string, + requester: string | string[], +) => { const wallet = await ctx.db.wallet.findUnique({ where: { id: walletId } }); if (!wallet) { throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); @@ -27,72 +46,99 @@ const assertWalletAccess = async (ctx: AuthCtx, walletId: string, requester: str const sessionWallets: string[] = ctx.sessionWallets ?? []; const allRequesters = [...requesters, ...sessionWallets]; - const isSigner = allRequesters.some((addr) => - Array.isArray(wallet.signersAddresses) && wallet.signersAddresses.includes(addr) + const isSigner = allRequesters.some( + (addr) => + Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.includes(addr), ); const isOwner = allRequesters.some((addr) => wallet.ownerAddress === addr); if (!isSigner && !isOwner) { - throw new TRPCError({ code: "FORBIDDEN", message: "Not a signer of this wallet" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a signer of this wallet", + }); } return wallet; }; // Check if user is the owner of the wallet -const assertNewWalletOwnerAccess = async (ctx: AuthCtx, walletId: string, requester: string) => { +const assertNewWalletOwnerAccess = async ( + ctx: AuthCtx, + walletId: string, + requester: string, +) => { const wallet = await ctx.db.newWallet.findUnique({ where: { id: walletId } }); if (!wallet) { throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); } - + // Check if requester is the owner (exact match) const isOwner = wallet.ownerAddress === requester; - + // Also check if ownerAddress is in sessionWallets (user might have multiple authorized wallets) const sessionWallets: string[] = ctx.sessionWallets ?? []; const isOwnerViaSession = sessionWallets.includes(wallet.ownerAddress); - + if (!isOwner && !isOwnerViaSession) { - throw new TRPCError({ code: "FORBIDDEN", message: "Only the owner can perform this action" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the owner can perform this action", + }); } return wallet; }; // Check if user is a signer or owner (for read access) -const assertNewWalletSignerAccess = async (ctx: AuthCtx, walletId: string, requester: string) => { +const assertNewWalletSignerAccess = async ( + ctx: AuthCtx, + walletId: string, + requester: string, +) => { const wallet = await ctx.db.newWallet.findUnique({ where: { id: walletId } }); if (!wallet) { throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); } - + // Check if user is the owner (owners always have full access) const isOwner = wallet.ownerAddress === requester; - + // Also check if ownerAddress is in sessionWallets (user might have multiple authorized wallets) const sessionWallets: string[] = ctx.sessionWallets ?? []; const isOwnerViaSession = sessionWallets.includes(wallet.ownerAddress); - + if (isOwner || isOwnerViaSession) { return wallet; } - + // Check if user is a signer const isSigner = - Array.isArray(wallet.signersAddresses) && wallet.signersAddresses.includes(requester); - + Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.includes(requester); + // Also check if any signer address is in sessionWallets - const isSignerViaSession = Array.isArray(wallet.signersAddresses) && - wallet.signersAddresses.some((addr: string) => sessionWallets.includes(addr)); - + const isSignerViaSession = + Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.some((addr: string) => + sessionWallets.includes(addr), + ); + if (!isSigner && !isSignerViaSession) { - throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this wallet" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not authorized for this wallet", + }); } return wallet; }; // Check if user can read the wallet (signer or owner) -const assertNewWalletAccess = async (ctx: AuthCtx, walletId: string, requester: string) => { +const assertNewWalletAccess = async ( + ctx: AuthCtx, + walletId: string, + requester: string, +) => { return assertNewWalletSignerAccess(ctx, walletId, requester); }; @@ -169,7 +215,9 @@ function walletDataFromPayload( scriptCbor: payload.scriptCbor, stakeCredentialHash: payload.stakeCredentialHash ?? undefined, type: payload.type, - rawImportBodies: JSON.parse(JSON.stringify(merged)) as Prisma.InputJsonValue, + rawImportBodies: JSON.parse( + JSON.stringify(merged), + ) as Prisma.InputJsonValue, ownerAddress, }; } @@ -228,14 +276,20 @@ export const walletRouter = createTRPCRouter({ }); if (!wallet) { - throw new TRPCError({ code: "FORBIDDEN", message: "Not a signer of this wallet" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a signer of this wallet", + }); } if ( !Array.isArray(wallet.signersAddresses) || !wallet.signersAddresses.includes(input.address) ) { - throw new TRPCError({ code: "FORBIDDEN", message: "Not a signer of this wallet" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a signer of this wallet", + }); } return wallet; @@ -261,18 +315,23 @@ export const walletRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); try { - const numRequired = (input.type === "all" || input.type === "any") ? null : input.numRequiredSigners; + const numRequired = + input.type === "all" || input.type === "any" + ? null + : input.numRequiredSigners; - const signersStakeKeys = (input.signersStakeKeys || []).map(key => - key === null || key === undefined ? "" : key + const signersStakeKeys = (input.signersStakeKeys || []).map((key) => + key === null || key === undefined ? "" : key, ); - const signersDRepKeys = (input.signersDRepKeys || []).map(key => - key === null || key === undefined ? "" : key + const signersDRepKeys = (input.signersDRepKeys || []).map((key) => + key === null || key === undefined ? "" : key, ); let rawImportBodiesValue: Prisma.InputJsonValue | undefined = undefined; if (input.rawImportBodies) { - rawImportBodiesValue = JSON.parse(JSON.stringify(input.rawImportBodies)) as Prisma.InputJsonValue; + rawImportBodiesValue = JSON.parse( + JSON.stringify(input.rawImportBodies), + ) as Prisma.InputJsonValue; } const data: Prisma.WalletCreateInput = { @@ -287,7 +346,9 @@ export const walletRouter = createTRPCRouter({ stakeCredentialHash: input.stakeCredentialHash, type: input.type, rawImportBodies: rawImportBodiesValue, - ...(input.ownerAddress != null && { ownerAddress: input.ownerAddress }), + ...(input.ownerAddress != null && { + ownerAddress: input.ownerAddress, + }), }; const wallet = await ctx.db.wallet.create({ data }); @@ -323,13 +384,32 @@ export const walletRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); await assertWalletAccess(ctx, input.walletId, sessionAddress); + + const wallet = await ctx.db.wallet.findUnique({ + where: { id: input.walletId }, + select: { verified: true }, + }); + if (!wallet) { + throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); + } + + // Verification proves control of YOUR OWN address, so a caller may only + // add or remove themselves. Everyone else's entries are read back from + // the database and whatever the client submitted for them is ignored. + // + // Taking the array verbatim let any member of a wallet write any address + // into `verified` — including one that had never signed anything. That + // matters more than it looks: acceptance is what decides whether a + // wallet's attacker-choosable name is trusted enough to show, so a + // forgeable `verified` would forge trust along with it. + const others = wallet.verified.filter((a) => a !== sessionAddress); + const verified = input.verified.includes(sessionAddress) + ? [...others, sessionAddress] + : others; + return ctx.db.wallet.update({ - where: { - id: input.walletId, - }, - data: { - verified: input.verified, - }, + where: { id: input.walletId }, + data: { verified }, }); }), @@ -356,12 +436,12 @@ export const walletRouter = createTRPCRouter({ description: input.description, isArchived: input.isArchived, }; - + // Only update profileImageIpfsUrl if it's explicitly provided if (input.profileImageIpfsUrl !== undefined) { updateData.profileImageIpfsUrl = input.profileImageIpfsUrl ?? null; } - + return ctx.db.wallet.update({ where: { id: input.walletId, @@ -476,9 +556,15 @@ export const walletRouter = createTRPCRouter({ sessionWallets.includes(input.ownerAddress); if (!isAuthorized) { - throw new TRPCError({ code: "FORBIDDEN", message: "Owner address mismatch" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Owner address mismatch", + }); } - const numRequired = (input.scriptType === "all" || input.scriptType === "any") ? null : input.numRequiredSigners; + const numRequired = + input.scriptType === "all" || input.scriptType === "any" + ? null + : input.numRequiredSigners; const wallet = await ctx.db.newWallet.create({ data: { name: input.name, @@ -537,7 +623,10 @@ export const walletRouter = createTRPCRouter({ const sessionAddress = requireSessionAddress(ctx); // Only owners can update the entire wallet await assertNewWalletOwnerAccess(ctx, input.walletId, sessionAddress); - const numRequired = (input.scriptType === "all" || input.scriptType === "any") ? null : input.numRequiredSigners; + const numRequired = + input.scriptType === "all" || input.scriptType === "any" + ? null + : input.numRequiredSigners; return ctx.db.newWallet.update({ where: { id: input.walletId, @@ -592,33 +681,51 @@ export const walletRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); - const wallet = await assertNewWalletSignerAccess(ctx, input.walletId, sessionAddress); - + const wallet = await assertNewWalletSignerAccess( + ctx, + input.walletId, + sessionAddress, + ); + // Check if user is the owner - owners can update all descriptions const isOwner = wallet.ownerAddress === sessionAddress; - + if (!isOwner) { // Non-owners can only update their own description // Find the signer's index in the signersAddresses array const signerIndex = wallet.signersAddresses.indexOf(sessionAddress); if (signerIndex < 0) { - throw new TRPCError({ code: "FORBIDDEN", message: "You are not a signer of this wallet" }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not a signer of this wallet", + }); } - + // Verify that only the signer's own description is being changed // All other descriptions must remain the same for (let i = 0; i < wallet.signersDescriptions.length; i++) { - if (i !== signerIndex && wallet.signersDescriptions[i] !== input.signersDescriptions[i]) { - throw new TRPCError({ code: "FORBIDDEN", message: "You can only update your own description" }); + if ( + i !== signerIndex && + wallet.signersDescriptions[i] !== input.signersDescriptions[i] + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only update your own description", + }); } } - + // Verify the array lengths match - if (input.signersDescriptions.length !== wallet.signersDescriptions.length) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Descriptions array length mismatch" }); + if ( + input.signersDescriptions.length !== wallet.signersDescriptions.length + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Descriptions array length mismatch", + }); } } - + // Owners can update all, signers can only update their own (validated above) return ctx.db.newWallet.update({ where: { @@ -641,7 +748,9 @@ export const walletRouter = createTRPCRouter({ const sessionAddress = requireSessionAddress(ctx); const requester = sessionAddress; // Look up user's stake address for stake-key membership check - const user = await ctx.db.user.findUnique({ where: { address: input.ownerAddress } }); + const user = await ctx.db.user.findUnique({ + where: { address: input.ownerAddress }, + }); const stakeAddr = user?.stakeAddress || ""; // Atomic conditional claim: only if owner is currently "all" AND caller qualifies @@ -651,7 +760,9 @@ export const walletRouter = createTRPCRouter({ ownerAddress: "all", OR: [ { signersAddresses: { has: input.ownerAddress } }, - stakeAddr ? { signersStakeKeys: { has: stakeAddr } } : { id: "__never__" }, + stakeAddr + ? { signersStakeKeys: { has: stakeAddr } } + : { id: "__never__" }, { signersAddresses: { has: requester } }, ], }, @@ -730,7 +841,10 @@ export const walletRouter = createTRPCRouter({ where: { id: input.walletId }, }); if (!wallet) { - throw new TRPCError({ code: "NOT_FOUND", message: "Wallet invite not found" }); + throw new TRPCError({ + code: "NOT_FOUND", + message: "Wallet invite not found", + }); } const raw = wallet.rawImportBodies as { @@ -904,7 +1018,8 @@ export const walletRouter = createTRPCRouter({ const existingDRep = signersDRepKeys[slotIndex] ?? ""; signersStakeKeys[slotIndex] = existingStake || healedStake || stakeContribution; - signersDRepKeys[slotIndex] = existingDRep || healedDRep || drepContribution; + signersDRepKeys[slotIndex] = + existingDRep || healedDRep || drepContribution; if (input.description !== undefined) { signersDescriptions[slotIndex] = input.description; } @@ -1019,10 +1134,12 @@ export const walletRouter = createTRPCRouter({ }), setMigrationTarget: protectedProcedure - .input(z.object({ - walletId: z.string(), - migrationTargetWalletId: z.string() - })) + .input( + z.object({ + walletId: z.string(), + migrationTargetWalletId: z.string(), + }), + ) .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); await assertWalletAccess(ctx, input.walletId, sessionAddress); @@ -1041,7 +1158,8 @@ export const walletRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const sessionWallets: string[] = ctx.sessionWallets ?? []; const sessionAddress = requireSessionAddress(ctx); - const requesters = sessionWallets.length > 0 ? sessionWallets : [sessionAddress]; + const requesters = + sessionWallets.length > 0 ? sessionWallets : [sessionAddress]; await assertWalletAccess(ctx, input.walletId, requesters); return ctx.db.wallet.update({ where: { @@ -1054,10 +1172,12 @@ export const walletRouter = createTRPCRouter({ }), abortMigration: protectedProcedure - .input(z.object({ - walletId: z.string(), - newWalletId: z.string().optional() - })) + .input( + z.object({ + walletId: z.string(), + newWalletId: z.string().optional(), + }), + ) .mutation(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); await assertWalletAccess(ctx, input.walletId, sessionAddress); @@ -1066,27 +1186,31 @@ export const walletRouter = createTRPCRouter({ try { // First check if it exists in NewWallet table const newWallet = await ctx.db.newWallet.findUnique({ - where: { id: input.newWalletId } + where: { id: input.newWalletId }, }); - + if (newWallet) { await ctx.db.newWallet.delete({ - where: { id: input.newWalletId } + where: { id: input.newWalletId }, }); console.log("Deleted NewWallet:", input.newWalletId); } else { // Check if it exists in Wallet table const wallet = await ctx.db.wallet.findUnique({ - where: { id: input.newWalletId } + where: { id: input.newWalletId }, }); - + if (wallet) { await ctx.db.wallet.delete({ - where: { id: input.newWalletId } + where: { id: input.newWalletId }, }); console.log("Deleted Wallet:", input.newWalletId); } else { - console.log("No wallet found with ID:", input.newWalletId, "- migration might be in a different state"); + console.log( + "No wallet found with ID:", + input.newWalletId, + "- migration might be in a different state", + ); } } } catch (error) { @@ -1111,7 +1235,8 @@ export const walletRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const sessionWallets: string[] = ctx.sessionWallets ?? []; const sessionAddress = requireSessionAddress(ctx); - const requesters = sessionWallets.length > 0 ? sessionWallets : [sessionAddress]; + const requesters = + sessionWallets.length > 0 ? sessionWallets : [sessionAddress]; await assertWalletAccess(ctx, input.walletId, requesters); const updated = await ctx.db.wallet.update({ where: { @@ -1142,7 +1267,8 @@ export const walletRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const sessionAddress = requireSessionAddress(ctx); const sessionWallets: string[] = ctx.sessionWallets ?? []; - const requesters = sessionWallets.length > 0 ? sessionWallets : [sessionAddress]; + const requesters = + sessionWallets.length > 0 ? sessionWallets : [sessionAddress]; const wallet = await assertWalletAccess(ctx, input.walletId, requesters); const payload = { @@ -1160,7 +1286,8 @@ export const walletRouter = createTRPCRouter({ type: wallet.type, rawImportBodies: wallet.rawImportBodies ?? null, }; - const { hashPayload } = await import("@/pages/api/v1/exportWallet/redeem"); + const { hashPayload } = + await import("@/pages/api/v1/exportWallet/redeem"); return { payload, payloadHash: hashPayload(payload) }; }), @@ -1222,9 +1349,15 @@ export const walletRouter = createTRPCRouter({ importedAt: now, }; lockedSigners = true; - data = walletDataFromPayload(input.payload, provenance, lockedSigners, sessionAddress); + data = walletDataFromPayload( + input.payload, + provenance, + lockedSigners, + sessionAddress, + ); } else if (input.source === "json") { - const { hashPayload } = await import("@/pages/api/v1/exportWallet/redeem"); + const { hashPayload } = + await import("@/pages/api/v1/exportWallet/redeem"); const expected = hashPayload(input.payload); if (expected !== input.payloadHash) { throw new TRPCError({ @@ -1232,11 +1365,10 @@ export const walletRouter = createTRPCRouter({ message: "Payload hash mismatch — file may be corrupt or tampered", }); } - const claimedAddresses = [ - ...callerAddresses, - ].filter((addr) => - input.payload.signersStakeKeys.includes(addr) || - input.payload.signersAddresses.includes(addr), + const claimedAddresses = [...callerAddresses].filter( + (addr) => + input.payload.signersStakeKeys.includes(addr) || + input.payload.signersAddresses.includes(addr), ); if (claimedAddresses.length === 0) { throw new TRPCError({ @@ -1252,7 +1384,12 @@ export const walletRouter = createTRPCRouter({ importedAt: now, }; lockedSigners = true; - data = walletDataFromPayload(input.payload, provenance, lockedSigners, sessionAddress); + data = walletDataFromPayload( + input.payload, + provenance, + lockedSigners, + sessionAddress, + ); } else { // source === "cbor" assertCallerIsClaimedSigner(input.verifiedSigner, callerAddresses); @@ -1298,4 +1435,118 @@ export const walletRouter = createTRPCRouter({ const wallet = await ctx.db.wallet.create({ data }); return wallet; }), + + exportTransferPayload: protectedProcedure + .input( + z.object({ + walletId: z.string(), + includeContacts: z.boolean().default(false), + includeBallots: z.boolean().default(false), + }), + ) + .mutation(async ({ ctx, input }) => { + const sessionAddress = requireSessionAddress(ctx); + const sessionWallets: string[] = ctx.sessionWallets ?? []; + const requesters = + sessionWallets.length > 0 ? sessionWallets : [sessionAddress]; + + const wallet = await ctx.db.wallet.findUnique({ + where: { id: input.walletId }, + }); + if (!wallet) { + throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); + } + const isOwner = requesters.some((a) => wallet.ownerAddress === a); + if (!isOwner) { + void audit(ctx.db, { + actorAddress: sessionAddress, + actorType: "user", + action: "wallet.transfer.export", + resourceType: "wallet", + resourceId: input.walletId, + ip: ctx.ip ?? null, + outcome: "denied", + reason: "not_owner", + }); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the wallet owner can export this wallet", + }); + } + + const type: WalletTransferType = TRANSFER_ALLOWED_TYPES.includes( + wallet.type as WalletTransferType, + ) + ? (wallet.type as WalletTransferType) + : "atLeast"; + + const payload: WalletTransferPayloadV1 = { + format: WALLET_TRANSFER_FORMAT, + version: WALLET_TRANSFER_VERSION, + exportedAt: new Date().toISOString(), + exportedFromOrigin: "", + exporterAddress: sessionAddress, + wallet: { + name: wallet.name, + description: wallet.description ?? "", + type, + signersAddresses: wallet.signersAddresses ?? [], + signersStakeKeys: wallet.signersStakeKeys ?? [], + signersDRepKeys: wallet.signersDRepKeys ?? [], + signersDescriptions: wallet.signersDescriptions ?? [], + numRequiredSigners: wallet.numRequiredSigners ?? null, + scriptCbor: wallet.scriptCbor, + stakeCredentialHash: wallet.stakeCredentialHash ?? null, + profileImageIpfsUrl: wallet.profileImageIpfsUrl ?? null, + }, + }; + + if (input.includeContacts) { + const contacts = await ctx.db.contact.findMany({ + where: { walletId: input.walletId }, + orderBy: { createdAt: "asc" }, + take: 500, + }); + payload.contacts = contacts.map<WalletTransferContact>((c) => ({ + name: c.name, + address: c.address, + description: c.description ?? null, + })); + } + + if (input.includeBallots) { + const ballots = await ctx.db.ballot.findMany({ + where: { walletId: input.walletId }, + orderBy: { createdAt: "asc" }, + take: 200, + }); + payload.ballots = ballots.map<WalletTransferBallot>((b) => ({ + description: b.description ?? null, + items: b.items ?? [], + itemDescriptions: b.itemDescriptions ?? [], + choices: b.choices ?? [], + anchorUrls: b.anchorUrls ?? [], + anchorHashes: b.anchorHashes ?? [], + rationaleComments: b.rationaleComments ?? [], + type: b.type, + })); + } + + void audit(ctx.db, { + actorAddress: sessionAddress, + actorType: "user", + action: "wallet.transfer.export", + resourceType: "wallet", + resourceId: input.walletId, + ip: ctx.ip ?? null, + outcome: "success", + metadata: { + includeContacts: input.includeContacts, + includeBallots: input.includeBallots, + signerCount: payload.wallet.signersAddresses.length, + }, + }); + + return payload; + }), }); diff --git a/src/types/tx-draft.ts b/src/types/tx-draft.ts index 81ca04dd..ee368442 100644 --- a/src/types/tx-draft.ts +++ b/src/types/tx-draft.ts @@ -83,9 +83,26 @@ export type DraftCertificate = { pairId?: string; }; +/** + * Who funds the transaction and receives its change. Only the multisig + * source can be proposed (and carry staking certificates / votes, which need + * the multisig's script credentials); the connected wallet signs and submits + * directly; an arbitrary address is build-and-export only. + */ +export type DraftSource = + | { kind: "multisig" } + /** The connected CIP-30 signer wallet; its address is resolved at runtime. */ + | { kind: "connected" } + /** Any pasted payment address the user controls elsewhere. */ + | { kind: "address"; address: string }; + +export type DraftSourceKind = DraftSource["kind"]; + export type TxDraft = { /** Draft id; the flow tx node id becomes `txd:<id>`. */ id: string; + /** Funding wallet and change target; defaults to the multisig. */ + source: DraftSource; outputs: DraftOutput[]; utxoSelection: DraftUtxoSelection; /** Off-chain description shown to signers (≤128 chars convention). */ diff --git a/src/types/wallet.ts b/src/types/wallet.ts index fadbfb68..12bcd961 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -44,6 +44,14 @@ export interface RawImportBodies { [key: string]: unknown; } +export interface WalletCapabilities { + canStake: boolean; + canVote: boolean; + address: string; + stakeAddress?: string; + dRepId?: string; +} + export type WalletImportProvenance = | { origin: "summon"; @@ -93,5 +101,6 @@ export type Wallet = DbWalletWithLegacy & { address: string; dRepId: string; stakeScriptCbor?: string; + capabilities?: WalletCapabilities; }; diff --git a/src/types/walletTransfer.ts b/src/types/walletTransfer.ts new file mode 100644 index 00000000..f51341b0 --- /dev/null +++ b/src/types/walletTransfer.ts @@ -0,0 +1,46 @@ +export const WALLET_TRANSFER_FORMAT = "multisig-wallet-transfer" as const; +export const WALLET_TRANSFER_VERSION = 1 as const; + +export type WalletTransferType = "atLeast" | "all" | "any"; + +export type WalletTransferDefinition = { + name: string; + description: string; + type: WalletTransferType; + signersAddresses: string[]; + signersStakeKeys: string[]; + signersDRepKeys: string[]; + signersDescriptions: string[]; + numRequiredSigners: number | null; + scriptCbor: string; + stakeCredentialHash: string | null; + profileImageIpfsUrl?: string | null; +}; + +export type WalletTransferContact = { + name: string; + address: string; + description?: string | null; +}; + +export type WalletTransferBallot = { + description?: string | null; + items: string[]; + itemDescriptions: string[]; + choices: string[]; + anchorUrls: string[]; + anchorHashes: string[]; + rationaleComments: string[]; + type: number; +}; + +export type WalletTransferPayloadV1 = { + format: typeof WALLET_TRANSFER_FORMAT; + version: typeof WALLET_TRANSFER_VERSION; + exportedAt: string; + exportedFromOrigin: string; + exporterAddress: string; + wallet: WalletTransferDefinition; + contacts?: WalletTransferContact[]; + ballots?: WalletTransferBallot[]; +}; diff --git a/src/utils/cip146Discovery.ts b/src/utils/cip146Discovery.ts index 89f897c9..88f64b92 100644 --- a/src/utils/cip146Discovery.ts +++ b/src/utils/cip146Discovery.ts @@ -21,6 +21,10 @@ import { type Label1854LookupItem, } from "./cip146Registration"; import { MultisigWallet, type MultisigKey } from "./multisigSDK"; +import { + collectNativeScriptSigHashes, + providerScriptJsonToNativeScript, +} from "./nativeScriptJson"; import { decodeNativeScriptFromCbor, decodedToNativeScript, @@ -47,67 +51,9 @@ export type DiscoveredImportInput = { recovery?: RoleRecovery; }; -/** - * Convert provider (cardano-cli style) timelock JSON into a Mesh - * NativeScript. Throws on malformed or non-timelock input. - */ -export function providerScriptJsonToNativeScript(json: unknown): NativeScript { - if (!json || typeof json !== "object") { - throw new Error("Invalid native script JSON"); - } - const node = json as Record<string, unknown>; - const type = node.type; - - if (type === "sig") { - if (typeof node.keyHash !== "string") { - throw new Error("Invalid sig script: missing keyHash"); - } - return { type: "sig", keyHash: node.keyHash }; - } - if (type === "all" || type === "any") { - if (!Array.isArray(node.scripts)) { - throw new Error(`Invalid ${type} script: missing scripts`); - } - return { - type, - scripts: node.scripts.map(providerScriptJsonToNativeScript), - }; - } - if (type === "atLeast") { - if (!Array.isArray(node.scripts) || typeof node.required !== "number") { - throw new Error("Invalid atLeast script: missing scripts/required"); - } - return { - type: "atLeast", - required: node.required, - scripts: node.scripts.map(providerScriptJsonToNativeScript), - }; - } - if (type === "after" || type === "before") { - if (node.slot === undefined || node.slot === null) { - throw new Error(`Invalid ${type} script: missing slot`); - } - return { type, slot: String(node.slot) }; - } - throw new Error(`Unsupported native script type: ${String(type)}`); -} - -/** Collect sig key hashes in script order (lowercased, deduplicated). */ -export function collectNativeScriptSigHashes(script: NativeScript): string[] { - const out: string[] = []; - const walk = (node: NativeScript) => { - if (node.type === "sig") { - const hash = node.keyHash.toLowerCase(); - if (!out.includes(hash)) out.push(hash); - return; - } - if ("scripts" in node && Array.isArray(node.scripts)) { - for (const child of node.scripts) walk(child); - } - }; - walk(script); - return out; -} +// Script-JSON parsing lives in nativeScriptJson.ts (no runtime Mesh +// imports) so API routes can share it; re-exported here for callers. +export { providerScriptJsonToNativeScript, collectNativeScriptSigHashes }; /** Enterprise (payment-only) bech32 address for a payment key hash. */ export function keyHashToEnterpriseAddress( diff --git a/src/utils/cip146Registration.ts b/src/utils/cip146Registration.ts index c4c6a4eb..d7d8a928 100644 --- a/src/utils/cip146Registration.ts +++ b/src/utils/cip146Registration.ts @@ -143,6 +143,22 @@ export function participantsMatchExactly( return true; } +/** + * True when every given hash is a participant of the item + * (case-insensitive). Used for policy lookups: a native script's sig + * hashes must all appear in the registration's participants (which may + * additionally list stake/dRep hashes) for the registration to be that + * script's wallet. + */ +export function participantsInclude( + item: Label1854LookupItem, + keyHashes: string[], +): boolean { + const onChain = participantKeySet(item); + if (!onChain || keyHashes.length === 0) return false; + return keyHashes.every((hash) => onChain.has(hash.toLowerCase())); +} + /** * True when an on-chain registration's metadata already reflects the * wallet's current metadata (name, description, participant names and diff --git a/src/utils/common.ts b/src/utils/common.ts index 9a3555ca..39523748 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -7,6 +7,7 @@ import { resolveScriptHashDRepId, resolveStakeKeyHash, serializeNativeScript, + serializeRewardAddress, UTxO, } from "@meshsdk/core"; import { getDRepIds } from "@meshsdk/core-cst"; @@ -135,13 +136,13 @@ function hasNonEmptyEntries(values?: string[] | null): boolean { export function getWalletType(wallet: DbWalletWithLegacy): WalletType { if (wallet.rawImportBodies?.multisig) return 'summon'; - + // Legacy: only payment keys (no stake keys, no DRep keys) // External stake credential hash doesn't make it SDK - it's still legacy if only payment keys const hasStakeKeys = hasNonEmptyEntries(wallet.signersStakeKeys); const hasDRepKeys = hasNonEmptyEntries(wallet.signersDRepKeys); if (!hasStakeKeys && !hasDRepKeys) return 'legacy'; - + return 'sdk'; } @@ -154,7 +155,7 @@ export function buildMultisigWallet( network?: number, ): MultisigWallet | undefined { const walletType = getWalletType(wallet); - + // Only build MultisigWallet for SDK wallets if (walletType !== 'sdk') { return undefined; @@ -162,7 +163,7 @@ export function buildMultisigWallet( const keys: MultisigKey[] = []; const resolvedNetwork = resolveWalletNetwork(wallet, network); - + // Add payment keys (role 0) if (wallet.signersAddresses.length > 0) { wallet.signersAddresses.forEach((addr, i) => { @@ -182,7 +183,7 @@ export function buildMultisigWallet( } }); } - + // Add staking keys (role 2) if (wallet.signersStakeKeys && wallet.signersStakeKeys.length > 0) { wallet.signersStakeKeys.forEach((stakeKey, i) => { @@ -200,7 +201,7 @@ export function buildMultisigWallet( } }); } - + // Add DRep keys (role 3) if (wallet.signersDRepKeys && wallet.signersDRepKeys.length > 0) { wallet.signersDRepKeys.forEach((dRepKey, i) => { @@ -263,7 +264,7 @@ export function buildWallet( if (!multisig) { throw new Error("rawImportBodies.multisig is required for Summon wallets"); } - + // Always use stored address from rawImportBodies const address = multisig.address; if (!address) { @@ -276,7 +277,10 @@ export function buildWallet( stakeScript: multisig.stake_script, }); - // Always use the script that matches the address payment credential hash + // Always use the scriptCbor from metadata if available. This is CRITICAL + // for backward compatibility with wallets created with "unordered CBOR lists". + // Re-deriving the address from a canonicalized script would change the hash + // and break existing wallets. const scriptCbor = paymentScriptCbor; if (!scriptCbor) { throw new Error("A valid payment script is required in rawImportBodies.multisig"); @@ -293,20 +297,32 @@ export function buildWallet( // Fallback to placeholder if decoding fails nativeScript = scriptType === "atLeast" ? { - type: "atLeast", - required: wallet.numRequiredSigners ?? 1, - scripts: [], - } + type: "atLeast", + required: wallet.numRequiredSigners ?? 1, + scripts: [], + } : { - type: scriptType, - scripts: [], - }; + type: scriptType, + scripts: [], + }; } // For rawImportBodies wallets, dRepId cannot be easily derived from stored CBOR // Set to empty string - it can be derived later if needed from the actual script const dRepId = ""; + // Capability logic for Summon + // Staking is enabled if a stake script is present + const canStake = !!stakeScriptCbor; + const stakeScriptHash = stakeScriptCbor ? scriptHashFromCbor(stakeScriptCbor) : undefined; + const stakeAddress = stakeScriptHash + ? serializeRewardAddress( + stakeScriptHash, + true, + network as 0 | 1 + ) + : undefined; + return { ...wallet, scriptCbor, @@ -314,6 +330,18 @@ export function buildWallet( address, dRepId, stakeScriptCbor, + capabilities: { + canStake, + // TODO: flip once Summon exports a DRep script. Summon's + // rawImportBodies.multisig carries only payment_script and + // stake_script, so there is nothing to derive a DRep credential from — + // `dRepId` above is empty for the same reason. See the review thread on + // https://github.com/MeshJS/multisig/pull/212 before changing this. + canVote: false, + address, + stakeAddress, + dRepId: dRepId || undefined, + }, } as Wallet; } @@ -343,6 +371,12 @@ export function buildWallet( nativeScript, address, dRepId: dRepIdCip129, + capabilities: { + canStake: false, + canVote: false, + address, + dRepId: dRepIdCip129, + }, } as Wallet; } @@ -395,5 +429,12 @@ export function buildWallet( nativeScript, address, dRepId: dRepIdCip129, + capabilities: { + canStake: mWallet.stakingEnabled(), + canVote: mWallet.drepEnabled(), + address, + stakeAddress: mWallet.getStakeAddress(), + dRepId: mWallet.getDRepId(), + }, } as Wallet; } diff --git a/src/utils/discoverQuery.ts b/src/utils/discoverQuery.ts new file mode 100644 index 00000000..bf8ffd27 --- /dev/null +++ b/src/utils/discoverQuery.ts @@ -0,0 +1,105 @@ +/** + * Classify free-text input on the Discover tab into a lookup query. + * + * Accepted inputs: + * - a 56-hex hash — ambiguous between a signer key hash and a native + * script hash ("policy"); resolved at query time by trying the script + * first (the provider 404s for a key hash) + * - a payment address (addr1 / addr_test1) — a script-credential address + * is a policy query, a key-credential address is a signer query using + * its payment (and, when present, stake) key hash + * - a stake address (stake1 / stake_test1) — signer query by stake hash + */ + +import { deserializeAddress } from "@meshsdk/core"; + +import { tryResolveKeyHash } from "./addressCompatibility"; + +export type DiscoverQuery = + | { kind: "empty" } + | { kind: "signer"; keyHashes: string[] } + | { kind: "policy"; scriptHash: string; address?: string } + | { kind: "hash"; hash: string } + | { kind: "invalid"; reason: "malformed" | "wrong-network" }; + +const HASH_RE = /^[0-9a-f]{56}$/i; + +export function classifyDiscoverInput( + raw: string, + networkId: number, +): DiscoverQuery { + const value = raw.trim(); + if (!value) return { kind: "empty" }; + + if (HASH_RE.test(value)) { + return { kind: "hash", hash: value.toLowerCase() }; + } + + const isPayment = value.startsWith("addr1") || value.startsWith("addr_test1"); + const isStake = value.startsWith("stake1") || value.startsWith("stake_test1"); + if (!isPayment && !isStake) { + return { kind: "invalid", reason: "malformed" }; + } + + // Prefix check mirrors matchAddressesToSigSlots: a wrong-network + // address parses fine but can never match this network's registrations. + const expectedPrefix = isPayment + ? networkId === 0 + ? "addr_test1" + : "addr1" + : networkId === 0 + ? "stake_test1" + : "stake1"; + if (!value.startsWith(expectedPrefix)) { + return { kind: "invalid", reason: "wrong-network" }; + } + + if (isStake) { + const resolved = tryResolveKeyHash(value); + return resolved + ? { kind: "signer", keyHashes: [resolved.keyHash.toLowerCase()] } + : { kind: "invalid", reason: "malformed" }; + } + + let parts: ReturnType<typeof deserializeAddress>; + try { + parts = deserializeAddress(value); + } catch { + return { kind: "invalid", reason: "malformed" }; + } + + if (parts.scriptHash) { + return { + kind: "policy", + scriptHash: parts.scriptHash.toLowerCase(), + address: value, + }; + } + + const keyHashes: string[] = []; + if (parts.pubKeyHash) keyHashes.push(parts.pubKeyHash.toLowerCase()); + // Same rule as the tab's own-key lookup: a key-based stake credential + // may itself be registered as a participant; a script stake credential + // never is. + if (!parts.stakeScriptCredentialHash && parts.stakeCredentialHash) { + keyHashes.push(parts.stakeCredentialHash.toLowerCase()); + } + if (keyHashes.length === 0) { + return { kind: "invalid", reason: "malformed" }; + } + return { kind: "signer", keyHashes }; +} + +/** Short human label for a query kind, used in result copy. */ +export function describeDiscoverQuery(query: DiscoverQuery): string { + switch (query.kind) { + case "signer": + return "this signer"; + case "policy": + return "this wallet"; + case "hash": + return "this hash"; + default: + return "your keys"; + } +} diff --git a/src/utils/download-file.ts b/src/utils/download-file.ts new file mode 100644 index 00000000..b9d8f58a --- /dev/null +++ b/src/utils/download-file.ts @@ -0,0 +1,23 @@ +/** + * Hand the browser a file the page built in memory. + * + * Object URLs leak the blob for the lifetime of the document unless they are + * revoked, which is the step that is easy to forget when this is written inline + * at each call site. + */ +export function downloadFile( + data: BlobPart, + fileName: string, + mimeType: string, +): void { + const url = URL.createObjectURL(new Blob([data], { type: mimeType })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function downloadJson(value: unknown, fileName: string): void { + downloadFile(JSON.stringify(value, null, 2), fileName, "application/json"); +} diff --git a/src/utils/nativeScriptJson.ts b/src/utils/nativeScriptJson.ts new file mode 100644 index 00000000..563ce846 --- /dev/null +++ b/src/utils/nativeScriptJson.ts @@ -0,0 +1,71 @@ +/** + * Provider (cardano-cli style) native-script JSON helpers. + * + * Kept free of runtime Mesh imports (only the NativeScript type) so API + * routes can use them without pulling the SDK/WASM into their module + * graph. Re-exported from cip146Discovery for the client-side callers. + */ + +import type { NativeScript } from "@meshsdk/core"; + +/** + * Convert provider (cardano-cli style) timelock JSON into a Mesh + * NativeScript. Throws on malformed or non-timelock input. + */ +export function providerScriptJsonToNativeScript(json: unknown): NativeScript { + if (!json || typeof json !== "object") { + throw new Error("Invalid native script JSON"); + } + const node = json as Record<string, unknown>; + const type = node.type; + + if (type === "sig") { + if (typeof node.keyHash !== "string") { + throw new Error("Invalid sig script: missing keyHash"); + } + return { type: "sig", keyHash: node.keyHash }; + } + if (type === "all" || type === "any") { + if (!Array.isArray(node.scripts)) { + throw new Error(`Invalid ${type} script: missing scripts`); + } + return { + type, + scripts: node.scripts.map(providerScriptJsonToNativeScript), + }; + } + if (type === "atLeast") { + if (!Array.isArray(node.scripts) || typeof node.required !== "number") { + throw new Error("Invalid atLeast script: missing scripts/required"); + } + return { + type: "atLeast", + required: node.required, + scripts: node.scripts.map(providerScriptJsonToNativeScript), + }; + } + if (type === "after" || type === "before") { + if (node.slot === undefined || node.slot === null) { + throw new Error(`Invalid ${type} script: missing slot`); + } + return { type, slot: String(node.slot) }; + } + throw new Error(`Unsupported native script type: ${String(type)}`); +} + +/** Collect sig key hashes in script order (lowercased, deduplicated). */ +export function collectNativeScriptSigHashes(script: NativeScript): string[] { + const out: string[] = []; + const walk = (node: NativeScript) => { + if (node.type === "sig") { + const hash = node.keyHash.toLowerCase(); + if (!out.includes(hash)) out.push(hash); + return; + } + if ("scripts" in node && Array.isArray(node.scripts)) { + for (const child of node.scripts) walk(child); + } + }; + walk(script); + return out; +} diff --git a/src/utils/nativeScriptUtils.ts b/src/utils/nativeScriptUtils.ts index 7af0877a..0f47a3c9 100644 --- a/src/utils/nativeScriptUtils.ts +++ b/src/utils/nativeScriptUtils.ts @@ -121,15 +121,15 @@ export function decodeNativeScriptFromCsl( return { type: "any", scripts }; } - const sn = ns.as_script_n_of_k(); - if (sn) { - const list = sn.native_scripts(); + const saNOfK = ns.as_script_n_of_k(); + if (saNOfK) { + const n = saNOfK.n(); + const list = saNOfK.native_scripts(); const scripts: DecodedNativeScript[] = []; for (let i = 0; i < list.len(); i++) { const child = list.get(i); scripts.push(decodeNativeScriptFromCsl(child)); } - const n = sn.n(); const required = typeof n === "number" ? n diff --git a/src/utils/signing.ts b/src/utils/signing.ts index bc8ad9b4..5e47aaa9 100644 --- a/src/utils/signing.ts +++ b/src/utils/signing.ts @@ -1,9 +1,4 @@ -import { - checkSignature, - DataSignature, - generateNonce, - IWallet, -} from "@meshsdk/core"; +import { checkSignature, DataSignature, IWallet } from "@meshsdk/core"; export type SignRole = 0 | 2 | 3; @@ -35,9 +30,16 @@ export async function sign( throw new Error("sign: missing address for the chosen role"); } - const nonce = generateNonce(payload); const signature = await wallet.signData(payload, address); - const verified = await checkSignature(nonce, signature, address); + // Verify against the payload itself — the exact bytes the wallet signed. + // This previously checked against `generateNonce(payload)`, which returns + // `hex(payload + 32 random characters)`; that can never equal the COSE + // payload, so every honest signature failed here and the throw below fired + // on every call. A nonce belongs to a server-issued challenge flow, where + // the nonce IS the signed message. Here the caller's payload already carries + // its own replay protection (the sign-off statement embeds `signedAt`, which + // the server checks against a ten-minute window). + const verified = await checkSignature(payload, signature, address); if (!verified) { throw new Error("Signature failed verification"); diff --git a/src/utils/swagger.ts b/src/utils/swagger.ts index 61e8ff98..978f7432 100644 --- a/src/utils/swagger.ts +++ b/src/utils/swagger.ts @@ -1180,6 +1180,125 @@ This API uses **Bearer Token** authentication (JWT). }, }, }, + "/api/v1/resolveRegistrationScript": { + get: { + tags: ["V1"], + summary: + "Resolve the native script(s) of a CIP-0146 registration transaction", + description: + "Reads the transaction's UTxO addresses and returns every script-credential address seen, with its script hash, stake credential and timelock JSON. Public, unauthenticated.", + parameters: [ + { + name: "txHash", + in: "query", + required: true, + description: "Registration transaction hash (64 hex chars)", + schema: { type: "string" }, + }, + { + name: "network", + in: "query", + required: false, + description: "0 = preprod, 1 = mainnet (default)", + schema: { type: "number" }, + }, + ], + responses: { + 200: { + description: "Script candidates (empty when the tx is unknown)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + txHash: { type: "string" }, + candidates: { + type: "array", + items: { + type: "object", + properties: { + address: { type: "string" }, + scriptHash: { type: "string" }, + stakeCredentialHash: { + type: "string", + nullable: true, + }, + scriptJson: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + }, + 400: { description: "Missing or invalid txHash / network" }, + 405: { description: "Method not allowed" }, + 500: { description: "Internal Server Error" }, + }, + }, + }, + "/api/v1/resolveScript": { + get: { + tags: ["V1"], + summary: + "Resolve a native script by hash (policy) or multisig address to its signer key hashes", + description: + "Backs lookup-by-policy: pass exactly one of scriptHash or address. Returns the script's timelock JSON and sig key hashes, which can then be matched against CIP-1854 registrations via /api/v1/lookupMultisigWallet. Public, unauthenticated.", + parameters: [ + { + name: "scriptHash", + in: "query", + required: false, + description: "Native-script hash / policy id (56 hex chars)", + schema: { type: "string" }, + }, + { + name: "address", + in: "query", + required: false, + description: + "Bech32 multisig wallet address (script payment credential)", + schema: { type: "string" }, + }, + { + name: "network", + in: "query", + required: false, + description: "0 = preprod, 1 = mainnet (default)", + schema: { type: "number" }, + }, + ], + responses: { + 200: { + description: + "Resolved script (scriptJson null and sigHashes empty when unknown or not a native script)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + scriptHash: { type: "string" }, + stakeCredentialHash: { type: "string", nullable: true }, + scriptJson: { type: "object", nullable: true }, + sigHashes: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }, + 400: { + description: + "Neither/both selectors given, invalid scriptHash, or address without a script credential", + }, + 405: { description: "Method not allowed" }, + 500: { description: "Internal Server Error" }, + }, + }, + }, "/api/v1/getNonce": { get: { tags: ["Auth"], @@ -1876,6 +1995,167 @@ This API uses **Bearer Token** authentication (JWT). }, }, }, + "/api/v1/tasks": { + get: { + tags: ["V1", "Bot"], + summary: "List a wallet's project task board", + description: + "Every task on the wallet's board with its column (Backlog, InProgress, InReview, Done), assignee, due date, payment recipients (base units: lovelace or a token's raw quantity) and derived payout state — state (none, ready, pending, paid), payable (true when it can be paid right now), blocker (not_done, no_recipients, pending, paid, or null) and the pending transactionId. Optional status and payable=true filters; payableCount is the wallet-wide total before filtering. Human wallet JWT (signer or owner) or bot JWT with any granted wallet access (observer is enough). Backs the task_list MCP tool.", + parameters: [ + { name: "walletId", in: "query", required: true, schema: { type: "string" } }, + { + name: "address", + in: "query", + required: true, + schema: { type: "string" }, + description: "Must match the JWT address (bots: the bot's paymentAddress)", + }, + { + name: "status", + in: "query", + required: false, + schema: { type: "string", enum: ["Backlog", "InProgress", "InReview", "Done"] }, + }, + { + name: "payable", + in: "query", + required: false, + schema: { type: "string", enum: ["true"] }, + description: "Only tasks that can be paid right now", + }, + ], + responses: { + 200: { + description: "The board", + content: { + "application/json": { + schema: { + type: "object", + properties: { + tasks: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + walletId: { type: "string" }, + title: { type: "string" }, + description: { type: "string", nullable: true }, + status: { type: "string" }, + priority: { type: "string", nullable: true }, + assigneeAddress: { type: "string", nullable: true }, + dueDate: { type: "string", nullable: true }, + position: { type: "number" }, + createdBy: { type: "string" }, + createdAt: { type: "string" }, + updatedAt: { type: "string" }, + recipients: { + type: "array", + items: { + type: "object", + properties: { + address: { type: "string" }, + unit: { type: "string" }, + quantity: { type: "string", description: "Base units" }, + label: { type: "string", nullable: true }, + }, + }, + }, + payout: { + type: "object", + properties: { + state: { type: "string", enum: ["none", "ready", "pending", "paid"] }, + transactionId: { type: "string", nullable: true }, + txHash: { type: "string", nullable: true }, + payable: { type: "boolean" }, + blocker: { + type: "string", + nullable: true, + enum: ["not_done", "no_recipients", "pending", "paid"], + }, + }, + }, + }, + }, + }, + count: { type: "number" }, + payableCount: { type: "number", description: "Wallet-wide, before filters" }, + }, + }, + }, + }, + }, + 400: { description: "Invalid walletId, address or status parameter" }, + 401: { description: "Unauthorized" }, + 403: { description: "Address mismatch, not a signer, or bot not granted on this wallet" }, + 404: { description: "Wallet not found" }, + 500: { description: "Internal server error" }, + }, + }, + }, + "/api/v1/taskUpsert": { + post: { + tags: ["V1"], + summary: "Create, edit or move a task on a wallet's board", + description: + "Without taskId, creates a task (title required); with taskId, updates it, and a status and/or position also moves it (positions in the destination column are renumbered). recipients are in DISPLAY units — ada, or a token with its registered decimals; a fractional amount for a token without registered decimals is refused — and replace the task's recipient list. Records a task only: it creates, signs and broadcasts no transaction (payouts go through the app or the task_prepare_payout MCP tool). A task is read-only while its payout awaits signatures and after it is paid (409). Human wallet JWT (signer or owner) only; bot keys get 403. Backs the task_upsert MCP tool.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + walletId: { type: "string" }, + taskId: { type: "string", description: "Omit to create" }, + title: { type: "string" }, + description: { type: "string", nullable: true }, + status: { type: "string", enum: ["Backlog", "InProgress", "InReview", "Done"] }, + priority: { type: "string", nullable: true, enum: ["Low", "Medium", "High"] }, + assigneeAddress: { type: "string", nullable: true }, + dueDate: { type: "string", nullable: true, description: "ISO 8601; null clears" }, + position: { type: "number", description: "Index within the column" }, + recipients: { + type: "array", + items: { + type: "object", + properties: { + address: { type: "string" }, + ada: { type: "string", description: "Display units, e.g. \"12.5\"" }, + assets: { + type: "array", + items: { + type: "object", + properties: { + unit: { type: "string", description: "policyId + assetName hex" }, + quantity: { type: "string", description: "Display units" }, + }, + required: ["unit", "quantity"], + }, + }, + }, + required: ["address"], + }, + }, + }, + required: ["walletId"], + }, + }, + }, + }, + responses: { + 200: { description: "Task updated: { task, created: false }" }, + 201: { description: "Task created: { task, created: true }" }, + 400: { description: "Missing walletId or title, invalid field, or recipients that cannot be understood (code INVALID_SPEC with issues)" }, + 401: { description: "Unauthorized" }, + 403: { description: "Not a signer of this wallet, or a bot key" }, + 404: { description: "Wallet or task not found" }, + 409: { description: "Task locked by a pending or paid payout" }, + 413: { description: "Body too large" }, + 500: { description: "Internal server error" }, + }, + }, + }, }, }, apis: [], diff --git a/src/utils/token-flow/change.ts b/src/utils/token-flow/change.ts new file mode 100644 index 00000000..c6cacb6c --- /dev/null +++ b/src/utils/token-flow/change.ts @@ -0,0 +1,30 @@ +/** + * Splits a completed builder body's outputs into payments and the change + * output(s) Mesh's `complete()` appends last. Outputs are never re-sorted, so + * the trailing run at the change address is the change. + * + * `paymentCount` is how many leading outputs the caller intended as payments + * (a draft's or spec's output count). With it, a certificate-only transaction + * (zero payments, one change output) is classified correctly. Without it the + * split never takes the whole list: a consolidation transaction paying only + * the wallet keeps its first output as the payment. + */ +export function splitTrailingChange<T extends { address?: unknown }>( + outputs: T[], + changeAddress: string, + paymentCount?: number, +): { payments: T[]; change: T[] } { + const floor = Math.max(0, paymentCount ?? 1); + let firstChangeIndex = outputs.length; + while ( + changeAddress && + firstChangeIndex > floor && + outputs[firstChangeIndex - 1]!.address === changeAddress + ) { + firstChangeIndex--; + } + return { + payments: outputs.slice(0, firstChangeIndex), + change: outputs.slice(firstChangeIndex), + }; +} diff --git a/src/utils/token-flow/from-draft.ts b/src/utils/token-flow/from-draft.ts index 11b3183b..aca2698f 100644 --- a/src/utils/token-flow/from-draft.ts +++ b/src/utils/token-flow/from-draft.ts @@ -1,5 +1,8 @@ +import type { MeshTxBuilder } from "@meshsdk/core"; + import type { AddressLabeler, + AssetQuantity, TokenFlow, TransactionFlowNode, } from "@/types/token-flow"; @@ -11,7 +14,23 @@ import { type PoolNameResolver, type ProposalTitleResolver, } from "./certificates"; -import { FlowGraphBuilder } from "./graph-builder"; +import { splitTrailingChange } from "./change"; +import { + assetMapToList, + FlowGraphBuilder, + lovelace, + sumAssets, +} from "./graph-builder"; + +/** + * The parts of a completed builder body (post-`complete()`) that a test + * build overlays onto the draft flow: the fee, the concretely selected + * inputs, and the change output(s) Mesh appends at the change address. + */ +export type DraftBuildOverlay = Pick< + MeshTxBuilder["meshTxBuilderBody"], + "inputs" | "outputs" | "changeAddress" | "fee" +>; /** * Projects a builder draft onto the shared TokenFlow model so the canvas @@ -23,26 +42,59 @@ import { FlowGraphBuilder } from "./graph-builder"; * - unset recipient: "draftout:<outputId>" (placeholder card) * Output edges always carry the output id as discriminator, so edge → output * mapping is a suffix match and two outputs to one address stay separate. + * + * With `opts.built` (a successful test build) the same graph gains the facts + * only `complete()` can supply — the fee pill, one edge per selected input + * instead of "auto selection", and the change amount — while every draft id + * stays identical, so selection, positions and drag-connect are unaffected. */ +/** Placeholder card shown while the source address isn't known yet. */ +export const DRAFT_SOURCE_NODE_ID = "draftsource"; + export function draftToTokenFlow( draft: TxDraft, opts: { labelAddress: AddressLabeler; + /** The source (funding + change) address; "" while not yet known. */ walletAddress: string; /** Optional "txHash#certIndex" → proposal title lookup for vote badges. */ resolveProposalTitle?: ProposalTitleResolver; /** Optional pool id → pool name lookup for delegation badges. */ resolvePoolName?: PoolNameResolver; + /** Completed body of the current draft; overlays fee, inputs and change. */ + built?: DraftBuildOverlay | null; }, ): TokenFlow { const graph = new FlowGraphBuilder(opts.labelAddress); const txNodeId = `txd:${draft.id}`; + const built = opts.built ?? undefined; + + // The source card: the wallet address when known, else a placeholder + // (a source set to "other address" before one is entered, or the + // connected-wallet source without a connected wallet). + const sourceNodeId = (): string => { + if (opts.walletAddress) return graph.addressNode(opts.walletAddress).id; + graph.addNode({ + id: DRAFT_SOURCE_NODE_ID, + kind: "address", + address: "", + label: "Set source address", + partyType: "self", + }); + return DRAFT_SOURCE_NODE_ID; + }; + + const builtFee = + built && typeof built.fee === "string" && safeBigInt(built.fee) > 0n + ? built.fee + : undefined; const txNode: TransactionFlowNode = { id: txNodeId, kind: "transaction", status: "pending", label: draft.description || "New transaction", + fee: builtFee, // Certificates before votes, matching the pending-view badge order. badges: [ ...draft.certificates.map((cert) => @@ -55,8 +107,24 @@ export function draftToTokenFlow( }; graph.addNode(txNode); - // Inputs - if (draft.utxoSelection.mode === "manual") { + // Inputs — a built body knows the exact UTxOs (auto selection resolved); + // manual picks are used verbatim by the builder, so the sets coincide. + if (built) { + for (const input of built.inputs) { + const txIn = input.txIn; + const nodeId = txIn.address + ? graph.addressNode(txIn.address).id + : sourceNodeId(); + graph.addEdge( + nodeId, + txNodeId, + "input", + txIn.amount ?? [], + `${getFirstAndLast(txIn.txHash, 8, 4)}#${txIn.txIndex}`, + `${txIn.txHash}#${txIn.txIndex}`, + ); + } + } else if (draft.utxoSelection.mode === "manual") { for (const utxo of draft.utxoSelection.utxos) { const node = graph.addressNode(utxo.output.address); graph.addEdge( @@ -70,8 +138,7 @@ export function draftToTokenFlow( } } else { // Concrete inputs are only known at build time (keepRelevant). - const node = graph.addressNode(opts.walletAddress); - graph.addEdge(node.id, txNodeId, "input", [], "auto selection"); + graph.addEdge(sourceNodeId(), txNodeId, "input", [], "auto selection"); } // Outputs — one edge per draft output, discriminated by output id. @@ -99,13 +166,59 @@ export function draftToTokenFlow( ); } - // Change — amount-less edge, same convention as pending flows. - const changeNode = graph.addressNode(opts.walletAddress); - graph.addEdge(txNodeId, changeNode.id, "output", [], "change"); + // Change — amount-less edge, same convention as pending flows; a built + // body fills in the amount from the change output(s) complete() appended. + // The edge id stays the same either way (no discriminator), so position + // and selection mapping are unaffected by building. + const changeNodeId = sourceNodeId(); + if (built) { + const { change } = splitTrailingChange( + built.outputs, + built.changeAddress || opts.walletAddress, + draft.outputs.length, + ); + const changeAssets = builtChangeAssets(change); + graph.addEdge( + txNodeId, + changeNodeId, + "output", + changeAssets, + changeAssets.length > 0 ? "change" : "no change", + ); + } else { + graph.addEdge(txNodeId, changeNodeId, "output", [], "change"); + } + + // Fee — only a built body knows it; rendered as the "Network fee" pill. + if (builtFee) { + graph.addEdge( + txNodeId, + graph.protocolNode("fee").id, + "fee", + lovelace(builtFee), + ); + } return graph.build(); } +function safeBigInt(value: string): bigint { + try { + return BigInt(value); + } catch { + return 0n; + } +} + +/** Sums the change outputs' assets into one list (lovelace first). */ +function builtChangeAssets( + change: DraftBuildOverlay["outputs"], +): AssetQuantity[] { + const totals = new Map<string, bigint>(); + for (const output of change) sumAssets(totals, output.amount ?? []); + return assetMapToList(totals); +} + /** * Maps a React Flow node or edge id back to the draft entity it represents. * Handles the layout's "@in"/"@out" address instance suffixes, placeholder @@ -129,6 +242,8 @@ export function flowIdToDraftEntity( } if (baseId === `txd:${draft.id}`) return { kind: "tx" }; + // The source placeholder is edited through the tx inspector's source picker. + if (baseId === DRAFT_SOURCE_NODE_ID) return { kind: "tx" }; if (baseId.startsWith("draftout:")) { const outputId = baseId.slice("draftout:".length); diff --git a/src/utils/token-flow/from-pending.ts b/src/utils/token-flow/from-pending.ts index 3fd72620..e0d79b7c 100644 --- a/src/utils/token-flow/from-pending.ts +++ b/src/utils/token-flow/from-pending.ts @@ -11,6 +11,7 @@ import { meshVoteToBadge, type ProposalTitleResolver, } from "./certificates"; +import { splitTrailingChange } from "./change"; import { FlowGraphBuilder, lovelace } from "./graph-builder"; export type ResolvedInputMap = Map< @@ -124,14 +125,8 @@ export function pendingTxToTokenFlow( ); const changeAddress = typeof txJson?.changeAddress === "string" ? txJson.changeAddress : ""; - let firstChangeIndex = outputs.length; - while ( - changeAddress && - firstChangeIndex > 1 && - outputs[firstChangeIndex - 1].address === changeAddress - ) { - firstChangeIndex--; - } + const firstChangeIndex = splitTrailingChange(outputs, changeAddress).payments + .length; outputs.forEach((output: any, index: number) => { const node = graph.addressNode(output.address); const isChange = index >= firstChangeIndex; diff --git a/src/utils/token-flow/index.ts b/src/utils/token-flow/index.ts index b05680aa..97cff3d1 100644 --- a/src/utils/token-flow/index.ts +++ b/src/utils/token-flow/index.ts @@ -4,7 +4,13 @@ export { resolvedInputKey, type ResolvedInputMap, } from "./from-pending"; -export { draftToTokenFlow, flowIdToDraftEntity } from "./from-draft"; +export { + DRAFT_SOURCE_NODE_ID, + draftToTokenFlow, + flowIdToDraftEntity, + type DraftBuildOverlay, +} from "./from-draft"; +export { splitTrailingChange } from "./change"; export { mergeTokenFlows } from "./merge"; export { focusColumnRange, diff --git a/vault/features/Discover Page.md b/vault/features/Discover Page.md index 0ddb358c..6c870746 100644 --- a/vault/features/Discover Page.md +++ b/vault/features/Discover Page.md @@ -1,18 +1,26 @@ --- type: feature area: Growth & Accounts -state: planned +state: in-progress owner: Andre -milestone: 2026-09 -issues: [52] -updated: 2026-07-27 +milestone: 2026-08 +issues: [33] +updated: 2026-08-27 --- # Discover Page -Browse wallets, DAOs and governance activity, plus lookup by signer and policy. -Moved up from February to ride the delivered Wallet V2 discovery work rather than -being built standalone. +Lookup by signer and by policy, built into the import wizard's "Discover on-chain" +tab rather than a standalone page. Moved up from February to ride the delivered +Wallet V2 discovery work. + +Scope as built: search a signer address / key hash, or a multisig wallet address / +native-script hash; results are the CIP-0146 registrations on chain, importable +when the connected wallet is a participant and view-only otherwise. Backed by the +public `resolveScript` route and exposed to agents through the MCP +`multisig_lookup_wallet` tool. Browsing wallets, DAOs and governance activity as a +directory is out of scope (dropped, not deferred). GitHub issue #52 was closed as +unspecified; the work is tracked under #33. ## Related