feat: harden operated production release

This commit is contained in:
jwinterm
2026-07-22 12:56:00 -04:00
parent dfe4d1ad97
commit 91f139eeaa
44 changed files with 4500 additions and 653 deletions
+32 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+22 -5
View File
@@ -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,
+43
View File
@@ -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)
+186
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+29 -416
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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}$/;
+3 -1
View File
@@ -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({
+301 -64
View File
@@ -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
View File
@@ -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',