mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 15:48:33 +02:00
feat: harden operated production release
This commit is contained in:
+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',
|
||||
|
||||
Reference in New Issue
Block a user