mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
feat: harden operated production release
This commit is contained in:
@@ -2,6 +2,20 @@
|
||||
|
||||
A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block timing. The same codebase runs the original solo dungeon, the social Tavern, and operator-selected PvP/race rulesets, with separate free and paid leaderboards.
|
||||
|
||||
## Such Software-operated products
|
||||
|
||||
Such Software (`apps@such.software`) operates only these two public products:
|
||||
|
||||
| Service | Operated scope |
|
||||
|---|---|
|
||||
| `play.wowne.ro` | Wownero mainnet free play and pay-for-credits leaderboard/prestige play. Credits are non-redeemable; there are no prizes, cash-out, or payouts, and the service is not offered or marketed as gambling. Legal classification depends on applicable law. |
|
||||
| `monerogue.app` | Monero **stagenet only**. Single-player 2×/3× outcomes are test gambling mechanics using test coins with **NO REAL VALUE**. Never send mainnet XMR. Crypto-match payouts remain off. |
|
||||
|
||||
The repository remains generally self-hostable under the MIT License. A third-party operator is
|
||||
solely responsible for its deployment, product, legal compliance, funds, players, claims, and
|
||||
support. Such Software does not operate, supervise, endorse, or accept responsibility for a
|
||||
third-party deployment.
|
||||
|
||||
## Features
|
||||
|
||||
- **Provably fair** solo runs with two-party, pre-entry commitments and per-depth fingerprints
|
||||
@@ -37,10 +51,10 @@ Open http://localhost:3000 to play.
|
||||
| **PAID_SINGLE** | Per-run chain payment | Profile-controlled | Paid prestige |
|
||||
| **PAID_CREDITS** | Purchased credit | Profile-controlled | Paid prestige |
|
||||
|
||||
The shipped mainnet profile offers free plus paid-prestige play with every payout switch off. The XMR
|
||||
stagenet profile offers the same modes with 2× escape and 3× treasure-escape test payouts. See
|
||||
`src/.env.mainnet.example` and `src/.env.stagenet.example`; payout switches are independent from
|
||||
payment intake and fail closed.
|
||||
The exact Such Software production profiles are `src/.env.mainnet.example` (credits only, every
|
||||
payout path off) and `src/.env.stagenet.example` (stagenet 2×/3× solo test payouts, crypto-match
|
||||
payouts off). Each opts into a startup contract with `OPERATED_PRODUCT_PROFILE`; preflight refuses
|
||||
network, operator-identity, or economic-scope drift. Generic self-hosts leave that variable unset.
|
||||
|
||||
## Match Mode (Multiplayer Races)
|
||||
|
||||
@@ -199,7 +213,8 @@ curl -X POST http://localhost:3000/api/admin/credits/adjust \
|
||||
|
||||
## Configuration
|
||||
|
||||
Key environment variables (see `.env.example` for full list):
|
||||
Key environment variables for a generic self-host (not an operated production profile; see
|
||||
`.env.example` for the full list):
|
||||
|
||||
```bash
|
||||
# Core
|
||||
@@ -292,4 +307,12 @@ test/
|
||||
|
||||
## License
|
||||
|
||||
See LICENSE file for details.
|
||||
The source is available under the [MIT License](LICENSE). MIT permission to use, copy, modify,
|
||||
merge, publish, distribute, sublicense, and/or sell copies is subject to including the copyright
|
||||
and permission notice in all copies or substantial portions. The software is provided “AS IS”,
|
||||
without warranty of any kind as stated in the License.
|
||||
|
||||
The code, documentation, examples, and disclosures are not legal advice, a licence, or a compliance
|
||||
approval. Such Software operates only the two products and scopes listed above. Any other operator
|
||||
is solely responsible for its service, and receives no endorsement or assumption of responsibility
|
||||
from Such Software.
|
||||
|
||||
@@ -25,13 +25,13 @@ the same `DatabaseManager.runMigrations()` used by application startup: lexical
|
||||
the `schema_migrations` ledger, and one transaction per newly applied file. Finally, it requires the
|
||||
ledger to equal the complete repository migration manifest, in order, with no missing or extra
|
||||
entry. The manifest is discovered from the candidate release and must be contiguous from 001; the
|
||||
current floor is migration 040. A successful current release reports:
|
||||
current floor is migration 042. A successful current release reports:
|
||||
|
||||
```text
|
||||
clone_migration_status=ok
|
||||
database=monerogue_restore_20260721t220048z_28d513b29b36
|
||||
migration_count=40
|
||||
latest_migration=040_paid_match_entropy_precommit.sql
|
||||
migration_count=42
|
||||
latest_migration=042_immutable_financial_event_snapshots.sql
|
||||
```
|
||||
|
||||
## Invocation
|
||||
|
||||
+30
-21
@@ -2,6 +2,21 @@
|
||||
|
||||
This guide covers deploying Wownerogue to a production environment.
|
||||
|
||||
## Operated profiles versus independent deployments
|
||||
|
||||
Such Software (`apps@such.software`) operates only `play.wowne.ro` (Wownero mainnet
|
||||
pay-for-credits leaderboard/prestige, no prize/payout/cash-out and not marketed as gambling) and `monerogue.app` (Monero
|
||||
stagenet **NO REAL VALUE** 2×/3× solo test gambling mechanics, with crypto-match payouts off).
|
||||
Classification of any product under applicable law requires jurisdiction-specific advice.
|
||||
Their reviewed environment templates set `OPERATED_PRODUCT_PROFILE`, so preflight/startup rejects
|
||||
scope drift. Independent MIT deployments must leave that variable unset and identify their actual
|
||||
operator.
|
||||
|
||||
MIT rights are subject to retaining the copyright and permission notice. The software is provided
|
||||
“AS IS”, without warranty, and the code/docs are not legal advice or compliance approval. Each
|
||||
third-party operator is solely responsible for its deployment, legal obligations, funds, players,
|
||||
claims, and support; Such Software neither endorses nor accepts responsibility for it.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
@@ -129,6 +144,10 @@ the read-only historical audit and the rollback-only native validation proof des
|
||||
[`FINANCIAL_CONSTRAINT_VALIDATION.md`](FINANCIAL_CONSTRAINT_VALIDATION.md) for each restored
|
||||
database. The native `VALIDATE CONSTRAINT` gate deliberately refuses live database names.
|
||||
|
||||
If the Wownero mainnet service will export accounting events, review and configure the durable
|
||||
[financial-event outbox](FINANCIAL_EVENT_EXPORT.md) before cutover. Monero stagenet export must
|
||||
remain unset: those no-value test events are marked ignored locally.
|
||||
|
||||
Install the reviewed `wowngeon-db-backup.sh`, `.service`, and `.timer` through the hash-pinned fleet
|
||||
operations change—not from the runtime artifact—and create
|
||||
`/var/backups/wowngeon/daily` as `postgres:postgres` mode `0700`, then enable the timer. Each run
|
||||
@@ -147,28 +166,18 @@ npm run db:create # Recreate schema
|
||||
|
||||
## systemd Service
|
||||
|
||||
Install reviewed application/firewall templates through the hash-pinned fleet operations change;
|
||||
the runtime artifact intentionally has no service units. Wallet-RPC replacement remains subject to
|
||||
the explicit NO-GO gates in [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md). Application units read secrets from
|
||||
`/etc/wownerogue/app.env` or `/etc/monerogue/app.env`, not from an immutable release directory.
|
||||
They run `npm run preflight` before every start and use `/var/www/<instance>/current/src` so rollback
|
||||
is an atomic symlink switch.
|
||||
Install reviewed application, wallet, backup, and firewall artifacts only through the hash-pinned
|
||||
fleet operations change; the runtime artifact intentionally has no service units. Application
|
||||
units read secrets from `/etc/wownerogue/app.env` or `/etc/monerogue/app.env` plus the narrow
|
||||
wallet-RPC environment, never from an immutable release directory. They run `npm run preflight`
|
||||
before every start and use `/var/www/<instance>/current/src` so rollback is an atomic symlink
|
||||
switch.
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
# Install the correct profile outside the release and replace every placeholder.
|
||||
sudo install -d -m 0750 -o root -g wownerogue /etc/wownerogue
|
||||
# Use the separately reviewed source/fleet copy; environment examples are not in the runtime archive.
|
||||
sudo install -m 0640 -o root -g wownerogue <reviewed-source>/src/.env.mainnet.example /etc/wownerogue/app.env
|
||||
sudoedit /etc/wownerogue/app.env
|
||||
|
||||
# Enable and start service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wownerogue
|
||||
sudo systemctl start wownerogue
|
||||
sudo systemctl status wownerogue
|
||||
```
|
||||
For the two Such Software services, do not replace this boundary with manual `install`, `systemctl`,
|
||||
or symlink commands. Follow the default-closed wallet candidate/promotion, candidate validation,
|
||||
and one-instance activation procedures in [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md) and the
|
||||
hash-pinned `~/src/such-fleet/RUNBOOK-wowngeon.md`. Independent MIT operators must design and review
|
||||
their own equivalent control plane.
|
||||
|
||||
The systemd hardening options:
|
||||
- `NoNewPrivileges` - prevents privilege escalation
|
||||
|
||||
+65
-36
@@ -4,9 +4,13 @@ Two independent instances of the same secured codebase, behind Nginx Proxy Manag
|
||||
|
||||
| Instance | Domain | Port | Network | Money | Payouts | Leaderboards |
|
||||
|----------|--------|------|---------|-------|---------|--------------|
|
||||
| **wownerogue** (existing) | play.wowne.ro | 3000 | Wownero **mainnet** | real WOW | **OFF** | Pleb + Hall of Champions |
|
||||
| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet** | fake XMR | **ON (target; wallet-gated)** | Pleb + Hall of Champions |
|
||||
| **wownerogue** (existing) | play.wowne.ro | 3000 | Wownero **mainnet** | purchased service credits | **OFF, always** | Pleb + paid prestige |
|
||||
| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet only** | test coins — **NO REAL VALUE** | **ON for 2×/3× solo only (target; wallet-gated)** | Pleb + Hall of Champions |
|
||||
|
||||
Such Software (`apps@such.software`) operates only these two domains and these exact scopes.
|
||||
`play.wowne.ro` is pay-for-credits prestige with no prize/payout/cash-out and is not marketed as
|
||||
gambling; legal classification depends on applicable law. `monerogue.app` is a conspicuous
|
||||
no-real-value test gambling surface; never send mainnet XMR and never enable crypto-match payouts.
|
||||
Stagenet payouts are a technical test surface, not authorization to offer real-money wagering.
|
||||
`ALLOW_MAINNET_PAYOUTS=true` is only an engineering interlock; obtain jurisdiction-specific legal,
|
||||
age/access-control, responsible-play, and operational review before attaching redeemable value.
|
||||
@@ -25,15 +29,16 @@ transfer only that blob into a new root-owned mode-0700 candidate inbox. Compare
|
||||
with the independently recorded literal digest; the transferred sidecar is not an independent
|
||||
provenance source.
|
||||
|
||||
There is currently no reviewed fleet extraction/activation helper. Blob-only staging is allowed;
|
||||
manual extraction under `/var/www/<instance>/releases`, dependency installation, clone migration,
|
||||
preflight, service restart, and `current` changes remain blocked until that helper implements the
|
||||
complete ownership/path/hash/rollback contract. Never make a release directory writable by either
|
||||
application service identity.
|
||||
Such Software production uses only the separate, default-closed fleet playbooks for inert blob
|
||||
staging, two-dump clone validation, wallet candidate preparation/promotion, and one-instance release
|
||||
activation. Each operation is bound to literal hashes and exact receipts; none infers a release,
|
||||
dump, wallet, predecessor, or instance. Do not substitute manual extraction under
|
||||
`/var/www/<instance>/releases`, dependency installation, service commands, or a hand-written
|
||||
`current` switch. Never make a release directory writable by either application service identity.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Reconfigure the MAINNET instance: free-to-play, NO payouts
|
||||
## Step 1 — Reconfigure MAINNET: free + purchased-credit prestige, NO payouts
|
||||
|
||||
Install the reviewed source profile `src/.env.mainnet.example` as `/etc/wownerogue/app.env` (mode
|
||||
0640, `root:wownerogue`), replace every placeholder, and review these money gates. Environment
|
||||
@@ -41,7 +46,14 @@ examples are intentionally not present in the runtime artifact; provision this f
|
||||
hash-pinned fleet/source change, never from the application release directory.
|
||||
|
||||
```ini
|
||||
OPERATED_PRODUCT_PROFILE=such-play-wow-prestige
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
FREE_PLAY_ENABLED=true # offer free play as a choice (Pleb board)
|
||||
PAYMENT_MODES=credits # only purchased-credit prestige is operated here
|
||||
DIRECT_PAYMENT_ENABLED=false # per-run payment is outside the operated scope
|
||||
CREDITS_ENABLED=true
|
||||
PAYOUTS_ENABLED=false # no crypto leaves the house wallet
|
||||
DIRECT_PAYOUTS_ENABLED=false
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
@@ -51,9 +63,10 @@ TRUST_PROXY=true
|
||||
TRUST_PROXY_HOPS=1 # exactly one NPM hop
|
||||
```
|
||||
|
||||
Players still buy credits / pay entry (→ Hall of Champions prestige); free players → Pleb
|
||||
board. This profile sends no winnings, but legal classification still depends on the product,
|
||||
jurisdiction, and access controls; obtain counsel before treating that as a legal conclusion.
|
||||
Players buy non-redeemable credits for leaderboard/prestige play; free players use the Pleb board.
|
||||
This operated product offers no winnings, payouts, or cash-out and is not offered or marketed as
|
||||
gambling. Classification depends on applicable law; this documentation is not legal advice or a
|
||||
jurisdiction-specific compliance approval.
|
||||
|
||||
The following are post-activation verification commands, not authorization to restart the live
|
||||
service while the wallet/preflight and fleet activation gates remain open:
|
||||
@@ -61,7 +74,7 @@ service while the wallet/preflight and fleet activation gates remain open:
|
||||
```bash
|
||||
sudo systemctl restart wownerogue.service
|
||||
journalctl -u wownerogue.service -n 50 --no-pager # verify: "Payment system: ENABLED", payouts disabled
|
||||
curl -s localhost:3000/api/game-modes | jq '{freePlayEnabled, PAID_SINGLE:.PAID_SINGLE.payoutMultiplier}'
|
||||
curl -s localhost:3000/api/game-modes | jq '{freePlayEnabled, PAID_SINGLE, PAID_CREDITS}'
|
||||
```
|
||||
|
||||
**MAINNET WALLET NO-GO:** this application release does not authorize changing, copying, stopping,
|
||||
@@ -73,24 +86,27 @@ not files in the runtime artifact and not approval to install them.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — MONERO STAGENET wallet boundary: NO-GO
|
||||
## Step 2 — Prove and promote the MONERO STAGENET wallet boundary
|
||||
|
||||
Do **not** install a replacement unit, create wallet identities/directories, copy wallet or key
|
||||
files, capture a password, provision RPC credentials, stop/restart the wallet, or change the live
|
||||
application endpoint as part of this release. The current wallet, unit, and application dependency
|
||||
remain unchanged.
|
||||
Wallet work is a separate custody operation from application release work. Use only the reviewed
|
||||
`wowngeon_wallet_candidate` and `wowngeon_wallet_promote` roles from a clean, hash-pinned fleet
|
||||
revision, select exactly `monerogue`, and preserve every generic receipt. Candidate preparation
|
||||
must prove the fixed encrypted stagenet snapshot reopens the exact live address, has a spend key,
|
||||
and enforces the application's strong Digest credentials before it publishes inert dedicated-user
|
||||
material. Promotion must then stop only the selected legacy wallet, install only the reviewed
|
||||
loopback-only unit, require unauthenticated `401` plus authenticated address/network/balance probes,
|
||||
and automatically roll back to the exact legacy unit on a failed gate.
|
||||
|
||||
The only retained design is
|
||||
The earlier non-mutating design record is
|
||||
[`README-monero-stagenet-parallel-candidate.md`](../scripts/deploy/README-monero-stagenet-parallel-candidate.md).
|
||||
It currently approves only a disposable synthetic fixture and, after independent review, a fixed
|
||||
read-only inventory preflight. Even its manual candidate service is not approved for installation.
|
||||
Before any host mutation, its complete crash/failure-injection gates, stopped-copy restart proof,
|
||||
offline address match, keyfile/password restore, mnemonic/off-host custody, and isolated unfunded
|
||||
rehearsal must all pass against an immutable reviewed hash.
|
||||
It is not an installation procedure and its sample unit is not a promotion artifact.
|
||||
|
||||
Until a separate wallet promotion is approved, a stagenet application candidate must use an
|
||||
isolated database with payments, payouts, and match money disabled. Paid intake and the payout
|
||||
canary remain blocked; do not bypass application preflight to make them start.
|
||||
Neither wallet role reads, exports, or proves a mnemonic. A passing keyfile/password proof is not
|
||||
independent seed custody or a full off-host recovery drill. Record those separately as unproven
|
||||
until a human-controlled custody inventory and isolated off-host restore establish them. This gap
|
||||
does not authorize weakening a proof, printing a secret, or applying either role to the funded
|
||||
Wownero mainnet wallet. Wownero wallet promotion and the corresponding application activation
|
||||
remain blocked pending that separate review.
|
||||
|
||||
## Step 3 — Create the monerogue identity, immutable layout, and DB
|
||||
|
||||
@@ -109,8 +125,9 @@ the production host or recursively `chown` `/var/www/monerogue` to the service u
|
||||
|
||||
## Step 4 — The monerogue `.env`
|
||||
|
||||
This is the eventual paid stagenet profile, not permission to activate it while Step 2 is NO-GO.
|
||||
Write `/etc/monerogue/app.env` as `root:monerogue` mode `0640`. Amounts are XMR atomic units
|
||||
This is the exact no-real-value stagenet test profile. Apply its non-secret policy keys through the
|
||||
fleet runtime-policy role; do not overwrite or log the protected environment. Keep
|
||||
`/etc/monerogue/app.env` as `root:monerogue` mode `0640`. Amounts are XMR atomic units
|
||||
(1 XMR = 1e12):
|
||||
|
||||
```ini
|
||||
@@ -118,11 +135,17 @@ NODE_ENV=production
|
||||
PORT=3001
|
||||
TRUST_PROXY=true
|
||||
TRUST_PROXY_HOPS=1
|
||||
OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
CRYPTO_TYPE=XMR
|
||||
MONERO_NETWORK=stagenet
|
||||
HOSTED_BY=https://monerogue.app
|
||||
DIFFICULTY_PRESET=casino
|
||||
|
||||
# NO REAL VALUE: test coins only. Never send mainnet XMR.
|
||||
|
||||
# --- payments + free play ---
|
||||
PAYMENTS_ENABLED=true
|
||||
PAYMENT_MODES=direct,credits
|
||||
@@ -150,6 +173,7 @@ PREFER_CREDITS_FIRST=true
|
||||
|
||||
# --- payouts --- (verify the unlocked stagenet bankroll before every rollout)
|
||||
PAYOUTS_ENABLED=true
|
||||
ALLOW_MAINNET_PAYOUTS=false
|
||||
PAYOUT_MIN_AMOUNT=1000000000 # 0.001 XMR
|
||||
PAYOUT_MAX_PER_GAME=50000000000 # 0.05 XMR safety cap (real max is 3x*0.01 = 0.03)
|
||||
PAYOUT_BATCH_INTERVAL=300
|
||||
@@ -179,11 +203,15 @@ ADMIN_API_KEY=GENERATE_WITH_openssl_rand_hex_32
|
||||
|
||||
# --- smirk: disable for monero stagenet (Smirk doesn't support stagenet) ---
|
||||
SMIRK_ENABLED=false
|
||||
|
||||
# --- PvP financial boundary: free/prestige rulesets may evolve, crypto payouts may not ---
|
||||
MATCH_CRYPTO_RACE_ENABLED=false
|
||||
MATCH_PAYOUTS_ENABLED=false
|
||||
```
|
||||
|
||||
The application unit separately requires `/etc/monerogue/wallet-rpc.env`, owned
|
||||
`root:monerogue` mode `0640`, containing only `WALLET_RPC_USER` and `WALLET_RPC_PASSWORD`. Provision
|
||||
it through the approved secrets workflow only after the wallet gate clears. Never place either
|
||||
it only through the reviewed wallet-candidate workflow. Never place either
|
||||
password in a command argument, unit `ExecStart`, shell history, or this runbook.
|
||||
|
||||
Production startup proves that wallet RPC actually enforces Digest authentication: an intentional
|
||||
@@ -200,10 +228,10 @@ sudo -u monerogue bash -c 'set -a; . /etc/monerogue/app.env; . /etc/monerogue/wa
|
||||
|
||||
## Step 5 — Install + start the monerogue instance
|
||||
|
||||
The runtime artifact contains no service units. Install or update `monerogue.service` only through
|
||||
the separately reviewed, hash-pinned `~/src/such-fleet` change after the wallet and application
|
||||
gates pass. Do not copy a unit from the application release or start this paid profile while Step 2
|
||||
is NO-GO.
|
||||
The runtime artifact contains no service units. Promote the wallet and activate `monerogue` only
|
||||
through the separately reviewed, hash-pinned `~/src/such-fleet` roles after the recovery,
|
||||
clone-validation, protected-environment, wallet, drain, and financial-audit gates pass. Do not copy
|
||||
a unit from the application release.
|
||||
|
||||
Install the hash-pinned fleet copies of `wowngeon-firewall.sh` and
|
||||
`wowngeon-firewall.service`, then set the fixed proxy source as `NPM_SOURCE_IPV4=<address>` in
|
||||
@@ -213,11 +241,12 @@ domains, websocket upgrades, and local readiness probes still work.
|
||||
## Step 6 — Verify end to end
|
||||
|
||||
- `monerogue.app` loads; `play.wowne.ro` still healthy.
|
||||
- After a separate wallet GO and isolated canary: buy credits / pay entry with a stagenet wallet →
|
||||
game starts → win → payout arrives. This check is blocked while Step 2 is NO-GO.
|
||||
- After wallet promotion and the separately approved bounded canary: buy credits / pay entry with a
|
||||
stagenet wallet → game starts → qualifying solo outcome → 2×/3× test-coin payout arrives.
|
||||
- After the corresponding gates clear, both boards populate: free game → Pleb, paid game → Hall of
|
||||
Champions, on each instance.
|
||||
- Mainnet: confirm a winning paid game does NOT send a payout (no crypto out).
|
||||
- Mainnet, only after its separate recovery gate and release: confirm purchased-credit play never
|
||||
creates a payout or cash-out path.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Financial-event export
|
||||
|
||||
Migrations 041 and 042 create a durable outbox for confirmed customer payments and final refund
|
||||
states. Migration 042 stores an immutable source snapshot before delivery; the exporter never
|
||||
reconstructs an event from later-mutated payment rows. Payloads contain transaction classification,
|
||||
atomic amount, asset/network, catalog/provider identifiers, and receipt count, but no player ID,
|
||||
wallet address, session token, IP address, email address, or authentication material.
|
||||
|
||||
Delivery is **at least once**. Every request sets `Idempotency-Key` to the immutable financial event
|
||||
ID. The receiving system must durably deduplicate that key before acknowledging with a 2xx response.
|
||||
A timeout or lost acknowledgement can otherwise produce a repeat request. The application marks a
|
||||
row delivered only after a 2xx response; bounded retries use a durable lease and exponential delay,
|
||||
then move an exhausted row to `dead_letter` for operator review.
|
||||
|
||||
The public health response exposes only aggregate exporter state: enabled/running flags, backlog,
|
||||
dead-letter and ignored counts, and cumulative delivery counters. It exposes no sink URL,
|
||||
token, event ID, error text, payload, or source identity. Accounting export is informational and does
|
||||
not independently make gameplay readiness fail, so production monitoring must alert on backlog age,
|
||||
dead letters, and the exporter process logs/metrics needed to detect stale delivery.
|
||||
|
||||
## Configuration contract
|
||||
|
||||
Configure both of these only in the protected Wownero mainnet runtime environment:
|
||||
|
||||
```dotenv
|
||||
FINANCIAL_EVENT_SINK_URL=https://ledger.operator.example/v1/events
|
||||
FINANCIAL_EVENT_SINK_TOKEN=<strong protected bearer token>
|
||||
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:wow-receipts
|
||||
FINANCIAL_EVENT_POLL_MS=60000
|
||||
FINANCIAL_EVENT_BATCH_SIZE=20
|
||||
FINANCIAL_EVENT_MAX_ATTEMPTS=8
|
||||
```
|
||||
|
||||
Production requires HTTPS, forbids URL-embedded credentials, and requires a strong token. The account
|
||||
reference is a non-PII identifier. URL and token must be set together. Never place the token in Git,
|
||||
Ansible inventory, an invocation, a receipt, a log, or a public health check.
|
||||
|
||||
Monero stagenet deliberately sends nothing to an accounting sink. Its pending, leased, or dead-letter
|
||||
test rows are moved to `ignored` with reason `non_mainnet_network`; startup rejects sink configuration
|
||||
on any non-mainnet network. This keeps **NO REAL VALUE** test activity out of real books.
|
||||
|
||||
## Operations
|
||||
|
||||
Before enabling mainnet export, prove in a disposable environment that the sink:
|
||||
|
||||
1. authenticates the protected bearer token over TLS;
|
||||
2. deduplicates repeated requests by `Idempotency-Key`;
|
||||
3. durably records the event before returning 2xx;
|
||||
4. rejects malformed events without echoing secrets; and
|
||||
5. supports alerting and reconciliation for a deliberately generated dead letter.
|
||||
|
||||
Keep the outbox in database backups. Do not delete or rewrite a dead-letter row to make monitoring
|
||||
green; reconcile it against the receiving ledger, preserve the evidence, and use a separately reviewed
|
||||
repair procedure. An absent sink is permitted, but confirmed mainnet events then remain pending and
|
||||
must be included in the production accounting handoff.
|
||||
@@ -4,6 +4,32 @@ This implementation makes the running server describe its actual free, paid-pres
|
||||
test-network, and PvP switches. It does **not** claim that the product is licensed or lawful in a
|
||||
particular place. Obtain jurisdiction-specific advice before accepting assets with real value.
|
||||
|
||||
## Operated-product boundary
|
||||
|
||||
Such Software (`apps@such.software`) operates only:
|
||||
|
||||
| Profile | Public service | Exact scope |
|
||||
|---|---|---|
|
||||
| `such-play-wow-prestige` | `play.wowne.ro` | Wownero mainnet free play and pay-for-credits leaderboard/prestige. Credits are non-redeemable; there are no prizes, payouts, cash-out, or crypto racing, and the service is not offered/marketed as gambling. Legal classification depends on applicable law. |
|
||||
| `such-monerogue-stagenet` | `monerogue.app` | Monero stagenet only. Single-player 2×/3× test gambling mechanics using test coins with **NO REAL VALUE**. Never mainnet XMR and no crypto-match payouts. |
|
||||
|
||||
The corresponding `.env.mainnet.example` and `.env.stagenet.example` files opt into a fail-closed
|
||||
startup contract with `OPERATED_PRODUCT_PROFILE`. Preflight and normal startup reject any drift in
|
||||
host, operator identity, chain/network, paid modes, multipliers, or payout gates. Independent MIT
|
||||
self-hosts leave this setting unset and configure a truthful operator identity of their own.
|
||||
|
||||
## Open-source and operator disclaimer
|
||||
|
||||
MIT permission to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies is
|
||||
subject to retaining the copyright and permission notice in all copies or substantial portions.
|
||||
The software is provided “AS IS”, without warranty of any kind, as stated in `LICENSE`. The code,
|
||||
documentation, examples, and disclosure copy are not legal advice, a licence, or a compliance
|
||||
approval.
|
||||
|
||||
A third-party or self-hosted operator is solely responsible for its deployment, product design,
|
||||
legal compliance, funds, players, representations, and support. Such Software does not operate,
|
||||
supervise, endorse, or accept responsibility for third-party deployments.
|
||||
|
||||
## Required production settings
|
||||
|
||||
Every production instance with `PAYMENTS_ENABLED=true` fails preflight/startup unless all of these
|
||||
@@ -13,8 +39,8 @@ are explicit and valid:
|
||||
OPERATOR_NAME=Truthful responsible operator name
|
||||
OPERATOR_CONTACT_URL=mailto:support@example.com
|
||||
OPERATOR_CONTACT_LABEL=Support
|
||||
LEGAL_POLICY_VERSION=2026-07-21-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-21
|
||||
LEGAL_POLICY_VERSION=2026-07-22-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-22
|
||||
MINIMUM_AGE=18
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
```
|
||||
@@ -60,6 +86,10 @@ The acknowledgement is a player statement, not proof of identity, age, capacity,
|
||||
| Crypto PvP on | Paid ticket escrow and configured winner/fee contract; separate from free/prestige PvP |
|
||||
| XMR stagenet/testnet | Valueless test currency; explicit warning never to send mainnet XMR |
|
||||
|
||||
For the operated products, the generic rows narrow further: `play.wowne.ro` exposes purchased
|
||||
credits only, offers no payout/cash-out, and is not marketed as gambling; `monerogue.app` labels its 2×/3× stagenet mechanics as
|
||||
**NO REAL VALUE** and keeps crypto-match payouts disabled.
|
||||
|
||||
## Unresolved launch decisions
|
||||
|
||||
Technical copy and checkboxes do not resolve these. Keep real-value modes gated until the responsible
|
||||
|
||||
@@ -77,14 +77,17 @@ activation boundary must then:
|
||||
4. restore each fresh pre-deploy dump into a separately named clone database, run
|
||||
`npm run db:migrate:clone` under the exact disposable-target contract in
|
||||
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), require its exact manifest proof (currently through
|
||||
migration 040), and run the separately reviewed and hash-pinned fleet copy of
|
||||
migration 042), and run the separately reviewed and hash-pinned fleet copy of
|
||||
`financial-audit.sql` there—the SQL is intentionally not inside the runtime artifact;
|
||||
5. run `npm run preflight` from the candidate with each final protected environment. Preflight is
|
||||
intentionally non-mutating and does not replace database, daemon, or wallet health checks.
|
||||
|
||||
The clone runner is deliberately non-activating; there is still no reviewed activation script. Do
|
||||
not point `current` at a candidate until the clone proof and the complete
|
||||
drain/audit/symlink/health/rollback sequence in `~/src/such-fleet/RUNBOOK-wowngeon.md` are satisfied.
|
||||
The clone runner is deliberately non-activating. Such Software production activation must use the
|
||||
separately reviewed, default-closed `wowngeon_release_activate` role in `~/src/such-fleet`; do not
|
||||
substitute manual extraction, dependency installation, service commands, or a hand-written
|
||||
`current` switch. The role still refuses activation until the matching clone-validation receipt,
|
||||
wallet-promotion receipt, drain/audit gates, literal hashes, and rollback target satisfy the
|
||||
complete contract in `~/src/such-fleet/RUNBOOK-wowngeon.md`.
|
||||
|
||||
## Source and operations boundary
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Wallet keyfile recovery proof
|
||||
|
||||
**Status: source and synthetic tests only. This helper has not been installed on or run against
|
||||
`deb`, and this document does not authorize a host mutation or a production ceremony.**
|
||||
|
||||
This runbook covers one deliberately narrow question: can an audited encrypted wallet snapshot and
|
||||
the password already exposed in the matching legacy wallet-RPC process open offline and reproduce
|
||||
the exact public address currently returned by that process? The fixed helper is
|
||||
`scripts/deploy/wallet-keyfile-recovery-proof.py`.
|
||||
|
||||
The answer is emitted as exactly one status line:
|
||||
|
||||
```text
|
||||
result=passed
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```text
|
||||
result=refused
|
||||
```
|
||||
|
||||
There is intentionally no diagnostic mode. Never add shell tracing, a debugger, `strace`, command
|
||||
line capture, wallet-RPC response logging, or exception rendering to investigate a refusal. Those
|
||||
would defeat the secret-handling boundary this proof is meant to preserve.
|
||||
|
||||
## What a pass means
|
||||
|
||||
A pass establishes all of the following for the selected profile at that moment:
|
||||
|
||||
- the expected legacy systemd unit has a stable MainPID, executable, UID/GID, cgroup, listener,
|
||||
network flag, RPC port, and exact `--wallet-file` argument;
|
||||
- its already-exposed inline wallet password could be copied from the NUL-delimited process command
|
||||
line without rendering it;
|
||||
- both fixed, root-only pre-production snapshot files were real, single-link regular files with the
|
||||
audited metadata and were copied byte-for-byte into a newly created private `/run` directory;
|
||||
- the matching wallet-RPC executable opened that copied pair offline on the fixed loopback proof
|
||||
port as the exact legacy UID/GID, with an empty supplementary-group list, using a password file and
|
||||
an ephemeral Digest credential;
|
||||
- the proof endpoint rejected an unauthenticated request with HTTP 401, accepted an authenticated
|
||||
`get_address` request with HTTP 200, returned an address shaped for the expected network, and that
|
||||
address exactly equalled the current live address;
|
||||
- that restored wallet signed one fixed, non-secret proof message and its own wallet-RPC verified the
|
||||
signature as a spend-key signature (or, for the legacy response shape, verified the default
|
||||
spend-key signature), so a watch-only keyfile cannot pass; and
|
||||
- the candidate stopped, its listener disappeared, and only the exact ephemeral files and directory
|
||||
were securely cleaned up.
|
||||
|
||||
The helper does not stop, start, restart, reload, reconfigure, or write to a live unit or live wallet.
|
||||
The proof candidate is offline; it cannot refresh, submit transactions, or contact a daemon.
|
||||
|
||||
## What a pass does not mean
|
||||
|
||||
This is **not seed custody** and it is **not an off-host disaster-recovery test**. In particular, a
|
||||
pass does not establish:
|
||||
|
||||
- that a mnemonic or seed has ever been recorded, is readable, is complete, or is held by the right
|
||||
custodians;
|
||||
- that the encrypted keyfile, its password, or a seed exists independently off this host—the password
|
||||
used here comes only from the already-running process, not from an independent custody source;
|
||||
- that Restic media, repository credentials, retention, or an actual off-host restore path works;
|
||||
- that a restore can rescan chain history, discover the expected balance, or spend funds;
|
||||
- that daemon, database, application, DNS, proxy, Nebula, or fleet recovery works; or
|
||||
- that payouts, wagering, mainnet money movement, or a production release is authorized.
|
||||
|
||||
Do not query, display, export, hash, or automate retrieval of a mnemonic to fill this gap. Seed
|
||||
custody requires a separate human-controlled inventory and an off-host restore drill with an agreed
|
||||
handling ceremony. Until that evidence exists, record seed custody and off-host recovery as
|
||||
**unproven**, even if this helper passes.
|
||||
|
||||
## Fixed audited profiles
|
||||
|
||||
No path, unit, binary, port, user, or RPC method is accepted from the caller. The only accepted
|
||||
profile names are `monero-stagenet` and `wownero-mainnet`.
|
||||
|
||||
| Profile | Fixed legacy identity | Fixed snapshots | Private proof target |
|
||||
| --- | --- | --- | --- |
|
||||
| `monero-stagenet` | `monero-wallet-rpc.service`, `jw:jw`, `/usr/bin/monero-wallet-rpc`, wallet `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test`, `127.0.0.1:38083`, `--stagenet` | `/var/backups/wowngeon/wallets/monero-stagenet-preprod-20260721T1140Z.wallet` and `/var/backups/wowngeon/wallets/monero-stagenet-preprod-20260721T1140Z.keys` | outer `/run/wowngeon-wallet-keyfile-proof-monero-stagenet`, inner `candidate`, `127.0.0.1:39084` |
|
||||
| `wownero-mainnet` | `wownero-wallet-rpc.service`, `jw:jw`, `/usr/bin/wownero-wallet-rpc`, wallet `/home/jw/Programs/wow-11.3.0/game`, audited legacy listener `0.0.0.0:34570` | `/var/backups/wowngeon/wallets/wownero-mainnet-preprod-20260721T1140Z.wallet` and `/var/backups/wowngeon/wallets/wownero-mainnet-preprod-20260721T1140Z.keys` | outer `/run/wowngeon-wallet-keyfile-proof-wownero-mainnet`, inner `candidate`, `127.0.0.1:39570` |
|
||||
|
||||
The live equality observations are unauthenticated because the legacy sources are. Monero is
|
||||
loopback-only; Wownero's wildcard listener is an insecure legacy source contained by the host
|
||||
firewall, not a state this proof endorses. `current_live_address` makes one read-only public-address
|
||||
observation solely for equality; a match is **not** an RPC-hardening or network-security pass. The
|
||||
helper neither changes nor resolves the wildcard/unauthenticated service. Its proof candidate always
|
||||
binds loopback and requires an ephemeral Digest credential.
|
||||
|
||||
Each fixed proof path is a root-owned outer directory, `root:jw` mode `0710`, which prevents the
|
||||
candidate identity from listing or writing its parent. Root builds the inner `candidate` subtree and
|
||||
files before publishing only that subtree as `jw:jw` mode `0700` with `0600` files. Immediately before
|
||||
exec, the child clears supplementary groups, sets all real/effective/saved GIDs and UIDs to `jw:jw`,
|
||||
clears retained capabilities, then reapplies no-dump, no-new-privileges, and zero-core limits. The
|
||||
parent verifies empty inheritable/permitted/effective/ambient capability sets. Wallet-RPC never runs
|
||||
as root.
|
||||
|
||||
The audited snapshot directory is `/var/backups/wowngeon/wallets`, `root:root` mode `0700`; each
|
||||
snapshot is `root:root` mode `0600` with one link. Its parent is `/var/backups/wowngeon`,
|
||||
`root:postgres` mode `0710`. Any deviation refuses the proof.
|
||||
|
||||
## Review and synthetic verification
|
||||
|
||||
Before any future host ceremony, review the helper and run the disposable fixture locally:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile scripts/deploy/wallet-keyfile-recovery-proof.py \
|
||||
scripts/deploy/wallet-keyfile-recovery-fixture-test.py
|
||||
python3 scripts/deploy/wallet-keyfile-recovery-fixture-test.py
|
||||
```
|
||||
|
||||
The fixture uses only generated temporary files and synthetic credentials. It covers exact argument
|
||||
selection and wiping, indirect or duplicate password refusal, network mismatch, link and metadata
|
||||
failures, source mutation during copy, byte equality, cleanup containment, Digest authentication
|
||||
shape, candidate config shape, privilege dropping, fixed-message spend signing/verification,
|
||||
watch-only refusal, and proof-port collision. It never opens a real wallet.
|
||||
|
||||
Review must also confirm that the executable still contains only read-only `systemctl is-active` and
|
||||
`systemctl show` calls; only the `get_address`, `sign`, and `verify` wallet RPC methods; no
|
||||
caller-controlled paths, ports, or messages; and only the two status strings as runtime output. The
|
||||
address and signature are never output or retained as ceremony evidence.
|
||||
|
||||
## Future authorized ceremony
|
||||
|
||||
Run Monero stagenet first. Wownero mainnet remains an offline keyfile-open proof only and must not be
|
||||
treated as approval to enable payouts or submit a transaction.
|
||||
|
||||
Only after a reviewed revision is explicitly approved for installation should an operator install
|
||||
that exact file to a root-owned, non-writable path such as
|
||||
`/usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof` with owner `root:root` and mode `0700`.
|
||||
Do not run it from a mutable checkout. Record the reviewed source revision, but do not record wallet
|
||||
bytes, addresses, passwords, command lines, Digest material, wallet-derived hashes, or RPC bodies.
|
||||
|
||||
Before each profile:
|
||||
|
||||
1. Confirm no debugger, tracing wrapper, shell xtrace, terminal recorder, or broad process collector
|
||||
will observe the root helper.
|
||||
2. Confirm the expected proof port is not reserved for another operation and the exact fixed `/run`
|
||||
proof directory does not exist.
|
||||
3. Confirm the legacy unit is expected to remain online and unchanged for the short proof window.
|
||||
4. Invoke the installed helper as root with exactly one fixed profile argument and capture only its
|
||||
one-line status.
|
||||
|
||||
The future invocations are:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof monero-stagenet
|
||||
sudo /usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof wownero-mainnet
|
||||
```
|
||||
|
||||
These commands are examples for a later authorized ceremony, not evidence they have been run.
|
||||
|
||||
After each invocation, verify only that its fixed proof port is closed and its fixed proof directory
|
||||
is absent. A pass is invalid if cleanup did not complete; the helper reports refusal in that case.
|
||||
|
||||
## Refusal or interrupted execution
|
||||
|
||||
`result=refused` is intentionally non-diagnostic. Do not weaken the helper or print an exception to
|
||||
learn why. Review the fixed-path metadata, expected unit state, listener ownership, port availability,
|
||||
and synthetic suite through non-secret checks, then arrange a new reviewed ceremony.
|
||||
|
||||
If a fixed proof directory remains after interruption, **do not blindly remove it**. Treat it as
|
||||
potentially containing wallet material. First establish through metadata-only checks that no proof
|
||||
wallet-RPC process or proof-port listener remains, keep the incident off shared logs, and have an
|
||||
authorized operator review cleanup of that exact path. Never use a recursive command against `/run`
|
||||
or a variable/glob target.
|
||||
|
||||
The next independent backup milestone is a human-approved seed-custody inventory followed by a
|
||||
restore from genuinely off-host media into an isolated environment. That is the evidence needed to
|
||||
answer “do we have recoverable wallet seeds?”; this helper deliberately cannot answer it.
|
||||
@@ -150,7 +150,9 @@
|
||||
|
||||
var service = resolvedPolicy.service;
|
||||
var summary;
|
||||
if (service.isTestNetwork) {
|
||||
if (resolvedPolicy.operatedProduct && resolvedPolicy.operatedProduct.commerceSummary) {
|
||||
summary = resolvedPolicy.operatedProduct.commerceSummary;
|
||||
} else if (service.isTestNetwork) {
|
||||
summary = service.currencyLabel + ' is valueless test-network currency. Never send mainnet ' + service.cryptoType + '.';
|
||||
} else if (service.paidPrestigeOnly) {
|
||||
summary = 'This paid mode purchases entry, credits, or products; this server currently offers no cryptocurrency prize.';
|
||||
@@ -159,7 +161,7 @@
|
||||
} else {
|
||||
summary = 'Blockchain payments may be irreversible. Verify the asset, network, amount, and address before sending.';
|
||||
}
|
||||
dialog.appendChild(el('p', 'commerce-summary', summary));
|
||||
dialog.appendChild(el('p', 'commerce-summary' + (resolvedPolicy.operatedProduct && resolvedPolicy.operatedProduct.noRealValueNotice ? ' no-real-value' : ''), summary));
|
||||
|
||||
var age = checkbox('commerceAge', 'I confirm I am at least ' + resolvedPolicy.minimumAge + ' and may lawfully use this paid mode where I am located.');
|
||||
var terms = checkbox('commerceTerms', 'I have read the Terms of Use and Privacy Notice, including the anonymous-session and blockchain risks.');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.commerce-consent-dialog { width:min(620px,100%); max-height:calc(100vh - 32px); overflow:auto; padding:20px; border:1px solid #9a6b25; border-radius:10px; background:#11161b; color:#d7dde5; box-shadow:0 24px 80px #000; font:14px/1.45 system-ui,-apple-system,sans-serif; }
|
||||
.commerce-consent-dialog h2 { margin:0 0 10px; color:#fff; font-size:1.4rem; }
|
||||
.commerce-summary { padding:10px 12px; border-left:3px solid #efb64b; background:#211a0f; }
|
||||
.commerce-summary.no-real-value { border:2px solid #efb64b; color:#ffe2a4; font-weight:700; }
|
||||
.commerce-check { display:flex; align-items:flex-start; gap:10px; margin:10px 0; padding:10px; border:1px solid #303944; border-radius:6px; background:#171d24; cursor:pointer; }
|
||||
.commerce-check input { flex:0 0 auto; width:18px; height:18px; margin-top:1px; }
|
||||
.commerce-links,.commerce-links a { color:#99e9a8; }
|
||||
|
||||
@@ -13,6 +13,7 @@ h2 { color:#fff; font-size:1.15rem; }
|
||||
header>p:last-child,footer { color:var(--muted); }
|
||||
section { margin:12px 0; padding:18px clamp(16px,4vw,24px); border:1px solid var(--line); border-radius:10px; background:rgba(20,25,31,.96); }
|
||||
.notice { border-color:#7a5c28; background:rgba(78,55,16,.24); }
|
||||
.no-real-value { padding:12px 14px; border:2px solid var(--gold); border-radius:7px; background:#2e2009; color:#ffe2a4; letter-spacing:.015em; }
|
||||
li+li { margin-top:.45rem; }
|
||||
footer { padding:12px 4px; }
|
||||
strong { color:#fff; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Parallel Monero stagenet wallet candidate
|
||||
|
||||
> **STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT.**
|
||||
> **STATUS: HISTORICAL NON-MUTATING DESIGN RECORD.** It is not an installation procedure. Such
|
||||
> Software production wallet preparation/promotion is owned by the separately reviewed,
|
||||
> default-closed roles in `~/src/such-fleet`; this document and its sample unit authorize nothing.
|
||||
>
|
||||
> The read-only preflight and disposable fixture test in this change are executable. The candidate
|
||||
> unit is an inert, manual-only template. There is intentionally no password-capture, wallet-copy,
|
||||
|
||||
@@ -61,8 +61,8 @@ FROM audit
|
||||
ORDER BY category;
|
||||
|
||||
WITH ledger AS (
|
||||
SELECT COUNT(*) = 40
|
||||
AND MAX(filename) = '040_paid_match_entropy_precommit.sql' AS valid
|
||||
SELECT COUNT(*) = 42
|
||||
AND MAX(filename) = '042_immutable_financial_event_snapshots.sql' AS valid
|
||||
FROM schema_migrations
|
||||
), audit(row_count) AS (
|
||||
SELECT COUNT(*) FROM payouts WHERE status IS DISTINCT FROM 'completed'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# monerogue.app game instance (Monero STAGENET, pay-to-play WITH payouts).
|
||||
# monerogue.app: Such Software operated Monero STAGENET test mechanics.
|
||||
# NO REAL VALUE; 2x/3x solo test-coin outcomes only; never mainnet or crypto-match payouts.
|
||||
# Install to /etc/systemd/system/monerogue.service
|
||||
#
|
||||
# Separate code checkout + user + DB + .env from the wownerogue (mainnet) instance, so the
|
||||
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synthetic-only tests for the fixed wallet keyfile recovery proof primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).with_name("wallet-keyfile-recovery-proof.py")
|
||||
SPEC = importlib.util.spec_from_file_location("wallet_keyfile_recovery_proof", SCRIPT)
|
||||
if SPEC is None or SPEC.loader is None: # pragma: no cover
|
||||
raise RuntimeError("fixture import unavailable")
|
||||
proof = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = proof
|
||||
SPEC.loader.exec_module(proof)
|
||||
|
||||
|
||||
def synthetic_profile(stagenet: bool = True):
|
||||
return proof.WalletProfile(
|
||||
name="synthetic",
|
||||
unit="synthetic.service",
|
||||
user="synthetic",
|
||||
group="synthetic",
|
||||
binary=pathlib.Path("/fixed/bin/wallet-rpc"),
|
||||
live_wallet=pathlib.Path("/fixed/live/wallet"),
|
||||
live_rpc_port=38083,
|
||||
live_bind_address="127.0.0.1",
|
||||
snapshot_wallet=pathlib.Path("/fixed/snapshot.wallet"),
|
||||
snapshot_keys=pathlib.Path("/fixed/snapshot.keys"),
|
||||
proof_port=39084,
|
||||
stagenet=stagenet,
|
||||
address_kind="xmr-stagenet" if stagenet else "wow-mainnet",
|
||||
proof_directory=pathlib.Path("/fixed/proof"),
|
||||
)
|
||||
|
||||
|
||||
def cmdline(profile, *, wallet=None, passwords=(b"synthetic-wallet-password",), extra=()):
|
||||
arguments = [os.fsencode(profile.binary)]
|
||||
if profile.stagenet:
|
||||
arguments.append(b"--stagenet")
|
||||
arguments.extend((b"--wallet-file", os.fsencode(wallet or profile.live_wallet)))
|
||||
for password in passwords:
|
||||
arguments.extend((b"--password", password))
|
||||
arguments.extend((b"--rpc-bind-port", str(profile.live_rpc_port).encode("ascii")))
|
||||
arguments.extend(extra)
|
||||
raw = bytearray(b"\0".join(arguments) + b"\0")
|
||||
return raw, len(raw)
|
||||
|
||||
|
||||
class CmdlineContractFixture(unittest.TestCase):
|
||||
def test_exact_fixed_argv_extracts_only_the_inline_password_and_wipes_input(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile)
|
||||
secret = proof.extract_inline_wallet_password(raw, length, profile)
|
||||
try:
|
||||
self.assertEqual(secret, b"synthetic-wallet-password")
|
||||
self.assertFalse(any(raw))
|
||||
finally:
|
||||
proof.wipe_buffer(secret)
|
||||
|
||||
def test_wrong_wallet_path_is_refused_and_input_is_wiped(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile, wallet=pathlib.Path("/wrong/wallet"))
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
def test_duplicate_password_is_refused(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile, passwords=(b"one", b"two"))
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
def test_password_file_or_wrong_network_is_refused(self):
|
||||
profile = synthetic_profile()
|
||||
for indirect in (
|
||||
(b"--password-file", b"/not-accepted"),
|
||||
(b"--password-file=/not-accepted",),
|
||||
(b"--wallet-dir=/not-accepted",),
|
||||
):
|
||||
raw, length = cmdline(profile, extra=indirect)
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
mainnet = synthetic_profile(stagenet=False)
|
||||
for wrong_network in ((b"--stagenet",), (b"--stagenet=1",), (b"--testnet=1",)):
|
||||
raw, length = cmdline(mainnet, extra=wrong_network)
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, mainnet)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
|
||||
class SnapshotCopyFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="wallet-recovery-fixture-")
|
||||
self.root = pathlib.Path(self.temporary.name)
|
||||
self.source = self.root / "source"
|
||||
self.destination = self.root / "destination"
|
||||
self.source.mkdir(mode=0o700)
|
||||
self.destination.mkdir(mode=0o700)
|
||||
self.wallet = self.source / "snapshot.wallet"
|
||||
self.keys = self.source / "snapshot.keys"
|
||||
self.wallet.write_bytes(b"synthetic encrypted wallet cache\0bytes")
|
||||
self.keys.write_bytes(b"synthetic encrypted keyfile\0bytes")
|
||||
self.wallet.chmod(0o600)
|
||||
self.keys.chmod(0o600)
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def copy(self, after_first_copy=None):
|
||||
proof.copy_snapshot_pair(
|
||||
self.source,
|
||||
self.wallet.name,
|
||||
self.keys.name,
|
||||
self.destination,
|
||||
os.getuid(),
|
||||
os.getgid(),
|
||||
after_first_copy,
|
||||
)
|
||||
|
||||
def test_copy_is_byte_equal_private_and_source_is_unchanged(self):
|
||||
wallet_before = self.wallet.read_bytes()
|
||||
keys_before = self.keys.read_bytes()
|
||||
self.copy()
|
||||
self.assertEqual((self.destination / "wallet").read_bytes(), wallet_before)
|
||||
self.assertEqual((self.destination / "wallet.keys").read_bytes(), keys_before)
|
||||
self.assertEqual(self.wallet.read_bytes(), wallet_before)
|
||||
self.assertEqual(self.keys.read_bytes(), keys_before)
|
||||
self.assertEqual((self.destination / "wallet").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((self.destination / "wallet.keys").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_hard_linked_source_is_refused(self):
|
||||
os.link(self.keys, self.root / "second-key-link")
|
||||
with self.assertRaises(proof.Refusal):
|
||||
self.copy()
|
||||
self.assertFalse((self.destination / "wallet").exists())
|
||||
|
||||
def test_symlink_source_component_is_refused(self):
|
||||
linked = self.root / "linked-source"
|
||||
linked.symlink_to(self.source, target_is_directory=True)
|
||||
with self.assertRaises((OSError, proof.Refusal)):
|
||||
proof.copy_snapshot_pair(
|
||||
linked,
|
||||
self.wallet.name,
|
||||
self.keys.name,
|
||||
self.destination,
|
||||
os.getuid(),
|
||||
os.getgid(),
|
||||
)
|
||||
|
||||
def test_source_change_during_copy_is_refused_and_partial_copy_removed(self):
|
||||
def mutate():
|
||||
with self.wallet.open("ab") as output:
|
||||
output.write(b"changed")
|
||||
|
||||
with self.assertRaises(proof.Refusal):
|
||||
self.copy(mutate)
|
||||
self.assertFalse((self.destination / "wallet").exists())
|
||||
self.assertFalse((self.destination / "wallet.keys").exists())
|
||||
|
||||
|
||||
class CleanupAndProtocolFixture(unittest.TestCase):
|
||||
def test_protection_failure_wipes_the_synthetic_secret(self):
|
||||
value = bytearray(b"synthetic-secret")
|
||||
with mock.patch.object(proof.LIBC, "mlock", return_value=-1):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.protect_buffer(value)
|
||||
self.assertFalse(any(value))
|
||||
|
||||
def test_cleanup_never_follows_a_link_outside_its_exact_directory(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-cleanup-fixture-") as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
proof_dir = root / "proof"
|
||||
proof_dir.mkdir(mode=0o700)
|
||||
secret = proof_dir / "wallet-password"
|
||||
secret.write_bytes(b"synthetic secret")
|
||||
secret.chmod(0o600)
|
||||
external = root / "external"
|
||||
external.write_bytes(b"must survive")
|
||||
(proof_dir / "linked").symlink_to(external)
|
||||
details = proof_dir.stat()
|
||||
|
||||
self.assertFalse(
|
||||
proof.secure_remove_proof_directory(
|
||||
proof_dir, (details.st_dev, details.st_ino)
|
||||
)
|
||||
)
|
||||
self.assertFalse(proof_dir.exists())
|
||||
self.assertEqual(external.read_bytes(), b"must survive")
|
||||
|
||||
def test_cleanup_never_overwrites_an_external_hard_link(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-hardlink-fixture-") as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
proof_dir = root / "proof"
|
||||
proof_dir.mkdir(mode=0o700)
|
||||
external = root / "external"
|
||||
external.write_bytes(b"must survive")
|
||||
os.link(external, proof_dir / "linked")
|
||||
details = proof_dir.stat()
|
||||
|
||||
self.assertFalse(
|
||||
proof.secure_remove_proof_directory(
|
||||
proof_dir, (details.st_dev, details.st_ino)
|
||||
)
|
||||
)
|
||||
self.assertFalse(proof_dir.exists())
|
||||
self.assertEqual(external.read_bytes(), b"must survive")
|
||||
|
||||
def test_fixed_address_shapes_reject_cross_network_values(self):
|
||||
xmr = synthetic_profile()
|
||||
wow = synthetic_profile(stagenet=False)
|
||||
xmr_address = "5" + "1" * 94
|
||||
wow_address = "Wo" + "1" * 95
|
||||
self.assertTrue(proof.network_shaped_address(xmr_address, xmr))
|
||||
self.assertTrue(proof.network_shaped_address(wow_address, wow))
|
||||
self.assertFalse(proof.network_shaped_address(wow_address, xmr))
|
||||
self.assertFalse(proof.network_shaped_address(xmr_address, wow))
|
||||
|
||||
def test_digest_header_does_not_embed_the_ephemeral_rpc_password(self):
|
||||
challenge = 'Digest realm="wallet-rpc", nonce="abcdef", qop="auth", algorithm=MD5'
|
||||
rpc_secret = bytearray(b"a" * 64)
|
||||
authorization = proof.digest_authorization(challenge, rpc_secret)
|
||||
self.assertTrue(authorization.startswith("Digest "))
|
||||
self.assertNotIn(rpc_secret.decode("ascii"), authorization)
|
||||
|
||||
def test_digest_requires_auth_qop_but_accepts_a_spaced_offer(self):
|
||||
parsed = proof.parse_digest_challenge(
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int, auth"'
|
||||
)
|
||||
self.assertEqual(parsed["nonce"], "abcdef")
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.parse_digest_challenge(
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int"'
|
||||
)
|
||||
|
||||
def test_candidate_files_use_password_file_loopback_offline_contract(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-config-fixture-") as temporary:
|
||||
profile = dataclasses.replace(
|
||||
synthetic_profile(), proof_directory=pathlib.Path(temporary)
|
||||
)
|
||||
profile.candidate_directory.mkdir(mode=0o700)
|
||||
written = {}
|
||||
|
||||
def capture(_directory_fd, name, value):
|
||||
written[name] = bytes(value)
|
||||
|
||||
with mock.patch.object(proof, "write_new_file", side_effect=capture):
|
||||
rpc_secret = proof.prepare_candidate_files(
|
||||
profile, bytearray(b"synthetic-wallet-password")
|
||||
)
|
||||
try:
|
||||
config = written["wallet-rpc.conf"]
|
||||
self.assertEqual(
|
||||
written["wallet-password"], b"synthetic-wallet-password\n"
|
||||
)
|
||||
self.assertIn(
|
||||
b"wallet-file="
|
||||
+ os.fsencode(profile.candidate_directory / "wallet")
|
||||
+ b"\n",
|
||||
config,
|
||||
)
|
||||
self.assertIn(
|
||||
b"password-file="
|
||||
+ os.fsencode(profile.candidate_directory / "wallet-password")
|
||||
+ b"\n",
|
||||
config,
|
||||
)
|
||||
self.assertIn(b"rpc-bind-ip=127.0.0.1\n", config)
|
||||
self.assertIn(b"rpc-bind-port=39084\n", config)
|
||||
self.assertIn(b"non-interactive=1\n", config)
|
||||
self.assertIn(b"rpc-login=recovery-proof:" + bytes(rpc_secret), config)
|
||||
self.assertIn(b"stagenet=1\n", config)
|
||||
self.assertNotIn(b"daemon-address", config)
|
||||
finally:
|
||||
proof.wipe_buffer(rpc_secret, True)
|
||||
|
||||
def test_fixed_message_signature_must_verify_as_spend_capability(self):
|
||||
profile = synthetic_profile()
|
||||
requests = []
|
||||
signature = "SigV1" + "1" * 80
|
||||
|
||||
def answer(_profile, _challenge, _rpc_secret, request_body, nonce_count):
|
||||
request = json.loads(bytes(request_body).decode("ascii"))
|
||||
requests.append((request, nonce_count))
|
||||
if request["method"] == "sign":
|
||||
return json.dumps({"result": {"signature": signature}}).encode()
|
||||
return json.dumps(
|
||||
{"result": {"good": True, "signature_type": "spend"}}
|
||||
).encode()
|
||||
|
||||
with mock.patch.object(proof, "authenticated_candidate_rpc", side_effect=answer):
|
||||
proof.prove_spend_capability(
|
||||
profile,
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
self.assertEqual([request["method"] for request, _ in requests], ["sign", "verify"])
|
||||
self.assertEqual([nonce_count for _, nonce_count in requests], [2, 3])
|
||||
self.assertEqual(requests[0][0]["params"], {"data": proof.SIGN_MESSAGE})
|
||||
self.assertEqual(requests[1][0]["params"]["signature"], signature)
|
||||
|
||||
def test_missing_spend_signature_is_refused(self):
|
||||
with mock.patch.object(
|
||||
proof,
|
||||
"authenticated_candidate_rpc",
|
||||
return_value=b'{"error":{"message":"watch-only"}}',
|
||||
):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.prove_spend_capability(
|
||||
synthetic_profile(),
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
def test_verified_view_signature_is_not_spend_capability(self):
|
||||
signature = "SigV1" + "1" * 80
|
||||
responses = [
|
||||
json.dumps({"result": {"signature": signature}}).encode(),
|
||||
b'{"result":{"good":true,"signature_type":"view"}}',
|
||||
]
|
||||
with mock.patch.object(
|
||||
proof, "authenticated_candidate_rpc", side_effect=responses
|
||||
):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.prove_spend_capability(
|
||||
synthetic_profile(),
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
def test_child_drops_to_exact_ids_with_no_supplementary_groups(self):
|
||||
with (
|
||||
mock.patch.object(proof.os, "setgroups") as setgroups,
|
||||
mock.patch.object(proof.os, "setgid") as setgid,
|
||||
mock.patch.object(proof.os, "setuid") as setuid,
|
||||
mock.patch.object(proof.os, "getresuid", return_value=(123, 123, 123)),
|
||||
mock.patch.object(proof.os, "getresgid", return_value=(456, 456, 456)),
|
||||
mock.patch.object(proof.os, "getgroups", return_value=[]),
|
||||
mock.patch.object(proof.os, "umask"),
|
||||
mock.patch.object(proof.resource, "setrlimit"),
|
||||
mock.patch.object(proof.LIBC, "prctl", return_value=0) as prctl,
|
||||
):
|
||||
proof.child_hardening(123, 456)
|
||||
setgroups.assert_called_once_with([])
|
||||
setgid.assert_called_once_with(456)
|
||||
setuid.assert_called_once_with(123)
|
||||
self.assertEqual(prctl.call_count, 3)
|
||||
|
||||
def test_listener_collision_is_refused(self):
|
||||
with mock.patch.object(proof, "listeners", return_value=[("127.0.0.1", 42)]):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.require_unused_port(39084)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
unittest.main(verbosity=2)
|
||||
+1646
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
# play.wowne.ro game instance (Wownero mainnet, free + paid prestige, no payouts).
|
||||
# play.wowne.ro: Such Software operated Wownero mainnet free + pay-for-credits prestige.
|
||||
# No direct paid entry, payouts, cash-out, or crypto racing; not marketed as gambling.
|
||||
# Install to /etc/systemd/system/wownerogue.service.
|
||||
[Unit]
|
||||
Description=Wownerogue Game Server (Wownero Mainnet)
|
||||
|
||||
+32
-8
@@ -5,6 +5,11 @@
|
||||
# The server validates these settings at startup. Production fails closed on missing secrets,
|
||||
# ambiguous payout flags, simulated blocks, or missing wallet/daemon RPC endpoints.
|
||||
#
|
||||
# This is a generic MIT self-hosting template. The named operator is solely responsible for the
|
||||
# deployment and its legal/financial obligations; Such Software does not operate or endorse it.
|
||||
# Do not set OPERATED_PRODUCT_PROFILE. That opt-in identifier is reserved for the two exact
|
||||
# Such Software operated templates in .env.mainnet.example and .env.stagenet.example.
|
||||
#
|
||||
# QUICK START CONFIGURATIONS:
|
||||
# ---------------------------
|
||||
# 1. FREE MODE (no payments, just play):
|
||||
@@ -163,12 +168,13 @@ PAYMENTS_ENABLED=true
|
||||
# Champions, which is for paid games). Always effectively on when PAYMENTS_ENABLED=false.
|
||||
#
|
||||
# DEPLOYMENT PATTERNS
|
||||
# • Real-money / mainnet (legitimacy): free-to-play + sell credits/entry, but NO PAYOUTS.
|
||||
# Players pay for prestige (Hall of Champions), not crypto winnings — not gambling.
|
||||
# • Mainnet prestige: free-to-play + sell service credits/entry, with NO PAYOUTS.
|
||||
# No-payout design is not a legal conclusion; an independent operator needs its own review.
|
||||
# PAYMENTS_ENABLED=true FREE_PLAY_ENABLED=true PAYOUTS_ENABLED=false
|
||||
# DIRECT_PAYOUTS_ENABLED=false CREDITS_PAYOUTS_ENABLED=false
|
||||
# DIRECT_REQUIRES_ADDRESS=false CREDITS_REQUIRES_ADDRESS=false (no payout = no address)
|
||||
# • Test/stagenet (fake money): full pay-to-play WITH payouts is fine.
|
||||
# • Test/stagenet: test mechanics can exercise payouts only with conspicuous no-real-value copy.
|
||||
# Never accept or represent mainnet funds under a test-only profile.
|
||||
# PAYMENTS_ENABLED=true PAYOUTS_ENABLED=true *_PAYOUTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
|
||||
@@ -218,7 +224,7 @@ CREDITS_REQUIRES_ADDRESS=false
|
||||
ALLOW_MIXED_MODE=true # Show both options to user
|
||||
PREFER_CREDITS_FIRST=true # Auto-deduct credits if user has them
|
||||
|
||||
# Credits payouts (usually DISABLED - credits are a bulk discount, not gambling)
|
||||
# Credits payouts (usually DISABLED so purchases grant service play, not prize eligibility)
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
CREDITS_PAYOUT_BASE=50000000000 # Base amount for credit wins
|
||||
CREDITS_PAYOUT_ESCAPE=2.0 # Same as direct mode
|
||||
@@ -283,8 +289,8 @@ EARLY_ENTRY_CREDITS_MODE=true
|
||||
# Controls how winnings are sent back to players.
|
||||
# Payouts are batched for efficiency and to reduce transaction fees.
|
||||
|
||||
# Master payout switch. The safe mainnet product sells entry/credits for leaderboard prestige
|
||||
# without crypto winnings. The separate XMR stagenet instance enables this for 2x/3x demo payouts.
|
||||
# Master payout switch. The Such-operated mainnet product sells credits for leaderboard prestige
|
||||
# without crypto winnings. Its separate XMR stagenet test product uses 2x/3x test-coin payouts.
|
||||
# This switch suppresses payout creation, block/batch dispatch, and retry workers.
|
||||
PAYOUTS_ENABLED=false
|
||||
|
||||
@@ -385,13 +391,17 @@ RPC_POLL_INTERVAL=2000
|
||||
|
||||
# Public operator and paid-play disclosure controls. Production paid instances fail startup
|
||||
# until these are explicitly set to truthful, non-placeholder values.
|
||||
# Independent deployments must identify their own operator. Such Software operates only
|
||||
# play.wowne.ro and monerogue.app; it does not endorse or accept responsibility for self-hosts.
|
||||
# OPERATOR_NAME=Responsible operator name
|
||||
# OPERATOR_CONTACT_URL=mailto:support@example.com
|
||||
# OPERATOR_CONTACT_LABEL=Support
|
||||
# LEGAL_POLICY_VERSION=2026-07-21-v1
|
||||
# TERMS_EFFECTIVE_DATE=2026-07-21
|
||||
# LEGAL_POLICY_VERSION=2026-07-22-v1
|
||||
# TERMS_EFFECTIVE_DATE=2026-07-22
|
||||
# MINIMUM_AGE=18
|
||||
# PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
# Leave unset for third-party/self-hosted deployments. Official templates set one reviewed value:
|
||||
# OPERATED_PRODUCT_PROFILE=such-play-wow-prestige | such-monerogue-stagenet
|
||||
# Optional plain-language location restriction notice supplied after legal review:
|
||||
# RESTRICTED_LOCATIONS_NOTICE=
|
||||
|
||||
@@ -475,6 +485,20 @@ FINANCIAL_REVIEW_STALE_MS=900000
|
||||
# Default: 3600000 (1 hour)
|
||||
ALERT_COOLDOWN_MS=3600000
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 13b: OPTIONAL MAINNET FINANCIAL-EVENT EXPORT
|
||||
# =============================================================================
|
||||
# Confirmed payments and final refunds are snapshotted to a durable outbox. Configure both values
|
||||
# only for a reviewed MAINNET accounting sink that honors Idempotency-Key. Test-network rows are
|
||||
# suppressed locally and must never be exported. Keep the bearer token in the protected runtime
|
||||
# environment; never commit it.
|
||||
# FINANCIAL_EVENT_SINK_URL=https://ledger.operator.example/v1/events
|
||||
# FINANCIAL_EVENT_SINK_TOKEN=CHANGE_ME_STRONG_ACCOUNTING_SINK_TOKEN
|
||||
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:receipts
|
||||
# FINANCIAL_EVENT_POLL_MS=60000
|
||||
# FINANCIAL_EVENT_BATCH_SIZE=20
|
||||
# FINANCIAL_EVENT_MAX_ATTEMPTS=8
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 14: SMIRK WALLET INTEGRATION
|
||||
# =============================================================================
|
||||
|
||||
+25
-12
@@ -1,4 +1,7 @@
|
||||
# play.wowne.ro production profile: Wownero mainnet, free + paid prestige boards, no payouts.
|
||||
# Such Software operated product: play.wowne.ro.
|
||||
# Wownero mainnet free play + pay-for-credits leaderboard/prestige only.
|
||||
# No payouts, prizes, cash-out, or crypto racing; not offered/marketed as gambling.
|
||||
# Legal classification still depends on applicable law.
|
||||
# Copy to .env, replace every CHANGE_ME value, then run `npm run preflight`.
|
||||
|
||||
NODE_ENV=production
|
||||
@@ -13,6 +16,7 @@ CRYPTO_TYPE=WOW
|
||||
MONERO_NETWORK=mainnet
|
||||
SMIRK_ENABLED=false
|
||||
HOSTED_BY=https://play.wowne.ro
|
||||
OPERATED_PRODUCT_PROFILE=such-play-wow-prestige
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
@@ -21,26 +25,26 @@ DB_USER=wownerogue
|
||||
DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD
|
||||
ADMIN_API_KEY=CHANGE_ME_WITH_OPENSSL_RAND_HEX_32
|
||||
|
||||
# Public operator/disclosure metadata. These are startup-required for production payments.
|
||||
# Do not copy placeholder identity/contact values into a live instance.
|
||||
OPERATOR_NAME=CHANGE_ME_RESPONSIBLE_OPERATOR
|
||||
OPERATOR_CONTACT_URL=mailto:CHANGE_ME_SUPPORT_ADDRESS
|
||||
OPERATOR_CONTACT_LABEL=Support
|
||||
LEGAL_POLICY_VERSION=2026-07-21-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-21
|
||||
# Exact public operator/disclosure metadata for the Such Software operated service.
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
LEGAL_POLICY_VERSION=2026-07-22-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-22
|
||||
MINIMUM_AGE=18
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
# Optional plain-language notice supplied after jurisdiction-specific legal review.
|
||||
# RESTRICTED_LOCATIONS_NOTICE=
|
||||
|
||||
# Free runs rank on the Pleb board; direct/credit runs rank on the paid prestige board.
|
||||
# Free runs rank on the Pleb board; purchased credits rank on the paid prestige board.
|
||||
PAYMENTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
PAYMENT_MODES=direct,credits
|
||||
ALLOW_MIXED_MODE=true
|
||||
PAYMENT_MODES=credits
|
||||
ALLOW_MIXED_MODE=false
|
||||
PREFER_CREDITS_FIRST=true
|
||||
|
||||
DIRECT_PAYMENT_ENABLED=true
|
||||
# Direct per-run payment is outside the operated product scope and must remain disabled.
|
||||
DIRECT_PAYMENT_ENABLED=false
|
||||
DIRECT_GAME_PRICE=100000000000
|
||||
DIRECT_REQUIRES_ADDRESS=false
|
||||
DIRECT_ALLOW_GUEST_PLAY=false
|
||||
@@ -59,6 +63,15 @@ PAYMENT_CONFIRMATIONS=1
|
||||
LATE_PAYMENT_RECONCILE_HOURS=72
|
||||
LATE_PAYMENT_RECONCILE_BATCH=100
|
||||
|
||||
# Optional mainnet-only accounting export. Configure URL and token together in the protected live
|
||||
# environment only after the sink's Idempotency-Key contract and dead-letter monitoring are ready.
|
||||
# FINANCIAL_EVENT_SINK_URL=https://ledger.such.software/v1/events
|
||||
# FINANCIAL_EVENT_SINK_TOKEN=CHANGE_ME_STRONG_ACCOUNTING_SINK_TOKEN
|
||||
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:wow-receipts
|
||||
FINANCIAL_EVENT_POLL_MS=60000
|
||||
FINANCIAL_EVENT_BATCH_SIZE=20
|
||||
FINANCIAL_EVENT_MAX_ATTEMPTS=8
|
||||
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
|
||||
WALLET_RPC_USER=wownerogue
|
||||
WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD
|
||||
|
||||
+20
-18
@@ -1,4 +1,6 @@
|
||||
# Monerogue production profile: XMR stagenet, free + paid solo, Tavern, and PvP race.
|
||||
# Such Software operated test product: monerogue.app.
|
||||
# NO REAL VALUE: Monero stagenet test coins only. Never send mainnet XMR.
|
||||
# Single-player test gambling mechanics pay 2x/3x test coins; crypto-match payouts stay off.
|
||||
# Copy to .env, replace every CHANGE_ME value, then run `npm run preflight`.
|
||||
|
||||
NODE_ENV=production
|
||||
@@ -13,6 +15,7 @@ CRYPTO_TYPE=XMR
|
||||
MONERO_NETWORK=stagenet
|
||||
SMIRK_ENABLED=false
|
||||
HOSTED_BY=https://monerogue.app
|
||||
OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet
|
||||
EXPLORER_TX_URL=
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
@@ -22,26 +25,25 @@ DB_USER=monerogue
|
||||
DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD
|
||||
ADMIN_API_KEY=CHANGE_ME_WITH_OPENSSL_RAND_HEX_32
|
||||
|
||||
# Public operator/disclosure metadata. These are startup-required for production payments.
|
||||
# Do not copy placeholder identity/contact values into a live instance.
|
||||
OPERATOR_NAME=CHANGE_ME_RESPONSIBLE_OPERATOR
|
||||
OPERATOR_CONTACT_URL=mailto:CHANGE_ME_SUPPORT_ADDRESS
|
||||
OPERATOR_CONTACT_LABEL=Support
|
||||
LEGAL_POLICY_VERSION=2026-07-21-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-21
|
||||
# Exact public operator/disclosure metadata for the Such Software operated service.
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
LEGAL_POLICY_VERSION=2026-07-22-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-22
|
||||
MINIMUM_AGE=18
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
# Optional plain-language notice supplied after jurisdiction-specific legal review.
|
||||
# RESTRICTED_LOCATIONS_NOTICE=
|
||||
|
||||
# The original solo game remains available for free and through both paid entry paths.
|
||||
# The original solo game remains available for free and through both test-coin entry paths.
|
||||
PAYMENTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
PAYMENT_MODES=direct,credits
|
||||
ALLOW_MIXED_MODE=true
|
||||
PREFER_CREDITS_FIRST=true
|
||||
|
||||
# 0.01 stagenet XMR per direct run; escape pays 2x, treasure escape pays 3x.
|
||||
# NO REAL VALUE: 0.01 stagenet XMR per direct run; escape pays 2x, treasure escape pays 3x.
|
||||
DIRECT_PAYMENT_ENABLED=true
|
||||
DIRECT_GAME_PRICE=10000000000
|
||||
DIRECT_REQUIRES_ADDRESS=true
|
||||
@@ -58,11 +60,8 @@ CREDITS_PAYOUT_BASE=10000000000
|
||||
CREDITS_PAYOUT_ESCAPE=2.0
|
||||
CREDITS_PAYOUT_TREASURE=3.0
|
||||
CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"90000000000","bonus":0},{"id":"medium","credits":20,"price":"170000000000","bonus":2}]'
|
||||
# Provision this product before enabling the crypto-match gates below. The full price is
|
||||
# allocated as two independently tracked 0.01 XMR race-ticket backing lots.
|
||||
COSMETIC_PRODUCTS='[{"id":"race_2","label":"Two stagenet race entries","price":"20000000000","grants":{"race_entries":2,"race_entry_value_atomic":"10000000000"}}]'
|
||||
|
||||
# Stagenet-only money switch and bankroll caps. Mainnet acknowledgement deliberately stays off.
|
||||
# Stagenet-test-coin-only payout switch and caps. Mainnet acknowledgement deliberately stays off.
|
||||
PAYOUTS_ENABLED=true
|
||||
ALLOW_MAINNET_PAYOUTS=false
|
||||
PAYOUT_MIN_AMOUNT=1000000000
|
||||
@@ -76,6 +75,12 @@ BALANCE_CRITICAL=50000000000
|
||||
LATE_PAYMENT_RECONCILE_HOURS=72
|
||||
LATE_PAYMENT_RECONCILE_BATCH=100
|
||||
|
||||
# Deliberately blank: NO REAL VALUE stagenet events are suppressed locally and must not reach a
|
||||
# real accounting sink. Startup rejects a configured financial-event sink on this network.
|
||||
FINANCIAL_EVENT_SINK_URL=
|
||||
FINANCIAL_EVENT_SINK_TOKEN=
|
||||
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:xmr-stagenet-test
|
||||
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083
|
||||
WALLET_RPC_USER=monerogue
|
||||
WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD
|
||||
@@ -99,9 +104,6 @@ MATCH_TICK_MS=250
|
||||
MATCH_MIN_DURATION_MS=20000
|
||||
MATCH_HARD_CEILING_MS=240000
|
||||
MATCH_CREDITS_COST=1
|
||||
MATCH_HOUSE_FEE_PERCENT=5
|
||||
# Enable only after provisioning race-entry ticket products and validating escrow end to end.
|
||||
# Operated PvP may use free/prestige economies, but never a crypto-pot/race payout economy.
|
||||
MATCH_CRYPTO_RACE_ENABLED=false
|
||||
MATCH_PAYOUTS_ENABLED=false
|
||||
MATCH_ENTRY_FEE_ATOMIC=10000000000
|
||||
MATCH_PAYOUT_MAX=50000000000
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
* actions unless the browser echoes the current policy version and every required acknowledgement.
|
||||
*/
|
||||
|
||||
const DEFAULT_POLICY_VERSION = '2026-07-21';
|
||||
const {
|
||||
SOURCE_SOFTWARE,
|
||||
getOperatedProductProfile
|
||||
} = require('./operatedProductProfiles');
|
||||
|
||||
const DEFAULT_POLICY_VERSION = '2026-07-22';
|
||||
const DEFAULT_MINIMUM_AGE = 18;
|
||||
const ALLOWED_CONTACT_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']);
|
||||
const ACKNOWLEDGEMENT_KEYS = new Set([
|
||||
@@ -73,6 +78,7 @@ function paidAcknowledgementRequired(env, gameModeManager) {
|
||||
}
|
||||
|
||||
function buildCommerceDisclosure(gameModeManager, env = process.env) {
|
||||
const operatedProduct = getOperatedProductProfile(env);
|
||||
const cryptoType = String(gameModeManager?.cryptoType || env.CRYPTO_TYPE || 'WOW').toUpperCase();
|
||||
const network = String(gameModeManager?.network || env.MONERO_NETWORK || 'mainnet').toLowerCase();
|
||||
const isTestNetwork = Boolean(gameModeManager?.isTestNetwork)
|
||||
@@ -92,7 +98,17 @@ function buildCommerceDisclosure(gameModeManager, env = process.env) {
|
||||
&& Boolean(gameModeManager?.payoutsEnabled);
|
||||
const anyPayouts = directPayouts || creditsPayouts || cryptoMatchPayouts;
|
||||
const currencyLabel = String(gameModeManager?.currencyLabel || (isTestNetwork ? `s${cryptoType}` : cryptoType));
|
||||
const operatorContactUrl = safeContactUrl(env.OPERATOR_CONTACT_URL);
|
||||
const operatorContactUrl = safeContactUrl(
|
||||
operatedProduct?.operatorContactUrl || env.OPERATOR_CONTACT_URL
|
||||
);
|
||||
const operatorName = operatedProduct?.operatorName
|
||||
|| String(env.OPERATOR_NAME || '').trim()
|
||||
|| 'Site operator (identity not configured)';
|
||||
const operatorContactLabel = operatedProduct?.operatorContactLabel
|
||||
|| String(env.OPERATOR_CONTACT_LABEL || '').trim()
|
||||
|| (operatorContactUrl
|
||||
? operatorContactUrl.replace(/^mailto:/, '')
|
||||
: 'Operator contact is not configured');
|
||||
|
||||
return Object.freeze({
|
||||
policyVersion: String(env.LEGAL_POLICY_VERSION || DEFAULT_POLICY_VERSION).trim(),
|
||||
@@ -100,11 +116,12 @@ function buildCommerceDisclosure(gameModeManager, env = process.env) {
|
||||
minimumAge: minimumAge(env),
|
||||
paidAcknowledgementRequired: paidAcknowledgementRequired(env, gameModeManager),
|
||||
operator: Object.freeze({
|
||||
name: String(env.OPERATOR_NAME || '').trim() || 'Site operator (identity not configured)',
|
||||
name: operatorName,
|
||||
contactUrl: operatorContactUrl,
|
||||
contactLabel: String(env.OPERATOR_CONTACT_LABEL || '').trim()
|
||||
|| (operatorContactUrl ? operatorContactUrl.replace(/^mailto:/, '') : 'Operator contact is not configured')
|
||||
contactLabel: operatorContactLabel
|
||||
}),
|
||||
operatedProduct,
|
||||
software: SOURCE_SOFTWARE,
|
||||
service: Object.freeze({
|
||||
gameName: String(gameModeManager?.gameName || 'Wowngeon'),
|
||||
cryptoType,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const { isSupported, familyFor } = require('../chain/chainProfile');
|
||||
const { resolveMatchRuleset } = require('../game/rulesets');
|
||||
const money = require('../money/atomic');
|
||||
const { validateOperatedProductProfile } = require('./operatedProductProfiles');
|
||||
const {
|
||||
PG_BIGINT_MAX,
|
||||
cryptoRulesetSupported,
|
||||
@@ -129,6 +130,8 @@ class EnvironmentValidator {
|
||||
const walletRequired = paymentsEnabled || payoutMasterEnabled;
|
||||
const cryptoType = String(env.CRYPTO_TYPE || config?.currency?.symbol || 'WOW').trim().toUpperCase();
|
||||
const network = String(env.MONERO_NETWORK || 'mainnet').trim().toLowerCase();
|
||||
const financialSinkUrl = String(env.FINANCIAL_EVENT_SINK_URL || '').trim();
|
||||
const financialSinkToken = String(env.FINANCIAL_EVENT_SINK_TOKEN || '').trim();
|
||||
|
||||
if (env.GAME_MODE && env.PAYMENT_MODES) {
|
||||
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
|
||||
@@ -229,6 +232,41 @@ class EnvironmentValidator {
|
||||
this._pushEndpointTransportWarning(warnings, 'PRIMARY_WALLET_ENDPOINT', env.PRIMARY_WALLET_ENDPOINT);
|
||||
this._pushEndpointTransportWarning(warnings, 'PRIMARY_RPC_ENDPOINT', env.PRIMARY_RPC_ENDPOINT);
|
||||
|
||||
// Accounting export is optional, but a partial or misleading configuration is not. The
|
||||
// stagenet product intentionally suppresses its no-value test events instead of sending
|
||||
// them to a real accounting sink.
|
||||
if (Boolean(financialSinkUrl) !== Boolean(financialSinkToken)) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL and FINANCIAL_EVENT_SINK_TOKEN must be configured together.');
|
||||
}
|
||||
if (financialSinkUrl && financialSinkToken) {
|
||||
try {
|
||||
const endpoint = new URL(financialSinkUrl);
|
||||
if (!['http:', 'https:'].includes(endpoint.protocol)) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must use http:// or https://.');
|
||||
}
|
||||
if (endpoint.username || endpoint.password) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must not embed credentials.');
|
||||
}
|
||||
if (production && endpoint.protocol !== 'https:') {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must use https:// in production.');
|
||||
}
|
||||
} catch (_) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must be a valid URL.');
|
||||
}
|
||||
if (financialSinkToken.length < 32
|
||||
|| PLACEHOLDER_SECRET.test(financialSinkToken)
|
||||
|| new Set(financialSinkToken).size < 8) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_TOKEN must be a strong non-placeholder secret of at least 32 characters.');
|
||||
}
|
||||
if (network !== 'mainnet') {
|
||||
errors.push('Financial event export must be unset on non-mainnet networks; test-network events are suppressed locally.');
|
||||
}
|
||||
const accountRef = String(env.FINANCIAL_EVENT_ACCOUNT_REF || 'wowngeon:receipts').trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(accountRef)) {
|
||||
errors.push('FINANCIAL_EVENT_ACCOUNT_REF must be a non-PII identifier using only letters, digits, dot, underscore, colon, slash, or hyphen.');
|
||||
}
|
||||
}
|
||||
|
||||
if (config?.modes?.direct?.enabled && config.modes.direct.price <= 0n) {
|
||||
errors.push('Direct payment mode is enabled but its price is not positive.');
|
||||
}
|
||||
@@ -272,6 +310,11 @@ class EnvironmentValidator {
|
||||
warnings.push('Payout rules are enabled but PAYOUTS_ENABLED is off; the master switch suppresses all payout creation and dispatch.');
|
||||
}
|
||||
|
||||
// A Such Software operated-product identifier is an exact public claim in every
|
||||
// environment. Never let a development or preview process impersonate that identity
|
||||
// while bypassing the host/network/economic contract.
|
||||
errors.push(...validateOperatedProductProfile(env, config));
|
||||
|
||||
if (production) {
|
||||
for (const key of BOOLEAN_ENV_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(env, key)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Opt-in contracts for the two public products operated by Such Software.
|
||||
*
|
||||
* Independent MIT deployments do not set OPERATED_PRODUCT_PROFILE and are deliberately not
|
||||
* constrained by these product decisions. Official production environments do set it, causing
|
||||
* preflight and normal startup to reject any network, identity, or economic-scope drift.
|
||||
*/
|
||||
|
||||
const OPERATED_PRODUCT_PROFILE_IDS = Object.freeze({
|
||||
WOW_PRESTIGE: 'such-play-wow-prestige',
|
||||
XMR_STAGENET: 'such-monerogue-stagenet'
|
||||
});
|
||||
|
||||
const SOURCE_SOFTWARE = Object.freeze({
|
||||
publisherName: 'Such Software',
|
||||
contactUrl: 'mailto:apps@such.software',
|
||||
contactLabel: 'apps@such.software',
|
||||
license: 'MIT',
|
||||
rightsNotice: 'Permission to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies is granted under the MIT License only if the copyright and permission notice is included in all copies or substantial portions.',
|
||||
warrantyNotice: 'The software is provided “AS IS”, without warranty of any kind, express or implied, to the fullest extent stated in the MIT License.',
|
||||
legalAdviceNotice: 'The software, documentation, examples, and disclosures are not legal advice, a licence, or a compliance approval.',
|
||||
thirdPartyNotice: 'A third-party or self-hosted operator is solely responsible for its deployment, product design, legal compliance, funds, players, claims, and support. Such Software does not operate, supervise, endorse, or accept responsibility for third-party deployments.',
|
||||
operatedBoundaryNotice: 'Such Software operates only play.wowne.ro and monerogue.app, and only within the product scopes stated for those services. It does not operate or endorse deployments made from this source by anyone else.'
|
||||
});
|
||||
|
||||
const PROFILES = Object.freeze({
|
||||
[OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE]: Object.freeze({
|
||||
id: OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE,
|
||||
hostname: 'play.wowne.ro',
|
||||
publicUrl: 'https://play.wowne.ro',
|
||||
operatorName: 'Such Software',
|
||||
operatorContactUrl: SOURCE_SOFTWARE.contactUrl,
|
||||
operatorContactLabel: SOURCE_SOFTWARE.contactLabel,
|
||||
scopeNotice: 'Such Software operates play.wowne.ro only as Wownero mainnet free play and pay-for-credits leaderboard/prestige play. Credits are non-redeemable service entitlements. The service offers no prizes, winnings, cash-out, or payouts and is not offered or marketed as gambling; legal classification depends on applicable law.',
|
||||
commerceSummary: 'This purchase grants non-redeemable service credits for leaderboard/prestige play only. play.wowne.ro offers no prize, payout, or cash-out and is not marketed as gambling.',
|
||||
noRealValueNotice: null
|
||||
}),
|
||||
[OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET]: Object.freeze({
|
||||
id: OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET,
|
||||
hostname: 'monerogue.app',
|
||||
publicUrl: 'https://monerogue.app',
|
||||
operatorName: 'Such Software',
|
||||
operatorContactUrl: SOURCE_SOFTWARE.contactUrl,
|
||||
operatorContactLabel: SOURCE_SOFTWARE.contactLabel,
|
||||
scopeNotice: 'Such Software operates monerogue.app only with Monero stagenet test coins. Its single-player 2×/3× outcomes are test gambling mechanics; no real-money or mainnet gambling is offered.',
|
||||
commerceSummary: 'NO REAL VALUE — monerogue.app uses Monero stagenet test coins only. Single-player qualifying outcomes pay 2× or 3× in test coins; never send mainnet XMR.',
|
||||
noRealValueNotice: 'NO REAL VALUE — Monero stagenet coins are test data, not money, deposits, redeemable prizes, or a promise of value. Never send mainnet XMR to this service.'
|
||||
})
|
||||
});
|
||||
|
||||
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
|
||||
const FALSE_VALUES = new Set(['false', '0', 'no', 'off']);
|
||||
|
||||
function isExplicitTrue(value) {
|
||||
return TRUE_VALUES.has(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isExplicitFalse(value) {
|
||||
return FALSE_VALUES.has(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function selectedProfileId(env = process.env) {
|
||||
return String(env.OPERATED_PRODUCT_PROFILE || '').trim();
|
||||
}
|
||||
|
||||
function getOperatedProductProfile(env = process.env) {
|
||||
return PROFILES[selectedProfileId(env)] || null;
|
||||
}
|
||||
|
||||
function isExactPublicRoot(value, expected) {
|
||||
try {
|
||||
const actual = new URL(String(value || ''));
|
||||
const target = new URL(expected);
|
||||
return actual.protocol === 'https:'
|
||||
&& actual.username === ''
|
||||
&& actual.password === ''
|
||||
&& actual.origin === target.origin
|
||||
&& (actual.pathname === '' || actual.pathname === '/')
|
||||
&& actual.search === ''
|
||||
&& actual.hash === '';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function exactMultiplier(value, expected) {
|
||||
return String(value ?? '').trim() !== '' && Number(value) === expected;
|
||||
}
|
||||
|
||||
function hasOutOfScopeProduct(config) {
|
||||
const standaloneProducts = config?.products?.cosmetic || [];
|
||||
const creditPackages = config?.modes?.credits?.packages || [];
|
||||
|
||||
// These two operated products sell only the top-level credits/bonus declared by a credit
|
||||
// package. Reject the entire extensible grants object, including unknown future keys, so a
|
||||
// new entitlement cannot silently widen the public product contract.
|
||||
return standaloneProducts.length > 0
|
||||
|| creditPackages.some(product => product
|
||||
&& Object.prototype.hasOwnProperty.call(product, 'grants'));
|
||||
}
|
||||
|
||||
function validateOperatedProductProfile(env = process.env, config = {}) {
|
||||
const id = selectedProfileId(env);
|
||||
if (!id) return [];
|
||||
|
||||
const profile = PROFILES[id];
|
||||
if (!profile) {
|
||||
return [`OPERATED_PRODUCT_PROFILE=${id} is unknown. Remove it for an independent MIT deployment or use a reviewed Such Software profile.`];
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const requireContract = (condition, requirement) => {
|
||||
if (!condition) errors.push(`OPERATED_PRODUCT_PROFILE=${id} requires ${requirement}.`);
|
||||
};
|
||||
const cryptoType = String(env.CRYPTO_TYPE || config?.currency?.symbol || '').trim().toUpperCase();
|
||||
const network = String(env.MONERO_NETWORK || '').trim().toLowerCase();
|
||||
|
||||
requireContract(isExactPublicRoot(env.HOSTED_BY, profile.publicUrl), `HOSTED_BY=${profile.publicUrl}`);
|
||||
requireContract(String(env.OPERATOR_NAME || '').trim() === profile.operatorName,
|
||||
`OPERATOR_NAME=${profile.operatorName}`);
|
||||
requireContract(String(env.OPERATOR_CONTACT_URL || '').trim() === profile.operatorContactUrl,
|
||||
`OPERATOR_CONTACT_URL=${profile.operatorContactUrl}`);
|
||||
requireContract(String(env.OPERATOR_CONTACT_LABEL || '').trim() === profile.operatorContactLabel,
|
||||
`OPERATOR_CONTACT_LABEL=${profile.operatorContactLabel}`);
|
||||
requireContract(isExplicitTrue(env.PAYMENTS_ENABLED) && config?.paymentsEnabled === true,
|
||||
'PAYMENTS_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED),
|
||||
'PAID_ACKNOWLEDGEMENT_REQUIRED=true');
|
||||
requireContract(isExplicitTrue(env.FREE_PLAY_ENABLED), 'FREE_PLAY_ENABLED=true');
|
||||
requireContract(isExplicitFalse(env.MATCH_CRYPTO_RACE_ENABLED), 'MATCH_CRYPTO_RACE_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.MATCH_PAYOUTS_ENABLED), 'MATCH_PAYOUTS_ENABLED=false');
|
||||
requireContract(!hasOutOfScopeProduct(config),
|
||||
'no standalone products or bundled entitlement grants; credit packages may grant only their top-level credits and bonus');
|
||||
|
||||
if (id === OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE) {
|
||||
requireContract(cryptoType === 'WOW', 'CRYPTO_TYPE=WOW');
|
||||
requireContract(network === 'mainnet', 'MONERO_NETWORK=mainnet');
|
||||
requireContract(String(env.PAYMENT_MODES || '').trim().toLowerCase() === 'credits',
|
||||
'PAYMENT_MODES=credits');
|
||||
requireContract(isExplicitFalse(env.DIRECT_PAYMENT_ENABLED)
|
||||
&& config?.modes?.direct?.enabled === false, 'DIRECT_PAYMENT_ENABLED=false');
|
||||
requireContract(isExplicitTrue(env.CREDITS_ENABLED)
|
||||
&& config?.modes?.credits?.enabled === true, 'CREDITS_ENABLED=true');
|
||||
requireContract(isExplicitFalse(env.PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.enabled === false, 'PAYOUTS_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.DIRECT_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.direct?.enabled === false, 'DIRECT_PAYOUTS_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.CREDITS_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.credits?.enabled === false, 'CREDITS_PAYOUTS_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.ALLOW_MAINNET_PAYOUTS), 'ALLOW_MAINNET_PAYOUTS=false');
|
||||
}
|
||||
|
||||
if (id === OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET) {
|
||||
requireContract(cryptoType === 'XMR', 'CRYPTO_TYPE=XMR');
|
||||
requireContract(network === 'stagenet', 'MONERO_NETWORK=stagenet');
|
||||
requireContract(String(env.PAYMENT_MODES || '').split(',').map(value => value.trim().toLowerCase()).join(',') === 'direct,credits',
|
||||
'PAYMENT_MODES=direct,credits');
|
||||
requireContract(isExplicitTrue(env.DIRECT_PAYMENT_ENABLED)
|
||||
&& config?.modes?.direct?.enabled === true, 'DIRECT_PAYMENT_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.CREDITS_ENABLED)
|
||||
&& config?.modes?.credits?.enabled === true, 'CREDITS_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.enabled === true, 'PAYOUTS_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.DIRECT_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.direct?.enabled === true, 'DIRECT_PAYOUTS_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.CREDITS_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.credits?.enabled === true, 'CREDITS_PAYOUTS_ENABLED=true');
|
||||
requireContract(exactMultiplier(env.DIRECT_PAYOUT_ESCAPE, 2), 'DIRECT_PAYOUT_ESCAPE=2');
|
||||
requireContract(exactMultiplier(env.DIRECT_PAYOUT_TREASURE, 3), 'DIRECT_PAYOUT_TREASURE=3');
|
||||
requireContract(exactMultiplier(env.CREDITS_PAYOUT_ESCAPE, 2), 'CREDITS_PAYOUT_ESCAPE=2');
|
||||
requireContract(exactMultiplier(env.CREDITS_PAYOUT_TREASURE, 3), 'CREDITS_PAYOUT_TREASURE=3');
|
||||
requireContract(isExplicitFalse(env.ALLOW_MAINNET_PAYOUTS), 'ALLOW_MAINNET_PAYOUTS=false');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OPERATED_PRODUCT_PROFILE_IDS,
|
||||
SOURCE_SOFTWARE,
|
||||
getOperatedProductProfile,
|
||||
selectedProfileId,
|
||||
validateOperatedProductProfile
|
||||
};
|
||||
+5
-1
@@ -458,7 +458,7 @@ app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandl
|
||||
|
||||
function publicHealthSnapshot() {
|
||||
const walletHealth = walletRPCService.getHealthStatus();
|
||||
return HealthService.buildPublicHealth({
|
||||
const health = HealthService.buildPublicHealth({
|
||||
databaseReady: databaseManager.isHealthy(),
|
||||
blockHeight: debugManager.getCurrentBlockHeight(),
|
||||
simulatedBlocks: debugManager.SIMULATED_BLOCKS,
|
||||
@@ -478,6 +478,10 @@ function publicHealthSnapshot() {
|
||||
gameMode: gameModeManager.gameMode,
|
||||
uptime: process.uptime()
|
||||
});
|
||||
// Informational only: an optional accounting sink must not make gameplay readiness fail.
|
||||
// The exporter exposes a deliberately sanitized view with no endpoint/token/error/event data.
|
||||
health.financialEvents = financialEventExporter.getPublicHealth();
|
||||
return health;
|
||||
}
|
||||
|
||||
// Public probes intentionally omit balances, RPC addresses, secrets, memory details, and abuse
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@ module.exports = {
|
||||
roots: ['<rootDir>/test'],
|
||||
collectCoverage: false,
|
||||
moduleFileExtensions: ['js','json'],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
-- Correct the migration-041 accounting outbox before it is used in production.
|
||||
--
|
||||
-- The payment/refund transaction only appends an immutable, non-PII source snapshot. Network
|
||||
-- policy and HTTP delivery remain asynchronous in FinancialEventExporter, so a slow or failed
|
||||
-- accounting sink can never hold a payment-confirmation transaction open. The exporter writes
|
||||
-- the exact delivery document once and PostgreSQL prevents either snapshot from being rewritten.
|
||||
|
||||
ALTER TABLE financial_event_outbox
|
||||
ADD COLUMN IF NOT EXISTS source_type VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS source_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS event_id VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS payload_snapshot JSONB,
|
||||
ADD COLUMN IF NOT EXISTS delivery_payload JSONB,
|
||||
ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS ignored_reason VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS dead_lettered_at TIMESTAMPTZ;
|
||||
|
||||
-- Migration 041 had a three-state constraint. Drop it before converting malformed legacy rows
|
||||
-- to an explicit terminal state; a replacement five-state constraint is installed below.
|
||||
ALTER TABLE financial_event_outbox
|
||||
DROP CONSTRAINT IF EXISTS financial_event_outbox_status_check,
|
||||
DROP CONSTRAINT IF EXISTS financial_event_outbox_state;
|
||||
|
||||
-- Snapshot every 041 identity without consulting the payment again at delivery time. Confirmed
|
||||
-- receipt totals are preferred for old rows because receipt evidence is append-only; received_amount
|
||||
-- is the compatibility fallback for pre-receipt history. A missing confirmed_at is not allowed to
|
||||
-- poison the queue: the durable outbox/payment creation timestamp is used instead.
|
||||
WITH payment_source AS (
|
||||
SELECT
|
||||
o.id AS outbox_id,
|
||||
p.id AS payment_id,
|
||||
COALESCE(
|
||||
NULLIF((
|
||||
SELECT COALESCE(SUM(r.amount), 0)
|
||||
FROM payment_receipts r
|
||||
WHERE r.payment_id = p.id AND r.confirmed = TRUE
|
||||
), 0),
|
||||
p.received_amount
|
||||
) AS atomic_amount,
|
||||
COALESCE(p.confirmed_at, p.created_at, o.created_at, NOW()) AS occurred_at,
|
||||
p.payment_type,
|
||||
p.product_id,
|
||||
p.provider_id,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM payment_receipts r
|
||||
WHERE r.payment_id = p.id AND r.confirmed = TRUE
|
||||
) AS receipt_count
|
||||
FROM financial_event_outbox o
|
||||
JOIN payments p ON p.id = o.aggregate_id
|
||||
WHERE o.event_kind = 'payment.confirmed'
|
||||
)
|
||||
UPDATE financial_event_outbox o
|
||||
SET source_type = 'payment',
|
||||
source_id = s.payment_id,
|
||||
event_id = 'payment-confirmed-' || s.payment_id::text,
|
||||
payload_snapshot = jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', 'payment-confirmed-' || s.payment_id::text,
|
||||
'event_kind', 'payment.confirmed',
|
||||
'source_type', 'payment',
|
||||
'source_id', s.payment_id,
|
||||
'aggregate_id', s.payment_id,
|
||||
'occurred_at', s.occurred_at,
|
||||
'atomic_amount', COALESCE(s.atomic_amount, 0)::text,
|
||||
'payment_type', COALESCE(s.payment_type, ''),
|
||||
'product_id', COALESCE(s.product_id, ''),
|
||||
'provider_id', COALESCE(s.provider_id, ''),
|
||||
'receipt_count', s.receipt_count
|
||||
),
|
||||
updated_at = NOW()
|
||||
FROM payment_source s
|
||||
WHERE o.id = s.outbox_id
|
||||
AND o.payload_snapshot IS NULL;
|
||||
|
||||
-- A 041 row whose historical amount has become unusable must terminate safely instead of being
|
||||
-- retried forever. Already-delivered rows are retained as delivered audit history.
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'ignored',
|
||||
source_type = COALESCE(source_type, 'payment'),
|
||||
source_id = COALESCE(source_id, aggregate_id),
|
||||
event_id = COALESCE(event_id, 'ignored-legacy-outbox-' || id::text),
|
||||
payload_snapshot = COALESCE(payload_snapshot, jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', COALESCE(event_id, 'ignored-legacy-outbox-' || id::text),
|
||||
'event_kind', event_kind,
|
||||
'source_type', COALESCE(source_type, 'payment'),
|
||||
'source_id', COALESCE(source_id, aggregate_id),
|
||||
'aggregate_id', aggregate_id,
|
||||
'occurred_at', COALESCE(created_at, NOW()),
|
||||
'atomic_amount', '0'
|
||||
)),
|
||||
lease_until = NULL,
|
||||
ignored_at = NOW(),
|
||||
ignored_reason = 'malformed_legacy_snapshot',
|
||||
last_error = 'Malformed legacy financial-event snapshot was suppressed',
|
||||
updated_at = NOW()
|
||||
WHERE delivered_at IS NULL
|
||||
AND (
|
||||
payload_snapshot IS NULL
|
||||
OR payload_snapshot->>'atomic_amount' !~ '^[1-9][0-9]*$'
|
||||
);
|
||||
|
||||
-- Build the exact safe source document at the confirmation boundary for all future payments.
|
||||
CREATE OR REPLACE FUNCTION enqueue_confirmed_payment_financial_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
safe_occurred_at TIMESTAMPTZ;
|
||||
safe_receipt_count BIGINT;
|
||||
stable_event_id TEXT;
|
||||
BEGIN
|
||||
IF NEW.status = 'confirmed'
|
||||
AND COALESCE(NEW.received_amount, 0) > 0
|
||||
AND (
|
||||
TG_OP = 'INSERT'
|
||||
OR OLD.status IS DISTINCT FROM 'confirmed'
|
||||
OR COALESCE(OLD.received_amount, 0) <= 0
|
||||
) THEN
|
||||
safe_occurred_at := COALESCE(NEW.confirmed_at, NEW.created_at, NOW());
|
||||
stable_event_id := 'payment-confirmed-' || NEW.id::text;
|
||||
SELECT COUNT(*) INTO safe_receipt_count
|
||||
FROM payment_receipts r
|
||||
WHERE r.payment_id = NEW.id AND r.confirmed = TRUE;
|
||||
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
) VALUES (
|
||||
'payment.confirmed',
|
||||
NEW.id,
|
||||
'payment',
|
||||
NEW.id,
|
||||
stable_event_id,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', stable_event_id,
|
||||
'event_kind', 'payment.confirmed',
|
||||
'source_type', 'payment',
|
||||
'source_id', NEW.id,
|
||||
'aggregate_id', NEW.id,
|
||||
'occurred_at', safe_occurred_at,
|
||||
'atomic_amount', NEW.received_amount::text,
|
||||
'payment_type', COALESCE(NEW.payment_type, ''),
|
||||
'product_id', COALESCE(NEW.product_id, ''),
|
||||
'provider_id', COALESCE(NEW.provider_id, ''),
|
||||
'receipt_count', safe_receipt_count
|
||||
)
|
||||
)
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enqueue_confirmed_payment_financial_event ON payments;
|
||||
CREATE TRIGGER trg_enqueue_confirmed_payment_financial_event
|
||||
AFTER INSERT OR UPDATE OF status, received_amount ON payments
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_confirmed_payment_financial_event();
|
||||
|
||||
-- If migration 041 was applied while writes continued, add any valid confirmation identity it
|
||||
-- missed. This backfill is deliberately receipt/value based and never copies customer identity,
|
||||
-- invoice destinations, wallet addresses, transaction hashes, provider invoice IDs, or metadata.
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
)
|
||||
SELECT
|
||||
'payment.confirmed',
|
||||
p.id,
|
||||
'payment',
|
||||
p.id,
|
||||
'payment-confirmed-' || p.id::text,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', 'payment-confirmed-' || p.id::text,
|
||||
'event_kind', 'payment.confirmed',
|
||||
'source_type', 'payment',
|
||||
'source_id', p.id,
|
||||
'aggregate_id', p.id,
|
||||
'occurred_at', COALESCE(p.confirmed_at, p.created_at, NOW()),
|
||||
'atomic_amount', p.received_amount::text,
|
||||
'payment_type', COALESCE(p.payment_type, ''),
|
||||
'product_id', COALESCE(p.product_id, ''),
|
||||
'provider_id', COALESCE(p.provider_id, ''),
|
||||
'receipt_count', (
|
||||
SELECT COUNT(*) FROM payment_receipts r
|
||||
WHERE r.payment_id = p.id AND r.confirmed = TRUE
|
||||
)
|
||||
)
|
||||
FROM payments p
|
||||
WHERE p.status = 'confirmed' AND COALESCE(p.received_amount, 0) > 0
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
|
||||
-- A refund produces exactly one final reversal identity. `recorded` is the terminal bookkeeping
|
||||
-- reversal used when no wallet transfer was requested; `completed` is the terminal wallet refund.
|
||||
-- requested/processing/needs_review states are intentionally never accounting events.
|
||||
CREATE OR REPLACE FUNCTION enqueue_final_payment_refund_financial_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
stable_event_id TEXT;
|
||||
safe_occurred_at TIMESTAMPTZ;
|
||||
BEGIN
|
||||
IF NEW.status IN ('recorded', 'completed')
|
||||
AND (
|
||||
TG_OP = 'INSERT'
|
||||
OR OLD.status IS DISTINCT FROM NEW.status
|
||||
) THEN
|
||||
stable_event_id := 'payment-refund-' || NEW.id::text;
|
||||
safe_occurred_at := CASE
|
||||
WHEN NEW.status = 'completed' THEN COALESCE(NEW.completed_at, NEW.updated_at, NOW())
|
||||
ELSE COALESCE(NEW.created_at, NEW.updated_at, NOW())
|
||||
END;
|
||||
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
) VALUES (
|
||||
'payment.refund',
|
||||
NEW.payment_id,
|
||||
'payment_refund',
|
||||
NEW.id,
|
||||
stable_event_id,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', stable_event_id,
|
||||
'event_kind', 'payment.refund',
|
||||
'source_type', 'payment_refund',
|
||||
'source_id', NEW.id,
|
||||
'aggregate_id', NEW.payment_id,
|
||||
'occurred_at', safe_occurred_at,
|
||||
'atomic_amount', NEW.amount::text,
|
||||
'refund_status', NEW.status
|
||||
)
|
||||
)
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enqueue_final_payment_refund_financial_event ON payment_refunds;
|
||||
CREATE TRIGGER trg_enqueue_final_payment_refund_financial_event
|
||||
AFTER INSERT OR UPDATE OF status ON payment_refunds
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_final_payment_refund_financial_event();
|
||||
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
)
|
||||
SELECT
|
||||
'payment.refund',
|
||||
r.payment_id,
|
||||
'payment_refund',
|
||||
r.id,
|
||||
'payment-refund-' || r.id::text,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', 'payment-refund-' || r.id::text,
|
||||
'event_kind', 'payment.refund',
|
||||
'source_type', 'payment_refund',
|
||||
'source_id', r.id,
|
||||
'aggregate_id', r.payment_id,
|
||||
'occurred_at', CASE
|
||||
WHEN r.status = 'completed' THEN COALESCE(r.completed_at, r.updated_at, NOW())
|
||||
ELSE COALESCE(r.created_at, r.updated_at, NOW())
|
||||
END,
|
||||
'atomic_amount', r.amount::text,
|
||||
'refund_status', r.status
|
||||
)
|
||||
FROM payment_refunds r
|
||||
WHERE r.status IN ('recorded', 'completed')
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
|
||||
-- All 041 rows now have a complete identity or are terminally ignored. Require new writes to
|
||||
-- carry one, constrain the source document to the deliberately small non-PII key set, and make
|
||||
-- each source/event identity globally stable.
|
||||
ALTER TABLE financial_event_outbox
|
||||
ALTER COLUMN source_type SET NOT NULL,
|
||||
ALTER COLUMN source_id SET NOT NULL,
|
||||
ALTER COLUMN event_id SET NOT NULL,
|
||||
ALTER COLUMN payload_snapshot SET NOT NULL;
|
||||
|
||||
ALTER TABLE financial_event_outbox
|
||||
ADD CONSTRAINT financial_event_outbox_event_id_once UNIQUE (event_id),
|
||||
ADD CONSTRAINT financial_event_outbox_source_once UNIQUE (event_kind, source_type, source_id),
|
||||
ADD CONSTRAINT financial_event_outbox_source_shape CHECK (
|
||||
source_type IN ('payment', 'payment_refund')
|
||||
AND jsonb_typeof(payload_snapshot) = 'object'
|
||||
AND (payload_snapshot->>'schema') IS NOT DISTINCT FROM 'financial-event-source/v1'
|
||||
AND (payload_snapshot->>'event_id') IS NOT DISTINCT FROM event_id
|
||||
AND (payload_snapshot->>'event_kind') IS NOT DISTINCT FROM event_kind
|
||||
AND (payload_snapshot->>'source_type') IS NOT DISTINCT FROM source_type
|
||||
AND (payload_snapshot->>'source_id') IS NOT DISTINCT FROM source_id::text
|
||||
AND (payload_snapshot->>'aggregate_id') IS NOT DISTINCT FROM aggregate_id::text
|
||||
AND (payload_snapshot - ARRAY[
|
||||
'schema', 'event_id', 'event_kind', 'source_type', 'source_id', 'aggregate_id',
|
||||
'occurred_at', 'atomic_amount', 'payment_type', 'product_id', 'provider_id',
|
||||
'receipt_count', 'refund_status'
|
||||
]) = '{}'::jsonb
|
||||
),
|
||||
ADD CONSTRAINT financial_event_outbox_delivery_shape CHECK (
|
||||
delivery_payload IS NULL
|
||||
OR (
|
||||
jsonb_typeof(delivery_payload) = 'object'
|
||||
AND (delivery_payload->>'schema') IS NOT DISTINCT FROM 'financial-event/v1'
|
||||
AND (delivery_payload->>'id') IS NOT DISTINCT FROM event_id
|
||||
)
|
||||
),
|
||||
ADD CONSTRAINT financial_event_outbox_status_check CHECK (
|
||||
status IN ('pending', 'in_flight', 'delivered', 'ignored', 'dead_letter')
|
||||
),
|
||||
ADD CONSTRAINT financial_event_outbox_state CHECK (
|
||||
(status = 'pending'
|
||||
AND lease_until IS NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NULL)
|
||||
OR (status = 'in_flight'
|
||||
AND lease_until IS NOT NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NULL)
|
||||
OR (status = 'delivered'
|
||||
AND lease_until IS NULL AND delivered_at IS NOT NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NULL)
|
||||
OR (status = 'ignored'
|
||||
AND lease_until IS NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NOT NULL AND ignored_reason IS NOT NULL
|
||||
AND dead_lettered_at IS NULL)
|
||||
OR (status = 'dead_letter'
|
||||
AND lease_until IS NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NOT NULL)
|
||||
);
|
||||
|
||||
-- The source snapshot and stable identity are immutable immediately. The delivery document has
|
||||
-- one allowed transition (NULL -> exact payload) so the worker can bind the deployment's mainnet
|
||||
-- asset/network/account reference before its first HTTP attempt. Retries must reuse that document.
|
||||
CREATE OR REPLACE FUNCTION reject_financial_event_identity_mutation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.event_kind IS DISTINCT FROM OLD.event_kind
|
||||
OR NEW.aggregate_id IS DISTINCT FROM OLD.aggregate_id
|
||||
OR NEW.source_type IS DISTINCT FROM OLD.source_type
|
||||
OR NEW.source_id IS DISTINCT FROM OLD.source_id
|
||||
OR NEW.event_id IS DISTINCT FROM OLD.event_id
|
||||
OR NEW.payload_snapshot IS DISTINCT FROM OLD.payload_snapshot
|
||||
OR (OLD.delivery_payload IS NOT NULL
|
||||
AND NEW.delivery_payload IS DISTINCT FROM OLD.delivery_payload)
|
||||
OR (OLD.delivery_payload IS NULL AND NEW.delivery_payload IS NOT NULL
|
||||
AND (OLD.status IS DISTINCT FROM 'in_flight'
|
||||
OR NEW.status IS DISTINCT FROM 'in_flight'))
|
||||
OR (OLD.delivery_payload IS NULL AND NEW.delivery_payload IS NULL
|
||||
AND NEW.status IN ('delivered'))
|
||||
THEN
|
||||
RAISE EXCEPTION 'financial event identity/payload is immutable for outbox row %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_financial_event_identity_immutable ON financial_event_outbox;
|
||||
CREATE TRIGGER trg_financial_event_identity_immutable
|
||||
BEFORE UPDATE ON financial_event_outbox
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_financial_event_identity_mutation();
|
||||
|
||||
DROP INDEX IF EXISTS idx_financial_event_outbox_due;
|
||||
CREATE INDEX idx_financial_event_outbox_due
|
||||
ON financial_event_outbox(next_attempt_at, created_at, id)
|
||||
WHERE status IN ('pending', 'in_flight');
|
||||
|
||||
CREATE INDEX idx_financial_event_outbox_terminal_health
|
||||
ON financial_event_outbox(status, updated_at)
|
||||
WHERE status IN ('dead_letter', 'ignored');
|
||||
Generated
+29
-416
@@ -8,15 +8,10 @@
|
||||
"name": "wownerogue",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.16.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.21.3",
|
||||
"node-pg-migrate": "^8.0.4",
|
||||
"nostr-tools": "^2.23.9",
|
||||
"pg": "^8.15.5",
|
||||
"qrcode": "^1.5.3",
|
||||
@@ -29,6 +24,10 @@
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"playwright-core": "^1.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -566,23 +565,6 @@
|
||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||
"strip-ansi": "^7.0.1",
|
||||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||
@@ -1335,30 +1317,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -1594,27 +1552,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion/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==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -1816,6 +1753,7 @@
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
@@ -1829,6 +1767,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1837,6 +1776,7 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
@@ -1850,12 +1790,14 @@
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
@@ -1869,6 +1811,7 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
@@ -1880,6 +1823,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
@@ -2018,6 +1962,7 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
@@ -2193,12 +2138,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -2223,12 +2162,6 @@
|
||||
"url": "https://github.com/sindresorhus/emittery?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -2400,6 +2333,7 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -2621,22 +2555,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
@@ -2770,29 +2688,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.3.1",
|
||||
"jackspeak": "^4.1.1",
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -3084,7 +2979,8 @@
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
@@ -3187,21 +3083,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
|
||||
@@ -3998,15 +3879,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
@@ -4134,30 +4006,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -4183,31 +4031,6 @@
|
||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/node-pg-migrate": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-8.0.4.tgz",
|
||||
"integrity": "sha512-HTlJ6fOT/2xHhAUtsqSN85PGMAqSbfGJNRwQF8+ZwQ1+sVGNUTl/ZGEshPsOI3yV22tPIyHXrKXr3S0JxeYLrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glob": "~11.1.0",
|
||||
"yargs": "~17.7.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-pg-migrate": "bin/node-pg-migrate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/pg": ">=6.0.0 <9.0.0",
|
||||
"pg": ">=4.3.0 <9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/pg": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
@@ -4356,12 +4179,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
@@ -4422,6 +4239,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -4432,22 +4250,6 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
@@ -5157,6 +4959,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
@@ -5168,6 +4971,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -5244,18 +5048,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
@@ -5500,102 +5292,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs": {
|
||||
"name": "string-width",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs": {
|
||||
"name": "strip-ansi",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
|
||||
@@ -5874,6 +5570,7 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
@@ -5889,97 +5586,6 @@
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs": {
|
||||
"name": "wrap-ansi",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -6046,6 +5652,7 @@
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -6061,6 +5668,7 @@
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
@@ -6078,6 +5686,7 @@
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -6086,6 +5695,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -6093,12 +5703,14 @@
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
@@ -6112,6 +5724,7 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
|
||||
+11
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wownerogue",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"description": "Block-timed browser roguelike with solo, Tavern, and configurable PvP modes for Wownero and Monero.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=production node index.js",
|
||||
@@ -34,13 +34,20 @@
|
||||
"simulated_blocks": false
|
||||
}
|
||||
},
|
||||
"author": "",
|
||||
"author": "Such Software <apps@such.software>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Such-Software/wownerogue.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Such-Software/wownerogue/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Such-Software/wownerogue#readme",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"axios": "^1.16.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.21.3",
|
||||
"node-pg-migrate": "^8.0.4",
|
||||
"nostr-tools": "^2.23.9",
|
||||
"pg": "^8.15.5",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -15,7 +15,7 @@ const { Pool } = require('pg');
|
||||
const DatabaseManager = require('../db/databaseManager');
|
||||
|
||||
const CONFIRM_PREFIX = 'MIGRATE_DISPOSABLE_CLONE:';
|
||||
const MINIMUM_RELEASE_MIGRATION = 40;
|
||||
const MINIMUM_RELEASE_MIGRATION = 42;
|
||||
const MIGRATIONS_PATH = path.join(__dirname, '..', 'migrations');
|
||||
const DATABASE_NAME = /^[a-z][a-z0-9_]{2,62}$/;
|
||||
const DATABASE_USER = /^[a-z_][a-z0-9_]{0,62}$/;
|
||||
|
||||
@@ -32,7 +32,9 @@ const REQUIRED_MIGRATIONS = Object.freeze([
|
||||
'037_payment_receipt_evidence.sql',
|
||||
'038_economic_identity_immutability.sql',
|
||||
'039_unresolved_solo_payout_review.sql',
|
||||
'040_paid_match_entropy_precommit.sql'
|
||||
'040_paid_match_entropy_precommit.sql',
|
||||
'041_financial_event_outbox.sql',
|
||||
'042_immutable_financial_event_snapshots.sql'
|
||||
]);
|
||||
|
||||
const SCENARIOS = Object.freeze({
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const { getProfile } = require('../chain/chainProfile');
|
||||
const money = require('../money/atomic');
|
||||
|
||||
const PLACEHOLDER = /(change[_-]?me|replace[_-]?with|example|password|secret)/i;
|
||||
const ACCOUNT_REF = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
||||
const DELIVERY_CONTRACT = Object.freeze({
|
||||
guarantee: 'at-least-once',
|
||||
deduplication: 'Idempotency-Key equals the immutable financial event id',
|
||||
acknowledgement: 'delivery is complete only after a 2xx response is durably recorded'
|
||||
});
|
||||
|
||||
function boundedInteger(value, fallback, min, max) {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
@@ -9,64 +17,132 @@ function boundedInteger(value, fallback, min, max) {
|
||||
}
|
||||
|
||||
function isoTimestamp(value) {
|
||||
if (value == null || (typeof value === 'string' && !value.trim())) {
|
||||
throw new Error('Financial event has no valid timestamp');
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) throw new Error('Confirmed payment has no valid timestamp');
|
||||
if (Number.isNaN(date.getTime())) throw new Error('Financial event has no valid timestamp');
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function receiptProvenance(receipts, paymentId) {
|
||||
const rows = Array.isArray(receipts) ? receipts : [];
|
||||
const normalized = rows.map((receipt) => ({
|
||||
kind: receipt.evidence_type === 'chain_output' ? 'monero-output' : 'provider-invoice',
|
||||
ref: String(receipt.evidence_id || '').slice(0, 500)
|
||||
})).filter((item) => item.ref);
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: [{ kind: 'wowngeon-payment', ref: String(paymentId) }];
|
||||
function safeText(value, maxLength = 300) {
|
||||
return String(value ?? '').slice(0, maxLength);
|
||||
}
|
||||
|
||||
function buildFinancialEvent(payment, env = process.env) {
|
||||
if (!payment || payment.id == null) throw new Error('Payment identity is required');
|
||||
const profile = getProfile(env.CRYPTO_TYPE || 'WOW');
|
||||
const atomic = money.toBig(payment.received_amount || payment.expected_amount || 0);
|
||||
if (atomic <= 0n) throw new Error('Confirmed payment has no positive received amount');
|
||||
const networkName = profile.id === 'WOW'
|
||||
? 'wownero:mainnet'
|
||||
: `monero:${String(env.MONERO_NETWORK || 'mainnet').toLowerCase()}`;
|
||||
const accountRef = String(env.FINANCIAL_EVENT_ACCOUNT_REF || 'wowngeon:receipts').trim();
|
||||
if (!accountRef || accountRef.length > 300) throw new Error('Invalid financial event account reference');
|
||||
function parseJsonObject(value, label) {
|
||||
let parsed = value;
|
||||
if (typeof parsed === 'string') {
|
||||
try { parsed = JSON.parse(parsed); } catch (_) { throw new Error(`${label} is not valid JSON`); }
|
||||
}
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error(`${label} is not an object`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sourceSnapshotFromPayment(payment) {
|
||||
if (!payment || payment.id == null) throw new Error('Payment identity is required');
|
||||
return {
|
||||
schema: 'financial-event-source/v1',
|
||||
event_id: `payment-confirmed-${payment.id}`,
|
||||
event_kind: 'payment.confirmed',
|
||||
source_type: 'payment',
|
||||
source_id: payment.id,
|
||||
aggregate_id: payment.id,
|
||||
occurred_at: payment.confirmed_at,
|
||||
atomic_amount: String(payment.received_amount ?? payment.expected_amount ?? 0),
|
||||
payment_type: safeText(payment.payment_type, 40),
|
||||
product_id: safeText(payment.product_id, 100),
|
||||
provider_id: safeText(payment.provider_id, 100),
|
||||
receipt_count: Array.isArray(payment.receipts) ? payment.receipts.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
function networkIdentity(profile, env) {
|
||||
const network = safeText(env.MONERO_NETWORK || 'mainnet', 30).trim().toLowerCase();
|
||||
return profile.id === 'WOW' ? 'wownero:mainnet' : `monero:${network}`;
|
||||
}
|
||||
|
||||
function buildFinancialEvent(sourceOrPayment, env = process.env) {
|
||||
const source = sourceOrPayment?.schema === 'financial-event-source/v1'
|
||||
? parseJsonObject(sourceOrPayment, 'Financial event source snapshot')
|
||||
: sourceSnapshotFromPayment(sourceOrPayment);
|
||||
if (!source.event_id || !source.event_kind || source.source_id == null) {
|
||||
throw new Error('Financial event source identity is incomplete');
|
||||
}
|
||||
|
||||
const profile = getProfile(env.CRYPTO_TYPE || 'WOW');
|
||||
const atomic = money.toBig(source.atomic_amount ?? 0);
|
||||
const isRefund = source.event_kind === 'payment.refund';
|
||||
if ((!isRefund && atomic <= 0n) || (isRefund && atomic < 0n)) {
|
||||
throw new Error('Financial event has an invalid atomic amount');
|
||||
}
|
||||
if (!isRefund && source.event_kind !== 'payment.confirmed') {
|
||||
throw new Error('Unsupported financial event kind');
|
||||
}
|
||||
|
||||
const accountRef = String(env.FINANCIAL_EVENT_ACCOUNT_REF || 'wowngeon:receipts').trim();
|
||||
if (!ACCOUNT_REF.test(accountRef)) throw new Error('Invalid non-PII financial event account reference');
|
||||
const refundStatus = safeText(source.refund_status, 20);
|
||||
if (isRefund && !['recorded', 'completed'].includes(refundStatus)) {
|
||||
throw new Error('Refund snapshot is not in a final exportable state');
|
||||
}
|
||||
|
||||
const sourceRef = isRefund
|
||||
? `payment-refund-${source.source_id}`
|
||||
: `payment-${source.aggregate_id}`;
|
||||
const event = {
|
||||
schema: 'financial-event/v1',
|
||||
id: `payment-${payment.id}`,
|
||||
occurred_at: isoTimestamp(payment.confirmed_at),
|
||||
id: safeText(source.event_id, 120),
|
||||
occurred_at: isoTimestamp(source.occurred_at),
|
||||
producer: 'wowngeon',
|
||||
product: 'wowngeon',
|
||||
activity: 'customer-payment',
|
||||
activity: isRefund
|
||||
? (refundStatus === 'completed' ? 'customer-refund' : 'customer-payment-reversal')
|
||||
: 'customer-payment',
|
||||
account_refs: [accountRef],
|
||||
provenance: receiptProvenance(payment.receipts, payment.id),
|
||||
provenance: [{
|
||||
kind: isRefund ? 'wowngeon-refund' : 'wowngeon-payment',
|
||||
ref: sourceRef
|
||||
}],
|
||||
amounts: {},
|
||||
legs: [{
|
||||
asset: profile.symbol,
|
||||
quantity: money.format(atomic, profile.decimals),
|
||||
direction: 'in',
|
||||
network: networkName,
|
||||
direction: isRefund ? 'out' : 'in',
|
||||
network: networkIdentity(profile, env),
|
||||
account_ref: accountRef,
|
||||
usd_value: '0',
|
||||
price_source: '',
|
||||
price_confidence: ''
|
||||
}],
|
||||
memo: 'Confirmed customer payment; USD valuation is required before accounting confirmation.',
|
||||
memo: isRefund
|
||||
? 'Final customer refund/reversal; USD valuation is required before accounting confirmation.'
|
||||
: 'Confirmed customer payment; USD valuation is required before accounting confirmation.',
|
||||
attributes: {
|
||||
valuation_required: true,
|
||||
atomic_amount: atomic.toString(),
|
||||
atomic_decimals: profile.decimals,
|
||||
payment_type: String(payment.payment_type || ''),
|
||||
product_id: String(payment.product_id || ''),
|
||||
provider_id: String(payment.provider_id || ''),
|
||||
receipt_count: Array.isArray(payment.receipts) ? payment.receipts.length : 0
|
||||
source_type: safeText(source.source_type, 32),
|
||||
source_id: safeText(source.source_id, 40),
|
||||
aggregate_id: safeText(source.aggregate_id, 40)
|
||||
}
|
||||
};
|
||||
|
||||
if (isRefund) {
|
||||
event.attributes.refund_status = refundStatus;
|
||||
} else {
|
||||
event.attributes.payment_type = safeText(source.payment_type, 40);
|
||||
event.attributes.product_id = safeText(source.product_id, 100);
|
||||
event.attributes.provider_id = safeText(source.provider_id, 100);
|
||||
event.attributes.receipt_count = boundedInteger(source.receipt_count, 0, 0, 1000000);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
function countValue(row, name) {
|
||||
const value = Number.parseInt(String(row?.[name] ?? '0'), 10);
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
class FinancialEventExporter {
|
||||
@@ -81,8 +157,30 @@ class FinancialEventExporter {
|
||||
this.enabled = Boolean(this.endpoint || this.token);
|
||||
this.intervalMs = boundedInteger(env.FINANCIAL_EVENT_POLL_MS, 60000, 5000, 3600000);
|
||||
this.batchSize = boundedInteger(env.FINANCIAL_EVENT_BATCH_SIZE, 20, 1, 100);
|
||||
this.maxAttempts = boundedInteger(env.FINANCIAL_EVENT_MAX_ATTEMPTS, 8, 1, 100);
|
||||
this.running = false;
|
||||
this.timer = null;
|
||||
this.profile = getProfile(env.CRYPTO_TYPE || 'WOW');
|
||||
this.network = safeText(env.MONERO_NETWORK || 'mainnet', 30).trim().toLowerCase();
|
||||
this.exportAllowed = this.network === 'mainnet';
|
||||
this.health = {
|
||||
contract: DELIVERY_CONTRACT.guarantee,
|
||||
enabled: this.enabled,
|
||||
exportAllowed: this.exportAllowed,
|
||||
running: false,
|
||||
runs: 0,
|
||||
attempted: 0,
|
||||
delivered: 0,
|
||||
deferred: 0,
|
||||
deadLettered: 0,
|
||||
ignored: 0,
|
||||
backlog: 0,
|
||||
deadLetters: 0,
|
||||
ignoredRows: 0,
|
||||
lastRunAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null
|
||||
};
|
||||
|
||||
if (Boolean(this.endpoint) !== Boolean(this.token)) {
|
||||
throw new Error('Financial event sink URL and token must be configured together');
|
||||
@@ -95,14 +193,66 @@ class FinancialEventExporter {
|
||||
|| (String(env.NODE_ENV).toLowerCase() === 'production' && parsed.protocol !== 'https:')) {
|
||||
throw new Error('Financial event sink must use HTTPS in production');
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error('Financial event sink URL must not embed credentials');
|
||||
}
|
||||
if (this.token.length < 32 || PLACEHOLDER.test(this.token)) {
|
||||
throw new Error('Financial event sink token must be a strong non-placeholder secret');
|
||||
}
|
||||
const profile = getProfile(env.CRYPTO_TYPE || 'WOW');
|
||||
const network = String(env.MONERO_NETWORK || 'mainnet').toLowerCase();
|
||||
if ((profile.id === 'XMR' && network !== 'mainnet') || profile.id === 'WOW' && network !== 'mainnet') {
|
||||
throw new Error('Testnet and stagenet payments must not be exported as financial events');
|
||||
}
|
||||
}
|
||||
|
||||
getDeliveryContract() {
|
||||
return { ...DELIVERY_CONTRACT };
|
||||
}
|
||||
|
||||
getHealth() {
|
||||
return { ...this.health, running: this.running };
|
||||
}
|
||||
|
||||
// Safe for the unauthenticated liveness payload: no endpoint, token, account reference,
|
||||
// row/event identifiers, error text, or timestamps. These counters are informational and
|
||||
// deliberately do not participate in application readiness.
|
||||
getPublicHealth() {
|
||||
const health = this.getHealth();
|
||||
return {
|
||||
contract: DELIVERY_CONTRACT.guarantee,
|
||||
enabled: health.enabled,
|
||||
exportAllowed: health.exportAllowed,
|
||||
running: health.running,
|
||||
backlog: health.backlog,
|
||||
deadLetters: health.deadLetters,
|
||||
ignoredRows: health.ignoredRows,
|
||||
delivered: health.delivered,
|
||||
deferred: health.deferred,
|
||||
deadLettered: health.deadLettered,
|
||||
ignored: health.ignored
|
||||
};
|
||||
}
|
||||
|
||||
async _suppressUnsafeNetwork() {
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'ignored', lease_until = NULL, delivered_at = NULL,
|
||||
ignored_at = NOW(), ignored_reason = 'non_mainnet_network',
|
||||
dead_lettered_at = NULL, last_error = NULL, updated_at = NOW()
|
||||
WHERE status IN ('pending', 'in_flight', 'dead_letter')
|
||||
RETURNING id
|
||||
`);
|
||||
return result.rowCount ?? result.rows?.length ?? 0;
|
||||
}
|
||||
|
||||
async _deadLetterExhausted() {
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'dead_letter', lease_until = NULL,
|
||||
dead_lettered_at = COALESCE(dead_lettered_at, NOW()),
|
||||
last_error = COALESCE(last_error, 'Maximum delivery attempts exhausted'),
|
||||
updated_at = NOW()
|
||||
WHERE attempts >= $1
|
||||
AND (status = 'pending' OR (status = 'in_flight' AND lease_until < NOW()))
|
||||
RETURNING id
|
||||
`, [this.maxAttempts]);
|
||||
return result.rowCount ?? result.rows?.length ?? 0;
|
||||
}
|
||||
|
||||
async _claim() {
|
||||
@@ -114,41 +264,50 @@ class FinancialEventExporter {
|
||||
SELECT id FROM financial_event_outbox
|
||||
WHERE delivered_at IS NULL
|
||||
AND next_attempt_at <= NOW()
|
||||
AND attempts < $1
|
||||
AND (status = 'pending' OR (status = 'in_flight' AND lease_until < NOW()))
|
||||
ORDER BY created_at, id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, aggregate_id, attempts
|
||||
`);
|
||||
RETURNING id, event_id, event_kind, source_type, source_id, aggregate_id,
|
||||
payload_snapshot, delivery_payload, attempts
|
||||
`, [this.maxAttempts]);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async _loadPayment(paymentId) {
|
||||
async _ensureDeliveryPayload(claim) {
|
||||
if (claim.delivery_payload) {
|
||||
const existing = parseJsonObject(claim.delivery_payload, 'Stored financial event payload');
|
||||
if (existing.id !== claim.event_id) throw new Error('Stored financial event id is inconsistent');
|
||||
return existing;
|
||||
}
|
||||
|
||||
const snapshot = parseJsonObject(claim.payload_snapshot, 'Financial event source snapshot');
|
||||
if (snapshot.event_id !== claim.event_id || snapshot.event_kind !== claim.event_kind) {
|
||||
throw new Error('Financial event source identity is inconsistent');
|
||||
}
|
||||
const payload = buildFinancialEvent(snapshot, this.env);
|
||||
const result = await this.db.query(`
|
||||
SELECT p.id, p.payment_type, p.expected_amount, p.received_amount,
|
||||
p.confirmed_at, p.provider_id, p.product_id,
|
||||
COALESCE(json_agg(json_build_object(
|
||||
'evidence_type', r.evidence_type,
|
||||
'evidence_id', r.evidence_id
|
||||
) ORDER BY r.id) FILTER (WHERE r.id IS NOT NULL), '[]'::json) AS receipts
|
||||
FROM payments p
|
||||
LEFT JOIN payment_receipts r ON r.payment_id = p.id AND r.confirmed = TRUE
|
||||
WHERE p.id = $1 AND p.status = 'confirmed'
|
||||
GROUP BY p.id
|
||||
`, [paymentId]);
|
||||
if (!result.rows[0]) throw new Error('Outbox payment is missing or no longer confirmed');
|
||||
return result.rows[0];
|
||||
UPDATE financial_event_outbox
|
||||
SET delivery_payload = $2::jsonb, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'in_flight' AND delivery_payload IS NULL
|
||||
RETURNING delivery_payload
|
||||
`, [claim.id, JSON.stringify(payload)]);
|
||||
if (!result.rows[0]?.delivery_payload) {
|
||||
throw new Error('Financial event payload could not be durably initialized');
|
||||
}
|
||||
return parseJsonObject(result.rows[0].delivery_payload, 'Stored financial event payload');
|
||||
}
|
||||
|
||||
async _deliver(claim) {
|
||||
const payment = await this._loadPayment(claim.aggregate_id);
|
||||
const payload = buildFinancialEvent(payment, this.env);
|
||||
const payload = await this._ensureDeliveryPayload(claim);
|
||||
const response = await this.fetchFn(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${this.token}`,
|
||||
'content-type': 'application/json'
|
||||
'content-type': 'application/json',
|
||||
'idempotency-key': claim.event_id
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
redirect: 'manual',
|
||||
@@ -157,52 +316,128 @@ class FinancialEventExporter {
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(`Financial event sink returned HTTP ${response?.status || 0}`);
|
||||
}
|
||||
await this.db.query(`
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'delivered', delivered_at = NOW(), lease_until = NULL,
|
||||
last_error = NULL, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND status = 'in_flight'
|
||||
RETURNING id
|
||||
`, [claim.id]);
|
||||
if ((result.rowCount ?? result.rows?.length ?? 0) !== 1) {
|
||||
throw new Error('Financial event delivery acknowledgement was not durably recorded');
|
||||
}
|
||||
}
|
||||
|
||||
async _retry(claim, error) {
|
||||
const message = safeText(error?.message || 'Financial event delivery failed', 500);
|
||||
if (Number(claim.attempts || 0) >= this.maxAttempts) {
|
||||
const result = await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'dead_letter', lease_until = NULL,
|
||||
dead_lettered_at = NOW(), last_error = $2, updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'in_flight'
|
||||
RETURNING id
|
||||
`, [claim.id, message]);
|
||||
const changed = (result.rowCount ?? result.rows?.length ?? 0) === 1;
|
||||
if (changed) this.logger.warn?.(`[FinancialEvent] Delivery ${claim.id} moved to dead letter: ${message}`);
|
||||
return { deadLettered: changed };
|
||||
}
|
||||
|
||||
const seconds = Math.min(3600, 30 * (2 ** Math.min(Number(claim.attempts || 1) - 1, 7)));
|
||||
const message = String(error?.message || 'Financial event delivery failed').slice(0, 500);
|
||||
await this.db.query(`
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'pending', lease_until = NULL,
|
||||
next_attempt_at = NOW() + ($2::integer * INTERVAL '1 second'),
|
||||
last_error = $3, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND status = 'in_flight'
|
||||
`, [claim.id, seconds, message]);
|
||||
this.logger.warn?.(`[FinancialEvent] Delivery ${claim.id} deferred: ${message}`);
|
||||
return { deadLettered: false };
|
||||
}
|
||||
|
||||
async _refreshHealth() {
|
||||
const result = await this.db.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status IN ('pending', 'in_flight')) AS backlog,
|
||||
COUNT(*) FILTER (WHERE status = 'dead_letter') AS dead_letters,
|
||||
COUNT(*) FILTER (WHERE status = 'ignored') AS ignored_rows
|
||||
FROM financial_event_outbox
|
||||
`);
|
||||
const row = result.rows[0] || {};
|
||||
this.health.backlog = countValue(row, 'backlog');
|
||||
this.health.deadLetters = countValue(row, 'dead_letters');
|
||||
this.health.ignoredRows = countValue(row, 'ignored_rows');
|
||||
}
|
||||
|
||||
async runOnce() {
|
||||
if (!this.enabled || this.running) return { skipped: true, delivered: 0, deferred: 0 };
|
||||
if (this.running) {
|
||||
return { skipped: true, delivered: 0, deferred: 0, deadLettered: 0, ignored: 0 };
|
||||
}
|
||||
this.running = true;
|
||||
this.health.running = true;
|
||||
this.health.runs += 1;
|
||||
this.health.lastRunAt = new Date().toISOString();
|
||||
let delivered = 0;
|
||||
let deferred = 0;
|
||||
let deadLettered = 0;
|
||||
let ignored = 0;
|
||||
let succeeded = false;
|
||||
let healthRefreshed = true;
|
||||
try {
|
||||
if (!this.exportAllowed) {
|
||||
ignored = await this._suppressUnsafeNetwork();
|
||||
this.health.ignored += ignored;
|
||||
succeeded = true;
|
||||
return { skipped: false, delivered, deferred, deadLettered, ignored };
|
||||
}
|
||||
if (!this.enabled) {
|
||||
succeeded = true;
|
||||
return { skipped: true, delivered, deferred, deadLettered, ignored };
|
||||
}
|
||||
|
||||
deadLettered += await this._deadLetterExhausted();
|
||||
for (let index = 0; index < this.batchSize; index += 1) {
|
||||
const claim = await this._claim();
|
||||
if (!claim) break;
|
||||
this.health.attempted += 1;
|
||||
try {
|
||||
await this._deliver(claim);
|
||||
delivered += 1;
|
||||
} catch (error) {
|
||||
await this._retry(claim, error);
|
||||
deferred += 1;
|
||||
const retry = await this._retry(claim, error);
|
||||
if (retry.deadLettered) deadLettered += 1;
|
||||
else deferred += 1;
|
||||
}
|
||||
}
|
||||
return { skipped: false, delivered, deferred };
|
||||
this.health.delivered += delivered;
|
||||
this.health.deferred += deferred;
|
||||
this.health.deadLettered += deadLettered;
|
||||
succeeded = true;
|
||||
return { skipped: false, delivered, deferred, deadLettered, ignored };
|
||||
} catch (error) {
|
||||
this.health.lastError = safeText(error?.message || error, 500);
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await this._refreshHealth();
|
||||
} catch (healthError) {
|
||||
healthRefreshed = false;
|
||||
this.health.lastError = safeText(healthError?.message || healthError, 500);
|
||||
this.logger.warn?.(`[FinancialEvent] Health counters unavailable: ${this.health.lastError}`);
|
||||
}
|
||||
if (succeeded && healthRefreshed) {
|
||||
this.health.lastSuccessAt = new Date().toISOString();
|
||||
this.health.lastError = null;
|
||||
}
|
||||
this.running = false;
|
||||
this.health.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.enabled || this.timer) return false;
|
||||
// Poll even without a sink: public health must show a real backlog, and non-mainnet
|
||||
// deployments must continuously suppress newly appended rows.
|
||||
if (this.timer) return false;
|
||||
this.runOnce().catch((error) => this.logger.warn?.(`[FinancialEvent] Export tick failed: ${error.message}`));
|
||||
this.timer = setInterval(() => {
|
||||
this.runOnce().catch((error) => this.logger.warn?.(`[FinancialEvent] Export tick failed: ${error.message}`));
|
||||
@@ -219,3 +454,5 @@ class FinancialEventExporter {
|
||||
|
||||
module.exports = FinancialEventExporter;
|
||||
module.exports.buildFinancialEvent = buildFinancialEvent;
|
||||
module.exports.sourceSnapshotFromPayment = sourceSnapshotFromPayment;
|
||||
module.exports.DELIVERY_CONTRACT = DELIVERY_CONTRACT;
|
||||
|
||||
+16
-2
@@ -31,11 +31,24 @@ function modeSummary(disclosure) {
|
||||
items.push('<li><strong>Reward-enabled PvP:</strong> the configured race may award the collected pot after the displayed operator fee. Other PvP economies remain separate.</li>');
|
||||
}
|
||||
if (service.isTestNetwork) {
|
||||
items.push(`<li><strong>Test network:</strong> ${escapeHtml(service.currencyLabel)} is test currency with no intended monetary value. Never send ${escapeHtml(service.cryptoType)} mainnet funds to a test-network address.</li>`);
|
||||
const testNetworkNotice = disclosure.operatedProduct?.noRealValueNotice
|
||||
|| `${service.currencyLabel} is test currency with no intended monetary value. Never send ${service.cryptoType} mainnet funds to a test-network address.`;
|
||||
items.push(`<li><strong>Test network:</strong> ${escapeHtml(testNetworkNotice)}</li>`);
|
||||
}
|
||||
return items.length ? `<ul>${items.join('')}</ul>` : '<p>This instance currently exposes no paid play.</p>';
|
||||
}
|
||||
|
||||
function operatorAndSoftwareStatus(disclosure) {
|
||||
const operated = disclosure.operatedProduct;
|
||||
const software = disclosure.software || {};
|
||||
const relationship = operated
|
||||
? `<p><strong>Operated-product scope:</strong> ${escapeHtml(operated.scopeNotice)}</p>${operated.noRealValueNotice
|
||||
? `<p class="no-real-value"><strong>${escapeHtml(operated.noRealValueNotice)}</strong></p>`
|
||||
: ''}<p>${escapeHtml(software.operatedBoundaryNotice)}</p><p>${escapeHtml(software.thirdPartyNotice)}</p>`
|
||||
: `<p><strong>Independent deployment:</strong> ${escapeHtml(software.thirdPartyNotice)}</p><p>${escapeHtml(software.operatedBoundaryNotice)}</p>`;
|
||||
return `<section class="notice"><h2>Operator and open-source status</h2>${relationship}<p><strong>${escapeHtml(software.license || 'MIT')} licence:</strong> ${escapeHtml(software.rightsNotice)}</p><p>${escapeHtml(software.warrantyNotice)}</p><p>${escapeHtml(software.legalAdviceNotice)}</p></section>`;
|
||||
}
|
||||
|
||||
function shell({ title, disclosure, body }) {
|
||||
const operator = escapeHtml(disclosure.operator?.name || 'Site operator');
|
||||
const gameName = escapeHtml(disclosure.service?.gameName || 'Wowngeon');
|
||||
@@ -54,6 +67,7 @@ function shell({ title, disclosure, body }) {
|
||||
<main>
|
||||
<nav aria-label="Legal and service navigation"><a href="/">Game</a><a href="/terms">Terms</a><a href="/privacy">Privacy</a><a href="/responsible-play">Responsible play</a></nav>
|
||||
<header><p class="eyebrow">${operator}</p><h1>${escapeHtml(title)}</h1><p>${effective} · policy ${escapeHtml(disclosure.policyVersion)}</p></header>
|
||||
${operatorAndSoftwareStatus(disclosure)}
|
||||
${body}
|
||||
<section class="notice"><h2>Important status</h2><p>${escapeHtml(disclosure.notices?.legalReview)}</p><p>${escapeHtml(disclosure.notices?.identityControl)}</p></section>
|
||||
<footer><p>Operator contact: ${contactMarkup(disclosure)}</p></footer>
|
||||
@@ -95,7 +109,7 @@ function renderPrivacy(disclosure) {
|
||||
|
||||
function renderResponsiblePlay(disclosure) {
|
||||
const network = disclosure.service?.isTestNetwork
|
||||
? `<p class="notice"><strong>Test-network reminder:</strong> ${escapeHtml(disclosure.service.currencyLabel)} is for testing and has no intended monetary value. Never substitute mainnet funds.</p>`
|
||||
? `<p class="no-real-value"><strong>Test-network reminder:</strong> ${escapeHtml(disclosure.operatedProduct?.noRealValueNotice || `${disclosure.service.currencyLabel} is for testing and has no intended monetary value. Never substitute mainnet funds.`)}</p>`
|
||||
: '';
|
||||
return shell({
|
||||
title: 'Responsible Play',
|
||||
|
||||
@@ -161,11 +161,11 @@ describe('disposable clone migration execution', () => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('discovers the contiguous repository manifest through migration 41 dynamically', () => {
|
||||
test('discovers the contiguous repository manifest through migration 42 dynamically', () => {
|
||||
const manifest = discoverMigrationManifest();
|
||||
expect(manifest.latestOrdinal).toBeGreaterThanOrEqual(MINIMUM_RELEASE_MIGRATION);
|
||||
expect(manifest.latestFilename).toBe('041_financial_event_outbox.sql');
|
||||
expect(manifest.files).toHaveLength(41);
|
||||
expect(manifest.latestFilename).toBe('042_immutable_financial_event_snapshots.sql');
|
||||
expect(manifest.files).toHaveLength(42);
|
||||
});
|
||||
|
||||
test('runs the normal ordered migration implementation and proves the exact ledger', async () => {
|
||||
@@ -185,9 +185,9 @@ describe('disposable clone migration execution', () => {
|
||||
});
|
||||
|
||||
expect(pool.transactions).toEqual(result.files);
|
||||
expect(result.latestFilename).toBe('041_financial_event_outbox.sql');
|
||||
expect(result.latestFilename).toBe('042_immutable_financial_event_snapshots.sql');
|
||||
expect(output).toContain('clone_migration_status=ok');
|
||||
expect(output).toContain('latest_migration=041_financial_event_outbox.sql');
|
||||
expect(output).toContain('latest_migration=042_immutable_financial_event_snapshots.sql');
|
||||
expect(output.join('\n')).not.toContain(secret);
|
||||
expect(pool.config).toEqual(expect.objectContaining({
|
||||
database: env.DB_NAME,
|
||||
|
||||
@@ -53,6 +53,64 @@ describe('commerce disclosure and paid-action policy', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
test('publishes the immutable play.wowne.ro scope and Such Software identity', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager({
|
||||
cryptoType: 'WOW',
|
||||
currencyLabel: 'WOW',
|
||||
network: 'mainnet',
|
||||
isTestNetwork: false,
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: true,
|
||||
payoutsEnabled: false,
|
||||
isPayoutEnabledForMode: () => false
|
||||
}), env({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
|
||||
OPERATOR_NAME: 'untrusted override',
|
||||
OPERATOR_CONTACT_URL: 'mailto:untrusted@example.invalid'
|
||||
}));
|
||||
|
||||
expect(disclosure.operator).toEqual({
|
||||
name: 'Such Software',
|
||||
contactUrl: 'mailto:apps@such.software',
|
||||
contactLabel: 'apps@such.software'
|
||||
});
|
||||
expect(disclosure.operatedProduct).toEqual(expect.objectContaining({
|
||||
id: 'such-play-wow-prestige',
|
||||
hostname: 'play.wowne.ro',
|
||||
noRealValueNotice: null
|
||||
}));
|
||||
expect(disclosure.operatedProduct.scopeNotice).toContain('pay-for-credits leaderboard/prestige');
|
||||
expect(disclosure.operatedProduct.scopeNotice).toContain('offers no prizes, winnings, cash-out, or payouts');
|
||||
expect(disclosure.operatedProduct.scopeNotice).toContain('not offered or marketed as gambling');
|
||||
expect(disclosure.operatedProduct.scopeNotice).toContain('legal classification depends on applicable law');
|
||||
});
|
||||
|
||||
test('publishes conspicuous no-real-value monerogue.app scope and MIT operator boundary', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager(), env({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet'
|
||||
}));
|
||||
|
||||
expect(disclosure.operatedProduct.scopeNotice).toContain('single-player 2×/3× outcomes are test gambling mechanics');
|
||||
expect(disclosure.operatedProduct.noRealValueNotice).toContain('NO REAL VALUE');
|
||||
expect(disclosure.operatedProduct.commerceSummary).toContain('never send mainnet XMR');
|
||||
expect(disclosure.software).toEqual(expect.objectContaining({
|
||||
license: 'MIT',
|
||||
publisherName: 'Such Software'
|
||||
}));
|
||||
expect(disclosure.software.rightsNotice).toContain('copyright and permission notice');
|
||||
expect(disclosure.software.warrantyNotice).toContain('AS IS');
|
||||
expect(disclosure.software.legalAdviceNotice).toContain('not legal advice');
|
||||
expect(disclosure.software.thirdPartyNotice).toContain('solely responsible');
|
||||
expect(disclosure.software.thirdPartyNotice).toContain('does not operate, supervise, endorse, or accept responsibility');
|
||||
});
|
||||
|
||||
test('an unprofiled deployment remains generic and is not represented as Such-operated', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager(), env());
|
||||
expect(disclosure.operatedProduct).toBeNull();
|
||||
expect(disclosure.operator.name).toBe('Test Operator');
|
||||
expect(disclosure.software.operatedBoundaryNotice).toContain('operates only play.wowne.ro and monerogue.app');
|
||||
});
|
||||
|
||||
test('describes paid prestige separately when every payout path is off', () => {
|
||||
const disclosure = buildCommerceDisclosure(manager({
|
||||
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
|
||||
|
||||
@@ -107,6 +107,291 @@ describe('EnvironmentValidator production launch gate', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
test('accepts a complete HTTPS mainnet financial-event sink without exposing its token', () => {
|
||||
const token = 'A1b2C3d4E5f6G7h8J9k0L1m2N3p4Q5r6';
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/v1/events',
|
||||
FINANCIAL_EVENT_SINK_TOKEN: token,
|
||||
FINANCIAL_EVENT_ACCOUNT_REF: 'wowngeon:receipts'
|
||||
}),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(JSON.stringify(result)).not.toContain(token);
|
||||
});
|
||||
|
||||
test('rejects partial, insecure, weak, credential-bearing, or testnet accounting export', () => {
|
||||
const token = 'A1b2C3d4E5f6G7h8J9k0L1m2N3p4Q5r6';
|
||||
const cases = [
|
||||
[
|
||||
{ FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events' },
|
||||
'must be configured together'
|
||||
],
|
||||
[
|
||||
{
|
||||
FINANCIAL_EVENT_SINK_URL: 'http://ledger.operator.invalid/events',
|
||||
FINANCIAL_EVENT_SINK_TOKEN: token,
|
||||
MONERO_NETWORK: 'mainnet'
|
||||
},
|
||||
'must use https:// in production'
|
||||
],
|
||||
[
|
||||
{
|
||||
FINANCIAL_EVENT_SINK_URL: 'https://user:pass@ledger.operator.invalid/events',
|
||||
FINANCIAL_EVENT_SINK_TOKEN: token,
|
||||
MONERO_NETWORK: 'mainnet'
|
||||
},
|
||||
'must not embed credentials'
|
||||
],
|
||||
[
|
||||
{
|
||||
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events',
|
||||
FINANCIAL_EVENT_SINK_TOKEN: 'change-me'
|
||||
},
|
||||
'strong non-placeholder secret'
|
||||
],
|
||||
[
|
||||
{
|
||||
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events',
|
||||
FINANCIAL_EVENT_SINK_TOKEN: token
|
||||
},
|
||||
'must be unset on non-mainnet networks'
|
||||
],
|
||||
[
|
||||
{
|
||||
FINANCIAL_EVENT_SINK_URL: 'https://ledger.operator.invalid/events',
|
||||
FINANCIAL_EVENT_SINK_TOKEN: token,
|
||||
FINANCIAL_EVENT_ACCOUNT_REF: 'customer@example.invalid',
|
||||
MONERO_NETWORK: 'mainnet'
|
||||
},
|
||||
'must be a non-PII identifier'
|
||||
]
|
||||
];
|
||||
|
||||
for (const [overrides, expected] of cases) {
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv(overrides), logger: silentLogger
|
||||
}).validate(config());
|
||||
expect(result.errors).toEqual(expect.arrayContaining([expect.stringContaining(expected)]));
|
||||
expect(JSON.stringify(result)).not.toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts the exact Such Software play.wowne.ro operated-product contract', () => {
|
||||
const env = productionEnv({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
|
||||
HOSTED_BY: 'https://play.wowne.ro',
|
||||
OPERATOR_NAME: 'Such Software',
|
||||
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
|
||||
OPERATOR_CONTACT_LABEL: 'apps@such.software',
|
||||
CRYPTO_TYPE: 'WOW',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
PAYMENT_MODES: 'credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'false',
|
||||
CREDITS_ENABLED: 'true',
|
||||
PAYOUTS_ENABLED: 'false',
|
||||
DIRECT_PAYOUTS_ENABLED: 'false',
|
||||
CREDITS_PAYOUTS_ENABLED: 'false',
|
||||
ALLOW_MAINNET_PAYOUTS: 'false',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'false',
|
||||
MATCH_PAYOUTS_ENABLED: 'false'
|
||||
});
|
||||
const operatedConfig = config({
|
||||
currency: { symbol: 'WOW' },
|
||||
modes: {
|
||||
direct: { enabled: false, price: 10000n },
|
||||
credits: {
|
||||
enabled: true,
|
||||
packages: [{ id: 'small', credits: 10, price: 90000000000n }]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.assertValid(operatedConfig).errors).toEqual([]);
|
||||
|
||||
expect(new EnvironmentValidator({ env, logger: silentLogger }).validate({
|
||||
...operatedConfig,
|
||||
products: { cosmetic: [{ id: 'unrelated-pack', price: 1n }] }
|
||||
}).errors).toContain(
|
||||
'OPERATED_PRODUCT_PROFILE=such-play-wow-prestige requires no standalone products or bundled entitlement grants; credit packages may grant only their top-level credits and bonus.'
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts the exact Such Software monerogue.app stagenet 2x/3x contract', () => {
|
||||
const env = productionEnv({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet',
|
||||
HOSTED_BY: 'https://monerogue.app',
|
||||
OPERATOR_NAME: 'Such Software',
|
||||
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
|
||||
OPERATOR_CONTACT_LABEL: 'apps@such.software',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
PAYMENT_MODES: 'direct,credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'true',
|
||||
CREDITS_ENABLED: 'true',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
CREDITS_PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUT_ESCAPE: '2.0',
|
||||
DIRECT_PAYOUT_TREASURE: '3.0',
|
||||
CREDITS_PAYOUT_ESCAPE: '2.0',
|
||||
CREDITS_PAYOUT_TREASURE: '3.0',
|
||||
ALLOW_MAINNET_PAYOUTS: 'false',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'false',
|
||||
MATCH_PAYOUTS_ENABLED: 'false'
|
||||
});
|
||||
const operatedConfig = config({
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
direct: { enabled: true },
|
||||
credits: { enabled: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.assertValid(operatedConfig).errors).toEqual([]);
|
||||
|
||||
const outOfScopeCatalogs = [
|
||||
{
|
||||
...operatedConfig,
|
||||
products: { cosmetic: [{ id: 'pack', price: 20000n }] }
|
||||
},
|
||||
...[
|
||||
{ packs: ['kenney-3d-characters'] },
|
||||
{ premiumLevel: 'supporter' },
|
||||
{ race_entries: 2, race_entry_value_atomic: '10000' },
|
||||
{ future_entitlement: true },
|
||||
{ credits: 10 }
|
||||
].map(grants => ({
|
||||
...operatedConfig,
|
||||
modes: {
|
||||
...operatedConfig.modes,
|
||||
credits: {
|
||||
...operatedConfig.modes.credits,
|
||||
packages: [{ id: 'small', credits: 10, price: 90000000000n, grants }]
|
||||
}
|
||||
}
|
||||
}))
|
||||
];
|
||||
for (const widenedCatalog of outOfScopeCatalogs) {
|
||||
expect(new EnvironmentValidator({ env, logger: silentLogger })
|
||||
.validate(widenedCatalog).errors).toContain(
|
||||
'OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet requires no standalone products or bundled entitlement grants; credit packages may grant only their top-level credits and bonus.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('play.wowne.ro profile rejects direct entry or any payout activation', () => {
|
||||
const env = productionEnv({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
|
||||
HOSTED_BY: 'https://play.wowne.ro',
|
||||
OPERATOR_NAME: 'Such Software',
|
||||
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
|
||||
OPERATOR_CONTACT_LABEL: 'apps@such.software',
|
||||
CRYPTO_TYPE: 'WOW',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
PAYMENT_MODES: 'direct,credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'true',
|
||||
CREDITS_ENABLED: 'true',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
CREDITS_PAYOUTS_ENABLED: 'false',
|
||||
ALLOW_MAINNET_PAYOUTS: 'false',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'false',
|
||||
MATCH_PAYOUTS_ENABLED: 'false'
|
||||
});
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({
|
||||
currency: { symbol: 'WOW' },
|
||||
payouts: { enabled: true, rules: { direct: { enabled: true } } }
|
||||
}));
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('requires PAYMENT_MODES=credits'),
|
||||
expect.stringContaining('requires DIRECT_PAYMENT_ENABLED=false'),
|
||||
expect.stringContaining('requires PAYOUTS_ENABLED=false'),
|
||||
expect.stringContaining('requires DIRECT_PAYOUTS_ENABLED=false')
|
||||
]));
|
||||
});
|
||||
|
||||
test('operated profiles fail closed on identity, network, mode, multiplier, or payout drift', () => {
|
||||
const env = productionEnv({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet',
|
||||
HOSTED_BY: 'https://play.wowne.ro',
|
||||
OPERATOR_NAME: 'Someone Else',
|
||||
OPERATOR_CONTACT_URL: 'mailto:other@example.invalid',
|
||||
OPERATOR_CONTACT_LABEL: 'Other',
|
||||
CRYPTO_TYPE: 'XMR',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
PAYMENT_MODES: 'direct,credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'true',
|
||||
CREDITS_ENABLED: 'true',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
CREDITS_PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUT_ESCAPE: '4',
|
||||
DIRECT_PAYOUT_TREASURE: '3',
|
||||
CREDITS_PAYOUT_ESCAPE: '2',
|
||||
CREDITS_PAYOUT_TREASURE: '3',
|
||||
ALLOW_MAINNET_PAYOUTS: 'true',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'true',
|
||||
MATCH_PAYOUTS_ENABLED: 'true'
|
||||
});
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config({
|
||||
payouts: {
|
||||
enabled: true,
|
||||
rules: { direct: { enabled: true }, credits: { enabled: true } }
|
||||
}
|
||||
}));
|
||||
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('requires HOSTED_BY=https://monerogue.app'),
|
||||
expect.stringContaining('requires OPERATOR_NAME=Such Software'),
|
||||
expect.stringContaining('requires OPERATOR_CONTACT_URL=mailto:apps@such.software'),
|
||||
expect.stringContaining('requires MONERO_NETWORK=stagenet'),
|
||||
expect.stringContaining('requires DIRECT_PAYOUT_ESCAPE=2'),
|
||||
expect.stringContaining('requires MATCH_CRYPTO_RACE_ENABLED=false'),
|
||||
expect.stringContaining('requires MATCH_PAYOUTS_ENABLED=false'),
|
||||
expect.stringContaining('requires ALLOW_MAINNET_PAYOUTS=false')
|
||||
]));
|
||||
});
|
||||
|
||||
test('unknown operated profile is rejected while an omitted profile preserves generic MIT hosting', () => {
|
||||
expect(new EnvironmentValidator({ env: productionEnv(), logger: silentLogger })
|
||||
.validate(config()).errors).toEqual([]);
|
||||
|
||||
const result = new EnvironmentValidator({
|
||||
env: productionEnv({ OPERATED_PRODUCT_PROFILE: 'third-party-claim' }),
|
||||
logger: silentLogger
|
||||
}).validate(config());
|
||||
expect(result.errors).toContain(
|
||||
'OPERATED_PRODUCT_PROFILE=third-party-claim is unknown. Remove it for an independent MIT deployment or use a reviewed Such Software profile.'
|
||||
);
|
||||
});
|
||||
|
||||
test('operated identity cannot bypass its exact contract outside production', () => {
|
||||
const env = productionEnv({
|
||||
NODE_ENV: 'development',
|
||||
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
|
||||
HOSTED_BY: 'http://localhost:3000',
|
||||
OPERATOR_NAME: 'Such Software'
|
||||
});
|
||||
|
||||
const result = new EnvironmentValidator({ env, logger: silentLogger }).validate(config());
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('requires HOSTED_BY=https://play.wowne.ro'),
|
||||
expect.stringContaining('requires CRYPTO_TYPE=WOW'),
|
||||
expect.stringContaining('requires PAYMENT_MODES=credits')
|
||||
]));
|
||||
});
|
||||
|
||||
test('accepts explicitly capped stagenet crypto match payouts', () => {
|
||||
const env = productionEnv({
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('deployment financial-audit contract', () => {
|
||||
test('nullable or unknown money/game states and a stale schema ledger fail closed', () => {
|
||||
expect(audit).toContain("payouts WHERE status IS DISTINCT FROM 'completed'");
|
||||
expect(audit).toContain("games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')");
|
||||
expect(audit).toContain("MAX(filename) = '040_paid_match_entropy_precommit.sql'");
|
||||
expect(audit).toContain('COUNT(*) = 40');
|
||||
expect(audit).toContain("MAX(filename) = '042_immutable_financial_event_snapshots.sql'");
|
||||
expect(audit).toContain('COUNT(*) = 42');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const FinancialEventExporter = require('../src/services/financialEventExporter');
|
||||
const { buildFinancialEvent } = FinancialEventExporter;
|
||||
const {
|
||||
buildFinancialEvent,
|
||||
sourceSnapshotFromPayment,
|
||||
DELIVERY_CONTRACT
|
||||
} = FinancialEventExporter;
|
||||
|
||||
const ENV = {
|
||||
NODE_ENV: 'production',
|
||||
@@ -7,7 +15,8 @@ const ENV = {
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FINANCIAL_EVENT_SINK_URL: 'https://accounting.example.test/api/events/wowngeon',
|
||||
FINANCIAL_EVENT_SINK_TOKEN: 'a'.repeat(64),
|
||||
FINANCIAL_EVENT_ACCOUNT_REF: 'wowngeon:receipts'
|
||||
FINANCIAL_EVENT_ACCOUNT_REF: 'wowngeon:receipts',
|
||||
FINANCIAL_EVENT_BATCH_SIZE: '2'
|
||||
};
|
||||
|
||||
const PAYMENT = {
|
||||
@@ -18,16 +27,64 @@ const PAYMENT = {
|
||||
confirmed_at: '2026-07-22T14:30:00Z',
|
||||
provider_id: 'native-monero',
|
||||
product_id: 'small',
|
||||
receipts: [{ evidence_type: 'chain_output', evidence_id: `${'a'.repeat(64)}:${'b'.repeat(64)}` }]
|
||||
receipts: [{
|
||||
evidence_type: 'chain_output',
|
||||
evidence_id: `${'a'.repeat(64)}:${'b'.repeat(64)}`,
|
||||
payout_address: 'wallet-address-must-not-escape'
|
||||
}]
|
||||
};
|
||||
|
||||
describe('financial event exporter', () => {
|
||||
test('builds a backend-neutral, unpriced WOW receipt without customer data', () => {
|
||||
const event = buildFinancialEvent(PAYMENT, ENV);
|
||||
function paymentClaim(overrides = {}) {
|
||||
return {
|
||||
id: 5,
|
||||
event_id: 'payment-confirmed-22',
|
||||
event_kind: 'payment.confirmed',
|
||||
source_type: 'payment',
|
||||
source_id: 22,
|
||||
aggregate_id: 22,
|
||||
payload_snapshot: sourceSnapshotFromPayment(PAYMENT),
|
||||
delivery_payload: null,
|
||||
attempts: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function healthRows(overrides = {}) {
|
||||
return { rows: [{ backlog: '0', dead_letters: '0', ignored_rows: '0', ...overrides }] };
|
||||
}
|
||||
|
||||
function standardDb({ claims = [paymentClaim()], onQuery } = {}) {
|
||||
const queue = [...claims];
|
||||
const db = {
|
||||
query: jest.fn(async (sql, params = []) => {
|
||||
if (onQuery) {
|
||||
const custom = await onQuery(sql, params);
|
||||
if (custom) return custom;
|
||||
}
|
||||
if (/attempts >= \$1/i.test(sql)) return { rows: [], rowCount: 0 };
|
||||
if (/RETURNING id, event_id, event_kind/i.test(sql)) {
|
||||
const next = queue.shift();
|
||||
return { rows: next ? [next] : [], rowCount: next ? 1 : 0 };
|
||||
}
|
||||
if (/SET delivery_payload = \$2::jsonb/i.test(sql)) {
|
||||
return { rows: [{ delivery_payload: JSON.parse(params[1]) }], rowCount: 1 };
|
||||
}
|
||||
if (/SET status = 'delivered'/i.test(sql)) return { rows: [{ id: params[0] }], rowCount: 1 };
|
||||
if (/COUNT\(\*\) FILTER/i.test(sql)) return healthRows();
|
||||
return { rows: [], rowCount: 0 };
|
||||
})
|
||||
};
|
||||
return db;
|
||||
}
|
||||
|
||||
describe('financial event payload snapshots', () => {
|
||||
test('builds a backend-neutral, unpriced WOW receipt without customer or wallet data', () => {
|
||||
const source = sourceSnapshotFromPayment(PAYMENT);
|
||||
const event = buildFinancialEvent(source, ENV);
|
||||
|
||||
expect(event).toEqual(expect.objectContaining({
|
||||
schema: 'financial-event/v1',
|
||||
id: 'payment-22',
|
||||
id: 'payment-confirmed-22',
|
||||
producer: 'wowngeon',
|
||||
product: 'wowngeon',
|
||||
activity: 'customer-payment',
|
||||
@@ -36,41 +93,296 @@ describe('financial event exporter', () => {
|
||||
attributes: expect.objectContaining({
|
||||
valuation_required: true,
|
||||
atomic_amount: '900000000000',
|
||||
atomic_decimals: 11
|
||||
atomic_decimals: 11,
|
||||
receipt_count: 1
|
||||
})
|
||||
}));
|
||||
expect(event.legs[0]).toEqual(expect.objectContaining({
|
||||
asset: 'WOW', quantity: '9', direction: 'in', usd_value: '0'
|
||||
}));
|
||||
expect(JSON.stringify(event)).not.toMatch(/user|email|address_index/i);
|
||||
expect(JSON.stringify(source)).not.toContain(PAYMENT.receipts[0].evidence_id);
|
||||
expect(JSON.stringify({ source, event })).not.toMatch(
|
||||
/user_id|socket_id|ip_address|subaddress|payout_address|provider_invoice_id|wallet-address-must-not-escape/i
|
||||
);
|
||||
});
|
||||
|
||||
test('claims and delivers one durable outbox row', async () => {
|
||||
const db = {
|
||||
query: jest.fn(async (sql) => {
|
||||
if (/RETURNING id, aggregate_id, attempts/i.test(sql)) {
|
||||
return db.claimed ? { rows: [] } : (db.claimed = true, { rows: [{ id: 5, aggregate_id: 22, attempts: 1 }] });
|
||||
}
|
||||
if (/FROM payments p/i.test(sql)) return { rows: [PAYMENT] };
|
||||
return { rows: [] };
|
||||
})
|
||||
};
|
||||
test('exports only the immutable snapshot after the payment becomes mutable/refunded', async () => {
|
||||
const snapshot = sourceSnapshotFromPayment(PAYMENT);
|
||||
const mutatedPayment = { ...PAYMENT, status: 'refunded', received_amount: '1' };
|
||||
const db = standardDb({ claims: [paymentClaim({ payload_snapshot: snapshot })] });
|
||||
const fetchFn = jest.fn(async () => ({ ok: true, status: 201 }));
|
||||
const exporter = new FinancialEventExporter({ db, env: ENV, fetchFn, logger: { warn: jest.fn() } });
|
||||
|
||||
await expect(exporter.runOnce()).resolves.toEqual({ skipped: false, delivered: 1, deferred: 0 });
|
||||
expect(fetchFn).toHaveBeenCalledWith(ENV.FINANCIAL_EVENT_SINK_URL, expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ authorization: `Bearer ${ENV.FINANCIAL_EVENT_SINK_TOKEN}` })
|
||||
}));
|
||||
expect(db.query.mock.calls.some(([sql]) => /SET status = 'delivered'/i.test(sql))).toBe(true);
|
||||
await expect(exporter.runOnce()).resolves.toEqual({
|
||||
skipped: false, delivered: 1, deferred: 0, deadLettered: 0, ignored: 0
|
||||
});
|
||||
const delivered = JSON.parse(fetchFn.mock.calls[0][1].body);
|
||||
expect(delivered.attributes.atomic_amount).toBe('900000000000');
|
||||
expect(mutatedPayment.received_amount).toBe('1');
|
||||
expect(db.query.mock.calls.some(([sql]) => /FROM payments\b/i.test(sql))).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses to export stagenet value into production accounting', () => {
|
||||
test.each([
|
||||
['recorded', 'customer-payment-reversal'],
|
||||
['completed', 'customer-refund']
|
||||
])('exports final %s refund as an outgoing leg', (refundStatus, activity) => {
|
||||
const event = buildFinancialEvent({
|
||||
schema: 'financial-event-source/v1',
|
||||
event_id: 'payment-refund-91',
|
||||
event_kind: 'payment.refund',
|
||||
source_type: 'payment_refund',
|
||||
source_id: 91,
|
||||
aggregate_id: 22,
|
||||
occurred_at: '2026-07-22T15:00:00Z',
|
||||
atomic_amount: '450000000000',
|
||||
refund_status: refundStatus
|
||||
}, ENV);
|
||||
|
||||
expect(event).toEqual(expect.objectContaining({
|
||||
id: 'payment-refund-91',
|
||||
activity,
|
||||
attributes: expect.objectContaining({ refund_status: refundStatus })
|
||||
}));
|
||||
expect(event.legs).toEqual([expect.objectContaining({
|
||||
quantity: '4.5', direction: 'out', asset: 'WOW'
|
||||
})]);
|
||||
});
|
||||
|
||||
test.each(['requested', 'processing', 'needs_review'])('refuses non-final %s refund snapshots', status => {
|
||||
expect(() => buildFinancialEvent({
|
||||
schema: 'financial-event-source/v1',
|
||||
event_id: 'payment-refund-91',
|
||||
event_kind: 'payment.refund',
|
||||
source_type: 'payment_refund',
|
||||
source_id: 91,
|
||||
aggregate_id: 22,
|
||||
occurred_at: '2026-07-22T15:00:00Z',
|
||||
atomic_amount: '1',
|
||||
refund_status: status
|
||||
}, ENV)).toThrow(/final exportable state/);
|
||||
});
|
||||
|
||||
test('rejects PII-shaped account references and credential-bearing sink URLs', () => {
|
||||
expect(() => buildFinancialEvent(sourceSnapshotFromPayment(PAYMENT), {
|
||||
...ENV,
|
||||
FINANCIAL_EVENT_ACCOUNT_REF: 'customer@example.invalid'
|
||||
})).toThrow(/non-PII/);
|
||||
expect(() => new FinancialEventExporter({
|
||||
db: { query: jest.fn() },
|
||||
env: { ...ENV, CRYPTO_TYPE: 'XMR', MONERO_NETWORK: 'stagenet' },
|
||||
env: { ...ENV, FINANCIAL_EVENT_SINK_URL: 'https://user:pass@accounting.example.test/events' },
|
||||
fetchFn: jest.fn()
|
||||
})).toThrow(/must not be exported/);
|
||||
})).toThrow(/must not embed credentials/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('at-least-once financial event delivery', () => {
|
||||
test('uses one stable idempotency key and stored body when the durable acknowledgement is retried', async () => {
|
||||
const claim1 = paymentClaim({ attempts: 1 });
|
||||
let storedPayload = null;
|
||||
let claimNumber = 0;
|
||||
let acknowledgementNumber = 0;
|
||||
const db = standardDb({
|
||||
claims: [],
|
||||
onQuery: async (sql, params) => {
|
||||
if (/RETURNING id, event_id, event_kind/i.test(sql)) {
|
||||
claimNumber += 1;
|
||||
if (claimNumber === 1) return { rows: [claim1], rowCount: 1 };
|
||||
if (claimNumber === 2) return { rows: [], rowCount: 0 };
|
||||
if (claimNumber === 3) {
|
||||
return { rows: [{ ...claim1, attempts: 2, delivery_payload: storedPayload }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (/SET delivery_payload = \$2::jsonb/i.test(sql)) {
|
||||
storedPayload = JSON.parse(params[1]);
|
||||
return { rows: [{ delivery_payload: storedPayload }], rowCount: 1 };
|
||||
}
|
||||
if (/SET status = 'delivered'/i.test(sql)) {
|
||||
acknowledgementNumber += 1;
|
||||
if (acknowledgementNumber === 1) throw new Error('database acknowledgement unavailable');
|
||||
return { rows: [{ id: params[0] }], rowCount: 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const fetchFn = jest.fn(async () => ({ ok: true, status: 202 }));
|
||||
const exporter = new FinancialEventExporter({ db, env: ENV, fetchFn, logger: { warn: jest.fn() } });
|
||||
|
||||
await expect(exporter.runOnce()).resolves.toEqual(expect.objectContaining({ deferred: 1 }));
|
||||
await expect(exporter.runOnce()).resolves.toEqual(expect.objectContaining({ delivered: 1 }));
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
const first = fetchFn.mock.calls[0][1];
|
||||
const second = fetchFn.mock.calls[1][1];
|
||||
expect(first.headers['idempotency-key']).toBe('payment-confirmed-22');
|
||||
expect(second.headers['idempotency-key']).toBe(first.headers['idempotency-key']);
|
||||
expect(second.body).toBe(first.body);
|
||||
expect(DELIVERY_CONTRACT).toEqual(expect.objectContaining({ guarantee: 'at-least-once' }));
|
||||
expect(exporter.getDeliveryContract().deduplication).toMatch(/Idempotency-Key/);
|
||||
});
|
||||
|
||||
test('bounds repeated failures and moves the last attempt to dead letter', async () => {
|
||||
const db = standardDb({
|
||||
claims: [paymentClaim({ attempts: 2 })],
|
||||
onQuery: async (sql, params) => {
|
||||
if (/SET status = 'dead_letter'/i.test(sql) && /WHERE id = \$1/i.test(sql)) {
|
||||
return { rows: [{ id: params[0] }], rowCount: 1 };
|
||||
}
|
||||
if (/COUNT\(\*\) FILTER/i.test(sql)) return healthRows({ backlog: '0', dead_letters: '1' });
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const fetchFn = jest.fn(async () => ({ ok: false, status: 503 }));
|
||||
const exporter = new FinancialEventExporter({
|
||||
db,
|
||||
env: { ...ENV, FINANCIAL_EVENT_MAX_ATTEMPTS: '2' },
|
||||
fetchFn,
|
||||
logger: { warn: jest.fn() }
|
||||
});
|
||||
|
||||
await expect(exporter.runOnce()).resolves.toEqual({
|
||||
skipped: false, delivered: 0, deferred: 0, deadLettered: 1, ignored: 0
|
||||
});
|
||||
expect(exporter.getHealth()).toEqual(expect.objectContaining({
|
||||
attempted: 1, deadLettered: 1, deadLetters: 1, backlog: 0
|
||||
}));
|
||||
expect(exporter.getPublicHealth()).toEqual(expect.objectContaining({
|
||||
contract: 'at-least-once', deadLettered: 1, deadLetters: 1, backlog: 0
|
||||
}));
|
||||
expect(JSON.stringify(exporter.getPublicHealth())).not.toMatch(
|
||||
/accounting\.example|authorization|token|lastError|event_id|payment-confirmed/i
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed stored snapshots fail closed and dead-letter without an HTTP attempt', async () => {
|
||||
const malformed = paymentClaim({
|
||||
attempts: 1,
|
||||
payload_snapshot: { ...paymentClaim().payload_snapshot, occurred_at: null }
|
||||
});
|
||||
const db = standardDb({
|
||||
claims: [malformed],
|
||||
onQuery: async (sql, params) => {
|
||||
if (/SET status = 'dead_letter'/i.test(sql) && /WHERE id = \$1/i.test(sql)) {
|
||||
return { rows: [{ id: params[0] }], rowCount: 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const fetchFn = jest.fn();
|
||||
const exporter = new FinancialEventExporter({
|
||||
db,
|
||||
env: { ...ENV, FINANCIAL_EVENT_MAX_ATTEMPTS: '1' },
|
||||
fetchFn,
|
||||
logger: { warn: jest.fn() }
|
||||
});
|
||||
|
||||
await expect(exporter.runOnce()).resolves.toEqual(expect.objectContaining({ deadLettered: 1 }));
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-mainnet suppression and migration safety', () => {
|
||||
test('keeps backlog counters current when the optional mainnet sink is disabled', async () => {
|
||||
const db = {
|
||||
query: jest.fn(async sql => {
|
||||
if (/COUNT\(\*\) FILTER/i.test(sql)) {
|
||||
return healthRows({ backlog: '7', dead_letters: '2', ignored_rows: '1' });
|
||||
}
|
||||
throw new Error(`unexpected SQL: ${sql}`);
|
||||
})
|
||||
};
|
||||
const exporter = new FinancialEventExporter({
|
||||
db,
|
||||
env: { NODE_ENV: 'production', CRYPTO_TYPE: 'WOW', MONERO_NETWORK: 'mainnet' },
|
||||
fetchFn: jest.fn(),
|
||||
logger: { warn: jest.fn() }
|
||||
});
|
||||
|
||||
await expect(exporter.runOnce()).resolves.toEqual({
|
||||
skipped: true, delivered: 0, deferred: 0, deadLettered: 0, ignored: 0
|
||||
});
|
||||
expect(exporter.getPublicHealth()).toEqual(expect.objectContaining({
|
||||
enabled: false, backlog: 7, deadLetters: 2, ignoredRows: 1
|
||||
}));
|
||||
expect(exporter.start()).toBe(true);
|
||||
exporter.stop();
|
||||
});
|
||||
|
||||
test.each(['stagenet', 'testnet'])('continuously suppresses XMR %s rows without a configured sink', async network => {
|
||||
const db = {
|
||||
query: jest.fn(async sql => {
|
||||
if (/ignored_reason = 'non_mainnet_network'/i.test(sql)) {
|
||||
return { rows: [{ id: 1 }, { id: 2 }], rowCount: 2 };
|
||||
}
|
||||
if (/COUNT\(\*\) FILTER/i.test(sql)) return healthRows({ ignored_rows: '2' });
|
||||
throw new Error(`unexpected SQL: ${sql}`);
|
||||
})
|
||||
};
|
||||
const fetchFn = jest.fn();
|
||||
const exporter = new FinancialEventExporter({
|
||||
db,
|
||||
env: { NODE_ENV: 'production', CRYPTO_TYPE: 'XMR', MONERO_NETWORK: network },
|
||||
fetchFn,
|
||||
logger: { warn: jest.fn() }
|
||||
});
|
||||
|
||||
await expect(exporter.runOnce()).resolves.toEqual({
|
||||
skipped: false, delivered: 0, deferred: 0, deadLettered: 0, ignored: 2
|
||||
});
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(exporter.getHealth()).toEqual(expect.objectContaining({
|
||||
exportAllowed: false, ignored: 2, ignoredRows: 2
|
||||
}));
|
||||
const suppressionSql = db.query.mock.calls.find(([sql]) =>
|
||||
/ignored_reason = 'non_mainnet_network'/i.test(sql)
|
||||
)[0];
|
||||
expect(suppressionSql).toMatch(/lease_until = NULL/i);
|
||||
expect(suppressionSql).toMatch(/dead_lettered_at = NULL/i);
|
||||
expect(suppressionSql).toMatch(/status IN \('pending', 'in_flight', 'dead_letter'\)/i);
|
||||
});
|
||||
|
||||
test('does not dead-letter an actively leased attempt from another worker', () => {
|
||||
const source = fs.readFileSync(path.join(
|
||||
__dirname, '../src/services/financialEventExporter.js'
|
||||
), 'utf8');
|
||||
expect(source).toMatch(
|
||||
/status = 'in_flight' AND lease_until < NOW\(\)[\s\S]*RETURNING id/
|
||||
);
|
||||
});
|
||||
|
||||
test('wires sanitized exporter counters into public health without a readiness dependency', () => {
|
||||
const indexSource = fs.readFileSync(path.join(__dirname, '../src/index.js'), 'utf8');
|
||||
expect(indexSource).toMatch(
|
||||
/health\.financialEvents = financialEventExporter\.getPublicHealth\(\)/
|
||||
);
|
||||
expect(indexSource).not.toMatch(
|
||||
/ready\s*=\s*[^;\n]*financialEventExporter|getPublicHealth\(\)[^;\n]*ready/i
|
||||
);
|
||||
});
|
||||
|
||||
test('042 corrects 041 without rereading at delivery or exporting unsettled refunds', () => {
|
||||
const migration = fs.readFileSync(path.join(
|
||||
__dirname, '../src/migrations/042_immutable_financial_event_snapshots.sql'
|
||||
), 'utf8');
|
||||
|
||||
expect(migration).toMatch(/payload_snapshot JSONB/i);
|
||||
expect(migration).toMatch(/delivery_payload JSONB/i);
|
||||
expect(migration).toMatch(/COALESCE\(p\.confirmed_at, p\.created_at, o\.created_at, NOW\(\)\)/i);
|
||||
expect(migration).toMatch(/malformed_legacy_snapshot/i);
|
||||
expect(migration).toMatch(/NEW\.status IN \('recorded', 'completed'\)/i);
|
||||
expect(migration).toMatch(/WHERE r\.status IN \('recorded', 'completed'\)/i);
|
||||
expect(migration).not.toMatch(/WHERE r\.status IN \([^)]*needs_review/i);
|
||||
expect(migration).toMatch(/reject_financial_event_identity_mutation/i);
|
||||
expect(migration).toMatch(
|
||||
/payload_snapshot->>'event_id'\) IS NOT DISTINCT FROM event_id/i
|
||||
);
|
||||
expect(migration).toMatch(
|
||||
/delivery_payload->>'id'\) IS NOT DISTINCT FROM event_id/i
|
||||
);
|
||||
expect(migration).toMatch(/OLD\.delivery_payload IS NOT NULL[\s\S]*NEW\.delivery_payload IS DISTINCT/i);
|
||||
expect(migration).toMatch(
|
||||
/OLD\.delivery_payload IS NULL AND NEW\.delivery_payload IS NOT NULL[\s\S]*OLD\.status IS DISTINCT FROM 'in_flight'/i
|
||||
);
|
||||
expect(migration).not.toMatch(/payout_address|subaddress|provider_invoice_id|evidence_id|tx_hash/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { createRequire } = require('module');
|
||||
|
||||
// The application is the package boundary; top-level tests must resolve its dependencies from
|
||||
// src/package.json instead of accidentally using an unrelated repository-root node_modules tree.
|
||||
module.exports = createRequire(path.join(__dirname, '..', '..', 'src', 'package.json'));
|
||||
+49
-2
@@ -1,12 +1,13 @@
|
||||
const { buildCommerceDisclosure } = require('../src/config/commerceDisclosurePolicy');
|
||||
const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('../src/views/legalPages');
|
||||
|
||||
function disclosure(overrides = {}) {
|
||||
function disclosure(overrides = {}, managerOverrides = {}) {
|
||||
const manager = {
|
||||
cryptoType: 'WOW', currencyLabel: 'WOW', network: 'mainnet', isTestNetwork: false,
|
||||
gameName: '<script>bad</script>', paymentsEnabled: true, freePlayEnabled: true,
|
||||
directModeEnabled: true, creditsModeEnabled: true, payoutsEnabled: false,
|
||||
isPayoutEnabledForMode: () => false
|
||||
isPayoutEnabledForMode: () => false,
|
||||
...managerOverrides
|
||||
};
|
||||
return buildCommerceDisclosure(manager, {
|
||||
NODE_ENV: 'production', LEGAL_POLICY_VERSION: 'v1', TERMS_EFFECTIVE_DATE: '2026-07-21',
|
||||
@@ -39,4 +40,50 @@ describe('production disclosure pages', () => {
|
||||
expect(html).toContain('credits_prestige matches use the Prestige board');
|
||||
expect(html).toContain('crypto_race matches use the Hall of Champions');
|
||||
});
|
||||
|
||||
test('generic self-hosted pages carry the MIT conditions and third-party operator disclaimer', () => {
|
||||
const html = renderTerms(disclosure());
|
||||
expect(html).toContain('Independent deployment');
|
||||
expect(html).toContain('solely responsible for its deployment');
|
||||
expect(html).toContain('does not operate, supervise, endorse, or accept responsibility');
|
||||
expect(html).toContain('copyright and permission notice');
|
||||
expect(html).toContain('AS IS');
|
||||
expect(html).toContain('not legal advice');
|
||||
expect(html).toContain('operates only play.wowne.ro and monerogue.app');
|
||||
});
|
||||
|
||||
test('play.wowne.ro pages state credits-only prestige and the no-payout product positioning', () => {
|
||||
const html = renderTerms(disclosure({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige'
|
||||
}, {
|
||||
directModeEnabled: false,
|
||||
creditsModeEnabled: true
|
||||
}));
|
||||
|
||||
expect(html).toContain('Such Software');
|
||||
expect(html).toContain('apps@such.software');
|
||||
expect(html).toContain('pay-for-credits leaderboard/prestige play');
|
||||
expect(html).toContain('offers no prizes, winnings, cash-out, or payouts');
|
||||
expect(html).toContain('not offered or marketed as gambling');
|
||||
expect(html).toContain('legal classification depends on applicable law');
|
||||
expect(html).not.toContain('NO REAL VALUE');
|
||||
});
|
||||
|
||||
test('monerogue.app pages conspicuously state stagenet 2x/3x test gambling has no real value', () => {
|
||||
const html = renderResponsiblePlay(disclosure({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet'
|
||||
}, {
|
||||
cryptoType: 'XMR',
|
||||
currencyLabel: 'sXMR',
|
||||
network: 'stagenet',
|
||||
isTestNetwork: true,
|
||||
payoutsEnabled: true,
|
||||
isPayoutEnabledForMode: () => true
|
||||
}));
|
||||
|
||||
expect(html).toContain('single-player 2×/3× outcomes are test gambling mechanics');
|
||||
expect(html).toContain('NO REAL VALUE');
|
||||
expect(html).toContain('Never send mainnet XMR');
|
||||
expect(html).toContain('no real-money or mainnet gambling is offered');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const appRequire = require('./helpers/appRequire');
|
||||
const dotenv = appRequire('dotenv');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
function parseExample(name) {
|
||||
const contents = fs.readFileSync(path.join(ROOT, 'src', name), 'utf8');
|
||||
return Object.fromEntries(contents.split(/\r?\n/).flatMap(line => {
|
||||
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
|
||||
return match ? [[match[1], match[2]]] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
function preflightExample(name) {
|
||||
const contents = fs.readFileSync(path.join(ROOT, 'src', name), 'utf8');
|
||||
const env = {
|
||||
...dotenv.parse(contents),
|
||||
DB_PASSWORD: '6f4d9b8270e1c35aa96d2408b75f13ce59a0472d7e86c914',
|
||||
ADMIN_API_KEY: 'b12f078ce49d6a3510ef7b29d8435ca6982e04f17bd6593c',
|
||||
WALLET_RPC_PASSWORD: '8ce2b970a64d1f35e0897c42db6513fa724eb960d18c53a7'
|
||||
};
|
||||
return spawnSync(process.execPath, [path.join(ROOT, 'src', 'scripts', 'preflight.js')], {
|
||||
cwd: ROOT,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
describe('operated product environment templates', () => {
|
||||
test('play.wowne.ro is exactly credits-only Wownero prestige with every payout path off', () => {
|
||||
const env = parseExample('.env.mainnet.example');
|
||||
|
||||
expect(env).toEqual(expect.objectContaining({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-play-wow-prestige',
|
||||
HOSTED_BY: 'https://play.wowne.ro',
|
||||
OPERATOR_NAME: 'Such Software',
|
||||
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
|
||||
OPERATOR_CONTACT_LABEL: 'apps@such.software',
|
||||
LEGAL_POLICY_VERSION: '2026-07-22-v1',
|
||||
TERMS_EFFECTIVE_DATE: '2026-07-22',
|
||||
MINIMUM_AGE: '18',
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
|
||||
CRYPTO_TYPE: 'WOW',
|
||||
MONERO_NETWORK: 'mainnet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
PAYMENT_MODES: 'credits',
|
||||
DIRECT_PAYMENT_ENABLED: 'false',
|
||||
CREDITS_ENABLED: 'true',
|
||||
PAYOUTS_ENABLED: 'false',
|
||||
DIRECT_PAYOUTS_ENABLED: 'false',
|
||||
CREDITS_PAYOUTS_ENABLED: 'false',
|
||||
ALLOW_MAINNET_PAYOUTS: 'false',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'false',
|
||||
MATCH_PAYOUTS_ENABLED: 'false'
|
||||
}));
|
||||
});
|
||||
|
||||
test('monerogue.app is exactly stagenet 2x/3x solo testing with crypto-match payouts off', () => {
|
||||
const env = parseExample('.env.stagenet.example');
|
||||
|
||||
expect(env).toEqual(expect.objectContaining({
|
||||
OPERATED_PRODUCT_PROFILE: 'such-monerogue-stagenet',
|
||||
HOSTED_BY: 'https://monerogue.app',
|
||||
OPERATOR_NAME: 'Such Software',
|
||||
OPERATOR_CONTACT_URL: 'mailto:apps@such.software',
|
||||
OPERATOR_CONTACT_LABEL: 'apps@such.software',
|
||||
LEGAL_POLICY_VERSION: '2026-07-22-v1',
|
||||
TERMS_EFFECTIVE_DATE: '2026-07-22',
|
||||
MINIMUM_AGE: '18',
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED: 'true',
|
||||
CRYPTO_TYPE: 'XMR',
|
||||
MONERO_NETWORK: 'stagenet',
|
||||
FREE_PLAY_ENABLED: 'true',
|
||||
PAYMENT_MODES: 'direct,credits',
|
||||
PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUTS_ENABLED: 'true',
|
||||
CREDITS_PAYOUTS_ENABLED: 'true',
|
||||
DIRECT_PAYOUT_ESCAPE: '2.0',
|
||||
DIRECT_PAYOUT_TREASURE: '3.0',
|
||||
CREDITS_PAYOUT_ESCAPE: '2.0',
|
||||
CREDITS_PAYOUT_TREASURE: '3.0',
|
||||
ALLOW_MAINNET_PAYOUTS: 'false',
|
||||
MATCH_CRYPTO_RACE_ENABLED: 'false',
|
||||
MATCH_PAYOUTS_ENABLED: 'false'
|
||||
}));
|
||||
expect(BigInt(env.PAYOUT_MAX_PER_GAME)).toBeGreaterThanOrEqual(
|
||||
3n * BigInt(env.DIRECT_GAME_PRICE)
|
||||
);
|
||||
expect(BigInt(env.PAYOUT_MAX_PER_GAME)).toBeGreaterThanOrEqual(
|
||||
3n * BigInt(env.CREDITS_PAYOUT_BASE)
|
||||
);
|
||||
expect(env.COSMETIC_PRODUCTS).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the generic MIT self-hosting template does not opt into a Such Software profile', () => {
|
||||
const env = parseExample('.env.example');
|
||||
expect(env.OPERATED_PRODUCT_PROFILE).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['.env.mainnet.example', 'paid modes: credits'],
|
||||
['.env.stagenet.example', 'paid modes: direct, credits']
|
||||
])('%s passes the real production preflight after only secret substitution', (name, summary) => {
|
||||
const result = preflightExample(name);
|
||||
expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
|
||||
expect(result.stdout).toContain('Production configuration preflight passed');
|
||||
expect(result.stdout).toContain(summary);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const appRequire = require('./helpers/appRequire');
|
||||
const express = appRequire('express');
|
||||
const http = require('http');
|
||||
const nacl = require('../src/node_modules/tweetnacl');
|
||||
const nacl = appRequire('tweetnacl');
|
||||
const createAuthRoutes = require('../src/routes/auth');
|
||||
|
||||
function normalized(sql) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// nostr-tools' self-contained pre-bundled build (an esbuild IIFE with deps inlined).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const appRequire = require('./helpers/appRequire');
|
||||
function loadNostrTools() {
|
||||
const cjsIndex = require.resolve('nostr-tools', { paths: [path.join(__dirname, '..', 'src')] });
|
||||
try {
|
||||
@@ -225,7 +226,7 @@ describe('verifyNip98Event — adversarial round-trip', () => {
|
||||
});
|
||||
|
||||
describe('(ix) route layer — single-use challenge consume rejects replay', () => {
|
||||
const express = require('express');
|
||||
const express = appRequire('express');
|
||||
const http = require('http');
|
||||
const createAuthRoutes = require('../src/routes/auth');
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const path = require('path');
|
||||
|
||||
const {
|
||||
LIVE_CONFIRM,
|
||||
REQUIRED_MIGRATIONS,
|
||||
SAFE_PROFILE_CONFIRM,
|
||||
SCENARIOS,
|
||||
addVisible,
|
||||
@@ -172,7 +173,7 @@ describe('XMR stagenet financial canary safety contract', () => {
|
||||
const row = {
|
||||
users: 1,
|
||||
seed_admins: 1,
|
||||
required_migrations: 5,
|
||||
required_migrations: REQUIRED_MIGRATIONS.length,
|
||||
payments: 0,
|
||||
games: 0,
|
||||
payouts: 0,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const rootPath = (...parts) => path.join(__dirname, '..', ...parts);
|
||||
const helperPath = rootPath('scripts', 'deploy', 'wallet-keyfile-recovery-proof.py');
|
||||
const fixturePath = rootPath('scripts', 'deploy', 'wallet-keyfile-recovery-fixture-test.py');
|
||||
const runbookPath = rootPath('docs', 'WALLET_KEYFILE_RECOVERY_PROOF.md');
|
||||
|
||||
describe('fixed wallet keyfile/password recovery proof boundary', () => {
|
||||
const helper = fs.readFileSync(helperPath, 'utf8');
|
||||
const runbook = fs.readFileSync(runbookPath, 'utf8');
|
||||
|
||||
test('accepts only the two audited legacy profiles and fixed recovery artifacts', () => {
|
||||
for (const expected of [
|
||||
'monero-wallet-rpc.service',
|
||||
'/usr/bin/monero-wallet-rpc',
|
||||
'/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test',
|
||||
'monero-stagenet-preprod-20260721T1140Z.wallet',
|
||||
'monero-stagenet-preprod-20260721T1140Z.keys',
|
||||
'/run/wowngeon-wallet-keyfile-proof-monero-stagenet',
|
||||
'wownero-wallet-rpc.service',
|
||||
'/usr/bin/wownero-wallet-rpc',
|
||||
'/home/jw/Programs/wow-11.3.0/game',
|
||||
'wownero-mainnet-preprod-20260721T1140Z.wallet',
|
||||
'wownero-mainnet-preprod-20260721T1140Z.keys',
|
||||
'/run/wowngeon-wallet-keyfile-proof-wownero-mainnet',
|
||||
]) {
|
||||
expect(helper).toContain(expected);
|
||||
}
|
||||
expect(helper).toContain('live_rpc_port=38083');
|
||||
expect(helper).toContain('proof_port=39084');
|
||||
expect(helper).toContain('live_rpc_port=34570');
|
||||
expect(helper).toContain('proof_port=39570');
|
||||
expect(helper).toContain('if len(arguments) != 1 or arguments[0] not in PROFILES:');
|
||||
expect(helper).not.toContain('argparse');
|
||||
});
|
||||
|
||||
test('validates live identity before extracting and wiping the exact inline password', () => {
|
||||
expect(helper).toContain('systemctl("is-active", "--quiet", profile.unit)');
|
||||
expect(helper).toContain('process_executable(pid) != profile.binary');
|
||||
expect(helper).toContain('process_executable_identity(pid) != expected_binary');
|
||||
expect(helper).toContain('process_has_unit_cgroup(pid, profile.unit)');
|
||||
expect(helper).toContain('require_exact_listener(');
|
||||
expect(helper).toContain('b"--wallet-file"');
|
||||
expect(helper).toContain('b"--password"');
|
||||
expect(helper).toContain('f"/proc/{pid}/cmdline"');
|
||||
expect(helper).toContain('os.readv(descriptor, [buffer])');
|
||||
expect(helper).toContain('wipe_buffer(buffer, buffer_is_protected)');
|
||||
expect(helper).toContain('PR_SET_DUMPABLE');
|
||||
expect(helper).toContain('PR_SET_NO_NEW_PRIVS');
|
||||
expect(helper).toContain('MADV_DONTDUMP');
|
||||
expect(helper).not.toMatch(/systemctl\([^\n]*(?:stop|start|restart|reload|enable|disable)/);
|
||||
});
|
||||
|
||||
test('drops candidate privilege and uses only fixed read/proof RPC methods', () => {
|
||||
expect(helper).toContain('os.setgroups([])');
|
||||
expect(helper).toContain('os.setgid(expected_gid)');
|
||||
expect(helper).toContain('os.setuid(expected_uid)');
|
||||
expect(helper).toContain('os.getresuid() != (expected_uid, expected_uid, expected_uid)');
|
||||
expect(helper).toContain('os.getresgid() != (expected_gid, expected_gid, expected_gid)');
|
||||
expect(helper).toContain('if process_groups(process.pid):');
|
||||
expect(helper).toContain('process_status_integer(process.pid, "NoNewPrivs") != 1');
|
||||
expect(helper).toContain('("CapInh", "CapPrm", "CapEff", "CapAmb")');
|
||||
expect(helper).toContain('LIBC.prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0)');
|
||||
expect(helper).toContain('publish_candidate_tree(profile, expected_uid, expected_gid)');
|
||||
expect(helper).toContain('"method": "get_address"');
|
||||
expect(helper).toContain('rpc_request("sign", {"data": SIGN_MESSAGE})');
|
||||
expect(helper).toContain('"verify",');
|
||||
expect(helper).toContain('signature_type not in (None, "spend")');
|
||||
expect(helper).toContain('"--offline"');
|
||||
expect(helper).toContain('"--log-file"');
|
||||
expect(helper).toContain('"/dev/null"');
|
||||
expect(helper).toContain('password-file=');
|
||||
expect(helper).toContain('rpc-bind-ip=127.0.0.1');
|
||||
expect(helper).toContain('rpc-login=');
|
||||
expect(helper).toContain('if status != 401:');
|
||||
expect(helper).toContain('if status != 200:');
|
||||
expect(helper).toContain('hmac.compare_digest(live_address, restored_address)');
|
||||
for (const forbiddenMethod of [
|
||||
'query_key',
|
||||
'get_seed',
|
||||
'get_spend_key',
|
||||
'get_view_key',
|
||||
'transfer_split',
|
||||
'sweep_all',
|
||||
]) {
|
||||
expect(helper).not.toContain(`"method": "${forbiddenMethod}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed around source links, races, port collisions, and exact cleanup', () => {
|
||||
expect(helper).toContain('details.st_nlink != 1');
|
||||
expect(helper).toContain('O_NOFOLLOW');
|
||||
expect(helper).toContain('file_identity(os.fstat(source_fd)) != expected_source');
|
||||
expect(helper).toContain('refuse("proof-port-collision")');
|
||||
expect(helper).toContain('secure_remove_proof_directory(');
|
||||
expect(helper).toContain('(details.st_dev, details.st_ino) != expected_identity');
|
||||
expect(helper).toContain('result=passed');
|
||||
expect(helper).toContain('result=refused');
|
||||
expect(helper).not.toMatch(/(?:import traceback|traceback\.)/);
|
||||
expect(helper).not.toMatch(/\bprint\(/);
|
||||
});
|
||||
|
||||
test('runbook keeps seed custody and off-host restore explicitly unproven', () => {
|
||||
expect(runbook).toContain('has not been installed on or run against');
|
||||
expect(runbook).toContain('This is **not seed custody**');
|
||||
expect(runbook).toContain('it is **not an off-host disaster-recovery test**');
|
||||
expect(runbook).toMatch(/record seed custody and off-host recovery as\s+\*\*unproven\*\*/);
|
||||
expect(runbook).toContain('The proof candidate is offline');
|
||||
expect(runbook).toMatch(/Wallet-RPC never runs\s+as root/);
|
||||
expect(runbook).toContain('not from an independent custody source');
|
||||
expect(runbook).toContain('**not** an RPC-hardening or');
|
||||
expect(runbook).toContain('not evidence they have been run');
|
||||
expect(runbook).toContain('do not blindly remove it');
|
||||
});
|
||||
|
||||
test('synthetic fixture passes without opening a real wallet', () => {
|
||||
const result = spawnSync('/usr/bin/python3', [fixturePath], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
expect({ status: result.status, signal: result.signal, stdout: result.stdout }).toEqual({
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
});
|
||||
expect(result.stderr).toContain('OK');
|
||||
});
|
||||
|
||||
test('invalid invocation renders only the refusal status', () => {
|
||||
const result = spawnSync('/usr/bin/python3', [helperPath, 'not-a-profile'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
expect({
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}).toEqual({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: 'result=refused\n',
|
||||
stderr: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user