From e224224ea9b513365399bf115a3072fc93d4e647 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:03 +0100 Subject: [PATCH 001/130] fix(bonanza): enforce 45-minute limit (00-preamble.js) --- userscripts/giveaway/src/00-preamble.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index ea2a93a2..02e558cc 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.14 +// @version 1.5.15 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -244,6 +244,8 @@ // fallback now preserves exclusive ownership, uses the chatbox-only slash-command // path so an ambiguous chat-API timeout can never be mistaken for a definite // no-send, and restores only the exact unsent terminal marker after durable read-back. +// - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across +// the form, presets, host/staff time commands and restored active snapshots. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From 5cc0d5167ffd742e5af6483d16fe8cc80fcd429f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:06 +0100 Subject: [PATCH 002/130] fix(bonanza): enforce 45-minute limit (01-global-constants.js) --- userscripts/giveaway/src/01-global-constants.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/01-global-constants.js b/userscripts/giveaway/src/01-global-constants.js index 981310e2..31f5844b 100644 --- a/userscripts/giveaway/src/01-global-constants.js +++ b/userscripts/giveaway/src/01-global-constants.js @@ -8,6 +8,7 @@ // - MIN_ACTION_GAP_MS blocks ultra-fast repeat triggers (usually bots/double-sends) // - REPEAT_COMMAND_COOLDOWNS_MS prevents the same command from being spammed for identical output // - strikes increase lockout length for repeat offenders (decays over time) + const MAX_GIVEAWAY_MINUTES = 45; // DarkPeers rule: hard total cap from public opening const MIN_ACTION_GAP_MS = 900; // ignore triggers faster than this per user const ENTRY_FEEDBACK_COOLDOWN_MS = 8000; // throttle duplicate/out-of-range feedback per user const STRIKE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes @@ -586,7 +587,7 @@ maxwinners: { label: "Max Winners", section: "giveaway", description: "Set max scaled winners.", usage: `!maxwinners 1-${MAX_WINNERS}`, requiresGiveaway: true, hostOnly: true, args: [{ name: "count", label: "Max", type: "int", required: true, min: 1, max: MAX_WINNERS, placeholder: "max" }] }, scale: { label: "Scale", section: "info", description: "Show scaling progress.", usage: "!scale", requiresGiveaway: true }, - addtime: { label: "Add Time", section: "giveaway", description: "Add giveaway minutes.", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, + addtime: { label: "Add Time", section: "giveaway", description: "Add minutes (45-minute total cap).", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, removetime: { label: "Remove Time", section: "giveaway", description: "Remove giveaway minutes.", usage: "!removetime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, From ea98ca750a27c7c411f3181aebd8fc825e7b3a80 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:09 +0100 Subject: [PATCH 003/130] fix(bonanza): enforce 45-minute limit (04-ui-template.js) --- userscripts/giveaway/src/04-ui-template.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/04-ui-template.js b/userscripts/giveaway/src/04-ui-template.js index bae25419..68599f4c 100644 --- a/userscripts/giveaway/src/04-ui-template.js +++ b/userscripts/giveaway/src/04-ui-template.js @@ -114,7 +114,9 @@ type="number" inputmode="numeric" min="1" + max="45" step="1" + title="Maximum giveaway duration: 45 minutes" value="5" autocomplete="off" > @@ -375,7 +377,7 @@
  • !time add N / remove N  - Adjust remaining minutes + Adjust remaining minutes (45 min total maximum)
  • !rig/!unrig  Toggle rigging (visual only)
  • !end [host] Host: !end. Staff: !end <host>
  • From e62a42b4f34e472c3186c575b29f7d96c90d7c5d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:12 +0100 Subject: [PATCH 004/130] fix(bonanza): enforce 45-minute limit (05-initialization.js) --- userscripts/giveaway/src/05-initialization.js | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index d073b202..07e53df1 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -521,8 +521,8 @@ remNumInput = document.getElementById("reminderNum"); reminderEvery = document.getElementById("reminderEvery"); - // Enforce whole-number minutes in the Time field (no decimals / exponent notation) - // (No page refresh required; prevents accidental fractional minutes.) + // Enforce whole-number minutes and DarkPeers' 45-minute maximum in the UI. + // The start handler separately validates the value against script/DOM bypasses. (function enforceWholeMinutesOnTimerField() { if (!timerInput) return; @@ -541,10 +541,12 @@ } if (/^\d+$/.test(v)) { - const n = parseInt(v, 10); - if (Number.isFinite(n) && n >= 1) { + const n = Number(v); + if (Number.isSafeInteger(n) && n >= 1) { + const bounded = String(Math.min(n, MAX_GIVEAWAY_MINUTES)); + timerInput.value = bounded; timerInput.setCustomValidity(""); - lastValid = v; + lastValid = bounded; return; } } @@ -967,7 +969,11 @@ if (coinInput && preset.amount) coinInput.value = preset.amount; if (startInput && preset.startNum) startInput.value = preset.startNum; if (endInput && preset.endNum) endInput.value = preset.endNum; - if (timerInput && preset.timer) timerInput.value = preset.timer; + if (timerInput && preset.timer) { + const minutes = Number(preset.timer); + timerInput.value = Number.isSafeInteger(minutes) && minutes >= 1 + ? String(Math.min(minutes, MAX_GIVEAWAY_MINUTES)) : "5"; + } if (remNumInput && preset.reminders != null) remNumInput.value = preset.reminders; if (winnersInput && preset.winners) winnersInput.value = preset.winners; if (scaleWinnersToggleInput) { @@ -1334,6 +1340,7 @@ totalEntries: giveawayData.totalEntries, winningNumber: giveawayData.winningNumber, totalSeconds: giveawayData.totalSeconds, + openedAt: giveawayData.openedAt, endTs: giveawayData.endTs, winnersNum: giveawayData.winnersNum, baseWinnersAtStart: giveawayData.baseWinnersAtStart, @@ -1476,8 +1483,18 @@ return null; } - const overdueMs = Date.now() - endTs; + // An older release may have saved a deadline beyond the DarkPeers cap. + // Restrict only an uncommitted draw: committed settlement timestamps + // are immutable and must survive crash/reload unchanged. + const startTs = Number(snap.giveawayData.openedAt ?? snap.startTime); + const maxEndTs = getGiveawayMaxEndTs(startTs); const committedSettlement = snap.giveawayData?.settlement?.committed === true; + const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs; + if (correctedDeadline) { + snap.giveawayData.endTs = maxEndTs; + console.warn("[BON Giveaway] Recovered deadline capped at 45 minutes after public opening."); + } + const overdueMs = Date.now() - Number(snap.giveawayData.endTs); const closingSettlementLatched = snap.giveawayData?.closingIntent === true || snap.giveawayData?.closingNoticeSent === true || @@ -1486,7 +1503,8 @@ if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched + !closingSettlementLatched && + !correctedDeadline ) { console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); clearGiveawaySnapshot(); @@ -1648,6 +1666,9 @@ try { // 1) Restore giveaway data giveawayData = snap.giveawayData; + const restoredOpenTs = Number(giveawayData.openedAt ?? snap.startTime); + giveawayData.openedAt = getGiveawayMaxEndTs(restoredOpenTs) !== null + ? restoredOpenTs : null; // Restore starts fail-closed for sponsor accounting. Entries and time // may resume, but BON mutation and settlement stay blocked until the @@ -1769,7 +1790,8 @@ updateRigToggleUI(); // 4) Restore start time - giveawayStartTime = snap.startTime ? new Date(snap.startTime) : new Date(); + giveawayStartTime = giveawayData.openedAt !== null + ? new Date(giveawayData.openedAt) : new Date(); lastKnownGiveawayHostKey = normUserKey(giveawayData.host) || lastKnownGiveawayHostKey; From 3bd347b8ddcbc973ab713c705dc166fe3d6d3c48 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:14 +0100 Subject: [PATCH 005/130] fix(bonanza): enforce 45-minute limit (06-lifecycle.js) --- userscripts/giveaway/src/06-lifecycle.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 7a94cf77..c6636934 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -10,6 +10,15 @@ return; } + // Validate in code, not just through the HTML max attribute: DevTools, + // presets or scripted property changes cannot start a longer giveaway. + const totalTimeMin = parseGiveawayMinutes(timerInput?.value); + if (totalTimeMin === null) { + window.alert(`DarkPeers giveaways must last between 1 and ${MAX_GIVEAWAY_MINUTES} whole minutes.`); + timerInput?.focus(); + return; + } + if (!giveawayForm.checkValidity()) { giveawayForm.reportValidity(); return; @@ -103,8 +112,7 @@ //startButton.parentElement.hidden = true; entriesWrapper.hidden = false; - let totalTimeMin = totalMinutes(); - let totalTimeMs = totalTimeMin * 60000; + const totalTimeMs = totalTimeMin * 60000; let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalTimeMin)[0]); if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0; const schedule = getReminderSchedule(totalTimeMin, reminderNum); @@ -137,6 +145,7 @@ totalEntries: parseInt(endInput.value, 10) - parseInt(startInput.value, 10) + 1, winningNumber: null, totalSeconds: totalTimeMs / 1000, + openedAt: null, timeLeft: totalTimeMs / 1000, endTs: Date.now() + (totalTimeMs), winnersNum, @@ -280,6 +289,7 @@ // Public opening is the temporal boundary. The pre-opening Gift History // snapshot means every subsequently appearing received gift is new. giveawayStartTime = new Date(); + giveawayData.openedAt = giveawayStartTime.getTime(); tracker.giveawayStartTs = giveawayStartTime.getTime(); // The advertised duration begins at the same public-opening boundary, // not while balance/history preflight or chat delivery is still running. From 0463922aecc22c7cdbc3bead9adb766347054c2e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:17 +0100 Subject: [PATCH 006/130] fix(bonanza): enforce 45-minute limit (10-command-handling.js) --- .../giveaway/src/10-command-handling.js | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 38a72ac8..24a11cc0 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -300,7 +300,7 @@ } const action = (args[0] || "").toLowerCase(); // "add" / "remove" - const minutes = parseFloat(args[1]); + const minutes = Number(args[1]); const isPriv = normalizeUserKey(author) === normalizeUserKey(giveawayData.host) || isAdmin(fancyName); if (!isPriv) return; // silently ignore non-host/non-admin @@ -1175,16 +1175,28 @@ if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return; const actionReply = makeStaffAttributedReply(ctx); - const mins = parseFloat(args[0]); - if (isNaN(mins) || mins <= 0) { + const mins = Number(args[0]); + if (!Number.isFinite(mins) || mins <= 0) { actionReply( "[color=red]Usage:[/color] !time add|remove or !addtime|!removetime " ); return; } - const deltaMs = sign * mins * 60_000; - giveawayData.endTs += deltaMs; // move the deadline + // Limit the TOTAL duration since the public opening, not merely the + // remaining countdown. Host and staff use this same enforcement path. + const maxEndTs = getGiveawayMaxEndTs(giveawayData.openedAt); + const oldEndTs = Number(giveawayData.endTs); + const nextEndTs = oldEndTs + sign * mins * 60_000; + if (maxEndTs === null || !Number.isFinite(oldEndTs) || !Number.isFinite(nextEndTs)) { + actionReply("[color=red]Time change refused:[/color] a trustworthy giveaway opening time is unavailable."); + return; + } + if (nextEndTs > maxEndTs) { + actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); + return; + } + giveawayData.endTs = nextEndTs; rebuildSchedule(); // rebuild reminder schedule From db7db56411791e5145af3d60b956986abb5cadb6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:19 +0100 Subject: [PATCH 007/130] fix(bonanza): enforce 45-minute limit (12-utilities.js) --- userscripts/giveaway/src/12-utilities.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index 7d084a93..5f6c4447 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -2624,9 +2624,22 @@ } + function parseGiveawayMinutes(value) { + const raw = String(value ?? "").trim(); + if (!/^\d+$/.test(raw)) return null; + const minutes = Number(raw); + return Number.isSafeInteger(minutes) && minutes >= 1 && minutes <= MAX_GIVEAWAY_MINUTES + ? minutes : null; + } + + function getGiveawayMaxEndTs(startTime) { + const startedAt = Number(startTime); + return Number.isFinite(startedAt) && startedAt > 0 + ? startedAt + MAX_GIVEAWAY_MINUTES * 60_000 : null; + } + function totalMinutes () { - const t = parseInt(String(timerInput?.value ?? ""), 10); - return (Number.isFinite(t) && t > 0) ? t : 0; + return parseGiveawayMinutes(timerInput?.value) ?? 0; } function nextReminderMs(schedule, msLeft) { From 2a6ea83f400818387884978f123f0d31d19190da Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:21 +0100 Subject: [PATCH 008/130] fix(bonanza): enforce 45-minute limit (bonanza-review-hardening.test.mjs) --- tests/bonanza-review-hardening.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bonanza-review-hardening.test.mjs b/tests/bonanza-review-hardening.test.mjs index 7b4e6769..5f2b2ecd 100644 --- a/tests/bonanza-review-hardening.test.mjs +++ b/tests/bonanza-review-hardening.test.mjs @@ -9,7 +9,7 @@ const sourcePath = new URL( const source = readFileSync(sourcePath, "utf8"); test("review hardening invariants stay present", () => { - assert.match(source, /^\/\/ @version\s+1\.5\.14$/m); + assert.match(source, /^\/\/ @version\s+1\.5\.15$/m); assert.doesNotMatch(source, /pollChatFallback/); assert.doesNotMatch(source, /onlyguardians/i); assert.match(source, /async function getLatestMainChatReplayBoundary\(\)/); From 7e9358c2ef0f3984f181061a84ddff84df1993e2 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:24 +0100 Subject: [PATCH 009/130] fix(bonanza): enforce 45-minute limit (bonanza-operations.md) --- docs/runbooks/bonanza-operations.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/runbooks/bonanza-operations.md b/docs/runbooks/bonanza-operations.md index dcc2077c..f3c5a683 100644 --- a/docs/runbooks/bonanza-operations.md +++ b/docs/runbooks/bonanza-operations.md @@ -4,7 +4,7 @@ This is the operational reference for hosting, rehearsing and recovering a DarkP ## If the pot or sponsor accounting looks wrong -1. Extend the giveaway time if entries are still open. +1. Extend the giveaway time if entries are still open, without exceeding 45 minutes in total from public opening. 2. Do not settle while the accounting warning is visible. 3. Do not manually resend winner gifts or BON Pool contributions unless the persisted settlement state explicitly says they were never attempted. 4. Reload Main Chat. The userscript restores the giveaway and reconciles sponsor accounting against persistent DarkPeers Gift History. @@ -25,6 +25,8 @@ DarkPeers staff may use operational recovery controls: - rig / unrig - time add / remove - addtime / removetime + +The 45-minute maximum applies to the initial form duration, saved presets and every host/staff extension. An extension cannot push the deadline beyond 45 minutes after the original public opening, even after a reload. - naughty Every non-host staff action is identified publicly in the command output with the staff username. From 46afa442253425783947280c89d6730f5f525258 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:42:13 +0100 Subject: [PATCH 010/130] test(bonanza): cover 45-minute start, extension, and restore limits --- tests/bonanza-duration-cap.test.mjs | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/bonanza-duration-cap.test.mjs diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs new file mode 100644 index 00000000..e2fd200e --- /dev/null +++ b/tests/bonanza-duration-cap.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const root = new URL("../", import.meta.url); +const read = (path) => readFileSync(new URL(path, root), "utf8"); +const prefix = "userscripts/giveaway/src/"; +const utilities = read(prefix + "12-utilities.js"); +const commands = read(prefix + "10-command-handling.js"); +const lifecycle = read(prefix + "06-lifecycle.js"); +const initialization = read(prefix + "05-initialization.js"); +const ui = read(prefix + "04-ui-template.js"); + +function namedFunction(source, name) { + const start = source.indexOf(` function ${name}(`); + assert.notEqual(start, -1, `Missing ${name}`); + const closing = source.indexOf("\n }\n", start); + assert.notEqual(closing, -1, `Missing closing brace for ${name}`); + return source.slice(start, closing + "\n }\n".length); +} + +const limitFunctions = [ + namedFunction(utilities, "parseGiveawayMinutes"), + namedFunction(utilities, "getGiveawayMaxEndTs"), + namedFunction(commands, "hostAdjustTime") +].join("\n"); + +function makeHarness(startedAt, initialEndTs) { + const replies = []; + let schedules = 0; + let snapshots = 0; + const countdownHeader = { textContent: "" }; + const runtime = vm.createContext({ + MAX_GIVEAWAY_MINUTES: 45, + isHostOrAdmin: (author, _fancyName, host) => author === host, + makeStaffAttributedReply: ({ reply }) => reply, + rebuildSchedule: () => { schedules++; }, + snapshotGiveaway: () => { snapshots++; }, + parseTime: (ms) => String(ms), + countdownHeader + }); + vm.runInContext(limitFunctions, runtime); + + const giveawayData = { host: "maghuro", openedAt: startedAt, endTs: initialEndTs }; + const adjust = (sign, minutes, author = "maghuro") => { + runtime.hostAdjustTime(sign)({ + author, fancyName: "", args: [String(minutes)], giveawayData, + reply: (message) => replies.push(message) + }); + }; + return { + runtime, giveawayData, replies, adjust, + get schedules() { return schedules; }, + get snapshots() { return snapshots; }, + countdownHeader + }; +} + +test("only whole durations from 1 through 45 minutes are accepted", () => { + const { runtime } = makeHarness(1_000_000, 2_000_000); + for (const value of ["1", "12", "45"]) { + assert.equal(runtime.parseGiveawayMinutes(value), Number(value)); + } + for (const value of ["", "0", "46", "99", "-1", "45.5", "1e2", "30abc", "Infinity", "999999999999999999999"]) { + assert.equal(runtime.parseGiveawayMinutes(value), null, value); + } + assert.equal(runtime.getGiveawayMaxEndTs(1_000_000), 1_000_000 + 45 * 60_000); + assert.equal(runtime.getGiveawayMaxEndTs(null), null); + assert.equal(runtime.getGiveawayMaxEndTs("not-a-timestamp"), null); +}); + +test("an extension may reach 45 minutes exactly but never cross the opening-based deadline", () => { + const startedAt = Date.now() - 10 * 60_000; + const h = makeHarness(startedAt, startedAt + 40 * 60_000); + h.adjust(1, 5); + assert.equal(h.giveawayData.endTs, startedAt + 45 * 60_000); + assert.equal(h.snapshots, 1); + assert.equal(h.schedules, 1); + + h.adjust(1, 0.01); + assert.equal(h.giveawayData.endTs, startedAt + 45 * 60_000); + assert.equal(h.snapshots, 1); + assert.equal(h.schedules, 1); + assert.match(h.replies.at(-1), /maximum of 45 minutes/); +}); + +test("the cap concerns total duration, not remaining time", () => { + const startedAt = Date.now() - 30 * 60_000; + const h = makeHarness(startedAt, startedAt + 40 * 60_000); + h.adjust(1, 10); // Only ten minutes are left, but the total would become 50. + assert.equal(h.giveawayData.endTs, startedAt + 40 * 60_000); + assert.equal(h.snapshots, 0); + h.adjust(-1, 1); + assert.equal(h.giveawayData.endTs, startedAt + 39 * 60_000); + assert.equal(h.snapshots, 1); +}); + +test("time adjustment fails closed without a trustworthy public-opening timestamp", () => { + const h = makeHarness(null, Date.now() + 10 * 60_000); + const original = h.giveawayData.endTs; + h.adjust(1, 1); + assert.equal(h.giveawayData.endTs, original); + assert.equal(h.snapshots, 0); + assert.match(h.replies.at(-1), /opening time is unavailable/); + h.adjust(1, 1, "another-user"); + assert.equal(h.replies.length, 1); // Non-hosts cannot change the deadline. +}); + +test("UI, start path, both time commands, and recovered snapshots share the hard cap", () => { + assert.match(ui, /id="timerNum"[\s\S]*?max="45"/); + assert.match(initialization, /Math\.min\(n, MAX_GIVEAWAY_MINUTES\)/); + assert.match(initialization, /Math\.min\(minutes, MAX_GIVEAWAY_MINUTES\)/); + assert.match(lifecycle, /const totalTimeMin = parseGiveawayMinutes\(timerInput\?\.value\)/); + assert.ok( + lifecycle.indexOf("const totalTimeMin = parseGiveawayMinutes") < + lifecycle.indexOf("await acquireTabLock()") + ); + assert.match(lifecycle, /giveawayData\.openedAt = giveawayStartTime\.getTime\(\)/); + assert.match(commands, /const addMinutes = hostAdjustTime\(\+1\)/); + assert.match(commands, /const removeMinutes = hostAdjustTime\(-1\)/); + assert.match(commands, /const maxEndTs = getGiveawayMaxEndTs\(giveawayData\.openedAt\)/); + assert.match(initialization, /openedAt: giveawayData\.openedAt/); + assert.match(initialization, /const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs/); + assert.match(initialization, /snap\.giveawayData\.endTs = maxEndTs/); +}); From 54d3e12e25ca27d70076d68db5bbdcb5a98ac92d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:42:36 +0100 Subject: [PATCH 011/130] build(bonanza): regenerate 1.5.15 userscript with 45-minute cap --- .../DarkPeers_BONanza_Giveaway.user.js | 104 ++++++++++++++---- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index f7a8c329..c5ff200e 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.14 +// @version 1.5.15 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -244,6 +244,8 @@ // fallback now preserves exclusive ownership, uses the chatbox-only slash-command // path so an ambiguous chat-API timeout can never be mistaken for a definite // no-send, and restores only the exact unsent terminal marker after durable read-back. +// - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across +// the form, presets, host/staff time commands and restored active snapshots. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -270,6 +272,7 @@ // - MIN_ACTION_GAP_MS blocks ultra-fast repeat triggers (usually bots/double-sends) // - REPEAT_COMMAND_COOLDOWNS_MS prevents the same command from being spammed for identical output // - strikes increase lockout length for repeat offenders (decays over time) + const MAX_GIVEAWAY_MINUTES = 45; // DarkPeers rule: hard total cap from public opening const MIN_ACTION_GAP_MS = 900; // ignore triggers faster than this per user const ENTRY_FEEDBACK_COOLDOWN_MS = 8000; // throttle duplicate/out-of-range feedback per user const STRIKE_WINDOW_MS = 10 * 60 * 1000; // 10 minutes @@ -848,7 +851,7 @@ maxwinners: { label: "Max Winners", section: "giveaway", description: "Set max scaled winners.", usage: `!maxwinners 1-${MAX_WINNERS}`, requiresGiveaway: true, hostOnly: true, args: [{ name: "count", label: "Max", type: "int", required: true, min: 1, max: MAX_WINNERS, placeholder: "max" }] }, scale: { label: "Scale", section: "info", description: "Show scaling progress.", usage: "!scale", requiresGiveaway: true }, - addtime: { label: "Add Time", section: "giveaway", description: "Add giveaway minutes.", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, + addtime: { label: "Add Time", section: "giveaway", description: "Add minutes (45-minute total cap).", usage: "!addtime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, removetime: { label: "Remove Time", section: "giveaway", description: "Remove giveaway minutes.", usage: "!removetime ", requiresGiveaway: true, hostOnly: true, args: [{ name: "minutes", label: "Min", type: "int", required: true, min: 1, placeholder: "minutes" }] }, @@ -1171,7 +1174,9 @@ type="number" inputmode="numeric" min="1" + max="45" step="1" + title="Maximum giveaway duration: 45 minutes" value="5" autocomplete="off" > @@ -1432,7 +1437,7 @@
  • !time add N / remove N  - Adjust remaining minutes + Adjust remaining minutes (45 min total maximum)
  • !rig/!unrig  Toggle rigging (visual only)
  • !end [host] Host: !end. Staff: !end <host>
  • @@ -2330,8 +2335,8 @@ body.host-panel-dragging * { remNumInput = document.getElementById("reminderNum"); reminderEvery = document.getElementById("reminderEvery"); - // Enforce whole-number minutes in the Time field (no decimals / exponent notation) - // (No page refresh required; prevents accidental fractional minutes.) + // Enforce whole-number minutes and DarkPeers' 45-minute maximum in the UI. + // The start handler separately validates the value against script/DOM bypasses. (function enforceWholeMinutesOnTimerField() { if (!timerInput) return; @@ -2350,10 +2355,12 @@ body.host-panel-dragging * { } if (/^\d+$/.test(v)) { - const n = parseInt(v, 10); - if (Number.isFinite(n) && n >= 1) { + const n = Number(v); + if (Number.isSafeInteger(n) && n >= 1) { + const bounded = String(Math.min(n, MAX_GIVEAWAY_MINUTES)); + timerInput.value = bounded; timerInput.setCustomValidity(""); - lastValid = v; + lastValid = bounded; return; } } @@ -2776,7 +2783,11 @@ body.host-panel-dragging * { if (coinInput && preset.amount) coinInput.value = preset.amount; if (startInput && preset.startNum) startInput.value = preset.startNum; if (endInput && preset.endNum) endInput.value = preset.endNum; - if (timerInput && preset.timer) timerInput.value = preset.timer; + if (timerInput && preset.timer) { + const minutes = Number(preset.timer); + timerInput.value = Number.isSafeInteger(minutes) && minutes >= 1 + ? String(Math.min(minutes, MAX_GIVEAWAY_MINUTES)) : "5"; + } if (remNumInput && preset.reminders != null) remNumInput.value = preset.reminders; if (winnersInput && preset.winners) winnersInput.value = preset.winners; if (scaleWinnersToggleInput) { @@ -3143,6 +3154,7 @@ body.host-panel-dragging * { totalEntries: giveawayData.totalEntries, winningNumber: giveawayData.winningNumber, totalSeconds: giveawayData.totalSeconds, + openedAt: giveawayData.openedAt, endTs: giveawayData.endTs, winnersNum: giveawayData.winnersNum, baseWinnersAtStart: giveawayData.baseWinnersAtStart, @@ -3285,8 +3297,18 @@ body.host-panel-dragging * { return null; } - const overdueMs = Date.now() - endTs; + // An older release may have saved a deadline beyond the DarkPeers cap. + // Restrict only an uncommitted draw: committed settlement timestamps + // are immutable and must survive crash/reload unchanged. + const startTs = Number(snap.giveawayData.openedAt ?? snap.startTime); + const maxEndTs = getGiveawayMaxEndTs(startTs); const committedSettlement = snap.giveawayData?.settlement?.committed === true; + const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs; + if (correctedDeadline) { + snap.giveawayData.endTs = maxEndTs; + console.warn("[BON Giveaway] Recovered deadline capped at 45 minutes after public opening."); + } + const overdueMs = Date.now() - Number(snap.giveawayData.endTs); const closingSettlementLatched = snap.giveawayData?.closingIntent === true || snap.giveawayData?.closingNoticeSent === true || @@ -3295,7 +3317,8 @@ body.host-panel-dragging * { if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched + !closingSettlementLatched && + !correctedDeadline ) { console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); clearGiveawaySnapshot(); @@ -3457,6 +3480,9 @@ body.host-panel-dragging * { try { // 1) Restore giveaway data giveawayData = snap.giveawayData; + const restoredOpenTs = Number(giveawayData.openedAt ?? snap.startTime); + giveawayData.openedAt = getGiveawayMaxEndTs(restoredOpenTs) !== null + ? restoredOpenTs : null; // Restore starts fail-closed for sponsor accounting. Entries and time // may resume, but BON mutation and settlement stay blocked until the @@ -3578,7 +3604,8 @@ body.host-panel-dragging * { updateRigToggleUI(); // 4) Restore start time - giveawayStartTime = snap.startTime ? new Date(snap.startTime) : new Date(); + giveawayStartTime = giveawayData.openedAt !== null + ? new Date(giveawayData.openedAt) : new Date(); lastKnownGiveawayHostKey = normUserKey(giveawayData.host) || lastKnownGiveawayHostKey; @@ -3862,6 +3889,15 @@ body.host-panel-dragging * { return; } + // Validate in code, not just through the HTML max attribute: DevTools, + // presets or scripted property changes cannot start a longer giveaway. + const totalTimeMin = parseGiveawayMinutes(timerInput?.value); + if (totalTimeMin === null) { + window.alert(`DarkPeers giveaways must last between 1 and ${MAX_GIVEAWAY_MINUTES} whole minutes.`); + timerInput?.focus(); + return; + } + if (!giveawayForm.checkValidity()) { giveawayForm.reportValidity(); return; @@ -3955,8 +3991,7 @@ body.host-panel-dragging * { //startButton.parentElement.hidden = true; entriesWrapper.hidden = false; - let totalTimeMin = totalMinutes(); - let totalTimeMs = totalTimeMin * 60000; + const totalTimeMs = totalTimeMin * 60000; let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalTimeMin)[0]); if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0; const schedule = getReminderSchedule(totalTimeMin, reminderNum); @@ -3989,6 +4024,7 @@ body.host-panel-dragging * { totalEntries: parseInt(endInput.value, 10) - parseInt(startInput.value, 10) + 1, winningNumber: null, totalSeconds: totalTimeMs / 1000, + openedAt: null, timeLeft: totalTimeMs / 1000, endTs: Date.now() + (totalTimeMs), winnersNum, @@ -4132,6 +4168,7 @@ body.host-panel-dragging * { // Public opening is the temporal boundary. The pre-opening Gift History // snapshot means every subsequently appearing received gift is new. giveawayStartTime = new Date(); + giveawayData.openedAt = giveawayStartTime.getTime(); tracker.giveawayStartTs = giveawayStartTime.getTime(); // The advertised duration begins at the same public-opening boundary, // not while balance/history preflight or chat delivery is still running. @@ -6998,7 +7035,7 @@ body.host-panel-dragging * { } const action = (args[0] || "").toLowerCase(); // "add" / "remove" - const minutes = parseFloat(args[1]); + const minutes = Number(args[1]); const isPriv = normalizeUserKey(author) === normalizeUserKey(giveawayData.host) || isAdmin(fancyName); if (!isPriv) return; // silently ignore non-host/non-admin @@ -7873,16 +7910,28 @@ body.host-panel-dragging * { if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return; const actionReply = makeStaffAttributedReply(ctx); - const mins = parseFloat(args[0]); - if (isNaN(mins) || mins <= 0) { + const mins = Number(args[0]); + if (!Number.isFinite(mins) || mins <= 0) { actionReply( "[color=red]Usage:[/color] !time add|remove or !addtime|!removetime " ); return; } - const deltaMs = sign * mins * 60_000; - giveawayData.endTs += deltaMs; // move the deadline + // Limit the TOTAL duration since the public opening, not merely the + // remaining countdown. Host and staff use this same enforcement path. + const maxEndTs = getGiveawayMaxEndTs(giveawayData.openedAt); + const oldEndTs = Number(giveawayData.endTs); + const nextEndTs = oldEndTs + sign * mins * 60_000; + if (maxEndTs === null || !Number.isFinite(oldEndTs) || !Number.isFinite(nextEndTs)) { + actionReply("[color=red]Time change refused:[/color] a trustworthy giveaway opening time is unavailable."); + return; + } + if (nextEndTs > maxEndTs) { + actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); + return; + } + giveawayData.endTs = nextEndTs; rebuildSchedule(); // rebuild reminder schedule @@ -13052,9 +13101,22 @@ body.host-panel-dragging * { } + function parseGiveawayMinutes(value) { + const raw = String(value ?? "").trim(); + if (!/^\d+$/.test(raw)) return null; + const minutes = Number(raw); + return Number.isSafeInteger(minutes) && minutes >= 1 && minutes <= MAX_GIVEAWAY_MINUTES + ? minutes : null; + } + + function getGiveawayMaxEndTs(startTime) { + const startedAt = Number(startTime); + return Number.isFinite(startedAt) && startedAt > 0 + ? startedAt + MAX_GIVEAWAY_MINUTES * 60_000 : null; + } + function totalMinutes () { - const t = parseInt(String(timerInput?.value ?? ""), 10); - return (Number.isFinite(t) && t > 0) ? t : 0; + return parseGiveawayMinutes(timerInput?.value) ?? 0; } function nextReminderMs(schedule, msLeft) { From 02ff6a0f7d54481529f454f73831896a7485d826 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:51:16 +0100 Subject: [PATCH 012/130] fix(bonanza): keep stale snapshot grace after duration cap --- userscripts/giveaway/src/05-initialization.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 07e53df1..cbbdf1a9 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1494,6 +1494,8 @@ snap.giveawayData.endTs = maxEndTs; console.warn("[BON Giveaway] Recovered deadline capped at 45 minutes after public opening."); } + // Apply the normal one-hour restore grace to the *capped* deadline. + // Otherwise ancient giveaways could unexpectedly resume settlement. const overdueMs = Date.now() - Number(snap.giveawayData.endTs); const closingSettlementLatched = snap.giveawayData?.closingIntent === true || @@ -1503,8 +1505,7 @@ if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched && - !correctedDeadline + !closingSettlementLatched ) { console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); clearGiveawaySnapshot(); From 7adcbc1fd69c9137d4c037d5d39c0145f60e85f7 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:51:58 +0100 Subject: [PATCH 013/130] test(bonanza): prevent stale legacy snapshot settlement after duration cap --- tests/bonanza-duration-recovery.test.mjs | 127 +++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/bonanza-duration-recovery.test.mjs diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs new file mode 100644 index 00000000..f583d0f3 --- /dev/null +++ b/tests/bonanza-duration-recovery.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const root = new URL("../", import.meta.url); +const read = (path) => readFileSync(new URL(path, root), "utf8"); +const initialization = read("userscripts/giveaway/src/05-initialization.js"); +const utilities = read("userscripts/giveaway/src/12-utilities.js"); + +function extractNamedFunction(source, name) { + const start = source.indexOf(` function ${name}(`); + assert.notEqual(start, -1, `Missing function ${name}`); + const end = source.indexOf("\n }\n", start); + assert.notEqual(end, -1, `Missing end of ${name}`); + return source.slice(start, end + "\n }\n".length); +} + +// Run the real recovery predicate, not a reimplementation of the logic. +const functions = [ + extractNamedFunction(utilities, "getGiveawayMaxEndTs"), + extractNamedFunction(initialization, "loadGiveawaySnapshot") +].join("\n"); + +const MINUTE = 60_000; +const NOW = 1_800_000_000_000; + +function recoveryHarness({ + ageMinutes, + advertisedMinutes = 90, + settlement = null, + closingIntent = false, + closingNoticeSent = false +}) { + const openedAt = NOW - ageMinutes * MINUTE; + const originalEnd = openedAt + advertisedMinutes * MINUTE; + const clears = []; + const warnings = []; + const snap = { + startTime: openedAt, // Legacy snapshots predate giveawayData.openedAt. + giveawayData: { + host: "maghuro", + rehearsalMode: false, + endTs: originalEnd, + settlement, + closingIntent, + closingNoticeSent + } + }; + const context = vm.createContext({ + Date: { now: () => NOW }, + MAX_GIVEAWAY_MINUTES: 45, + EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS: 60 * MINUTE, + REHEARSAL_MODE: false, + normalizeUserKey: (value) => String(value || "").toLowerCase(), + getLoggedInUsername: () => "maghuro", + readActiveGiveawayRawForHost: () => ({ raw: JSON.stringify(snap) }), + clearGiveawaySnapshot: () => { clears.push(true); }, + console: { warn: (message) => warnings.push(message) } + }); + vm.runInContext(functions, context); + return { + restore: () => context.loadGiveawaySnapshot(), + clears, warnings, originalEnd, cappedEnd: openedAt + 45 * MINUTE + }; +} + +test("stale legacy giveaway is not resurrected when capped deadline exceeds recovery grace", () => { + // Under the old 90-minute deadline this ended 20 minutes ago and was + // recoverable; its compliant 45-minute deadline was already 65 minutes ago. + const h = recoveryHarness({ ageMinutes: 110 }); + assert.equal(h.restore(), null); + assert.equal(h.clears.length, 1); + assert.ok(h.warnings.some(w => w.includes("Discarding stale expired"))); +}); + +test("long-abandoned giveaway cannot settle unexpectedly after a code upgrade", () => { + const h = recoveryHarness({ ageMinutes: 3 * 24 * 60, advertisedMinutes: 120 }); + assert.equal(h.restore(), null); + assert.equal(h.clears.length, 1); +}); + +test("recoverable legacy snapshot is capped and recognized as already expired", () => { + const h = recoveryHarness({ ageMinutes: 50 }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.endTs, h.cappedEnd); + assert.equal(result.__expiredAtLoad, true); + assert.equal(h.clears.length, 0); +}); + +test("a still-active legacy snapshot retains only its allowed remaining time", () => { + const h = recoveryHarness({ ageMinutes: 30 }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.endTs, h.cappedEnd); + assert.equal(result.__expiredAtLoad, false); + assert.equal(h.clears.length, 0); +}); + +test("the ordinary expiry grace boundary still applies to the capped deadline", () => { + const withinGrace = recoveryHarness({ ageMinutes: 105 }); + assert.ok(withinGrace.restore()); + assert.equal(withinGrace.clears.length, 0); + + const afterGrace = recoveryHarness({ ageMinutes: 105 + 1 / MINUTE }); + assert.equal(afterGrace.restore(), null); + assert.equal(afterGrace.clears.length, 1); +}); + +test("committed settlement remains recoverable without rewriting its deadline", () => { + const h = recoveryHarness({ ageMinutes: 150, settlement: { committed: true } }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.endTs, h.originalEnd); + assert.equal(result.__expiredAtLoad, true); + assert.equal(h.clears.length, 0); +}); + +test("latched in-progress settlement is retained after the normal grace", () => { + const h = recoveryHarness({ ageMinutes: 150, closingIntent: true }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.endTs, h.cappedEnd); + assert.equal(result.__expiredAtLoad, true); + assert.equal(h.clears.length, 0); +}); From 4d8f4e07ff1b9581cf8bdfa0a77cef7a8e27d484 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:52:11 +0100 Subject: [PATCH 014/130] build(bonanza): synchronize compiled script with corrected recovery grace --- userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index c5ff200e..3d074279 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -3308,6 +3308,8 @@ body.host-panel-dragging * { snap.giveawayData.endTs = maxEndTs; console.warn("[BON Giveaway] Recovered deadline capped at 45 minutes after public opening."); } + // Apply the normal one-hour restore grace to the *capped* deadline. + // Otherwise ancient giveaways could unexpectedly resume settlement. const overdueMs = Date.now() - Number(snap.giveawayData.endTs); const closingSettlementLatched = snap.giveawayData?.closingIntent === true || @@ -3317,8 +3319,7 @@ body.host-panel-dragging * { if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched && - !correctedDeadline + !closingSettlementLatched ) { console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); clearGiveawaySnapshot(); From 861795449797c60f2f1abd64b1548bbcbece59ea Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:56:29 +0100 Subject: [PATCH 015/130] docs: add repo-level agent instructions for generated BONanza script --- AGENTS.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d0adb3ab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# Repository instructions for coding agents + +These instructions apply to the whole repository. Read them before editing code. +Keep changes focused on the affected component; this repository contains +multiple userscripts, not just BONanza. + +## DarkPeers BONanza Giveaway (important) + +- **Single source of truth:** `userscripts/giveaway/src/*.js` (15 ordered + fragments). Never edit + `userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js` directly. + That file is **generated and committed** to Git for Tampermonkey. +- To modify the script, edit the relevant fragment. The file + `scripts/build-bonanza.mjs` defines the **exact concatenation order**. + Fragments are not independent JavaScript modules and must keep the + existing single-IIFE runtime intact. +- Update metadata, version notes and `@version` in + `userscripts/giveaway/src/00-preamble.js`, not in the generated file. + When bumping the version, adjust tests that assert the precise version. + Do not change `@namespace` or the update/download URLs unintentionally. +- From the repository root, **after every source change**, run: + + ```bash + node scripts/build-bonanza.mjs + node scripts/build-bonanza.mjs --check + node --check userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js + node --test tests/bonanza-*.test.mjs + git diff --check + ``` + +- Commit both the changed fragments and the regenerated + `DarkPeers_BONanza_Giveaway.user.js` (plus any tests/docs) in the same PR. + A source-only commit will fail CI because the generated file is stale. + Do not patch the generated file by hand, even to repair a review finding: + fix the source, then rebuild. +- The **Validate BONanza** GitHub Action verifies the tracked bundle, checks + syntax and runs tests on PRs and relevant pushes to `main`. It does not + commit the generated file for you. +- On relevant pushes to `main`, **Publish BONanza to Gist** rebuilds, + fails if the committed bundle differs, and then publishes the userscript + plus a generated `.meta.js` update manifest. No deployment occurs merely + from opening a PR. Do not manually publish or maintain the Gist. +- The script can transfer real BON. Preserve safety gates around ownership, + recovery, gift history, settlement and transfers. Add regression tests for + affected behavior, and use rehearsal mode for validation that would + otherwise post public chat messages or move real currency/points. Never + initiate a live payout for a routine code test. + +Full source layout and examples: +[`userscripts/giveaway/src/README.md`](userscripts/giveaway/src/README.md). + +If repository conventions or workflows change, update **both** this file +and the component's README in the same PR so future agents get accurate +instructions. From 4a65f537d3a9ad89500e38a6a8218b6265b97d20 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:56:31 +0100 Subject: [PATCH 016/130] docs(bonanza): explain source-of-truth build and Gist publishing workflow --- userscripts/giveaway/src/README.md | 46 ++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 389aa06c..db70c48f 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -36,6 +36,48 @@ Ordered source: 14. `13-menu-validation.js` 15. `14-internal-namespaces.js` -The validation workflow rejects stale generated output. The publishing workflow rebuilds and verifies the userscript before sending the single generated file to the secret Gist. +The validation workflow rejects stale generated output. The publishing workflow rebuilds, verifies and publishes the generated script. -When changing BONanza code, edit the relevant source fragment, rebuild, and commit both the source change and the generated userscript. +## Maintenance rule (source of truth) + +**Edit `userscripts/giveaway/src/*.js`, never the generated `.user.js` directly.** +The bundle is a byte-for-byte concatenation of the 15 fragments in the +order declared in `scripts/build-bonanza.mjs`. If you need to change the +runtime or metadata (`@version`, `@updateURL`, etc.), edit the appropriate +fragment (typically `00-preamble.js`), then rebuild. When the fragment list +changes, update the build script as well. + +From the **repository root**, before committing: + +```bash +node scripts/build-bonanza.mjs +node scripts/build-bonanza.mjs --check +node --check userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +node --test tests/bonanza-*.test.mjs +git diff --check +``` + +**Commit the edited source, relevant tests/docs and the regenerated +`userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js` together.** +The bundle is tracked in Git; it is *not* automatically updated in the +repository on a pull request. The validation workflow runs `--check` and +fails if the committed bundle is stale. + +On pushes to `main`, `.github/workflows/publish-bonanza-gist.yml` +**rebuilds and requires a clean bundle diff** before publishing the +generated userscript and an independently generated `.meta.js` manifest +to the configured Gist. A stale committed bundle fails publication; the +workflow does not commit generated files back to GitHub. Do not manually +maintain the Gist or its `.meta.js`. + +For Tampermonkey auto-updates, bump `@version` in `00-preamble.js` when +releasing changes, and update any tests asserting the exact version. +Preserve the existing `@namespace`, `@updateURL` and `@downloadURL` +unless deliberately migrating the script's update identity. + +Because the giveaway can move real BON, cover relevant start, restore, +timeout, settlement and idempotency boundaries with regression tests, +and prefer rehearsal mode for browser-level checks. Do not trigger a live +giveaway or actual BON transfers simply to validate a code change. + +See [the repository's agent instructions](../../../AGENTS.md). From 53a23387a6b7a0917c4fc435682d62e3b8277cae Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:01:15 +0100 Subject: [PATCH 017/130] fix(bonanza): rebuild reminder schedule when capping a restored giveaway --- userscripts/giveaway/src/05-initialization.js | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index cbbdf1a9..9091eaa6 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1492,7 +1492,27 @@ const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs; if (correctedDeadline) { snap.giveawayData.endTs = maxEndTs; - console.warn("[BON Giveaway] Recovered deadline capped at 45 minutes after public opening."); + + // Legacy reminder offsets belong to the *old* deadline. Rebuild + // them against the capped original duration, preserving the + // configured reminder count. countdownTimer/nextReminderMs will + // discard thresholds already elapsed without replaying them. + const requestedReminders = Number(snap.giveawayData.reminderNum); + const reminderCount = Number.isFinite(requestedReminders) + ? Math.max(0, Math.min( + Math.floor(requestedReminders), + getReminderLimits(MAX_GIVEAWAY_MINUTES)[0] + )) + : 0; + snap.giveawayData.reminderSchedule = getReminderSchedule(MAX_GIVEAWAY_MINUTES, reminderCount); + snap.giveawayData.reminderNum = reminderCount; + const cadenceSec = reminderCount > 0 + ? MAX_GIVEAWAY_MINUTES * 60 / (reminderCount + 1) + : 0; + snap.giveawayData.reminderFreqSec = cadenceSec; + snap.giveawayData.nextReminderSec = cadenceSec; + snap.giveawayData.totalSeconds = MAX_GIVEAWAY_MINUTES * 60; + console.warn("[BON Giveaway] Recovered deadline and reminder schedule capped at 45 minutes after public opening."); } // Apply the normal one-hour restore grace to the *capped* deadline. // Otherwise ancient giveaways could unexpectedly resume settlement. From e0a2e8897af95a0dc3d9665a9ce978eff4771b0a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:01:49 +0100 Subject: [PATCH 018/130] test(bonanza): exercise restored capped reminder schedules --- tests/bonanza-duration-recovery.test.mjs | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs index f583d0f3..4fd30a21 100644 --- a/tests/bonanza-duration-recovery.test.mjs +++ b/tests/bonanza-duration-recovery.test.mjs @@ -19,6 +19,9 @@ function extractNamedFunction(source, name) { // Run the real recovery predicate, not a reimplementation of the logic. const functions = [ extractNamedFunction(utilities, "getGiveawayMaxEndTs"), + extractNamedFunction(utilities, "getReminderLimits"), + extractNamedFunction(utilities, "getReminderSchedule"), + extractNamedFunction(utilities, "nextReminderMs"), extractNamedFunction(initialization, "loadGiveawaySnapshot") ].join("\n"); @@ -28,6 +31,7 @@ const NOW = 1_800_000_000_000; function recoveryHarness({ ageMinutes, advertisedMinutes = 90, + reminderCount = 1, settlement = null, closingIntent = false, closingNoticeSent = false @@ -42,6 +46,11 @@ function recoveryHarness({ host: "maghuro", rehearsalMode: false, endTs: originalEnd, + reminderNum: reminderCount, + reminderSchedule: [Math.round(advertisedMinutes / (reminderCount + 1) * MINUTE)], + reminderFreqSec: advertisedMinutes * 60 / (reminderCount + 1), + nextReminderSec: advertisedMinutes * 60 / (reminderCount + 1), + totalSeconds: advertisedMinutes * 60, settlement, closingIntent, closingNoticeSent @@ -50,6 +59,7 @@ function recoveryHarness({ const context = vm.createContext({ Date: { now: () => NOW }, MAX_GIVEAWAY_MINUTES: 45, + MAX_REMINDERS: 6, EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS: 60 * MINUTE, REHEARSAL_MODE: false, normalizeUserKey: (value) => String(value || "").toLowerCase(), @@ -61,6 +71,7 @@ function recoveryHarness({ vm.runInContext(functions, context); return { restore: () => context.loadGiveawaySnapshot(), + nextReminderMs: (schedule, msLeft) => context.nextReminderMs(schedule, msLeft), clears, warnings, originalEnd, cappedEnd: openedAt + 45 * MINUTE }; } @@ -125,3 +136,66 @@ test("latched in-progress settlement is retained after the normal grace", () => assert.equal(result.__expiredAtLoad, true); assert.equal(h.clears.length, 0); }); + +test("capped active legacy giveaway rebuilds a single reminder at the new midpoint", () => { + const h = recoveryHarness({ ageMinutes: 5, advertisedMinutes: 90, reminderCount: 1 }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.endTs, h.cappedEnd); + assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [22.5 * MINUTE]); + assert.equal(result.giveawayData.reminderNum, 1); + assert.equal(result.giveawayData.totalSeconds, 45 * 60); + assert.equal(result.giveawayData.reminderFreqSec, 22.5 * 60); + assert.equal(result.giveawayData.nextReminderSec, 22.5 * 60); + assert.equal(h.nextReminderMs(result.giveawayData.reminderSchedule, 40 * MINUTE), 17.5 * MINUTE); +}); + +test("capped restore drops already-passed reminder thresholds instead of replaying them", () => { + const h = recoveryHarness({ ageMinutes: 30, advertisedMinutes: 90, reminderCount: 1 }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.__expiredAtLoad, false); + assert.equal(h.nextReminderMs(result.giveawayData.reminderSchedule, 15 * MINUTE), null); + assert.equal(result.giveawayData.reminderSchedule.length, 0); +}); + +test("capped restore preserves multiple reminder count and recalculates the actual offsets", () => { + const h = recoveryHarness({ ageMinutes: 5, advertisedMinutes: 90, reminderCount: 3 }); + const result = h.restore(); + assert.ok(result); + assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [ + Math.round(33.75 * MINUTE), + Math.round(22.5 * MINUTE), + Math.round(11.25 * MINUTE) + ]); + assert.equal(result.giveawayData.reminderNum, 3); + assert.equal(result.giveawayData.reminderFreqSec, 11.25 * 60); +}); + +test("capped restore clamps a legacy excessive reminder count to the safe configured maximum", () => { + const h = recoveryHarness({ ageMinutes: 5, reminderCount: 99 }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.reminderNum, 6); + assert.equal(result.giveawayData.reminderSchedule.length, 6); +}); + +test("already compliant snapshots are not rescheduled on restore", () => { + const h = recoveryHarness({ ageMinutes: 5, advertisedMinutes: 30, reminderCount: 1 }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.endTs, h.originalEnd); + assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [15 * MINUTE]); + assert.equal(result.giveawayData.totalSeconds, 30 * 60); +}); + +test("committed settlement retains original reminders and deadline intact", () => { + const h = recoveryHarness({ + ageMinutes: 150, advertisedMinutes: 90, + reminderCount: 1, settlement: { committed: true } + }); + const result = h.restore(); + assert.ok(result); + assert.equal(result.giveawayData.endTs, h.originalEnd); + assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [45 * MINUTE]); +}); From c4d8849fe49b4ff12e07fdca9f8ef021408eb3a6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:02:06 +0100 Subject: [PATCH 019/130] build(bonanza): regenerate tracked userscript from corrected reminder source --- .../DarkPeers_BONanza_Giveaway.user.js | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 3d074279..795b0501 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -3306,7 +3306,27 @@ body.host-panel-dragging * { const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs; if (correctedDeadline) { snap.giveawayData.endTs = maxEndTs; - console.warn("[BON Giveaway] Recovered deadline capped at 45 minutes after public opening."); + + // Legacy reminder offsets belong to the *old* deadline. Rebuild + // them against the capped original duration, preserving the + // configured reminder count. countdownTimer/nextReminderMs will + // discard thresholds already elapsed without replaying them. + const requestedReminders = Number(snap.giveawayData.reminderNum); + const reminderCount = Number.isFinite(requestedReminders) + ? Math.max(0, Math.min( + Math.floor(requestedReminders), + getReminderLimits(MAX_GIVEAWAY_MINUTES)[0] + )) + : 0; + snap.giveawayData.reminderSchedule = getReminderSchedule(MAX_GIVEAWAY_MINUTES, reminderCount); + snap.giveawayData.reminderNum = reminderCount; + const cadenceSec = reminderCount > 0 + ? MAX_GIVEAWAY_MINUTES * 60 / (reminderCount + 1) + : 0; + snap.giveawayData.reminderFreqSec = cadenceSec; + snap.giveawayData.nextReminderSec = cadenceSec; + snap.giveawayData.totalSeconds = MAX_GIVEAWAY_MINUTES * 60; + console.warn("[BON Giveaway] Recovered deadline and reminder schedule capped at 45 minutes after public opening."); } // Apply the normal one-hour restore grace to the *capped* deadline. // Otherwise ancient giveaways could unexpectedly resume settlement. From 9c90d84390a9bee4f99d18dbd9b5288015b3381a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:03:01 +0100 Subject: [PATCH 020/130] fix(bonanza): restore reminder count to UI for later time adjustments --- userscripts/giveaway/src/05-initialization.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 9091eaa6..fd14e2a2 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1816,7 +1816,14 @@ lastKnownGiveawayHostKey = normUserKey(giveawayData.host) || lastKnownGiveawayHostKey; - // 5) Lock form fields + // 5) Lock form fields. Keep the restored reminder count reflected + // in the UI: a later !addtime/!removetime rebuilds from this field. + if (remNumInput) { + const savedReminderCount = Number(giveawayData.reminderNum); + remNumInput.value = String(Number.isFinite(savedReminderCount) + ? Math.max(0, Math.min(MAX_REMINDERS, Math.floor(savedReminderCount))) + : 0); + } startButton.disabled = false; coinInput.disabled = true; startInput.disabled = true; From c318a6d8e043654aba8a3b6cef0196e8bd737159 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:03:14 +0100 Subject: [PATCH 021/130] test(bonanza): ensure restored reminder count survives subsequent time edits --- tests/bonanza-duration-recovery.test.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs index 4fd30a21..1b15d43c 100644 --- a/tests/bonanza-duration-recovery.test.mjs +++ b/tests/bonanza-duration-recovery.test.mjs @@ -199,3 +199,10 @@ test("committed settlement retains original reminders and deadline intact", () = assert.equal(result.giveawayData.endTs, h.originalEnd); assert.deepEqual(Array.from(result.giveawayData.reminderSchedule), [45 * MINUTE]); }); + +test("restored reminder count also hydrates the UI for later host/staff time changes", () => { + assert.match( + initialization, + /if \(remNumInput\) \{[\s\S]*?remNumInput\.value = String\(Number\.isFinite\(savedReminderCount\)/ + ); +}); From 609d7d56096c33e2f3918638bcdfd0e3f22476bb Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:03:29 +0100 Subject: [PATCH 022/130] build(bonanza): synchronize generated userscript with reminder recovery fixes --- userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 795b0501..9c91133c 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -3630,7 +3630,14 @@ body.host-panel-dragging * { lastKnownGiveawayHostKey = normUserKey(giveawayData.host) || lastKnownGiveawayHostKey; - // 5) Lock form fields + // 5) Lock form fields. Keep the restored reminder count reflected + // in the UI: a later !addtime/!removetime rebuilds from this field. + if (remNumInput) { + const savedReminderCount = Number(giveawayData.reminderNum); + remNumInput.value = String(Number.isFinite(savedReminderCount) + ? Math.max(0, Math.min(MAX_REMINDERS, Math.floor(savedReminderCount))) + : 0); + } startButton.disabled = false; coinInput.disabled = true; startInput.disabled = true; From 7ddc4a30045d7558859644ad50f59519ab84d305 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:26:01 +0100 Subject: [PATCH 023/130] fix(bonanza): anchor 45-minute deadline before asynchronous opening send --- userscripts/giveaway/src/06-lifecycle.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index c6636934..26c1176e 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -270,6 +270,11 @@ return; } + // Capture the earliest possible public opening boundary BEFORE the + // async chat send. The server can publish the message well before its + // successful POST response reaches this tab; waiting for that response + // would incorrectly extend a 45-minute giveaway. + const openingSendStartedAt = Date.now(); const introSent = await sendMessage(introMessage, { requireExclusiveGiveawayOwnership: true }); @@ -286,15 +291,14 @@ return; } - // Public opening is the temporal boundary. The pre-opening Gift History - // snapshot means every subsequently appearing received gift is new. - giveawayStartTime = new Date(); - giveawayData.openedAt = giveawayStartTime.getTime(); - tracker.giveawayStartTs = giveawayStartTime.getTime(); - // The advertised duration begins at the same public-opening boundary, - // not while balance/history preflight or chat delivery is still running. - giveawayData.endTs = giveawayStartTime.getTime() + totalTimeMs; - giveawayData.timeLeft = totalTimeMs / 1000; + // Start no later than the earliest possible public opening. Never + // count the chat response latency as extra entry time. Gift History + // was bootstrapped before this send, so subsequent gifts remain new. + giveawayStartTime = new Date(openingSendStartedAt); + giveawayData.openedAt = openingSendStartedAt; + tracker.giveawayStartTs = openingSendStartedAt; + giveawayData.endTs = openingSendStartedAt + totalTimeMs; + syncGiveawayTimeLeft(giveawayData); window.__activeTracker = tracker; try { await tracker.poll(); } catch (e) { console.error(e); } From 123817677b94e898a111815862b1fcd086ca9113 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:26:41 +0100 Subject: [PATCH 024/130] test(bonanza): enforce 45-minute wall clock despite delayed opening POST --- tests/bonanza-duration-cap.test.mjs | 86 ++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs index e2fd200e..5161c5a9 100644 --- a/tests/bonanza-duration-cap.test.mjs +++ b/tests/bonanza-duration-cap.test.mjs @@ -116,7 +116,7 @@ test("UI, start path, both time commands, and recovered snapshots share the hard lifecycle.indexOf("const totalTimeMin = parseGiveawayMinutes") < lifecycle.indexOf("await acquireTabLock()") ); - assert.match(lifecycle, /giveawayData\.openedAt = giveawayStartTime\.getTime\(\)/); + assert.match(lifecycle, /giveawayData\.openedAt = openingSendStartedAt/); assert.match(commands, /const addMinutes = hostAdjustTime\(\+1\)/); assert.match(commands, /const removeMinutes = hostAdjustTime\(-1\)/); assert.match(commands, /const maxEndTs = getGiveawayMaxEndTs\(giveawayData\.openedAt\)/); @@ -124,3 +124,87 @@ test("UI, start path, both time commands, and recovered snapshots share the hard assert.match(initialization, /const correctedDeadline = !committedSettlement && maxEndTs !== null && endTs > maxEndTs/); assert.match(initialization, /snap\.giveawayData\.endTs = maxEndTs/); }); + +test("opening latency counts against the 45-minute cap, not in addition to it", async () => { + const start = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); + const end = lifecycle.indexOf("window.__activeTracker = tracker;", start); + assert.ok(start >= 0 && end > start); + const openingBlock = lifecycle.slice( + start, + end + "window.__activeTracker = tracker;".length + ); + assert.match(openingBlock, /const introSent = await sendMessage\(/); + assert.ok( + openingBlock.indexOf("openingSendStartedAt = Date.now()") < + openingBlock.indexOf("await sendMessage(") + ); + + const sentAt = 1_800_000_000_000; + let clock = sentAt; + class FakeDate extends Date { + static now() { return clock; } + } + const fakeWindow = { __activeTracker: null, alert() {} }; + const context = vm.createContext({ + Date: FakeDate, + window: fakeWindow, + sendMessage: async () => { + // Message is public before its delayed API response. + clock += 7000; + return true; + }, + syncGiveawayTimeLeft: data => { + data.timeLeft = Math.max(0, Math.ceil((data.endTs - clock) / 1000)); + }, + logEvent() {}, + resetGiveaway() {} + }); + const result = await vm.runInContext(`(async () => { + let giveawayStartTime = null; + const giveawayData = {}; + const tracker = {}; + const introMessage = "Giveaway opening"; + const totalTimeMs = 45 * 60_000; + ${openingBlock} + return { openingSendStartedAt, started: giveawayStartTime.getTime(), + openedAt: giveawayData.openedAt, endTs: giveawayData.endTs, + timeLeft: giveawayData.timeLeft, + trackerStart: tracker.giveawayStartTs }; + })()`, context); + assert.equal(result.openingSendStartedAt, sentAt); + assert.equal(result.started, sentAt); + assert.equal(result.openedAt, sentAt); + assert.equal(result.trackerStart, sentAt); + assert.equal(result.endTs, sentAt + 45 * 60_000); + assert.equal(result.timeLeft, 45 * 60 - 7); + assert.equal(fakeWindow.__activeTracker.giveawayStartTs, sentAt); +}); + +test("a failed opening message never commits its pre-send opening timestamp", async () => { + const start = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); + const end = lifecycle.indexOf("window.__activeTracker = tracker;", start); + const openingBlock = lifecycle.slice( + start, + end + "window.__activeTracker = tracker;".length + ); + let resetCalls = 0; + const runtime = vm.createContext({ + Date, + window: { __activeTracker: null, alert() {} }, + sendMessage: async () => false, + logEvent() {}, + resetGiveaway() { resetCalls++; }, + syncGiveawayTimeLeft() {} + }); + const result = await vm.runInContext(`(async () => { + let giveawayStartTime = null; + const giveawayData = {}; + const tracker = {}; + const introMessage = "Giveaway opening"; + const totalTimeMs = 45 * 60_000; + ${openingBlock} + return { openedAt: giveawayData.openedAt, endTs: giveawayData.endTs }; + })()`, runtime); + assert.equal(result, undefined); + assert.equal(resetCalls, 1); +}); From 9a905c1137c7f56f6ed3eb6081dc193c04f0faca Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:26:59 +0100 Subject: [PATCH 025/130] build(bonanza): regenerate userscript from pre-send opening timestamp fix --- .../DarkPeers_BONanza_Giveaway.user.js | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 9c91133c..d04413b8 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -4177,6 +4177,11 @@ body.host-panel-dragging * { return; } + // Capture the earliest possible public opening boundary BEFORE the + // async chat send. The server can publish the message well before its + // successful POST response reaches this tab; waiting for that response + // would incorrectly extend a 45-minute giveaway. + const openingSendStartedAt = Date.now(); const introSent = await sendMessage(introMessage, { requireExclusiveGiveawayOwnership: true }); @@ -4193,15 +4198,14 @@ body.host-panel-dragging * { return; } - // Public opening is the temporal boundary. The pre-opening Gift History - // snapshot means every subsequently appearing received gift is new. - giveawayStartTime = new Date(); - giveawayData.openedAt = giveawayStartTime.getTime(); - tracker.giveawayStartTs = giveawayStartTime.getTime(); - // The advertised duration begins at the same public-opening boundary, - // not while balance/history preflight or chat delivery is still running. - giveawayData.endTs = giveawayStartTime.getTime() + totalTimeMs; - giveawayData.timeLeft = totalTimeMs / 1000; + // Start no later than the earliest possible public opening. Never + // count the chat response latency as extra entry time. Gift History + // was bootstrapped before this send, so subsequent gifts remain new. + giveawayStartTime = new Date(openingSendStartedAt); + giveawayData.openedAt = openingSendStartedAt; + tracker.giveawayStartTs = openingSendStartedAt; + giveawayData.endTs = openingSendStartedAt + totalTimeMs; + syncGiveawayTimeLeft(giveawayData); window.__activeTracker = tracker; try { await tracker.poll(); } catch (e) { console.error(e); } From c82b7b308d327141a6eb147ee30eaf5dc9daeda1 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:32:01 +0100 Subject: [PATCH 026/130] fix(bonanza): announce rejected over-limit chat time commands publicly --- .../giveaway/src/10-command-handling.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 24a11cc0..e4529c12 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -1193,7 +1193,23 @@ return; } if (nextEndTs > maxEndTs) { - actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); + if (ctx.source === "chat") { + // A rejected !addtime / !time add command needs a visible + // explanation in Main Chat, even with Silent Mode enabled. + // Retain staff attribution; Rehearsal Mode still routes + // outgoing chat safely to the host's private messages. + const publicReply = makeStaffAttributedReply({ + ...ctx, + reply: (message) => sendMessage(message) + }); + publicReply( + `🚫 [color=red][b]Time limit exceeded![/b][/color] ` + + `DarkPeers giveaways can last at most [b]${MAX_GIVEAWAY_MINUTES} minutes total[/b] ` + + `from opening. [b]No time was added.[/b]` + ); + } else { + actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); + } return; } giveawayData.endTs = nextEndTs; From 6f1af32e8a7dda51e92b8014dfa25a9e9446440e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:32:31 +0100 Subject: [PATCH 027/130] test(bonanza): assert public chat notice for exceeding 45 minutes --- tests/bonanza-duration-cap.test.mjs | 78 +++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs index 5161c5a9..bdd5745e 100644 --- a/tests/bonanza-duration-cap.test.mjs +++ b/tests/bonanza-duration-cap.test.mjs @@ -23,18 +23,22 @@ function namedFunction(source, name) { const limitFunctions = [ namedFunction(utilities, "parseGiveawayMinutes"), namedFunction(utilities, "getGiveawayMaxEndTs"), + namedFunction(commands, "makeStaffAttributedReply"), namedFunction(commands, "hostAdjustTime") ].join("\n"); function makeHarness(startedAt, initialEndTs) { const replies = []; + const publicMessages = []; let schedules = 0; let snapshots = 0; const countdownHeader = { textContent: "" }; const runtime = vm.createContext({ MAX_GIVEAWAY_MINUTES: 45, - isHostOrAdmin: (author, _fancyName, host) => author === host, - makeStaffAttributedReply: ({ reply }) => reply, + isHostOrAdmin: (author, _fancyName, host) => author === host || author === "staff", + isNonHostStaffAction: (author, _fancyName, host) => author === "staff" && author !== host, + sanitizeNick: name => name, + sendMessage: message => { publicMessages.push(message); return true; }, rebuildSchedule: () => { schedules++; }, snapshotGiveaway: () => { snapshots++; }, parseTime: (ms) => String(ms), @@ -43,14 +47,14 @@ function makeHarness(startedAt, initialEndTs) { vm.runInContext(limitFunctions, runtime); const giveawayData = { host: "maghuro", openedAt: startedAt, endTs: initialEndTs }; - const adjust = (sign, minutes, author = "maghuro") => { + const adjust = (sign, minutes, author = "maghuro", source = "panel") => { runtime.hostAdjustTime(sign)({ - author, fancyName: "", args: [String(minutes)], giveawayData, + author, fancyName: "", args: [String(minutes)], giveawayData, source, reply: (message) => replies.push(message) }); }; return { - runtime, giveawayData, replies, adjust, + runtime, giveawayData, replies, publicMessages, adjust, get schedules() { return schedules; }, get snapshots() { return snapshots; }, countdownHeader @@ -208,3 +212,67 @@ test("a failed opening message never commits its pre-send opening timestamp", as assert.equal(result, undefined); assert.equal(resetCalls, 1); }); + +test("a rejected !addtime in chat is announced publicly without changing the timer", () => { + const openedAt = Date.now() - 5 * 60_000; + const h = makeHarness(openedAt, openedAt + 40 * 60_000); + h.adjust(1, 6, "maghuro", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 40 * 60_000); + assert.equal(h.snapshots, 0); + assert.equal(h.schedules, 0); + assert.equal(h.replies.length, 0, "chat command bypasses normal private reply / Silent Mode routing"); + assert.equal(h.publicMessages.length, 1); + assert.match(h.publicMessages[0], /Time limit exceeded!/); + assert.match(h.publicMessages[0], /45 minutes total/); + assert.match(h.publicMessages[0], /No time was added/); +}); + +test("staff gets a public, attributed cap warning in chat", () => { + const openedAt = Date.now() - 10 * 60_000; + const h = makeHarness(openedAt, openedAt + 45 * 60_000); + h.adjust(1, 1, "staff", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); + assert.equal(h.snapshots, 0); + assert.equal(h.publicMessages.length, 1); + assert.match(h.publicMessages[0], /Staff action by staff/); + assert.match(h.publicMessages[0], /Time limit exceeded!/); +}); + +test("panel commands keep their previous rejection reply instead of a new chat warning", () => { + const openedAt = Date.now() - 5 * 60_000; + const h = makeHarness(openedAt, openedAt + 40 * 60_000); + h.adjust(1, 10, "maghuro", "panel"); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.replies.length, 1); + assert.match(h.replies[0], /Time change refused/); + assert.doesNotMatch(h.replies[0], /Time limit exceeded!/); +}); + +test("allowed chat extensions still change the deadline and use the existing reply path", () => { + const openedAt = Date.now() - 5 * 60_000; + const h = makeHarness(openedAt, openedAt + 40 * 60_000); + h.adjust(1, 5, "maghuro", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); + assert.equal(h.snapshots, 1); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.replies.length, 1); + assert.match(h.replies[0], /Added/); +}); + +test("unprivileged users cannot trigger public cap notices or mutate the giveaway", () => { + const openedAt = Date.now() - 5 * 60_000; + const h = makeHarness(openedAt, openedAt + 40 * 60_000); + h.adjust(1, 10, "random-user", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 40 * 60_000); + assert.equal(h.snapshots, 0); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.replies.length, 0); +}); + +test("both chat aliases use the public over-limit rejection path", () => { + assert.match(commands, /addtime:\s*hostAdjustTime\(\+1\)/); + assert.match(commands, /const addMinutes = hostAdjustTime\(\+1\)/); + assert.match(commands, /if \(action === "add"\)\s*\{\s*addMinutes\(ctxWithArg\)/); + assert.match(commands, /source:\s*"chat"/); + assert.match(commands, /if \(ctx\.source === "chat"\)/); +}); From 88dfd035241cb7b4ba8724acb2cc5cdeaf550282 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:32:43 +0100 Subject: [PATCH 028/130] docs(bonanza): explain public chat warning for over-limit time commands --- userscripts/giveaway/src/00-preamble.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 02e558cc..2f9b5eed 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -246,6 +246,8 @@ // no-send, and restores only the exact unsent terminal marker after durable read-back. // - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across // the form, presets, host/staff time commands and restored active snapshots. +// Rejected !addtime / !time add attempts now announce the 45-minute total +// limit publicly, even in Silent Mode, without changing the deadline. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From 145824b9ef483616faccce6f827c400d0370561c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:33:02 +0100 Subject: [PATCH 029/130] build(bonanza): regenerate release with public time-limit chat warning --- .../DarkPeers_BONanza_Giveaway.user.js | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index d04413b8..03ec593f 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -246,6 +246,8 @@ // no-send, and restores only the exact unsent terminal marker after durable read-back. // - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across // the form, presets, host/staff time commands and restored active snapshots. +// Rejected !addtime / !time add attempts now announce the 45-minute total +// limit publicly, even in Silent Mode, without changing the deadline. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -7960,7 +7962,23 @@ body.host-panel-dragging * { return; } if (nextEndTs > maxEndTs) { - actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); + if (ctx.source === "chat") { + // A rejected !addtime / !time add command needs a visible + // explanation in Main Chat, even with Silent Mode enabled. + // Retain staff attribution; Rehearsal Mode still routes + // outgoing chat safely to the host's private messages. + const publicReply = makeStaffAttributedReply({ + ...ctx, + reply: (message) => sendMessage(message) + }); + publicReply( + `🚫 [color=red][b]Time limit exceeded![/b][/color] ` + + `DarkPeers giveaways can last at most [b]${MAX_GIVEAWAY_MINUTES} minutes total[/b] ` + + `from opening. [b]No time was added.[/b]` + ); + } else { + actionReply(`[color=red]Time change refused:[/color] DarkPeers allows a maximum of ${MAX_GIVEAWAY_MINUTES} minutes from the original opening.`); + } return; } giveawayData.endTs = nextEndTs; From fe325eddcc68481c68476063432c4a8dc21e7640 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:35:32 +0100 Subject: [PATCH 030/130] test(bonanza): confirm time-limit warnings stay private in rehearsal mode --- tests/bonanza-duration-cap.test.mjs | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs index bdd5745e..77651b88 100644 --- a/tests/bonanza-duration-cap.test.mjs +++ b/tests/bonanza-duration-cap.test.mjs @@ -276,3 +276,52 @@ test("both chat aliases use the public over-limit rejection path", () => { assert.match(commands, /source:\s*"chat"/); assert.match(commands, /if \(ctx\.source === "chat"\)/); }); + +test("Rehearsal Mode delivers the same 45-minute warning privately, without a public chat send", async () => { + const openedAt = Date.now() - 5 * 60_000; + const data = { host: "maghuro", openedAt, endTs: openedAt + 40 * 60_000 }; + const privateMessages = []; + let changed = 0; + const context = vm.createContext({ + MAX_GIVEAWAY_MINUTES: 45, + REHEARSAL_MODE: true, + GENERAL_SETTINGS: { silent_mode: true }, + DEBUG_SETTINGS: { disable_chat_output: false, verify_sendmessage: false, log_chat_messages: false }, + giveawayData: data, + getLoggedInUsername: () => "maghuro", + sanitizeNick: name => name, + logEvent() {}, + prepareOutgoingMessage: text => text, + formatRehearsalPrivateOutput: text => "[REHEARSAL] " + text, + sendPrivateMessage: (user, text) => { + privateMessages.push({ user, text }); + return Promise.resolve(true); + }, + isHostOrAdmin: (user, _, host) => user === host, + isNonHostStaffAction: () => false, + rebuildSchedule: () => { changed++; }, + snapshotGiveaway: () => { changed++; } + }); + vm.runInContext([ + namedFunction(utilities, "getGiveawayMaxEndTs"), + namedFunction(utilities, "sendMessage"), + namedFunction(commands, "makeStaffAttributedReply"), + namedFunction(commands, "hostAdjustTime") + ].join("\n"), context); + + // Command originates from chat, but rehearsal mode redirects outgoing + // script-generated chat output to the host's private inbox. + context.hostAdjustTime(+1)({ + author: "maghuro", fancyName: "", giveawayData: data, + source: "chat", args: ["10"], reply: () => { throw Error("Unexpected normal reply"); } + }); + await Promise.resolve(); + assert.equal(data.endTs, openedAt + 40 * 60_000); + assert.equal(changed, 0); + assert.equal(privateMessages.length, 1); + assert.equal(privateMessages[0].user, "maghuro"); + assert.match(privateMessages[0].text, /\[REHEARSAL\]/); + assert.match(privateMessages[0].text, /Time limit exceeded!/); + assert.match(privateMessages[0].text, /45 minutes total/); + assert.match(privateMessages[0].text, /No time was added/); +}); From f920a2fd750086db6250db4caa61a29e21f720bb Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:36:29 +0100 Subject: [PATCH 031/130] test(bonanza): extract async sendMessage correctly in rehearsal regression --- tests/bonanza-duration-cap.test.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs index 77651b88..8930115e 100644 --- a/tests/bonanza-duration-cap.test.mjs +++ b/tests/bonanza-duration-cap.test.mjs @@ -304,7 +304,14 @@ test("Rehearsal Mode delivers the same 45-minute warning privately, without a pu }); vm.runInContext([ namedFunction(utilities, "getGiveawayMaxEndTs"), - namedFunction(utilities, "sendMessage"), + (() => { + const start = utilities.indexOf(" async function sendMessage("); + assert.ok(start !== -1, "Missing async sendMessage"); + const marker = "\n }\n"; + const end = utilities.indexOf(marker, start); + assert.ok(end > start, "Missing end of sendMessage"); + return utilities.slice(start, end + marker.length); + })(), namedFunction(commands, "makeStaffAttributedReply"), namedFunction(commands, "hostAdjustTime") ].join("\n"), context); From b6905cbed827ea67fe483e5b353fc2bf519d7330 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:42:57 +0100 Subject: [PATCH 032/130] fix(bonanza): honor Silent Mode for all host/staff command replies --- .../giveaway/src/10-command-handling.js | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index e4529c12..9f1c6aca 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -231,16 +231,19 @@ function makeStaffAttributedReply(ctx) { const reply = ctx && typeof ctx.reply === "function" ? ctx.reply : sendMessage; - if (!ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { + // Silent Mode sends ALL command responses privately to the invoker, + // including staff errors/denials. Staff actions are attributed in the + // public chat only when the host has not enabled Silent Mode. + if (GENERAL_SETTINGS.silent_mode || + !ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { return reply; } const prefix = `👮 [b][color=#5DE2E7]Staff action by ${sanitizeNick(ctx.author)}:[/color][/b] `; - // Staff intervention is operationally significant and must stay public - // even when the host enabled Silent Mode. Rehearsal Mode still suppresses - // sendMessage itself, so testing cannot leak messages into live chat. + // Public staff attribution remains on when Silent Mode is disabled. + // Rehearsal Mode still redirects script output privately to the host. return (message) => sendMessage(prefix + message); } @@ -1194,15 +1197,10 @@ } if (nextEndTs > maxEndTs) { if (ctx.source === "chat") { - // A rejected !addtime / !time add command needs a visible - // explanation in Main Chat, even with Silent Mode enabled. - // Retain staff attribution; Rehearsal Mode still routes - // outgoing chat safely to the host's private messages. - const publicReply = makeStaffAttributedReply({ - ...ctx, - reply: (message) => sendMessage(message) - }); - publicReply( + // Use the ordinary command-response route. Silent Mode + // sends this privately to the command author; Rehearsal + // Mode redirects outgoing messages privately to the host. + actionReply( `🚫 [color=red][b]Time limit exceeded![/b][/color] ` + `DarkPeers giveaways can last at most [b]${MAX_GIVEAWAY_MINUTES} minutes total[/b] ` + `from opening. [b]No time was added.[/b]` From efd0b63936ada2b50e1d8fcd0db98a5710697488 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:43:23 +0100 Subject: [PATCH 033/130] test(bonanza): assert Silent Mode private time-command responses for host and staff --- tests/bonanza-duration-cap.test.mjs | 63 ++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs index 8930115e..30517aa4 100644 --- a/tests/bonanza-duration-cap.test.mjs +++ b/tests/bonanza-duration-cap.test.mjs @@ -27,14 +27,16 @@ const limitFunctions = [ namedFunction(commands, "hostAdjustTime") ].join("\n"); -function makeHarness(startedAt, initialEndTs) { +function makeHarness(startedAt, initialEndTs, { silentMode = false } = {}) { const replies = []; const publicMessages = []; + const privateMessages = []; let schedules = 0; let snapshots = 0; const countdownHeader = { textContent: "" }; const runtime = vm.createContext({ MAX_GIVEAWAY_MINUTES: 45, + GENERAL_SETTINGS: { silent_mode: silentMode }, isHostOrAdmin: (author, _fancyName, host) => author === host || author === "staff", isNonHostStaffAction: (author, _fancyName, host) => author === "staff" && author !== host, sanitizeNick: name => name, @@ -50,11 +52,17 @@ function makeHarness(startedAt, initialEndTs) { const adjust = (sign, minutes, author = "maghuro", source = "panel") => { runtime.hostAdjustTime(sign)({ author, fancyName: "", args: [String(minutes)], giveawayData, source, - reply: (message) => replies.push(message) + reply: (message) => { + replies.push(message); + if (source === "chat") { + if (silentMode) privateMessages.push({ author, message }); + else publicMessages.push(message); + } + } }); }; return { - runtime, giveawayData, replies, publicMessages, adjust, + runtime, giveawayData, replies, publicMessages, privateMessages, adjust, get schedules() { return schedules; }, get snapshots() { return snapshots; }, countdownHeader @@ -220,7 +228,7 @@ test("a rejected !addtime in chat is announced publicly without changing the tim assert.equal(h.giveawayData.endTs, openedAt + 40 * 60_000); assert.equal(h.snapshots, 0); assert.equal(h.schedules, 0); - assert.equal(h.replies.length, 0, "chat command bypasses normal private reply / Silent Mode routing"); + assert.equal(h.replies.length, 1, "chat warning uses normal command reply routing"); assert.equal(h.publicMessages.length, 1); assert.match(h.publicMessages[0], /Time limit exceeded!/); assert.match(h.publicMessages[0], /45 minutes total/); @@ -254,7 +262,7 @@ test("allowed chat extensions still change the deadline and use the existing rep h.adjust(1, 5, "maghuro", "chat"); assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); assert.equal(h.snapshots, 1); - assert.equal(h.publicMessages.length, 0); + assert.equal(h.publicMessages.length, 1); assert.equal(h.replies.length, 1); assert.match(h.replies[0], /Added/); }); @@ -275,6 +283,7 @@ test("both chat aliases use the public over-limit rejection path", () => { assert.match(commands, /if \(action === "add"\)\s*\{\s*addMinutes\(ctxWithArg\)/); assert.match(commands, /source:\s*"chat"/); assert.match(commands, /if \(ctx\.source === "chat"\)/); + assert.match(commands, /GENERAL_SETTINGS\.silent_mode/); }); test("Rehearsal Mode delivers the same 45-minute warning privately, without a public chat send", async () => { @@ -320,7 +329,7 @@ test("Rehearsal Mode delivers the same 45-minute warning privately, without a pu // script-generated chat output to the host's private inbox. context.hostAdjustTime(+1)({ author: "maghuro", fancyName: "", giveawayData: data, - source: "chat", args: ["10"], reply: () => { throw Error("Unexpected normal reply"); } + source: "chat", args: ["10"], reply: message => context.sendMessage(message) }); await Promise.resolve(); assert.equal(data.endTs, openedAt + 40 * 60_000); @@ -332,3 +341,45 @@ test("Rehearsal Mode delivers the same 45-minute warning privately, without a pu assert.match(privateMessages[0].text, /45 minutes total/); assert.match(privateMessages[0].text, /No time was added/); }); + +test("host over-limit feedback follows Silent Mode and is private to the invoker", () => { + const openedAt = Date.now() - 10 * 60_000; + const h = makeHarness(openedAt, openedAt + 40 * 60_000, { silentMode: true }); + h.adjust(1, 10, "maghuro", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 40 * 60_000); + assert.equal(h.snapshots, 0); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.privateMessages.length, 1); + assert.equal(h.privateMessages[0].author, "maghuro"); + assert.match(h.privateMessages[0].message, /Time limit exceeded!/); + assert.match(h.privateMessages[0].message, /No time was added/); +}); + +test("staff over-limit feedback is also private in Silent Mode", () => { + const openedAt = Date.now() - 10 * 60_000; + const h = makeHarness(openedAt, openedAt + 45 * 60_000, { silentMode: true }); + h.adjust(1, 1, "staff", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); + assert.equal(h.snapshots, 0); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.privateMessages.length, 1); + assert.equal(h.privateMessages[0].author, "staff"); + assert.match(h.privateMessages[0].message, /Time limit exceeded!/); +}); + +test("staff success and validation messages remain private in Silent Mode", () => { + const openedAt = Date.now() - 10 * 60_000; + const h = makeHarness(openedAt, openedAt + 40 * 60_000, { silentMode: true }); + h.adjust(1, 5, "staff", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); + assert.equal(h.snapshots, 1); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.privateMessages.length, 1); + assert.match(h.privateMessages[0].message, /Added/); + + h.adjust(1, "bad", "staff", "chat"); + assert.equal(h.giveawayData.endTs, openedAt + 45 * 60_000); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.privateMessages.length, 2); + assert.match(h.privateMessages[1].message, /Usage:/); +}); From 2726909685a927b06d9d6d2e562ebfdd61da19bf Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:43:36 +0100 Subject: [PATCH 034/130] docs(bonanza): document private Silent Mode limit warnings --- userscripts/giveaway/src/00-preamble.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 2f9b5eed..70e1ec59 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -246,8 +246,9 @@ // no-send, and restores only the exact unsent terminal marker after durable read-back. // - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across // the form, presets, host/staff time commands and restored active snapshots. -// Rejected !addtime / !time add attempts now announce the 45-minute total -// limit publicly, even in Silent Mode, without changing the deadline. +// Rejected !addtime / !time add attempts explain the 45-minute total +// limit without changing the deadline. Silent Mode sends all host/staff +// command replies privately; Rehearsal routes script output to host PMs. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From e75b27da4aab65b8b263643448d2662d350bf7d3 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:43:55 +0100 Subject: [PATCH 035/130] build(bonanza): regenerate userscript with Silent Mode command replies --- .../DarkPeers_BONanza_Giveaway.user.js | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 03ec593f..d7fafb18 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -246,8 +246,9 @@ // no-send, and restores only the exact unsent terminal marker after durable read-back. // - v1.5.15 enforces DarkPeers' 45-minute maximum from public opening across // the form, presets, host/staff time commands and restored active snapshots. -// Rejected !addtime / !time add attempts now announce the 45-minute total -// limit publicly, even in Silent Mode, without changing the deadline. +// Rejected !addtime / !time add attempts explain the 45-minute total +// limit without changing the deadline. Silent Mode sends all host/staff +// command replies privately; Rehearsal routes script output to host PMs. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -7000,16 +7001,19 @@ body.host-panel-dragging * { function makeStaffAttributedReply(ctx) { const reply = ctx && typeof ctx.reply === "function" ? ctx.reply : sendMessage; - if (!ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { + // Silent Mode sends ALL command responses privately to the invoker, + // including staff errors/denials. Staff actions are attributed in the + // public chat only when the host has not enabled Silent Mode. + if (GENERAL_SETTINGS.silent_mode || + !ctx || !isNonHostStaffAction(ctx.author, ctx.fancyName, ctx.giveawayData?.host)) { return reply; } const prefix = `👮 [b][color=#5DE2E7]Staff action by ${sanitizeNick(ctx.author)}:[/color][/b] `; - // Staff intervention is operationally significant and must stay public - // even when the host enabled Silent Mode. Rehearsal Mode still suppresses - // sendMessage itself, so testing cannot leak messages into live chat. + // Public staff attribution remains on when Silent Mode is disabled. + // Rehearsal Mode still redirects script output privately to the host. return (message) => sendMessage(prefix + message); } @@ -7963,15 +7967,10 @@ body.host-panel-dragging * { } if (nextEndTs > maxEndTs) { if (ctx.source === "chat") { - // A rejected !addtime / !time add command needs a visible - // explanation in Main Chat, even with Silent Mode enabled. - // Retain staff attribution; Rehearsal Mode still routes - // outgoing chat safely to the host's private messages. - const publicReply = makeStaffAttributedReply({ - ...ctx, - reply: (message) => sendMessage(message) - }); - publicReply( + // Use the ordinary command-response route. Silent Mode + // sends this privately to the command author; Rehearsal + // Mode redirects outgoing messages privately to the host. + actionReply( `🚫 [color=red][b]Time limit exceeded![/b][/color] ` + `DarkPeers giveaways can last at most [b]${MAX_GIVEAWAY_MINUTES} minutes total[/b] ` + `from opening. [b]No time was added.[/b]` From c9660d79db5f3410cc5cfe9723505c8035b1daa6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:50:51 +0100 Subject: [PATCH 036/130] fix(bonanza): route winners and addbon command announcements through Silent Mode --- .../giveaway/src/10-command-handling.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 9f1c6aca..238e5f68 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -813,7 +813,7 @@ const announcement = `[b][color=${SCALING_ACCENT_COLOR}]Host adjustment:[/color][/b] ` + `[b]Winners ${direction}[/b]: [b][color=#5DE2E7]${prevEffective} → ${newCount} (${sign}${delta})[/color][/b].`; - sendMessage(announcement); + reply(announcement); flashWinnersUI(); logEvent("Host adjusted winners", `${prevEffective} -> ${newCount} (${sign}${delta})`); } @@ -1136,11 +1136,18 @@ snapshotGiveaway({ force: true }); - const announced = await sendMessage( - `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + - [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "), - { requireExclusiveGiveawayOwnership: true } - ); + // Command-generated pot announcements must honor Silent Mode. + // Route privately to the invoking host there; preserve ownership + // checking in both public and private delivery paths. + const potAnnouncement = `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + + [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "); + const announced = ctx.source === "chat" + ? await sendCommandResponse(author, potAnnouncement, { + requireExclusiveGiveawayOwnership: true + }) + : await sendMessage(potAnnouncement, { + requireExclusiveGiveawayOwnership: true + }); if (announced === false) { logEvent( From f24d8d456ed7e0b186b6db85dc86dbcb458a2bae Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:51:04 +0100 Subject: [PATCH 037/130] fix(bonanza): preserve exclusive ownership when routing private command output --- userscripts/giveaway/src/12-utilities.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index 5f6c4447..1e3fa92f 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -1619,11 +1619,11 @@ return sendMessage(`/msg ${to} ${body}`, options); } - async function sendCommandResponse(username, messageStr) { + async function sendCommandResponse(username, messageStr, options = {}) { if (GENERAL_SETTINGS.silent_mode) { - return sendPrivateMessage(username, messageStr); + return sendPrivateMessage(username, messageStr, options); } - return sendMessage(messageStr); + return sendMessage(messageStr, options); } /** Prepare a message for sending: obfuscate "giveaway" and apply number formatting. */ From 175729cbd611afc3be0cc8f754f0f63559a14c74 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:52:29 +0100 Subject: [PATCH 038/130] fix(bonanza): persist and verify public opening before accepting sponsors --- userscripts/giveaway/src/06-lifecycle.js | 143 +++++++++++++++++++---- 1 file changed, 121 insertions(+), 22 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 26c1176e..7d2bd2d0 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -1,6 +1,68 @@ // SECTION 6: Giveaway Lifecycle // ─────────────────────────────────────────────────────────── + // Reconcile an opening announcement against authoritative Main Chat history. + // An HTTP timeout is NOT evidence the server rejected the POST. Never + // release a pending opening snapshot merely because sendMessage returned + // false; an announced giveaway may already exist publicly. + async function verifyPendingGiveawayOpening(checkpoint) { + if (!checkpoint || checkpoint.status !== "pending" || + !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { + return null; + } + + const afterId = optionalFiniteNumber(checkpoint.afterMessageId); + const startedAt = Number(checkpoint.startedAt); + const senderId = Math.floor(Number(OT_USER_ID)); + if (!Number.isFinite(senderId) || senderId <= 0) return null; + + for (let attempt = 0; attempt < 3; attempt++) { + if (!ownsTabLock()) return null; + try { + const url = new URL(`/api/chat/messages/${DARKPEERS_MAIN_CHATROOM_ID}`, location.origin); + if (afterId !== null) url.searchParams.set("after_id", String(afterId)); + const response = await fetchWithTimeout( + url, { credentials: "include", cache: "no-store" }, 5000 + ); + if (!ownsTabLock()) return null; + if (!response?.ok) throw new Error("Opening verification chat read failed"); + const payload = await response.json(); + if (!ownsTabLock()) return null; + if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); + + for (const message of payload.data) { + const id = optionalFiniteNumber(message?.id); + if (afterId !== null && id !== null && id <= afterId) continue; + const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); + if (authorId !== senderId) continue; + if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; + + const rawTs = String(message?.created_at || ""); + const timestamp = Date.parse(rawTs); + const resolutionMs = unit3dTimestampResolutionMs(rawTs); + // A coarse timestamp can include the send start instant, + // but an older identical announcement must not be reused. + if (!Number.isFinite(timestamp) || + timestamp + resolutionMs <= startedAt) continue; + + return { + messageId: id, + // Exclude the entire potentially ambiguous publication + // timestamp bucket from sponsorship. The 45-minute + // deadline remains anchored to the earlier send start. + sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) + }; + } + } catch (error) { + console.warn("[BON Giveaway] Opening reconciliation deferred:", error); + } + if (attempt < 2) { + await new Promise(resolve => setTimeout(resolve, 700)); + } + } + return null; + } + async function startGiveaway() { clearWinnersStatusUI(); @@ -270,36 +332,73 @@ return; } - // Capture the earliest possible public opening boundary BEFORE the - // async chat send. The server can publish the message well before its - // successful POST response reaches this tab; waiting for that response - // would incorrectly extend a 45-minute giveaway. + // Read a Main Chat cursor BEFORE preparing the durable checkpoint. + // UNIT3D may ignore after_id; the verifier also filters by author, + // exact prepared message and publication-time window. + const afterMessageId = REHEARSAL_MODE + ? null : await getLatestChatMessageId(DARKPEERS_MAIN_CHATROOM_ID); + if (!ownsTabLock()) return; + + // Deadline: conservative PRE-SEND time. Sponsorship: independently + // verified PUBLICATION time (never the earlier request timestamp). const openingSendStartedAt = Date.now(); - const introSent = await sendMessage(introMessage, { - requireExclusiveGiveawayOwnership: true + const preparedOpening = prepareOutgoingMessage(introMessage); + giveawayStartTime = new Date(openingSendStartedAt); + giveawayData.openedAt = openingSendStartedAt; + giveawayData.endTs = openingSendStartedAt + totalTimeMs; + giveawayData.__sponsorAccountingVerified = false; + giveawayData.openingCheckpoint = { + status: "pending", + startedAt: openingSendStartedAt, + afterMessageId, + preparedMessage: preparedOpening + }; + window.__activeTracker = tracker; + // If this write/read-back fails, DO NOT send anything publicly. + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + logEvent("Opening blocked", "Could not persist the opening checkpoint before posting."); + window.alert("GIVEAWAY ERROR: Unable to save the opening recovery checkpoint. No message was sent."); + resetGiveaway(); + return; + } + + const introSent = await sendMessage(preparedOpening, { + prepared: true, + requireExclusiveGiveawayOwnership: true, + noFallbackOnApiException: true }); - if (introSent === false) { - logEvent( - "Start aborted (opening announcement)", - "The public opening message could not be accepted for sending. Timers, entries and sponsor tracking were not started." - ); + if (!ownsTabLock()) return; + + // Rehearsal announcements deliberately go to a private host message; + // real openings require authoritative Main Chat confirmation. + const verifiedOpening = introSent !== false && REHEARSAL_MODE + ? { messageId: null, sponsorStartTs: openingSendStartedAt } + : await verifyPendingGiveawayOpening(giveawayData.openingCheckpoint); + + if (!verifiedOpening) { + logEvent("Opening unconfirmed (checkpoint retained)", + "Main Chat publication could not be verified; payout and sponsor tracking remain disabled until recovery verifies the exact opening."); window.alert( - "GIVEAWAY ERROR: The opening message could not be sent safely to DarkPeers Main Chat. " + - "The giveaway was not started." + "GIVEAWAY OPENING UNCONFIRMED: The opening message might already be public. " + + "A recovery checkpoint was saved. Reload Main Chat to retry verification. " + + "Do NOT start another giveaway or send BON manually." ); - resetGiveaway(); + // Keep the snapshot. Never reset or re-send an ambiguous opening. + releaseTabLock(); return; } - // Start no later than the earliest possible public opening. Never - // count the chat response latency as extra entry time. Gift History - // was bootstrapped before this send, so subsequent gifts remain new. - giveawayStartTime = new Date(openingSendStartedAt); - giveawayData.openedAt = openingSendStartedAt; - tracker.giveawayStartTs = openingSendStartedAt; - giveawayData.endTs = openingSendStartedAt + totalTimeMs; + giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs; + giveawayData.openingCheckpoint.status = "confirmed"; + giveawayData.openingCheckpoint.messageId = verifiedOpening.messageId; + tracker.giveawayStartTs = verifiedOpening.sponsorStartTs; syncGiveawayTimeLeft(giveawayData); - window.__activeTracker = tracker; + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + logEvent("Opening confirmation not durable", "Recovery checkpoint retained; settlement remains blocked."); + window.alert("GIVEAWAY OPENING PAUSED: Confirmed opening could not be saved. Reload to recover."); + releaseTabLock(); + return; + } try { await tracker.poll(); } catch (e) { console.error(e); } sponsorsInterval = setInterval( From 066675de8389cdb22d175e94fbd56a6432a8c52d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:53:11 +0100 Subject: [PATCH 039/130] fix(bonanza): retain and reconcile pending opening snapshots before restore --- userscripts/giveaway/src/05-initialization.js | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index fd14e2a2..e91d3f37 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1341,6 +1341,11 @@ winningNumber: giveawayData.winningNumber, totalSeconds: giveawayData.totalSeconds, openedAt: giveawayData.openedAt, + // Deadline and sponsor-window boundaries are different. + sponsorStartTs: giveawayData.sponsorStartTs, + openingCheckpoint: giveawayData.openingCheckpoint && + typeof giveawayData.openingCheckpoint === "object" + ? { ...giveawayData.openingCheckpoint } : null, endTs: giveawayData.endTs, winnersNum: giveawayData.winnersNum, baseWinnersAtStart: giveawayData.baseWinnersAtStart, @@ -1525,7 +1530,8 @@ if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched + !closingSettlementLatched && + snap.giveawayData?.openingCheckpoint?.status !== "pending" ) { console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); clearGiveawaySnapshot(); @@ -1685,6 +1691,66 @@ } try { + // An opening may have reached Main Chat even if its POST response was + // lost. It was durably checkpointed BEFORE sending; never resume + // entries, sponsor accounting or settlement until the exact public + // opening is verified from authoritative chat history. + if (snap.giveawayData?.openingCheckpoint?.status === "pending") { + const verified = await verifyPendingGiveawayOpening( + snap.giveawayData.openingCheckpoint + ); + if (!verified) { + logEvent("Opening recovery deferred", + "Main Chat could not prove the uncertain opening; existing checkpoint preserved."); + window.alert( + "GIVEAWAY RECOVERY PAUSED: The public opening could not be verified. " + + "No giveaway settlement or BON transfer will run. Retry from Main Chat later." + ); + releaseTabLock(); + return false; + } + + // Never resurrect a very old uncertain opening into automatic + // settlement. A human must investigate after recovery grace. + const overdue = Date.now() - Number(snap.giveawayData.endTs); + if (overdue > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS) { + logEvent("Opening recovery requires manual review", + "Opening verified but its automatic settlement grace has expired."); + window.alert( + "GIVEAWAY RECOVERY PAUSED: An old public opening was verified, " + + "but automatic settlement is unsafe after the recovery grace. " + + "Keep the saved recovery state for manual review." + ); + releaseTabLock(); + return false; + } + + snap.giveawayData.sponsorStartTs = verified.sponsorStartTs; + snap.giveawayData.openingCheckpoint.status = "confirmed"; + snap.giveawayData.openingCheckpoint.messageId = verified.messageId; + // Persist the verified boundary before any sponsor reconciliation. + // Do not call snapshotGiveaway yet: entries haven't been restored + // into memory, so serializing them now would overwrite recovery. + const storageKey = getActiveGiveawayStorageKey(snap.giveawayData.host); + if (!storageKey) { + releaseTabLock(); + return false; + } + const persisted = { ...snap }; + delete persisted.__expiredAtLoad; + const verifiedRaw = JSON.stringify(persisted); + try { + localStorage.setItem(storageKey, verifiedRaw); + if (localStorage.getItem(storageKey) !== verifiedRaw) { + throw new Error("Verified opening checkpoint read-back mismatch"); + } + } catch (error) { + console.warn("[BON Giveaway] Could not persist verified opening:", error); + releaseTabLock(); + return false; + } + } + // 1) Restore giveaway data giveawayData = snap.giveawayData; const restoredOpenTs = Number(giveawayData.openedAt ?? snap.startTime); @@ -1891,7 +1957,9 @@ const savedTracker = (snap.sponsorTracker && typeof snap.sponsorTracker === "object") ? snap.sponsorTracker : null; const tracker = new SponsorTracker({ chatroomId, - giveawayStartTime, + giveawayStartTime: new Date( + giveawayData.sponsorStartTs ?? giveawayStartTime.getTime() + ), giveawayData, lastMsgId: savedTracker ? savedTracker.lastMsgId : 0, cursorInitialized: savedTracker ? !!savedTracker.cursorInitialized : false, From d4c60bea953a2e21daef2b6fee4c811470ed58d7 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:53:25 +0100 Subject: [PATCH 040/130] fix(bonanza): avoid duplicate opening posts on ambiguous API timeouts --- userscripts/giveaway/src/12-utilities.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index 1e3fa92f..96018492 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -1751,6 +1751,16 @@ try { if (await trySendViaApi(messageStr)) return true; } catch (e) { + // An opening POST may already have committed before its reply + // timed out. Never issue a second, chatbox-based announcement + // during that uncertainty; the persisted opening checkpoint + // must be reconciled from authoritative Main Chat first. + if (options?.noFallbackOnApiException === true) { + if (DEBUG_SETTINGS.log_chat_messages) { + console.warn("Opening API result ambiguous; leaving checkpoint for verification:", e); + } + return false; + } if (DEBUG_SETTINGS.log_chat_messages) console.warn("API send failed, falling back to chatbox method:", e); if (DEBUG_SETTINGS.verify_sendmessage) console.debug("sendMessage: API send failed, falling back to chatbox method"); } From e75cfe2cc1f6884191f2a3b3f61d482cada1b378 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:54:08 +0100 Subject: [PATCH 041/130] fix(bonanza): apply Silent Mode to panel and chat pot announcements --- userscripts/giveaway/src/10-command-handling.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 238e5f68..003e17ee 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -1141,13 +1141,9 @@ // checking in both public and private delivery paths. const potAnnouncement = `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "); - const announced = ctx.source === "chat" - ? await sendCommandResponse(author, potAnnouncement, { - requireExclusiveGiveawayOwnership: true - }) - : await sendMessage(potAnnouncement, { - requireExclusiveGiveawayOwnership: true - }); + const announced = await sendCommandResponse(author, potAnnouncement, { + requireExclusiveGiveawayOwnership: true + }); if (announced === false) { logEvent( From 4ab876366b3566e14e248b2cddaa45348cfa791f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:55:04 +0100 Subject: [PATCH 042/130] test(bonanza): update deadline assertions for durable opening checkpoints --- tests/bonanza-duration-cap.test.mjs | 100 ++++++---------------------- 1 file changed, 20 insertions(+), 80 deletions(-) diff --git a/tests/bonanza-duration-cap.test.mjs b/tests/bonanza-duration-cap.test.mjs index 30517aa4..f09240a2 100644 --- a/tests/bonanza-duration-cap.test.mjs +++ b/tests/bonanza-duration-cap.test.mjs @@ -137,88 +137,28 @@ test("UI, start path, both time commands, and recovered snapshots share the hard assert.match(initialization, /snap\.giveawayData\.endTs = maxEndTs/); }); -test("opening latency counts against the 45-minute cap, not in addition to it", async () => { - const start = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); - const end = lifecycle.indexOf("window.__activeTracker = tracker;", start); - assert.ok(start >= 0 && end > start); - const openingBlock = lifecycle.slice( - start, - end + "window.__activeTracker = tracker;".length - ); - assert.match(openingBlock, /const introSent = await sendMessage\(/); - assert.ok( - openingBlock.indexOf("openingSendStartedAt = Date.now()") < - openingBlock.indexOf("await sendMessage(") - ); - - const sentAt = 1_800_000_000_000; - let clock = sentAt; - class FakeDate extends Date { - static now() { return clock; } - } - const fakeWindow = { __activeTracker: null, alert() {} }; - const context = vm.createContext({ - Date: FakeDate, - window: fakeWindow, - sendMessage: async () => { - // Message is public before its delayed API response. - clock += 7000; - return true; - }, - syncGiveawayTimeLeft: data => { - data.timeLeft = Math.max(0, Math.ceil((data.endTs - clock) / 1000)); - }, - logEvent() {}, - resetGiveaway() {} - }); - const result = await vm.runInContext(`(async () => { - let giveawayStartTime = null; - const giveawayData = {}; - const tracker = {}; - const introMessage = "Giveaway opening"; - const totalTimeMs = 45 * 60_000; - ${openingBlock} - return { openingSendStartedAt, started: giveawayStartTime.getTime(), - openedAt: giveawayData.openedAt, endTs: giveawayData.endTs, - timeLeft: giveawayData.timeLeft, - trackerStart: tracker.giveawayStartTs }; - })()`, context); - assert.equal(result.openingSendStartedAt, sentAt); - assert.equal(result.started, sentAt); - assert.equal(result.openedAt, sentAt); - assert.equal(result.trackerStart, sentAt); - assert.equal(result.endTs, sentAt + 45 * 60_000); - assert.equal(result.timeLeft, 45 * 60 - 7); - assert.equal(fakeWindow.__activeTracker.giveawayStartTs, sentAt); +test("opening time is durably capped from before the asynchronous chat send", () => { + const captured = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); + const checkpoint = lifecycle.indexOf("openingCheckpoint = {", captured); + const persist = lifecycle.indexOf("snapshotGiveaway({ force: true, verifyWrite: true })", checkpoint); + const send = lifecycle.indexOf("const introSent = await sendMessage(", persist); + const verify = lifecycle.indexOf("await verifyPendingGiveawayOpening(", send); + assert.ok(captured >= 0 && checkpoint > captured && persist > checkpoint); + assert.ok(send > persist && verify > send); + assert.match(lifecycle.slice(captured, send), /giveawayData\.endTs = openingSendStartedAt \+ totalTimeMs/); + assert.match(lifecycle.slice(captured, send), /giveawayData\.openedAt = openingSendStartedAt/); + assert.match(lifecycle.slice(send, verify), /noFallbackOnApiException: true/); }); -test("a failed opening message never commits its pre-send opening timestamp", async () => { - const start = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); - const end = lifecycle.indexOf("window.__activeTracker = tracker;", start); - const openingBlock = lifecycle.slice( - start, - end + "window.__activeTracker = tracker;".length - ); - let resetCalls = 0; - const runtime = vm.createContext({ - Date, - window: { __activeTracker: null, alert() {} }, - sendMessage: async () => false, - logEvent() {}, - resetGiveaway() { resetCalls++; }, - syncGiveawayTimeLeft() {} - }); - const result = await vm.runInContext(`(async () => { - let giveawayStartTime = null; - const giveawayData = {}; - const tracker = {}; - const introMessage = "Giveaway opening"; - const totalTimeMs = 45 * 60_000; - ${openingBlock} - return { openedAt: giveawayData.openedAt, endTs: giveawayData.endTs }; - })()`, runtime); - assert.equal(result, undefined); - assert.equal(resetCalls, 1); +test("unconfirmed openings are preserved instead of being reset or paid", () => { + const start = lifecycle.indexOf("if (!verifiedOpening)"); + const end = lifecycle.indexOf("giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs", start); + assert.ok(start >= 0 && end > start); + const path = lifecycle.slice(start, end); + assert.match(path, /releaseTabLock\(\)/); + assert.match(path, /return;/); + assert.doesNotMatch(path, /resetGiveaway\(\)/); + assert.match(path, /opening unconfirmed/i); }); test("a rejected !addtime in chat is announced publicly without changing the timer", () => { From 4fb4169cce7bc8983dc5878a6bd2c9baea512aa7 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:55:43 +0100 Subject: [PATCH 043/130] test(bonanza): verify durable opening, sponsor boundary, timeout and Silent Mode hardening --- tests/bonanza-opening-recovery.test.mjs | 190 ++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/bonanza-opening-recovery.test.mjs diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs new file mode 100644 index 00000000..bc4969a0 --- /dev/null +++ b/tests/bonanza-opening-recovery.test.mjs @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const root = new URL("../", import.meta.url); +const read = p => readFileSync(new URL(p, root), "utf8"); +const lifecycle = read("userscripts/giveaway/src/06-lifecycle.js"); +const initialization = read("userscripts/giveaway/src/05-initialization.js"); +const utilities = read("userscripts/giveaway/src/12-utilities.js"); +const commands = read("userscripts/giveaway/src/10-command-handling.js"); + +function extract(source, signature) { + const start = source.indexOf(" " + signature); + assert.notEqual(start, -1, "Missing " + signature); + const delimiter = "\n }\n"; + const end = source.indexOf(delimiter, start); + assert.notEqual(end, -1, "Unclosed " + signature); + return source.slice(start, end + delimiter.length); +} + +const verifyOpening = extract(lifecycle, "async function verifyPendingGiveawayOpening("); +const openingSend = extract(utilities, "async function sendMessage("); +const MINUTE = 60_000; +const START = Date.UTC(2026, 9, 10, 11, 0, 0, 500); +const sameSecond = new Date(START - 500).toISOString().replace(".000Z", "Z"); +const nextSecond = new Date(START + 500).toISOString().replace(".000Z", "Z"); +const preparedMessage = "🎁 I am hosting a giveaway for 100 BON"; + +function createHarness({ records = [], responses = null, owned = () => true } = {}) { + const calls = []; + const checks = { + status: "pending", + startedAt: START, + afterMessageId: 100, + preparedMessage + }; + const runtime = vm.createContext({ + URL, + location: { origin: "https://darkpeers.org" }, + OT_USER_ID: 17, + DARKPEERS_MAIN_CHATROOM_ID: 1, + ownsTabLock: owned, + optionalFiniteNumber: value => value == null ? null : Number(value), + unit3dTimestampResolutionMs: raw => String(raw).includes(".") ? 1 : 1000, + fetchWithTimeout: async url => { + calls.push(String(url)); + const result = responses ? responses.shift() : records; + if (result instanceof Error) throw result; + return { + ok: true, + json: async () => ({ data: result }) + }; + }, + setTimeout: fn => fn(), + console: { warn() {} } + }); + vm.runInContext(verifyOpening, runtime); + return { run: () => runtime.verifyPendingGiveawayOpening(checks), calls, checks }; +} + +const goodMessage = (overrides = {}) => ({ + id: 101, + user_id: 17, + message: preparedMessage, + created_at: nextSecond, + ...overrides +}); + +test("opening reconciliation requires authenticated author, exact announcement and cursor", async () => { + const h = createHarness({ records: [ + goodMessage({ id: 100 }), + goodMessage({ user_id: 18 }), + goodMessage({ message: "another giveaway" }), + goodMessage() + ] }); + const result = await h.run(); + assert.ok(result); + assert.equal(result.messageId, 101); + assert.equal(result.sponsorStartTs, START + 1500); + assert.match(h.calls[0], /after_id=100/); +}); + +test("verified sponsorship starts AFTER the publication timestamp bucket", async () => { + const h = createHarness({ records: [goodMessage({ created_at: sameSecond })] }); + const result = await h.run(); + assert.ok(result); + assert.equal(result.sponsorStartTs, START + 500); + assert.ok(result.sponsorStartTs >= START); +}); + +test("ambiguous opening does not accept someone else's message or mismatched text", async () => { + const h = createHarness({ records: [ + goodMessage({ user_id: 20 }), + goodMessage({ message: "old intro" }) + ] }); + assert.equal(await h.run(), null); + assert.equal(h.calls.length, 3); +}); + +test("a missing announcement is not treated as definitive non-publication", async () => { + const h = createHarness({ records: [] }); + assert.equal(await h.run(), null); + assert.equal(h.calls.length, 3); +}); + +test("failed authoritative chat reads preserve ambiguous opening", async () => { + const h = createHarness({ responses: [ + new Error("timeout"), new Error("timeout"), new Error("timeout") + ] }); + assert.equal(await h.run(), null); + assert.equal(h.calls.length, 3); +}); + +test("ownership loss during reconciliation aborts without verification", async () => { + let count = 0; + const h = createHarness({ + records: [goodMessage()], + owned: () => ++count === 1 + }); + assert.equal(await h.run(), null); +}); + +test("reconciliation refuses matching messages entirely before the send boundary", async () => { + const timestamp = new Date(START - 2500).toISOString().replace(".000Z", "Z"); + const h = createHarness({ records: [goodMessage({ created_at: timestamp })] }); + assert.equal(await h.run(), null); +}); + +test("ambiguous opening API timeouts do not trigger chatbox fallback duplicates", async () => { + let fallback = 0; + const runtime = vm.createContext({ + REHEARSAL_MODE: false, + DEBUG_SETTINGS: { + disable_chat_output: false, + suppressApiMessages: false, + log_chat_messages: false, + verify_sendmessage: false + }, + OT_USER_ID: 17, + OT_CHATROOM_ID: 1, + OT_CSRF_TOKEN: "token", + prepareOutgoingMessage: text => text, + trySendViaApi: async () => { throw new Error("POST timed out"); }, + sendViaChatbox: () => { fallback++; return true; }, + console: { warn() {} } + }); + vm.runInContext(openingSend, runtime); + assert.equal(await runtime.sendMessage("opening", { + prepared: true, noFallbackOnApiException: true + }), false); + assert.equal(fallback, 0); + assert.equal(await runtime.sendMessage("ordinary", { prepared: true }), true); + assert.equal(fallback, 1); +}); + +test("recovery checkpoint and independent sponsor boundary survive snapshots", () => { + assert.match(initialization, /openingCheckpoint: giveawayData\.openingCheckpoint/); + assert.match(initialization, /sponsorStartTs: giveawayData\.sponsorStartTs/); + assert.match(initialization, /verifyPendingGiveawayOpening\(/); + const start = initialization.indexOf('if (snap.giveawayData?.openingCheckpoint?.status === "pending")'); + const restore = initialization.indexOf("// 1) Restore giveaway data", start); + assert.ok(start >= 0 && restore > start); + const guard = initialization.slice(start, restore); + assert.match(guard, /if \(!verified\)/); + assert.match(guard, /releaseTabLock\(\)/); + assert.match(guard, /EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS/); + assert.match(guard, /localStorage\.setItem\(storageKey, verifiedRaw\)/); + assert.match(guard, /read-back mismatch/); + assert.doesNotMatch(guard, /endGiveaway\(/); + assert.match(initialization, /giveawayStartTime: new Date\(\s*giveawayData\.sponsorStartTs \?\?/); +}); + +test("winners and host top-ups route command announcements privately under Silent Mode", () => { + const winnerHandler = commands.slice( + commands.indexOf(" winners(ctx) {"), + commands.indexOf(" maxwinners(ctx) {") + ); + assert.match(winnerHandler, /reply\(announcement\)/); + assert.doesNotMatch(winnerHandler, /sendMessage\(announcement\)/); + + const topUp = commands.slice( + commands.indexOf(" async function hostAddBon("), + commands.indexOf(" function rebuildSchedule()") + ); + assert.match(topUp, /sendCommandResponse\(author, potAnnouncement, \{/); + assert.doesNotMatch(topUp, /await sendMessage\(\s*`\$\{bridgeMarker/); + assert.match(utilities, /async function sendCommandResponse\(username, messageStr, options = \{\}\)/); + assert.match(utilities, /return sendPrivateMessage\(username, messageStr, options\)/); +}); From 3951a205cb2761352754d832376a331a1ff48deb Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:56:05 +0100 Subject: [PATCH 044/130] test(bonanza): keep overdue ambiguous opening checkpoints recoverable --- tests/bonanza-duration-recovery.test.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs index 1b15d43c..4e2ddf0c 100644 --- a/tests/bonanza-duration-recovery.test.mjs +++ b/tests/bonanza-duration-recovery.test.mjs @@ -32,6 +32,7 @@ function recoveryHarness({ ageMinutes, advertisedMinutes = 90, reminderCount = 1, + openingCheckpoint = null, settlement = null, closingIntent = false, closingNoticeSent = false @@ -46,6 +47,7 @@ function recoveryHarness({ host: "maghuro", rehearsalMode: false, endTs: originalEnd, + openingCheckpoint, reminderNum: reminderCount, reminderSchedule: [Math.round(advertisedMinutes / (reminderCount + 1) * MINUTE)], reminderFreqSec: advertisedMinutes * 60 / (reminderCount + 1), @@ -206,3 +208,21 @@ test("restored reminder count also hydrates the UI for later host/staff time cha /if \(remNumInput\) \{[\s\S]*?remNumInput\.value = String\(Number\.isFinite\(savedReminderCount\)/ ); }); + +test("ancient uncertain opening retains recovery checkpoint without auto-settlement", () => { + const h = recoveryHarness({ + ageMinutes: 3 * 24 * 60, + advertisedMinutes: 90, + openingCheckpoint: { + status: "pending", + startedAt: NOW - 3 * 24 * 60 * MINUTE, + preparedMessage: "Public giveaway intro" + } + }); + const pending = h.restore(); + assert.ok(pending); + assert.equal(pending.giveawayData.openingCheckpoint.status, "pending"); + assert.equal(pending.__expiredAtLoad, true); + assert.equal(pending.giveawayData.endTs, h.cappedEnd); + assert.equal(h.clears.length, 0, "Never erase a possibly-public opening without authoritative reconciliation"); +}); From 7261fde05e8016fc2521a0efd3f3884be02029d8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:56:29 +0100 Subject: [PATCH 045/130] build(bonanza): regenerate bundle for durable opening recovery and command routing --- .../DarkPeers_BONanza_Giveaway.user.js | 246 +++++++++++++++--- 1 file changed, 213 insertions(+), 33 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index d7fafb18..6915984f 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -3158,6 +3158,11 @@ body.host-panel-dragging * { winningNumber: giveawayData.winningNumber, totalSeconds: giveawayData.totalSeconds, openedAt: giveawayData.openedAt, + // Deadline and sponsor-window boundaries are different. + sponsorStartTs: giveawayData.sponsorStartTs, + openingCheckpoint: giveawayData.openingCheckpoint && + typeof giveawayData.openingCheckpoint === "object" + ? { ...giveawayData.openingCheckpoint } : null, endTs: giveawayData.endTs, winnersNum: giveawayData.winnersNum, baseWinnersAtStart: giveawayData.baseWinnersAtStart, @@ -3342,7 +3347,8 @@ body.host-panel-dragging * { if ( overdueMs > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS && !committedSettlement && - !closingSettlementLatched + !closingSettlementLatched && + snap.giveawayData?.openingCheckpoint?.status !== "pending" ) { console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); clearGiveawaySnapshot(); @@ -3502,6 +3508,66 @@ body.host-panel-dragging * { } try { + // An opening may have reached Main Chat even if its POST response was + // lost. It was durably checkpointed BEFORE sending; never resume + // entries, sponsor accounting or settlement until the exact public + // opening is verified from authoritative chat history. + if (snap.giveawayData?.openingCheckpoint?.status === "pending") { + const verified = await verifyPendingGiveawayOpening( + snap.giveawayData.openingCheckpoint + ); + if (!verified) { + logEvent("Opening recovery deferred", + "Main Chat could not prove the uncertain opening; existing checkpoint preserved."); + window.alert( + "GIVEAWAY RECOVERY PAUSED: The public opening could not be verified. " + + "No giveaway settlement or BON transfer will run. Retry from Main Chat later." + ); + releaseTabLock(); + return false; + } + + // Never resurrect a very old uncertain opening into automatic + // settlement. A human must investigate after recovery grace. + const overdue = Date.now() - Number(snap.giveawayData.endTs); + if (overdue > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS) { + logEvent("Opening recovery requires manual review", + "Opening verified but its automatic settlement grace has expired."); + window.alert( + "GIVEAWAY RECOVERY PAUSED: An old public opening was verified, " + + "but automatic settlement is unsafe after the recovery grace. " + + "Keep the saved recovery state for manual review." + ); + releaseTabLock(); + return false; + } + + snap.giveawayData.sponsorStartTs = verified.sponsorStartTs; + snap.giveawayData.openingCheckpoint.status = "confirmed"; + snap.giveawayData.openingCheckpoint.messageId = verified.messageId; + // Persist the verified boundary before any sponsor reconciliation. + // Do not call snapshotGiveaway yet: entries haven't been restored + // into memory, so serializing them now would overwrite recovery. + const storageKey = getActiveGiveawayStorageKey(snap.giveawayData.host); + if (!storageKey) { + releaseTabLock(); + return false; + } + const persisted = { ...snap }; + delete persisted.__expiredAtLoad; + const verifiedRaw = JSON.stringify(persisted); + try { + localStorage.setItem(storageKey, verifiedRaw); + if (localStorage.getItem(storageKey) !== verifiedRaw) { + throw new Error("Verified opening checkpoint read-back mismatch"); + } + } catch (error) { + console.warn("[BON Giveaway] Could not persist verified opening:", error); + releaseTabLock(); + return false; + } + } + // 1) Restore giveaway data giveawayData = snap.giveawayData; const restoredOpenTs = Number(giveawayData.openedAt ?? snap.startTime); @@ -3708,7 +3774,9 @@ body.host-panel-dragging * { const savedTracker = (snap.sponsorTracker && typeof snap.sponsorTracker === "object") ? snap.sponsorTracker : null; const tracker = new SponsorTracker({ chatroomId, - giveawayStartTime, + giveawayStartTime: new Date( + giveawayData.sponsorStartTs ?? giveawayStartTime.getTime() + ), giveawayData, lastMsgId: savedTracker ? savedTracker.lastMsgId : 0, cursorInitialized: savedTracker ? !!savedTracker.cursorInitialized : false, @@ -3911,6 +3979,68 @@ body.host-panel-dragging * { // SECTION 6: Giveaway Lifecycle // ─────────────────────────────────────────────────────────── + // Reconcile an opening announcement against authoritative Main Chat history. + // An HTTP timeout is NOT evidence the server rejected the POST. Never + // release a pending opening snapshot merely because sendMessage returned + // false; an announced giveaway may already exist publicly. + async function verifyPendingGiveawayOpening(checkpoint) { + if (!checkpoint || checkpoint.status !== "pending" || + !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { + return null; + } + + const afterId = optionalFiniteNumber(checkpoint.afterMessageId); + const startedAt = Number(checkpoint.startedAt); + const senderId = Math.floor(Number(OT_USER_ID)); + if (!Number.isFinite(senderId) || senderId <= 0) return null; + + for (let attempt = 0; attempt < 3; attempt++) { + if (!ownsTabLock()) return null; + try { + const url = new URL(`/api/chat/messages/${DARKPEERS_MAIN_CHATROOM_ID}`, location.origin); + if (afterId !== null) url.searchParams.set("after_id", String(afterId)); + const response = await fetchWithTimeout( + url, { credentials: "include", cache: "no-store" }, 5000 + ); + if (!ownsTabLock()) return null; + if (!response?.ok) throw new Error("Opening verification chat read failed"); + const payload = await response.json(); + if (!ownsTabLock()) return null; + if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); + + for (const message of payload.data) { + const id = optionalFiniteNumber(message?.id); + if (afterId !== null && id !== null && id <= afterId) continue; + const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); + if (authorId !== senderId) continue; + if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; + + const rawTs = String(message?.created_at || ""); + const timestamp = Date.parse(rawTs); + const resolutionMs = unit3dTimestampResolutionMs(rawTs); + // A coarse timestamp can include the send start instant, + // but an older identical announcement must not be reused. + if (!Number.isFinite(timestamp) || + timestamp + resolutionMs <= startedAt) continue; + + return { + messageId: id, + // Exclude the entire potentially ambiguous publication + // timestamp bucket from sponsorship. The 45-minute + // deadline remains anchored to the earlier send start. + sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) + }; + } + } catch (error) { + console.warn("[BON Giveaway] Opening reconciliation deferred:", error); + } + if (attempt < 2) { + await new Promise(resolve => setTimeout(resolve, 700)); + } + } + return null; + } + async function startGiveaway() { clearWinnersStatusUI(); @@ -4180,36 +4310,73 @@ body.host-panel-dragging * { return; } - // Capture the earliest possible public opening boundary BEFORE the - // async chat send. The server can publish the message well before its - // successful POST response reaches this tab; waiting for that response - // would incorrectly extend a 45-minute giveaway. + // Read a Main Chat cursor BEFORE preparing the durable checkpoint. + // UNIT3D may ignore after_id; the verifier also filters by author, + // exact prepared message and publication-time window. + const afterMessageId = REHEARSAL_MODE + ? null : await getLatestChatMessageId(DARKPEERS_MAIN_CHATROOM_ID); + if (!ownsTabLock()) return; + + // Deadline: conservative PRE-SEND time. Sponsorship: independently + // verified PUBLICATION time (never the earlier request timestamp). const openingSendStartedAt = Date.now(); - const introSent = await sendMessage(introMessage, { - requireExclusiveGiveawayOwnership: true + const preparedOpening = prepareOutgoingMessage(introMessage); + giveawayStartTime = new Date(openingSendStartedAt); + giveawayData.openedAt = openingSendStartedAt; + giveawayData.endTs = openingSendStartedAt + totalTimeMs; + giveawayData.__sponsorAccountingVerified = false; + giveawayData.openingCheckpoint = { + status: "pending", + startedAt: openingSendStartedAt, + afterMessageId, + preparedMessage: preparedOpening + }; + window.__activeTracker = tracker; + // If this write/read-back fails, DO NOT send anything publicly. + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + logEvent("Opening blocked", "Could not persist the opening checkpoint before posting."); + window.alert("GIVEAWAY ERROR: Unable to save the opening recovery checkpoint. No message was sent."); + resetGiveaway(); + return; + } + + const introSent = await sendMessage(preparedOpening, { + prepared: true, + requireExclusiveGiveawayOwnership: true, + noFallbackOnApiException: true }); - if (introSent === false) { - logEvent( - "Start aborted (opening announcement)", - "The public opening message could not be accepted for sending. Timers, entries and sponsor tracking were not started." - ); + if (!ownsTabLock()) return; + + // Rehearsal announcements deliberately go to a private host message; + // real openings require authoritative Main Chat confirmation. + const verifiedOpening = introSent !== false && REHEARSAL_MODE + ? { messageId: null, sponsorStartTs: openingSendStartedAt } + : await verifyPendingGiveawayOpening(giveawayData.openingCheckpoint); + + if (!verifiedOpening) { + logEvent("Opening unconfirmed (checkpoint retained)", + "Main Chat publication could not be verified; payout and sponsor tracking remain disabled until recovery verifies the exact opening."); window.alert( - "GIVEAWAY ERROR: The opening message could not be sent safely to DarkPeers Main Chat. " + - "The giveaway was not started." + "GIVEAWAY OPENING UNCONFIRMED: The opening message might already be public. " + + "A recovery checkpoint was saved. Reload Main Chat to retry verification. " + + "Do NOT start another giveaway or send BON manually." ); - resetGiveaway(); + // Keep the snapshot. Never reset or re-send an ambiguous opening. + releaseTabLock(); return; } - // Start no later than the earliest possible public opening. Never - // count the chat response latency as extra entry time. Gift History - // was bootstrapped before this send, so subsequent gifts remain new. - giveawayStartTime = new Date(openingSendStartedAt); - giveawayData.openedAt = openingSendStartedAt; - tracker.giveawayStartTs = openingSendStartedAt; - giveawayData.endTs = openingSendStartedAt + totalTimeMs; + giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs; + giveawayData.openingCheckpoint.status = "confirmed"; + giveawayData.openingCheckpoint.messageId = verifiedOpening.messageId; + tracker.giveawayStartTs = verifiedOpening.sponsorStartTs; syncGiveawayTimeLeft(giveawayData); - window.__activeTracker = tracker; + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + logEvent("Opening confirmation not durable", "Recovery checkpoint retained; settlement remains blocked."); + window.alert("GIVEAWAY OPENING PAUSED: Confirmed opening could not be saved. Reload to recover."); + releaseTabLock(); + return; + } try { await tracker.poll(); } catch (e) { console.error(e); } sponsorsInterval = setInterval( @@ -7583,7 +7750,7 @@ body.host-panel-dragging * { const announcement = `[b][color=${SCALING_ACCENT_COLOR}]Host adjustment:[/color][/b] ` + `[b]Winners ${direction}[/b]: [b][color=#5DE2E7]${prevEffective} → ${newCount} (${sign}${delta})[/color][/b].`; - sendMessage(announcement); + reply(announcement); flashWinnersUI(); logEvent("Host adjusted winners", `${prevEffective} -> ${newCount} (${sign}${delta})`); } @@ -7906,11 +8073,14 @@ body.host-panel-dragging * { snapshotGiveaway({ force: true }); - const announced = await sendMessage( - `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + - [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "), - { requireExclusiveGiveawayOwnership: true } - ); + // Command-generated pot announcements must honor Silent Mode. + // Route privately to the invoking host there; preserve ownership + // checking in both public and private delivery paths. + const potAnnouncement = `${bridgeMarker(BRIDGE_MARKERS.POT, "💰")} ` + + [addedPart, totalPart, donationPart, scalingPart].filter(Boolean).join(" "); + const announced = await sendCommandResponse(author, potAnnouncement, { + requireExclusiveGiveawayOwnership: true + }); if (announced === false) { logEvent( @@ -12145,11 +12315,11 @@ body.host-panel-dragging * { return sendMessage(`/msg ${to} ${body}`, options); } - async function sendCommandResponse(username, messageStr) { + async function sendCommandResponse(username, messageStr, options = {}) { if (GENERAL_SETTINGS.silent_mode) { - return sendPrivateMessage(username, messageStr); + return sendPrivateMessage(username, messageStr, options); } - return sendMessage(messageStr); + return sendMessage(messageStr, options); } /** Prepare a message for sending: obfuscate "giveaway" and apply number formatting. */ @@ -12277,6 +12447,16 @@ body.host-panel-dragging * { try { if (await trySendViaApi(messageStr)) return true; } catch (e) { + // An opening POST may already have committed before its reply + // timed out. Never issue a second, chatbox-based announcement + // during that uncertainty; the persisted opening checkpoint + // must be reconciled from authoritative Main Chat first. + if (options?.noFallbackOnApiException === true) { + if (DEBUG_SETTINGS.log_chat_messages) { + console.warn("Opening API result ambiguous; leaving checkpoint for verification:", e); + } + return false; + } if (DEBUG_SETTINGS.log_chat_messages) console.warn("API send failed, falling back to chatbox method:", e); if (DEBUG_SETTINGS.verify_sendmessage) console.debug("sendMessage: API send failed, falling back to chatbox method"); } From cb2bfde16dcd5b495a3d752d88f5fbbeaa8a0353 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:57:10 +0100 Subject: [PATCH 046/130] test(bonanza): account for opening replay cursor in hardening invariants --- tests/bonanza-review-hardening.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bonanza-review-hardening.test.mjs b/tests/bonanza-review-hardening.test.mjs index 5f2b2ecd..1a646bfd 100644 --- a/tests/bonanza-review-hardening.test.mjs +++ b/tests/bonanza-review-hardening.test.mjs @@ -20,7 +20,7 @@ test("review hardening invariants stay present", () => { assert.match(source, /Rehearsal BON Pool contribution suppressed/); assert.equal( (source.match(/getLatestChatMessageId\(DARKPEERS_MAIN_CHATROOM_ID\)/g) || []).length, - 4 + 5 // Including the pre-send opening cursor for ambiguous POST recovery ); assert.equal( (source.match(/getLatestChatMessageId\(DARKPEERS_CHATROOM_ID\)/g) || []).length, From c12052e9fc62d11d73100b28a6989a00cc85751a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:58:24 +0100 Subject: [PATCH 047/130] fix(bonanza): send manually requested reminders via command reply routing --- userscripts/giveaway/src/10-command-handling.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 003e17ee..25bcd736 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -748,7 +748,7 @@ if (normalizeUserKey(ctx.author) === normalizeUserKey(ctx.giveawayData.host)) { // An explicit host !reminder is a manual override: send it now // instead of applying the automatic recent-reminder suppression. - sendReminder({ force: true }); + sendReminder({ force: true, commandAuthor: ctx.author }); } }, From a38fb3a3668d6c75c71eabcd9c519635b8396165 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:58:28 +0100 Subject: [PATCH 048/130] fix(bonanza): preserve public automatic reminders but private Silent Mode manual reminders --- userscripts/giveaway/src/12-utilities.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index 96018492..71852e82 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -687,7 +687,12 @@ `✨[b][color=#FB4F4F]Gift the host to add to the pot! [color=${GIFT_HINT_COLOR}]/gift ${getGiftSyntaxHostName()} AMOUNT MESSAGE[/color][/color][/b]✨` + silentLine + rigLine; - sendMessage(msg); + // Automatic interval reminders remain public so entrants can see them. + // A manual !reminder is command output: honor Silent Mode there. + if (options.commandAuthor) { + return sendCommandResponse(options.commandAuthor, msg); + } + return sendMessage(msg); } // ───────────── HTTP-based BON gifting helper ───────────── From ba2eadfd1a078af18e3b52441e87fd4f3acdf140 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:59:09 +0100 Subject: [PATCH 049/130] fix(bonanza): block new giveaways while uncertain opening is recoverable --- userscripts/giveaway/src/06-lifecycle.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 7d2bd2d0..50db9646 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -86,6 +86,30 @@ return; } + // Never overwrite a recoverable opening whose public POST was ambiguous. + // The pending snapshot is the only path to reconcile entrants/sponsors + // after a reload. A second start could otherwise destroy that evidence. + const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { + migrateLegacy: true + }); + if (prior.raw) { + try { + const saved = JSON.parse(prior.raw); + if (saved?.giveawayData?.openingCheckpoint?.status === "pending") { + window.alert( + "GIVEAWAY START BLOCKED: A previous opening is awaiting " + + "Main Chat verification. Reload DarkPeers and recover it " + + "before starting another giveaway. Its saved state was preserved." + ); + return; + } + } catch { + // Unknown existing recovery data must not be overwritten. + window.alert("GIVEAWAY START BLOCKED: Existing recovery data could not be read safely."); + return; + } + } + clearGiveawayLog(); // Normalize and validate the giveaway amount separately so we tolerate From a107db1f2cf99c8d249c94e8f8985cba912aa84c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:59:25 +0100 Subject: [PATCH 050/130] test(bonanza): protect uncertain recovery from restart and route manual reminders --- tests/bonanza-opening-recovery.test.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index bc4969a0..9feea22f 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -188,3 +188,23 @@ test("winners and host top-ups route command announcements privately under Silen assert.match(utilities, /async function sendCommandResponse\(username, messageStr, options = \{\}\)/); assert.match(utilities, /return sendPrivateMessage\(username, messageStr, options\)/); }); + +test("recoverable uncertain opening blocks a second start from overwriting its snapshot", () => { + const start = lifecycle.indexOf("async function startGiveaway()"); + const startLock = lifecycle.indexOf("await acquireTabLock()", start); + const safety = lifecycle.slice(start, startLock); + assert.match(safety, /readActiveGiveawayRawForHost\(getLoggedInUsername\(\)/); + assert.match(safety, /openingCheckpoint\?\.status === "pending"/); + assert.match(safety, /GIVEAWAY START BLOCKED/); +}); + +test("manual reminder follows private command routing while automatic timers remain public", () => { + assert.match(commands, /sendReminder\(\{ force: true, commandAuthor: ctx\.author \}\)/); + const reminder = utilities.slice( + utilities.indexOf(" function sendReminder("), + utilities.indexOf(" // ───────────── HTTP-based BON gifting helper") + ); + assert.match(reminder, /if \(options\.commandAuthor\)/); + assert.match(reminder, /return sendCommandResponse\(options\.commandAuthor, msg\)/); + assert.match(reminder, /return sendMessage\(msg\)/); +}); From c4494fdb1b6832b5bb41e3c5687399d093048c0b Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:59:49 +0100 Subject: [PATCH 051/130] build(bonanza): sync generated userscript after opening and Silent Mode review fixes --- .../DarkPeers_BONanza_Giveaway.user.js | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 6915984f..b2f25845 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -4064,6 +4064,30 @@ body.host-panel-dragging * { return; } + // Never overwrite a recoverable opening whose public POST was ambiguous. + // The pending snapshot is the only path to reconcile entrants/sponsors + // after a reload. A second start could otherwise destroy that evidence. + const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { + migrateLegacy: true + }); + if (prior.raw) { + try { + const saved = JSON.parse(prior.raw); + if (saved?.giveawayData?.openingCheckpoint?.status === "pending") { + window.alert( + "GIVEAWAY START BLOCKED: A previous opening is awaiting " + + "Main Chat verification. Reload DarkPeers and recover it " + + "before starting another giveaway. Its saved state was preserved." + ); + return; + } + } catch { + // Unknown existing recovery data must not be overwritten. + window.alert("GIVEAWAY START BLOCKED: Existing recovery data could not be read safely."); + return; + } + } + clearGiveawayLog(); // Normalize and validate the giveaway amount separately so we tolerate @@ -7685,7 +7709,7 @@ body.host-panel-dragging * { if (normalizeUserKey(ctx.author) === normalizeUserKey(ctx.giveawayData.host)) { // An explicit host !reminder is a manual override: send it now // instead of applying the automatic recent-reminder suppression. - sendReminder({ force: true }); + sendReminder({ force: true, commandAuthor: ctx.author }); } }, @@ -11383,7 +11407,12 @@ body.host-panel-dragging * { `✨[b][color=#FB4F4F]Gift the host to add to the pot! [color=${GIFT_HINT_COLOR}]/gift ${getGiftSyntaxHostName()} AMOUNT MESSAGE[/color][/color][/b]✨` + silentLine + rigLine; - sendMessage(msg); + // Automatic interval reminders remain public so entrants can see them. + // A manual !reminder is command output: honor Silent Mode there. + if (options.commandAuthor) { + return sendCommandResponse(options.commandAuthor, msg); + } + return sendMessage(msg); } // ───────────── HTTP-based BON gifting helper ───────────── From 86687c862795059b16db9540cd9720a42720cd1e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:01:14 +0100 Subject: [PATCH 052/130] docs(bonanza): record fail-closed opening, sponsor and Silent Mode invariants --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d0adb3ab..f5a5bae2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,30 @@ multiple userscripts, not just BONanza. otherwise post public chat messages or move real currency/points. Never initiate a live payout for a routine code test. +## Opening / sponsor accounting invariants + +- Before posting the public giveaway opening, persist and read back a + **pending opening checkpoint** with the exact prepared announcement, + pre-send timestamp and Main Chat cursor. A timeout/ownership loss is + ambiguous: do not erase this checkpoint, re-post blindly, or settle BON. + Restore must verify the exact own-user announcement in authoritative + Main Chat history before enabling entries or any sponsor/payout action. +- The 45-minute absolute deadline is anchored to the conservative + **pre-send** time, so server response latency cannot extend the event. + **Sponsor eligibility must NOT use that time.** It begins strictly after + the independently verified publication timestamp bucket, even if this + means omitting an ambiguous boundary gift. Persist `sponsorStartTs` and + use it for both live Gift History polling and reload reconciliation. +- Leave unresolved opening checkpoints intact for later recovery; never + overwrite them with a new giveaway or auto-settle a recovered ancient + opening beyond the one-hour expiry grace. Keep all BON-moving operations + blocked while publication or sponsor timing remains unverified. +- In **Silent Mode**, every response generated by a chat/host command, + including staff actions, errors, `!winners`, `!addbon` and manual + `!reminder`, follows private message routing. Scheduled automatic + reminders remain public. Rehearsal Mode routes generated output privately + to the host and must never send real BON. + Full source layout and examples: [`userscripts/giveaway/src/README.md`](userscripts/giveaway/src/README.md). From e1a9fd91e359e3d054de30b537a33e613a3b3243 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:01:17 +0100 Subject: [PATCH 053/130] docs(bonanza): explain checkpoint recovery and distinct sponsor boundary --- userscripts/giveaway/src/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index db70c48f..f2ca9020 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -38,6 +38,26 @@ Ordered source: The validation workflow rejects stale generated output. The publishing workflow rebuilds, verifies and publishes the generated script. +## Opening and chat safety contract + +An opening has two independent boundaries: `openedAt` is captured **before** +the public opening message is sent (and determines the maximum 45-minute +deadline), while `sponsorStartTs` is derived from the server-confirmed +announcement timestamp, conservatively beyond its timestamp resolution +bucket. A pre-send `openingCheckpoint` is durably stored before posting. +If delivery is ambiguous, preserve it and retry verification on reload; +do not silently discard the snapshot or perform settlement. + +A new giveaway must not overwrite a pending opening checkpoint. Recovered +opening checkpoints older than the normal settlement grace require manual +review rather than automatic BON transfers. Do not unify the two timestamps +or disable the fail-closed verification gates when changing the lifecycle. + +For command output, Silent Mode routes host and staff responses to private +messages, including winner/pot adjustment announcements and manual +`!reminder`. Scheduled reminders remain visible in Main Chat. Rehearsal +Mode keeps script output private to the host and BON transfers simulated. + ## Maintenance rule (source of truth) **Edit `userscripts/giveaway/src/*.js`, never the generated `.user.js` directly.** From a4b9d7a679a3e9c94bbfbdbfd19bf79d6b630292 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:01:20 +0100 Subject: [PATCH 054/130] docs(bonanza): annotate verified opening and sponsor safety in version notes --- userscripts/giveaway/src/00-preamble.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 70e1ec59..0bcd1d60 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -249,6 +249,10 @@ // Rejected !addtime / !time add attempts explain the 45-minute total // limit without changing the deadline. Silent Mode sends all host/staff // command replies privately; Rehearsal routes script output to host PMs. +// Opening delivery is checkpointed before the POST and verified against +// Main Chat; a timeout preserves recovery rather than resetting. Sponsor +// eligibility begins after the verified publication timestamp bucket, +// separately from the conservative 45-minute pre-send deadline. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From eab133f6334686f145fbc74bb6d122a34b53dd02 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:02:53 +0100 Subject: [PATCH 055/130] fix(bonanza): re-enable verified sponsorship after confirmed public opening --- userscripts/giveaway/src/06-lifecycle.js | 31 ++++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 50db9646..c09e47b2 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -424,11 +424,32 @@ return; } - try { await tracker.poll(); } catch (e) { console.error(e); } - sponsorsInterval = setInterval( - () => tracker.poll(), - SPONSOR_GIFT_HISTORY_POLL_MS - ); + // The pre-send checkpoint deliberately starts with accounting + // disabled. Once publication has been verified, reconcile the + // canonical Gift History BEFORE re-enabling pot mutations. Otherwise + // !addbon stays blocked forever on a fresh, otherwise healthy start. + try { + await tracker.reconcileCanonicalSponsorAccounting({ repairStats: false }); + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + throw new Error("Opening sponsor reconciliation snapshot did not persist"); + } + setSponsorAccountingVerificationState( + true, "Opening and canonical Gift History sponsor boundary verified." + ); + } catch (error) { + setSponsorAccountingVerificationState( + false, "Sponsor reconciliation deferred after public opening: " + + String(error?.message || error) + ); + scheduleSponsorAccountingRecovery(tracker); + } + + if (giveawayData.__sponsorAccountingVerified === true) { + try { await tracker.poll(); } catch (error) { console.error(error); } + sponsorsInterval = setInterval( + () => tracker.poll(), SPONSOR_GIFT_HISTORY_POLL_MS + ); + } chatReplayIgnoreBeforeTs = null; if (observer) { From 1e96ec3ca2ed53d6790074fd32930ecb99fd9fb8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:03:40 +0100 Subject: [PATCH 056/130] test(bonanza): assert sponsor verification gates on freshly confirmed openings --- tests/bonanza-opening-recovery.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 9feea22f..8e969ae2 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -208,3 +208,18 @@ test("manual reminder follows private command routing while automatic timers rem assert.match(reminder, /return sendCommandResponse\(options\.commandAuthor, msg\)/); assert.match(reminder, /return sendMessage\(msg\)/); }); + +test("a confirmed opening enables sponsor accounting only after canonical reconciliation", () => { + const anchor = lifecycle.indexOf("giveawayData.sponsorStartTs = verifiedOpening.sponsorStartTs"); + const code = lifecycle.slice(anchor, lifecycle.indexOf("chatReplayIgnoreBeforeTs = null;", anchor)); + const reconcile = code.indexOf("await tracker.reconcileCanonicalSponsorAccounting("); + const verifyPersist = code.indexOf("snapshotGiveaway({ force: true, verifyWrite: true })", reconcile); + const confirmed = code.indexOf("setSponsorAccountingVerificationState(", verifyPersist); + const deferred = code.indexOf("scheduleSponsorAccountingRecovery(tracker)"); + assert.ok(reconcile >= 0, "Must reconcile canonical Gift History after verified opening"); + assert.ok(verifyPersist > reconcile, "Must persist canonical reconciliation"); + assert.ok(confirmed > verifyPersist, "Cannot enable accounting before persistence"); + assert.ok(deferred > confirmed, "On failure recovery remains retryable"); + assert.match(code, /if \(giveawayData\.__sponsorAccountingVerified === true\)/); + assert.match(code, /scheduleSponsorAccountingRecovery\(tracker\)/); +}); From 91f736613dcdf8f153926865726eb91f253c64e6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:04:00 +0100 Subject: [PATCH 057/130] build(bonanza): regenerate bundle after verified opening sponsor reconciliation --- .../DarkPeers_BONanza_Giveaway.user.js | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index b2f25845..1f7064b7 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -249,6 +249,10 @@ // Rejected !addtime / !time add attempts explain the 45-minute total // limit without changing the deadline. Silent Mode sends all host/staff // command replies privately; Rehearsal routes script output to host PMs. +// Opening delivery is checkpointed before the POST and verified against +// Main Chat; a timeout preserves recovery rather than resetting. Sponsor +// eligibility begins after the verified publication timestamp bucket, +// separately from the conservative 45-minute pre-send deadline. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -4402,11 +4406,32 @@ body.host-panel-dragging * { return; } - try { await tracker.poll(); } catch (e) { console.error(e); } - sponsorsInterval = setInterval( - () => tracker.poll(), - SPONSOR_GIFT_HISTORY_POLL_MS - ); + // The pre-send checkpoint deliberately starts with accounting + // disabled. Once publication has been verified, reconcile the + // canonical Gift History BEFORE re-enabling pot mutations. Otherwise + // !addbon stays blocked forever on a fresh, otherwise healthy start. + try { + await tracker.reconcileCanonicalSponsorAccounting({ repairStats: false }); + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + throw new Error("Opening sponsor reconciliation snapshot did not persist"); + } + setSponsorAccountingVerificationState( + true, "Opening and canonical Gift History sponsor boundary verified." + ); + } catch (error) { + setSponsorAccountingVerificationState( + false, "Sponsor reconciliation deferred after public opening: " + + String(error?.message || error) + ); + scheduleSponsorAccountingRecovery(tracker); + } + + if (giveawayData.__sponsorAccountingVerified === true) { + try { await tracker.poll(); } catch (error) { console.error(error); } + sponsorsInterval = setInterval( + () => tracker.poll(), SPONSOR_GIFT_HISTORY_POLL_MS + ); + } chatReplayIgnoreBeforeTs = null; if (observer) { From 6370f47c2d656f162df7e971cbfa664f0a0ee99d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:10:49 +0100 Subject: [PATCH 058/130] fix(bonanza): keep explicit !reminder announcements in Main Chat --- userscripts/giveaway/src/10-command-handling.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js index 25bcd736..003e17ee 100644 --- a/userscripts/giveaway/src/10-command-handling.js +++ b/userscripts/giveaway/src/10-command-handling.js @@ -748,7 +748,7 @@ if (normalizeUserKey(ctx.author) === normalizeUserKey(ctx.giveawayData.host)) { // An explicit host !reminder is a manual override: send it now // instead of applying the automatic recent-reminder suppression. - sendReminder({ force: true, commandAuthor: ctx.author }); + sendReminder({ force: true }); } }, From 8c4c5a490b9e25d5ef085aad640834b7c8bd97ec Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:10:54 +0100 Subject: [PATCH 059/130] fix(bonanza): broadcast manual reminders even in Silent Mode --- userscripts/giveaway/src/12-utilities.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index 71852e82..b84909a5 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -687,11 +687,10 @@ `✨[b][color=#FB4F4F]Gift the host to add to the pot! [color=${GIFT_HINT_COLOR}]/gift ${getGiftSyntaxHostName()} AMOUNT MESSAGE[/color][/color][/b]✨` + silentLine + rigLine; - // Automatic interval reminders remain public so entrants can see them. - // A manual !reminder is command output: honor Silent Mode there. - if (options.commandAuthor) { - return sendCommandResponse(options.commandAuthor, msg); - } + // Both scheduled reminders and explicit !reminder requests are + // announcements to ALL entrants, not replies to the host command. + // Silent Mode must not make the requested reminder invisible. + // Rehearsal Mode still redirects sendMessage() to the host's PM. return sendMessage(msg); } From feaaf1ecc5afb4cb54ddb7b0c12949363af85301 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:11:20 +0100 Subject: [PATCH 060/130] fix(bonanza): require trustworthy Main Chat cursor before opening post --- userscripts/giveaway/src/06-lifecycle.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index c09e47b2..8a7c3bc0 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -12,6 +12,9 @@ } const afterId = optionalFiniteNumber(checkpoint.afterMessageId); + // A missing pre-send cursor makes same-second identical announcements + // indistinguishable. Never auto-confirm such a snapshot or payout. + if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; const startedAt = Number(checkpoint.startedAt); const senderId = Math.floor(Number(OT_USER_ID)); if (!Number.isFinite(senderId) || senderId <= 0) return null; @@ -32,7 +35,7 @@ for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); - if (afterId !== null && id !== null && id <= afterId) continue; + if (!Number.isSafeInteger(id) || id <= afterId) continue; const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); if (authorId !== senderId) continue; if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; @@ -362,6 +365,17 @@ const afterMessageId = REHEARSAL_MODE ? null : await getLatestChatMessageId(DARKPEERS_MAIN_CHATROOM_ID); if (!ownsTabLock()) return; + if (!REHEARSAL_MODE && + (!Number.isSafeInteger(afterMessageId) || afterMessageId <= 0)) { + logEvent("Opening blocked (Main Chat cursor)", + "Unable to obtain a trustworthy pre-send Main Chat message ID. No opening was posted."); + window.alert( + "GIVEAWAY ERROR: Cannot verify the latest Main Chat message. " + + "Nothing has been posted. Please try again when DarkPeers is responding." + ); + resetGiveaway(); + return; + } // Deadline: conservative PRE-SEND time. Sponsorship: independently // verified PUBLICATION time (never the earlier request timestamp). From 8a0f54cb3a24de9013e778f52dfc0523e4cf04fa Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:12:28 +0100 Subject: [PATCH 061/130] test(bonanza): verify manual reminder is public in Silent Mode and safe in rehearsal --- tests/bonanza-opening-recovery.test.mjs | 114 ++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 8 deletions(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 8e969ae2..144ec808 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -198,15 +198,113 @@ test("recoverable uncertain opening blocks a second start from overwriting its s assert.match(safety, /GIVEAWAY START BLOCKED/); }); -test("manual reminder follows private command routing while automatic timers remain public", () => { - assert.match(commands, /sendReminder\(\{ force: true, commandAuthor: ctx\.author \}\)/); - const reminder = utilities.slice( - utilities.indexOf(" function sendReminder("), - utilities.indexOf(" // ───────────── HTTP-based BON gifting helper") - ); - assert.match(reminder, /if \(options\.commandAuthor\)/); - assert.match(reminder, /return sendCommandResponse\(options\.commandAuthor, msg\)/); +test("manual !reminder uses the public broadcast path, regardless of Silent Mode", () => { + assert.match(commands, /sendReminder\(\{ force: true \}\)/); + assert.doesNotMatch(commands, /commandAuthor: ctx\.author/); + const reminder = extract(utilities, "function sendReminder("); assert.match(reminder, /return sendMessage\(msg\)/); + assert.doesNotMatch(reminder, /sendCommandResponse\(/); +}); + +function reminderHarness({ silent = false, rehearsal = false } = {}) { + const publicMessages = []; + const privateMessages = []; + const ctx = vm.createContext({ + giveawayData: { + host: "maghuro", amount: 100, startNum: 1, endNum: 20, + customMessage: "Good luck!", donationPercent: 0, totalEntries: 20 + }, + REHEARSAL_MODE: rehearsal, + GENERAL_SETTINGS: { silent_mode: silent }, + DEBUG_SETTINGS: { + disable_chat_output: false, suppressApiMessages: false, + log_chat_messages: false, verify_sendmessage: false + }, + OT_USER_ID: 17, OT_CHATROOM_ID: 1, OT_CSRF_TOKEN: "token", + reminderRetryTimeout: null, + isGiveawaySettling: () => false, + getGiveawayRemainingMs: () => 10 * MINUTE, + shouldSendReminder: () => true, + silentNote: () => "", + rigNote: () => "", + normalizeDonationPercent: () => 0, + riggedMode: false, + BRIDGE_MARKERS: { START: "start" }, + bridgeMarker: () => "🎁", + fmtBONCurrency: amount => String(amount), + cleanPotString: amount => amount, + buildWinnersAnnouncementLine: () => "1 winner", + parseTime: () => "10:00", + getGiftSyntaxHostName: () => "maghuro", + buildDonationContext: () => "", + GIFT_HINT_COLOR: "#abcdef", + getLoggedInUsername: () => "maghuro", + prepareOutgoingMessage: text => text, + trySendViaApi: async text => { publicMessages.push(text); return true; }, + sendPrivateMessage: async (user, text) => { + privateMessages.push({ user, text }); + return true; + }, + formatRehearsalPrivateOutput: text => "[REHEARSAL] " + text, + sanitizeNick: x => x, + logEvent() {} + }); + vm.runInContext([ + extract(utilities, "async function sendMessage("), + extract(utilities, "function sendReminder(") + ].join("\n"), ctx); + return { send: options => ctx.sendReminder(options), publicMessages, privateMessages }; +} + +test("!reminder broadcasts the actual reminder to Main Chat with Silent Mode enabled", async () => { + const h = reminderHarness({ silent: true }); + assert.equal(await h.send({ force: true }), true); + assert.equal(h.publicMessages.length, 1); + assert.match(h.publicMessages[0], /Ongoing giveaway for/); + assert.match(h.publicMessages[0], /Time left:/); + assert.equal(h.privateMessages.length, 0); +}); + +test("manual and scheduled reminders share the public chat route in normal mode", async () => { + const h = reminderHarness(); + assert.equal(await h.send({ force: true }), true); + assert.equal(await h.send(), true); + assert.equal(h.publicMessages.length, 2); + assert.equal(h.privateMessages.length, 0); +}); + +test("Rehearsal Mode keeps manually requested reminders private to the host", async () => { + const h = reminderHarness({ silent: true, rehearsal: true }); + assert.equal(await h.send({ force: true }), true); + assert.equal(h.publicMessages.length, 0); + assert.equal(h.privateMessages.length, 1); + assert.equal(h.privateMessages[0].user, "maghuro"); + assert.match(h.privateMessages[0].text, /\[REHEARSAL\]/); + assert.match(h.privateMessages[0].text, /Ongoing giveaway for/); +}); + +test("missing pre-send Main Chat cursor blocks live opening instead of matching an older post", async () => { + for (const cursor of [null, 0, -1]) { + const h = createHarness({ records: [goodMessage()] }); + h.checks.afterMessageId = cursor; + assert.equal(await h.run(), null, "Bad cursor " + cursor + " must fail closed"); + assert.equal(h.calls.length, 0, "No ambiguous authoritative match without a pre-send cursor"); + } + const start = lifecycle.indexOf("const afterMessageId = REHEARSAL_MODE"); + const post = lifecycle.indexOf("const introSent = await sendMessage(", start); + const guard = lifecycle.slice(start, post); + assert.match(guard, /!Number\.isSafeInteger\(afterMessageId\)/); + assert.match(guard, /afterMessageId <= 0/); + assert.match(guard, /resetGiveaway\(\)/); + assert.ok(guard.indexOf("resetGiveaway()") < guard.indexOf("openingCheckpoint = {")); +}); + +test("chat reconciliation does not accept missing IDs or records preceding the cursor", async () => { + const h = createHarness({ records: [ + goodMessage({ id: null }), + goodMessage({ id: 100 }) + ] }); + assert.equal(await h.run(), null); }); test("a confirmed opening enables sponsor accounting only after canonical reconciliation", () => { From b6f58a1a12b4fd13b028d2a4168a4830b35efd8d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:12:59 +0100 Subject: [PATCH 062/130] docs(bonanza): record public !reminder exception and mandatory opening cursor --- AGENTS.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5a5bae2..8ac1b151 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,9 @@ multiple userscripts, not just BONanza. ## Opening / sponsor accounting invariants +- Before posting a LIVE opening, require a trustworthy positive Main Chat + message cursor. If it is unavailable, abort BEFORE posting (otherwise an + identical old announcement might falsely verify the new opening). - Before posting the public giveaway opening, persist and read back a **pending opening checkpoint** with the exact prepared announcement, pre-send timestamp and Main Chat cursor. A timeout/ownership loss is @@ -64,11 +67,12 @@ multiple userscripts, not just BONanza. overwrite them with a new giveaway or auto-settle a recovered ancient opening beyond the one-hour expiry grace. Keep all BON-moving operations blocked while publication or sponsor timing remains unverified. -- In **Silent Mode**, every response generated by a chat/host command, - including staff actions, errors, `!winners`, `!addbon` and manual - `!reminder`, follows private message routing. Scheduled automatic - reminders remain public. Rehearsal Mode routes generated output privately - to the host and must never send real BON. +- In **Silent Mode**, ordinary command replies (staff responses, errors, + `!winners`, `!addbon` etc.) use private messages. **`!reminder` is NOT + an ordinary reply:** it explicitly requests an announcement for everyone. + Both manual `!reminder` and scheduled automatic reminders MUST broadcast + to Main Chat even in Silent Mode. Rehearsal Mode still redirects both + reminder types privately to the host and never transfers real BON. Full source layout and examples: [`userscripts/giveaway/src/README.md`](userscripts/giveaway/src/README.md). From 828f6bc8b7fddc87947f822db035b862de437366 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:13:05 +0100 Subject: [PATCH 063/130] docs(bonanza): clarify reminder broadcasts and fail-closed opening cursor --- userscripts/giveaway/src/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index f2ca9020..b45af4de 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -44,7 +44,9 @@ An opening has two independent boundaries: `openedAt` is captured **before** the public opening message is sent (and determines the maximum 45-minute deadline), while `sponsorStartTs` is derived from the server-confirmed announcement timestamp, conservatively beyond its timestamp resolution -bucket. A pre-send `openingCheckpoint` is durably stored before posting. +bucket. A valid Main Chat message cursor is mandatory before any LIVE opening; +without one, abort without posting. A pre-send `openingCheckpoint` +is durably stored before posting. If delivery is ambiguous, preserve it and retry verification on reload; do not silently discard the snapshot or perform settlement. @@ -53,10 +55,12 @@ opening checkpoints older than the normal settlement grace require manual review rather than automatic BON transfers. Do not unify the two timestamps or disable the fail-closed verification gates when changing the lifecycle. -For command output, Silent Mode routes host and staff responses to private -messages, including winner/pot adjustment announcements and manual -`!reminder`. Scheduled reminders remain visible in Main Chat. Rehearsal -Mode keeps script output private to the host and BON transfers simulated. +Silent Mode routes normal host and staff command replies privately, +including winner/pot adjustment messages. **`!reminder` is an explicit +broadcast request, not a private command reply:** both the manual reminder +and the scheduled automatic reminder must appear in Main Chat even in +Silent Mode. Rehearsal Mode redirects both to the host's private messages +and keeps BON transfers simulated. ## Maintenance rule (source of truth) From 4975b8e73a2fa738510f8d1e020c0a0d4c897f93 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:13:07 +0100 Subject: [PATCH 064/130] docs(bonanza): explain manual reminders and cursor safety in version notes --- userscripts/giveaway/src/00-preamble.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 0bcd1d60..d8cc52db 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -248,11 +248,13 @@ // the form, presets, host/staff time commands and restored active snapshots. // Rejected !addtime / !time add attempts explain the 45-minute total // limit without changing the deadline. Silent Mode sends all host/staff -// command replies privately; Rehearsal routes script output to host PMs. +// command replies privately, but explicit !reminder requests still post +// publicly just like automatic reminders. Rehearsal routes both to host PMs. // Opening delivery is checkpointed before the POST and verified against // Main Chat; a timeout preserves recovery rather than resetting. Sponsor // eligibility begins after the verified publication timestamp bucket, -// separately from the conservative 45-minute pre-send deadline. +// separately from the conservative 45-minute pre-send deadline. Live +// openings require a valid Main Chat cursor before any public POST. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From 602e6bf71b3e919755644399dc576a376212ca72 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:13:28 +0100 Subject: [PATCH 065/130] build(bonanza): regenerate userscript with public manual reminders and cursor guard --- .../DarkPeers_BONanza_Giveaway.user.js | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 1f7064b7..e86b0b18 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -248,11 +248,13 @@ // the form, presets, host/staff time commands and restored active snapshots. // Rejected !addtime / !time add attempts explain the 45-minute total // limit without changing the deadline. Silent Mode sends all host/staff -// command replies privately; Rehearsal routes script output to host PMs. +// command replies privately, but explicit !reminder requests still post +// publicly just like automatic reminders. Rehearsal routes both to host PMs. // Opening delivery is checkpointed before the POST and verified against // Main Chat; a timeout preserves recovery rather than resetting. Sponsor // eligibility begins after the verified publication timestamp bucket, -// separately from the conservative 45-minute pre-send deadline. +// separately from the conservative 45-minute pre-send deadline. Live +// openings require a valid Main Chat cursor before any public POST. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -3994,6 +3996,9 @@ body.host-panel-dragging * { } const afterId = optionalFiniteNumber(checkpoint.afterMessageId); + // A missing pre-send cursor makes same-second identical announcements + // indistinguishable. Never auto-confirm such a snapshot or payout. + if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; const startedAt = Number(checkpoint.startedAt); const senderId = Math.floor(Number(OT_USER_ID)); if (!Number.isFinite(senderId) || senderId <= 0) return null; @@ -4014,7 +4019,7 @@ body.host-panel-dragging * { for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); - if (afterId !== null && id !== null && id <= afterId) continue; + if (!Number.isSafeInteger(id) || id <= afterId) continue; const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); if (authorId !== senderId) continue; if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; @@ -4344,6 +4349,17 @@ body.host-panel-dragging * { const afterMessageId = REHEARSAL_MODE ? null : await getLatestChatMessageId(DARKPEERS_MAIN_CHATROOM_ID); if (!ownsTabLock()) return; + if (!REHEARSAL_MODE && + (!Number.isSafeInteger(afterMessageId) || afterMessageId <= 0)) { + logEvent("Opening blocked (Main Chat cursor)", + "Unable to obtain a trustworthy pre-send Main Chat message ID. No opening was posted."); + window.alert( + "GIVEAWAY ERROR: Cannot verify the latest Main Chat message. " + + "Nothing has been posted. Please try again when DarkPeers is responding." + ); + resetGiveaway(); + return; + } // Deadline: conservative PRE-SEND time. Sponsorship: independently // verified PUBLICATION time (never the earlier request timestamp). @@ -7734,7 +7750,7 @@ body.host-panel-dragging * { if (normalizeUserKey(ctx.author) === normalizeUserKey(ctx.giveawayData.host)) { // An explicit host !reminder is a manual override: send it now // instead of applying the automatic recent-reminder suppression. - sendReminder({ force: true, commandAuthor: ctx.author }); + sendReminder({ force: true }); } }, @@ -11432,11 +11448,10 @@ body.host-panel-dragging * { `✨[b][color=#FB4F4F]Gift the host to add to the pot! [color=${GIFT_HINT_COLOR}]/gift ${getGiftSyntaxHostName()} AMOUNT MESSAGE[/color][/color][/b]✨` + silentLine + rigLine; - // Automatic interval reminders remain public so entrants can see them. - // A manual !reminder is command output: honor Silent Mode there. - if (options.commandAuthor) { - return sendCommandResponse(options.commandAuthor, msg); - } + // Both scheduled reminders and explicit !reminder requests are + // announcements to ALL entrants, not replies to the host command. + // Silent Mode must not make the requested reminder invisible. + // Rehearsal Mode still redirects sendMessage() to the host's PM. return sendMessage(msg); } From ee010b429e55a27191c3b20202c7a3f392b36b54 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:37:41 +0100 Subject: [PATCH 066/130] fix(bonanza): bind opening reconciliation to complete timestamp buckets inside bounded send window --- userscripts/giveaway/src/06-lifecycle.js | 41 ++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 8a7c3bc0..a3976250 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -1,6 +1,11 @@ // SECTION 6: Giveaway Lifecycle // ─────────────────────────────────────────────────────────── + // The opening POST has a 7s HTTP timeout. Allow limited transport overhead + // for recovering an ambiguous result, but NEVER accept an announcement + // sent minutes/hours later as this giveaway's opening. + const OPENING_POST_MAX_PUBLICATION_WINDOW_MS = 10000; + // Reconcile an opening announcement against authoritative Main Chat history. // An HTTP timeout is NOT evidence the server rejected the POST. Never // release a pending opening snapshot merely because sendMessage returned @@ -16,6 +21,19 @@ // indistinguishable. Never auto-confirm such a snapshot or payout. if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; const startedAt = Number(checkpoint.startedAt); + const persistedHardEnd = optionalFiniteNumber(checkpoint.windowEndAt); + const completedAt = optionalFiniteNumber(checkpoint.sendCompletedAt); + const absoluteHardEnd = startedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS; + // An absent completion time means the tab may have crashed mid-POST: + // the pre-persisted hard window is still authoritative. A recorded + // completion can only NARROW the window, never extend it. + const windowEnd = Math.min( + absoluteHardEnd, + persistedHardEnd === null ? absoluteHardEnd : persistedHardEnd, + completedAt === null ? absoluteHardEnd : completedAt + ); + if (!Number.isFinite(startedAt) || !Number.isFinite(windowEnd) || + windowEnd <= startedAt) return null; const senderId = Math.floor(Number(OT_USER_ID)); if (!Number.isFinite(senderId) || senderId <= 0) return null; @@ -43,10 +61,15 @@ const rawTs = String(message?.created_at || ""); const timestamp = Date.parse(rawTs); const resolutionMs = unit3dTimestampResolutionMs(rawTs); - // A coarse timestamp can include the send start instant, - // but an older identical announcement must not be reused. + // Require the ENTIRE server timestamp bucket to be inside + // the original, bounded opening POST window. A bucket that + // overlaps the pre-send boundary is ambiguous and must not + // confirm an unrelated same-second message. An identical + // message sent after this attempt cannot confirm it either. if (!Number.isFinite(timestamp) || - timestamp + resolutionMs <= startedAt) continue; + !Number.isFinite(resolutionMs) || resolutionMs <= 0 || + timestamp < startedAt || + (timestamp + resolutionMs) > windowEnd) continue; return { messageId: id, @@ -389,6 +412,7 @@ status: "pending", startedAt: openingSendStartedAt, afterMessageId, + windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS, preparedMessage: preparedOpening }; window.__activeTracker = tracker; @@ -406,6 +430,17 @@ noFallbackOnApiException: true }); if (!ownsTabLock()) return; + // Constrain reconciliation to the ACTUAL time of completion if + // this document survived the POST. If it did not, recovery uses + // the previously persisted bounded upper limit instead. + giveawayData.openingCheckpoint.sendCompletedAt = Date.now(); + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + logEvent("Opening delivery checkpoint not durable", + "Could not preserve the end of the opening attempt; recovery remains pending."); + window.alert("GIVEAWAY OPENING PAUSED: Delivery timing could not be stored. Reload to retry verification."); + releaseTabLock(); + return; + } // Rehearsal announcements deliberately go to a private host message; // real openings require authoritative Main Chat confirmation. From c453d448645511e34c52f271ca233046c8c22b0f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:38:25 +0100 Subject: [PATCH 067/130] test(bonanza): reject same-second and late matching opening posts outside bounded send attempt --- tests/bonanza-opening-recovery.test.mjs | 82 ++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 144ec808..b02beb9b 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -33,6 +33,7 @@ function createHarness({ records = [], responses = null, owned = () => true } = status: "pending", startedAt: START, afterMessageId: 100, + windowEndAt: START + 10_000, preparedMessage }; const runtime = vm.createContext({ @@ -40,6 +41,7 @@ function createHarness({ records = [], responses = null, owned = () => true } = location: { origin: "https://darkpeers.org" }, OT_USER_ID: 17, DARKPEERS_MAIN_CHATROOM_ID: 1, + OPENING_POST_MAX_PUBLICATION_WINDOW_MS: 10_000, ownsTabLock: owned, optionalFiniteNumber: value => value == null ? null : Number(value), unit3dTimestampResolutionMs: raw => String(raw).includes(".") ? 1 : 1000, @@ -81,12 +83,63 @@ test("opening reconciliation requires authenticated author, exact announcement a assert.match(h.calls[0], /after_id=100/); }); -test("verified sponsorship starts AFTER the publication timestamp bucket", async () => { +test("a timestamp bucket overlapping the pre-send boundary stays unresolved", async () => { + // Same-second history can represent a message posted before this attempt. const h = createHarness({ records: [goodMessage({ created_at: sameSecond })] }); + assert.equal(await h.run(), null); + assert.equal(h.calls.length, 3); +}); + +test("verified sponsorship starts strictly AFTER a fully proven publication bucket", async () => { + const h = createHarness({ records: [goodMessage()] }); const result = await h.run(); assert.ok(result); - assert.equal(result.sponsorStartTs, START + 500); - assert.ok(result.sponsorStartTs >= START); + assert.equal(result.sponsorStartTs, START + 1500); + assert.ok(result.sponsorStartTs > START); +}); + +test("a later identical host message cannot confirm an old opening", async () => { + // Cursor and identity alone are insufficient: the same announcement can + // be posted later by the host while the original send was never published. + const later = new Date(START + 15_000).toISOString(); + const h = createHarness({ records: [goodMessage({ created_at: later, id: 102 })] }); + assert.equal(await h.run(), null); +}); + +test("opening publication buckets crossing the upper send window are rejected", async () => { + const upperBucket = new Date(START + 9_500).toISOString() + .replace(".000Z", "Z"); + const h = createHarness({ records: [ + goodMessage({ created_at: upperBucket }) + ] }); + // 11:00:10.000 .. 11:00:11.000 overlaps 11:00:10.500 bound. + assert.equal(await h.run(), null); +}); + +test("a timestamp bucket fully inside the persisted opening window is accepted", async () => { + const h = createHarness({ records: [ + goodMessage({ created_at: new Date(START + 9900).toISOString() }) + ] }); + const result = await h.run(); + assert.ok(result); + assert.equal(result.sponsorStartTs, START + 9901); +}); + +test("response completion narrows the publication window for confirmed and failed sends", async () => { + const accepted = createHarness({ records: [goodMessage()] }); + accepted.checks.sendCompletedAt = START + 1700; + assert.ok(await accepted.run()); + + const rejected = createHarness({ records: [goodMessage()] }); + rejected.checks.sendCompletedAt = START + 1200; + assert.equal(await rejected.run(), null); +}); + +test("an invalid persisted upper boundary never widens recovery into later history", async () => { + const h = createHarness({ records: [goodMessage()] }); + h.checks.windowEndAt = START - 1; + assert.equal(await h.run(), null); + assert.equal(h.calls.length, 0); }); test("ambiguous opening does not accept someone else's message or mismatched text", async () => { @@ -321,3 +374,26 @@ test("a confirmed opening enables sponsor accounting only after canonical reconc assert.match(code, /if \(giveawayData\.__sponsorAccountingVerified === true\)/); assert.match(code, /scheduleSponsorAccountingRecovery\(tracker\)/); }); + +test("the bounded send window is persisted before POST and completion before verification", () => { + const beginning = lifecycle.indexOf("const openingSendStartedAt = Date.now();"); + const storeHardEnd = lifecycle.indexOf( + "windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS", beginning + ); + const firstPersistence = lifecycle.indexOf( + "snapshotGiveaway({ force: true, verifyWrite: true })", storeHardEnd + ); + const posted = lifecycle.indexOf("const introSent = await sendMessage(", firstPersistence); + const finished = lifecycle.indexOf( + "giveawayData.openingCheckpoint.sendCompletedAt = Date.now();", posted + ); + const persistedCompletion = lifecycle.indexOf( + "snapshotGiveaway({ force: true, verifyWrite: true })", finished + ); + const verified = lifecycle.indexOf("await verifyPendingGiveawayOpening(", posted); + assert.ok(beginning >= 0 && storeHardEnd > beginning); + assert.ok(firstPersistence > storeHardEnd && posted > firstPersistence); + assert.ok(finished > posted && persistedCompletion > finished && verified > persistedCompletion); + assert.match(lifecycle, /timestamp < startedAt/); + assert.match(lifecycle, /\(timestamp \+ resolutionMs\) > windowEnd/); +}); From d8a9318cde21ca764f4cd491969f1d7e7f01a437 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:38:50 +0100 Subject: [PATCH 068/130] docs(bonanza): document bounded opening publication and ambiguous timestamps --- AGENTS.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ac1b151..71fffbae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,13 @@ multiple userscripts, not just BONanza. identical old announcement might falsely verify the new opening). - Before posting the public giveaway opening, persist and read back a **pending opening checkpoint** with the exact prepared announcement, - pre-send timestamp and Main Chat cursor. A timeout/ownership loss is - ambiguous: do not erase this checkpoint, re-post blindly, or settle BON. + pre-send timestamp, Main Chat cursor and a bounded publication window. + When the send resolves, persist its completion timestamp to further + narrow that window. Reconciliation must prove the **entire** server-side + timestamp-resolution bucket lies inside the original opening attempt: + same-second overlap or later identical announcements are NOT sufficient. + A timeout/ownership loss is ambiguous: do not erase the checkpoint, + re-post blindly, or settle BON. Restore must verify the exact own-user announcement in authoritative Main Chat history before enabling entries or any sponsor/payout action. - The 45-minute absolute deadline is anchored to the conservative From 74081e2a5f23041a4b64096da184e00defae71eb Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:38:53 +0100 Subject: [PATCH 069/130] docs(bonanza): record strict timestamp window for opening verification --- userscripts/giveaway/src/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index b45af4de..0a54af99 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -46,7 +46,11 @@ deadline), while `sponsorStartTs` is derived from the server-confirmed announcement timestamp, conservatively beyond its timestamp resolution bucket. A valid Main Chat message cursor is mandatory before any LIVE opening; without one, abort without posting. A pre-send `openingCheckpoint` -is durably stored before posting. +is durably stored before posting, including a fixed upper time boundary. +On return, the send completion timestamp is also persisted to tighten +the admissible window. A matching server message must have its **whole +timestamp-resolution bucket** inside the original bounded POST attempt: +overlapping same-second buckets and later identical posts remain ambiguous. If delivery is ambiguous, preserve it and retry verification on reload; do not silently discard the snapshot or perform settlement. From e44add6b64efdbcce3fe584a2848e173d9ba7af9 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:38:56 +0100 Subject: [PATCH 070/130] docs(bonanza): describe bounded posting window and safer reconciliation --- userscripts/giveaway/src/00-preamble.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index d8cc52db..7b4ca4c0 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -255,6 +255,9 @@ // eligibility begins after the verified publication timestamp bucket, // separately from the conservative 45-minute pre-send deadline. Live // openings require a valid Main Chat cursor before any public POST. +// Only exact announcements whose complete server timestamp bucket lies +// within a persisted, bounded opening attempt can confirm publication; +// later repeated or overlapping same-second announcements are rejected. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From e467aa0894d473e4615188aabeb33f1ae1bd8efd Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:39:20 +0100 Subject: [PATCH 071/130] build(bonanza): sync bundle with bounded public-opening timestamp verification --- .../DarkPeers_BONanza_Giveaway.user.js | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index e86b0b18..a4d2c628 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -255,6 +255,9 @@ // eligibility begins after the verified publication timestamp bucket, // separately from the conservative 45-minute pre-send deadline. Live // openings require a valid Main Chat cursor before any public POST. +// Only exact announcements whose complete server timestamp bucket lies +// within a persisted, bounded opening attempt can confirm publication; +// later repeated or overlapping same-second announcements are rejected. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -3985,6 +3988,11 @@ body.host-panel-dragging * { // SECTION 6: Giveaway Lifecycle // ─────────────────────────────────────────────────────────── + // The opening POST has a 7s HTTP timeout. Allow limited transport overhead + // for recovering an ambiguous result, but NEVER accept an announcement + // sent minutes/hours later as this giveaway's opening. + const OPENING_POST_MAX_PUBLICATION_WINDOW_MS = 10000; + // Reconcile an opening announcement against authoritative Main Chat history. // An HTTP timeout is NOT evidence the server rejected the POST. Never // release a pending opening snapshot merely because sendMessage returned @@ -4000,6 +4008,19 @@ body.host-panel-dragging * { // indistinguishable. Never auto-confirm such a snapshot or payout. if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; const startedAt = Number(checkpoint.startedAt); + const persistedHardEnd = optionalFiniteNumber(checkpoint.windowEndAt); + const completedAt = optionalFiniteNumber(checkpoint.sendCompletedAt); + const absoluteHardEnd = startedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS; + // An absent completion time means the tab may have crashed mid-POST: + // the pre-persisted hard window is still authoritative. A recorded + // completion can only NARROW the window, never extend it. + const windowEnd = Math.min( + absoluteHardEnd, + persistedHardEnd === null ? absoluteHardEnd : persistedHardEnd, + completedAt === null ? absoluteHardEnd : completedAt + ); + if (!Number.isFinite(startedAt) || !Number.isFinite(windowEnd) || + windowEnd <= startedAt) return null; const senderId = Math.floor(Number(OT_USER_ID)); if (!Number.isFinite(senderId) || senderId <= 0) return null; @@ -4027,10 +4048,15 @@ body.host-panel-dragging * { const rawTs = String(message?.created_at || ""); const timestamp = Date.parse(rawTs); const resolutionMs = unit3dTimestampResolutionMs(rawTs); - // A coarse timestamp can include the send start instant, - // but an older identical announcement must not be reused. + // Require the ENTIRE server timestamp bucket to be inside + // the original, bounded opening POST window. A bucket that + // overlaps the pre-send boundary is ambiguous and must not + // confirm an unrelated same-second message. An identical + // message sent after this attempt cannot confirm it either. if (!Number.isFinite(timestamp) || - timestamp + resolutionMs <= startedAt) continue; + !Number.isFinite(resolutionMs) || resolutionMs <= 0 || + timestamp < startedAt || + (timestamp + resolutionMs) > windowEnd) continue; return { messageId: id, @@ -4373,6 +4399,7 @@ body.host-panel-dragging * { status: "pending", startedAt: openingSendStartedAt, afterMessageId, + windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS, preparedMessage: preparedOpening }; window.__activeTracker = tracker; @@ -4390,6 +4417,17 @@ body.host-panel-dragging * { noFallbackOnApiException: true }); if (!ownsTabLock()) return; + // Constrain reconciliation to the ACTUAL time of completion if + // this document survived the POST. If it did not, recovery uses + // the previously persisted bounded upper limit instead. + giveawayData.openingCheckpoint.sendCompletedAt = Date.now(); + if (!snapshotGiveaway({ force: true, verifyWrite: true })) { + logEvent("Opening delivery checkpoint not durable", + "Could not preserve the end of the opening attempt; recovery remains pending."); + window.alert("GIVEAWAY OPENING PAUSED: Delivery timing could not be stored. Reload to retry verification."); + releaseTabLock(); + return; + } // Rehearsal announcements deliberately go to a private host message; // real openings require authoritative Main Chat confirmation. From 255e4b5c4fa1f78bceb48a111ca7d294c53a4c51 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:04:08 +0100 Subject: [PATCH 072/130] fix(bonanza): exclude ambiguous pre-opening Gift History timestamp buckets --- userscripts/giveaway/src/09-sponsorship-polling.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/userscripts/giveaway/src/09-sponsorship-polling.js b/userscripts/giveaway/src/09-sponsorship-polling.js index 9e4384fc..ac9b2623 100644 --- a/userscripts/giveaway/src/09-sponsorship-polling.js +++ b/userscripts/giveaway/src/09-sponsorship-polling.js @@ -870,7 +870,10 @@ if (offset !== null && Number.isFinite(wallTs)) { const start = wallTs - offset; - return (min === null || (start + resolutionMs) > min) ? [start] : []; + // A gift bucket overlapping public opening is ambiguous: + // only an interval STARTING at/after the confirmed sponsor + // boundary can be safely attributed to this giveaway. + return (min === null || start >= min) ? [start] : []; } return [ @@ -878,7 +881,7 @@ Number(item?.createdAtAltTs) ].filter(ts => Number.isFinite(ts) && - (min === null || (ts + resolutionMs) > min) + (min === null || ts >= min) ); }; @@ -958,7 +961,9 @@ if (offset !== null && Number.isFinite(wallTs)) { const eventTs = wallTs - offset; - const afterOpen = min === null || (eventTs + resolutionMs) > min; + // Entire Gift History timestamp bucket must be after + // confirmed publication, not merely overlap its boundary. + const afterOpen = min === null || eventTs >= min; if (!afterOpen) continue; if (max === null) { @@ -991,7 +996,7 @@ ].filter(Number.isFinite); const openingCandidates = candidates.filter(ts => - min === null || (ts + resolutionMs) > min + min === null || ts >= min ); if (!openingCandidates.length) continue; From f4401457515c5032a50262fdb2370f54d109fcbe Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:04:11 +0100 Subject: [PATCH 073/130] fix(bonanza): never chatbox-retry opening after non-OK API response --- userscripts/giveaway/src/12-utilities.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index b84909a5..b4bcec9f 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -1754,6 +1754,10 @@ if (!forceChatboxOnly && !DEBUG_SETTINGS.suppressApiMessages) { try { if (await trySendViaApi(messageStr)) return true; + // A 5xx/non-OK API response might still follow a committed + // public POST. An opening must NEVER post again via chatbox: + // the durable checkpoint is the only safe recovery path. + if (options?.noFallbackOnApiException === true) return false; } catch (e) { // An opening POST may already have committed before its reply // timed out. Never issue a second, chatbox-based announcement From 4c8727c92f3d57f39fa5c54d2e41933a29ac2a1a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:05:08 +0100 Subject: [PATCH 074/130] fix(bonanza): prevent every chatbox fallback for opening, including disabled API --- userscripts/giveaway/src/12-utilities.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/userscripts/giveaway/src/12-utilities.js b/userscripts/giveaway/src/12-utilities.js index b4bcec9f..f522ed2a 100644 --- a/userscripts/giveaway/src/12-utilities.js +++ b/userscripts/giveaway/src/12-utilities.js @@ -1774,6 +1774,11 @@ } } + // Opening output is single-shot. Even when the API was deliberately + // disabled, missing credentials, or returned non-OK, never silently + // publish a second opening through chatbox. + if (options?.noFallbackOnApiException === true) return false; + // Settlement output is authoritative. If the API attempt yielded while a // BFCache/pagehide handoff quarantined this document, never let the stale // continuation escape through the synchronous chatbox fallback. From 5e5a6bf14666807b5715d96d92fc019ec8a5a131 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:05:43 +0100 Subject: [PATCH 075/130] test(bonanza): execute sponsor timestamp and non-OK opening fallback regressions --- tests/bonanza-opening-recovery.test.mjs | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index b02beb9b..1aa9ae96 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -9,6 +9,7 @@ const lifecycle = read("userscripts/giveaway/src/06-lifecycle.js"); const initialization = read("userscripts/giveaway/src/05-initialization.js"); const utilities = read("userscripts/giveaway/src/12-utilities.js"); const commands = read("userscripts/giveaway/src/10-command-handling.js"); +const sponsorship = read("userscripts/giveaway/src/09-sponsorship-polling.js"); function extract(source, signature) { const start = source.indexOf(" " + signature); @@ -397,3 +398,126 @@ test("the bounded send window is persisted before POST and completion before ver assert.match(lifecycle, /timestamp < startedAt/); assert.match(lifecycle, /\(timestamp \+ resolutionMs\) > windowEnd/); }); + +function sponsorHistoryFilterHarness({ calibrated = true } = {}) { + const start = sponsorship.indexOf(" async filterHistoryRowsByWindow("); + const end = sponsorship.indexOf("\n async processGiftHistoryRows(", start); + assert.ok(start >= 0 && end > start, "SponsorTracker filter extraction failed"); + const method = sponsorship.slice(start, end); + const ctx = vm.createContext({ + optionalFiniteNumber: val => val === undefined || val === null ? null : Number(val), + DEBUG_SETTINGS: { log_chat_messages: false }, + unit3dTimestampResolutionMs: () => 1000, + normalizeUserKey: x => String(x || "").toLowerCase(), + logEvent() {}, + sanitizeNick: x => x, + fmtBONCurrency: x => x + }); + vm.runInContext(`class HistoryFilterTest { ${method} } + this.HistoryFilterTest = HistoryFilterTest;`, ctx); + const tracker = new ctx.HistoryFilterTest(); + tracker.data = { endTs: 5000 }; + tracker.maxAcceptedCreatedAtTs = null; + tracker.giftHistoryClockOffsetMs = calibrated ? 0 : null; + tracker.giftHistorySeenKeys = new Set(); + tracker.ensureGiftHistoryClockOffset = async () => calibrated ? 0 : null; + tracker.fetchRecentChatGiftEvents = async () => []; + return tracker; +} +function giftAt(time, key) { + return { + historyKey: key, + sender: "sponsor", recipient: "host", amount: 100, + createdAtTs: time, createdAtAltTs: time, + rawTimestamp: "2026-10-10 11:00:02", + timestampResolutionMs: 1000 + }; +} + +test("calibrated Gift History excludes a coarse gift overlapping the sponsor-opening boundary", async () => { + const tracker = sponsorHistoryFilterHarness({ calibrated: true }); + const result = await tracker.filterHistoryRowsByWindow([ + giftAt(2000, "before"), + giftAt(2001, "after"), + giftAt(2500, "later") + ], 2000.5, 4500); + assert.equal(result.accepted.length, 2); + assert.equal(result.accepted[0].historyKey, "after"); + assert.equal(result.accepted[1].historyKey, "later"); +}); + +test("fallback Gift History interpretations exclude overlapping opening buckets", async () => { + const tracker = sponsorHistoryFilterHarness({ calibrated: false }); + const result = await tracker.filterHistoryRowsByWindow([ + giftAt(2000, "before"), + giftAt(2001, "after") + ], 2000.5, 4500); + assert.equal(result.accepted.length, 1); + assert.equal(result.accepted[0].historyKey, "after"); +}); + +test("even without a closing timestamp, an overlapping pre-opening gift is not sponsored", async () => { + const tracker = sponsorHistoryFilterHarness(); + tracker.data.endTs = null; + const result = await tracker.filterHistoryRowsByWindow([ + giftAt(1999, "old"), + giftAt(2001, "new") + ], 2000.5, null); + assert.equal(result.accepted.length, 1); + assert.equal(result.accepted[0].historyKey, "new"); +}); + +test("non-OK opening POST never retries the same announcement via chatbox", async () => { + let publicApiCalls = 0; + let fallbackCalls = 0; + const sandbox = vm.createContext({ + REHEARSAL_MODE: false, + DEBUG_SETTINGS: { + disable_chat_output: false, suppressApiMessages: false, + verify_sendmessage: false, log_chat_messages: false + }, + OT_USER_ID: 17, OT_CHATROOM_ID: 1, OT_CSRF_TOKEN: "csrf", + prepareOutgoingMessage: x => x, + fetchWithTimeout: async () => { + publicApiCalls++; + return { ok: false, status: 500, text: async () => '{"error":"server error"}' }; + }, + sendViaChatbox: () => { fallbackCalls++; return true; }, + console: { error() {}, warn() {} } + }); + vm.runInContext([ + extract(utilities, "async function trySendViaApi("), + openingSend + ].join("\n"), sandbox); + assert.equal(await sandbox.sendMessage("opening", { + prepared: true, noFallbackOnApiException: true + }), false); + assert.equal(publicApiCalls, 1); + assert.equal(fallbackCalls, 0); + assert.equal(await sandbox.sendMessage("ordinary", { prepared: true }), true); + assert.equal(publicApiCalls, 2); + assert.equal(fallbackCalls, 1); +}); + +test("opening never falls back when the API is disabled or returns false before posting", async () => { + for (const suppressed of [true, false]) { + let fallbackCalls = 0; + const sandbox = vm.createContext({ + REHEARSAL_MODE: false, + DEBUG_SETTINGS: { + disable_chat_output: false, suppressApiMessages: suppressed, + verify_sendmessage: false, log_chat_messages: false + }, + OT_USER_ID: 17, OT_CHATROOM_ID: 1, OT_CSRF_TOKEN: "csrf", + prepareOutgoingMessage: x => x, + trySendViaApi: async () => false, + sendViaChatbox: () => { fallbackCalls++; return true; }, + console: { warn() {} } + }); + vm.runInContext(openingSend, sandbox); + assert.equal(await sandbox.sendMessage("opening", { + prepared: true, noFallbackOnApiException: true + }), false); + assert.equal(fallbackCalls, 0); + } +}); From 1ed223bcb9eeec35f0291a115871f02a8398f1ea Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:09 +0100 Subject: [PATCH 076/130] docs(bonanza): record safe sponsor bucket and failed opening POST rules --- AGENTS.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 71fffbae..df1bb0a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,9 @@ multiple userscripts, not just BONanza. narrow that window. Reconciliation must prove the **entire** server-side timestamp-resolution bucket lies inside the original opening attempt: same-second overlap or later identical announcements are NOT sufficient. + ANY opening API failure, including non-OK HTTP responses, must never + trigger the chatbox fallback: the server may already have published + the opening, so reconcile the original checkpoint instead. A timeout/ownership loss is ambiguous: do not erase the checkpoint, re-post blindly, or settle BON. Restore must verify the exact own-user announcement in authoritative @@ -65,8 +68,11 @@ multiple userscripts, not just BONanza. - The 45-minute absolute deadline is anchored to the conservative **pre-send** time, so server response latency cannot extend the event. **Sponsor eligibility must NOT use that time.** It begins strictly after - the independently verified publication timestamp bucket, even if this - means omitting an ambiguous boundary gift. Persist `sponsorStartTs` and + the independently verified publication timestamp bucket. Each Gift + History timestamp bucket must START at/after `sponsorStartTs`, not + merely overlap it; this applies to calibrated and fallback parsing. + Omit ambiguous boundary gifts rather than risking inflation of a real + BON payout. Persist `sponsorStartTs` and use it for both live Gift History polling and reload reconciliation. - Leave unresolved opening checkpoints intact for later recovery; never overwrite them with a new giveaway or auto-settle a recovered ancient From e92288969d5f52815147c375a925dfff15331ace Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:14 +0100 Subject: [PATCH 077/130] docs(bonanza): explain precise sponsor boundary and no-duplicate opening --- userscripts/giveaway/src/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 0a54af99..0472eb82 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -44,7 +44,9 @@ An opening has two independent boundaries: `openedAt` is captured **before** the public opening message is sent (and determines the maximum 45-minute deadline), while `sponsorStartTs` is derived from the server-confirmed announcement timestamp, conservatively beyond its timestamp resolution -bucket. A valid Main Chat message cursor is mandatory before any LIVE opening; +bucket. Gift History rows are eligible only when the ENTIRE original +timestamp bucket starts at/after `sponsorStartTs`, in calibrated and +fallback timestamp paths. A valid Main Chat message cursor is mandatory before any LIVE opening; without one, abort without posting. A pre-send `openingCheckpoint` is durably stored before posting, including a fixed upper time boundary. On return, the send completion timestamp is also persisted to tighten @@ -52,6 +54,8 @@ the admissible window. A matching server message must have its **whole timestamp-resolution bucket** inside the original bounded POST attempt: overlapping same-second buckets and later identical posts remain ambiguous. If delivery is ambiguous, preserve it and retry verification on reload; +even a non-OK opening POST response must NOT trigger a second chatbox +opening, since the server may have stored the original message. do not silently discard the snapshot or perform settlement. A new giveaway must not overwrite a pending opening checkpoint. Recovered From 48541707dab157e506b13743eeeade3a322dc580 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:17 +0100 Subject: [PATCH 078/130] docs(bonanza): note fail-closed sponsor and non-OK API safeguards --- userscripts/giveaway/src/00-preamble.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 7b4ca4c0..38c588e8 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -258,6 +258,9 @@ // Only exact announcements whose complete server timestamp bucket lies // within a persisted, bounded opening attempt can confirm publication; // later repeated or overlapping same-second announcements are rejected. +// Gift History timestamps also must start no earlier than the verified +// sponsor opening boundary; HTTP error/5xx opening POSTs must never +// trigger a second announcement via chatbox fallback. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From fe6d749471b6913f22fb6187c9bdc265fbbe6d0f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:40 +0100 Subject: [PATCH 079/130] build(bonanza): regenerate from fail-closed sponsor and opening retry fixes --- .../DarkPeers_BONanza_Giveaway.user.js | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index a4d2c628..2459b281 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -258,6 +258,9 @@ // Only exact announcements whose complete server timestamp bucket lies // within a persisted, bounded opening attempt can confirm publication; // later repeated or overlapping same-second announcements are rejected. +// Gift History timestamps also must start no earlier than the verified +// sponsor opening boundary; HTTP error/5xx opening POSTs must never +// trigger a second announcement via chatbox fallback. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -6085,7 +6088,10 @@ body.host-panel-dragging * { if (offset !== null && Number.isFinite(wallTs)) { const start = wallTs - offset; - return (min === null || (start + resolutionMs) > min) ? [start] : []; + // A gift bucket overlapping public opening is ambiguous: + // only an interval STARTING at/after the confirmed sponsor + // boundary can be safely attributed to this giveaway. + return (min === null || start >= min) ? [start] : []; } return [ @@ -6093,7 +6099,7 @@ body.host-panel-dragging * { Number(item?.createdAtAltTs) ].filter(ts => Number.isFinite(ts) && - (min === null || (ts + resolutionMs) > min) + (min === null || ts >= min) ); }; @@ -6173,7 +6179,9 @@ body.host-panel-dragging * { if (offset !== null && Number.isFinite(wallTs)) { const eventTs = wallTs - offset; - const afterOpen = min === null || (eventTs + resolutionMs) > min; + // Entire Gift History timestamp bucket must be after + // confirmed publication, not merely overlap its boundary. + const afterOpen = min === null || eventTs >= min; if (!afterOpen) continue; if (max === null) { @@ -6206,7 +6214,7 @@ body.host-panel-dragging * { ].filter(Number.isFinite); const openingCandidates = candidates.filter(ts => - min === null || (ts + resolutionMs) > min + min === null || ts >= min ); if (!openingCandidates.length) continue; @@ -12553,6 +12561,10 @@ body.host-panel-dragging * { if (!forceChatboxOnly && !DEBUG_SETTINGS.suppressApiMessages) { try { if (await trySendViaApi(messageStr)) return true; + // A 5xx/non-OK API response might still follow a committed + // public POST. An opening must NEVER post again via chatbox: + // the durable checkpoint is the only safe recovery path. + if (options?.noFallbackOnApiException === true) return false; } catch (e) { // An opening POST may already have committed before its reply // timed out. Never issue a second, chatbox-based announcement @@ -12569,6 +12581,11 @@ body.host-panel-dragging * { } } + // Opening output is single-shot. Even when the API was deliberately + // disabled, missing credentials, or returned non-OK, never silently + // publish a second opening through chatbox. + if (options?.noFallbackOnApiException === true) return false; + // Settlement output is authoritative. If the API attempt yielded while a // BFCache/pagehide handoff quarantined this document, never let the stale // continuation escape through the synchronous chatbox fallback. From 3660db6f4e1e3d48f8884b8b0a13387d26b3332f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:46:52 +0100 Subject: [PATCH 080/130] fix(bonanza): quarantine legacy snapshots without verified public opening --- userscripts/giveaway/src/05-initialization.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index e91d3f37..e365d882 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1482,6 +1482,32 @@ return null; } + // Pre-checkpoint releases never proved that an opening reached + // Main Chat, and their startTime was recorded AFTER the send. + // Preserve the original raw snapshot, including old/expired and + // partially-paid records, but NEVER auto-resume or auto-settle it. + // Rewriting its 45-minute deadline from that late startTime would + // also violate the absolute public-opening cap. + const checkpoint = snap.giveawayData.openingCheckpoint; + if (!checkpoint || ( + checkpoint.status !== "pending" && + checkpoint.status !== "confirmed" + ) || ( + checkpoint.status === "confirmed" && + (!Number.isFinite(Number(snap.giveawayData.openedAt)) || + Number(snap.giveawayData.openedAt) <= 0 || + !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || + Number(snap.giveawayData.sponsorStartTs) < + Number(snap.giveawayData.openedAt)) + )) { + console.warn( + "[BON Giveaway] Unverified legacy opening retained for manual review; " + + "automatic resume, deadline migration and BON settlement are blocked." + ); + snap.__legacyOpeningUnverified = true; + return snap; + } + const endTs = Number(snap.giveawayData.endTs); if (!Number.isFinite(endTs) || endTs <= 0) { clearGiveawaySnapshot(); @@ -1643,6 +1669,28 @@ async function restoreGiveawayFromSnapshot(snap) { if (!snap || !snap.giveawayData) return false; + // Do not let pre-checkpoint snapshots enter any automatic restore path, + // even if an alternate caller bypasses loadGiveawaySnapshot(). Keep + // persistent entries and paid-gift ledgers untouched for manual review. + const proof = snap.giveawayData.openingCheckpoint; + if (snap.__legacyOpeningUnverified === true || + !proof || (proof.status !== "pending" && proof.status !== "confirmed") || + (proof.status === "confirmed" && + (!Number.isFinite(Number(snap.giveawayData.openedAt)) || + Number(snap.giveawayData.openedAt) <= 0 || + !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || + Number(snap.giveawayData.sponsorStartTs) < + Number(snap.giveawayData.openedAt)))) { + console.warn("[BON Giveaway] Legacy opening lacks verified publication proof; keeping original snapshot."); + window.alert( + "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: This older snapshot " + + "does not prove that its opening was published or when it began. " + + "Automatic entries, countdown changes and BON settlement are blocked. " + + "The original recovery state was preserved." + ); + return false; + } + // Validate the live DarkPeers identity/Main Chat context BEFORE claiming // ownership or touching the recoverable snapshot. A transient/wrong chat // context must never destroy or mutate persisted giveaway state. From cad35824fd181c883de8e7dc8810769f6b67081a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:47:05 +0100 Subject: [PATCH 081/130] fix(bonanza): protect unverified legacy recovery before starting new giveaways --- userscripts/giveaway/src/06-lifecycle.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index a3976250..c1d0f932 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -121,11 +121,17 @@ if (prior.raw) { try { const saved = JSON.parse(prior.raw); - if (saved?.giveawayData?.openingCheckpoint?.status === "pending") { + const proof = saved?.giveawayData?.openingCheckpoint; + if (saved?.giveawayData && (!proof || proof.status !== "confirmed" || + !Number.isFinite(Number(saved.giveawayData.openedAt)) || + Number(saved.giveawayData.openedAt) <= 0 || + !Number.isFinite(Number(saved.giveawayData.sponsorStartTs)) || + Number(saved.giveawayData.sponsorStartTs) < + Number(saved.giveawayData.openedAt))) { window.alert( - "GIVEAWAY START BLOCKED: A previous opening is awaiting " + - "Main Chat verification. Reload DarkPeers and recover it " + - "before starting another giveaway. Its saved state was preserved." + "GIVEAWAY START BLOCKED: A saved opening still requires " + + "verification or manual review. The original recovery " + + "snapshot must not be overwritten by a new giveaway." ); return; } From 6f368780633a07f0fd097808012eca0262203c2c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:47:55 +0100 Subject: [PATCH 082/130] fix(bonanza): propagate calibrated clock uncertainty into sponsor eligibility --- .../giveaway/src/09-sponsorship-polling.js | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/userscripts/giveaway/src/09-sponsorship-polling.js b/userscripts/giveaway/src/09-sponsorship-polling.js index ac9b2623..188e821b 100644 --- a/userscripts/giveaway/src/09-sponsorship-polling.js +++ b/userscripts/giveaway/src/09-sponsorship-polling.js @@ -418,6 +418,7 @@ lastMsgId = 0, cursorInitialized = false, giftHistoryClockOffsetMs = null, + giftHistoryClockOffsetUncertaintyMs = null, giftHistoryInitialized = false, giftHistorySeenKeys = [], historyFallbackActive = false, @@ -430,6 +431,12 @@ this.lastMsgId = Math.max(0, Math.floor(Number(lastMsgId) || 0)); // persisted API cursor this.cursorInitialized = !!cursorInitialized; this.giftHistoryClockOffsetMs = optionalFiniteNumber(giftHistoryClockOffsetMs); + // Earlier versions stored a point-estimate without its fractional + // calibration error. DarkPeers Gift History has second precision, + // so reserve at least one complete second of unknown offset. + this.giftHistoryClockOffsetUncertaintyMs = Math.max( + 1000, optionalFiniteNumber(giftHistoryClockOffsetUncertaintyMs) ?? 1000 + ); this.giftHistoryInitialized = !!giftHistoryInitialized; this.giftHistorySeenKeys = new Set( (Array.isArray(giftHistorySeenKeys) ? giftHistorySeenKeys : []) @@ -869,7 +876,8 @@ : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { - const start = wallTs - offset; + const start = wallTs - offset - + Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); // A gift bucket overlapping public opening is ambiguous: // only an interval STARTING at/after the confirmed sponsor // boundary can be safely attributed to this giveaway. @@ -960,10 +968,17 @@ : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { + // The learned offset subtracts an exact event time from + // coarse Gift History time. This shifts all bucket starts + // forward by the anchor event's fractional second (and, + // for clustered anchors, possibly more). Use the EARLIEST + // possible start, including stored calibration uncertainty. const eventTs = wallTs - offset; + const earliestEventTs = eventTs - + Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); // Entire Gift History timestamp bucket must be after // confirmed publication, not merely overlap its boundary. - const afterOpen = min === null || eventTs >= min; + const afterOpen = min === null || earliestEventTs >= min; if (!afterOpen) continue; if (max === null) { @@ -1347,21 +1362,27 @@ if (!Number.isFinite(historyWallTs)) continue; const offset = historyWallTs - Number(event.createdAtTs); - if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) samples.push(offset); + const resolutionMs = Math.max( + 1, Number(item?.timestampResolutionMs) || + unit3dTimestampResolutionMs(item?.rawTimestamp) + ); + if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) { + samples.push({ offset, resolutionMs }); + } } if (!samples.length) return persisted; // Choose the densest 2-minute cluster, then its median. This rejects an // unrelated historical exact-value row without hardcoding any timezone. - samples.sort((a, b) => a - b); + samples.sort((a, b) => a.offset - b.offset); const CLUSTER_MS = 120_000; let best = []; for (let i = 0; i < samples.length; i++) { const cluster = []; for (let j = i; j < samples.length; j++) { - if (samples[j] - samples[i] > CLUSTER_MS) break; + if (samples[j].offset - samples[i].offset > CLUSTER_MS) break; cluster.push(samples[j]); } if (cluster.length > best.length) best = cluster; @@ -1370,8 +1391,20 @@ if (!best.length) return persisted; const mid = Math.floor(best.length / 2); const inferred = best.length % 2 - ? best[mid] - : Math.round((best[mid - 1] + best[mid]) / 2); + ? best[mid].offset + : Math.round((best[mid - 1].offset + best[mid].offset) / 2); + + // If the chosen offset is calibrated from a coarse timestamp, + // the true timezone offset can be up to one entire source bucket + // GREATER than the inferred point estimate. Include differences + // within the accepted anchor cluster, too. This upper error + // bound is persisted with the point estimate across page reloads. + const offsetUncertaintyMs = Math.max( + 1000, + ...best.map(sample => + Math.max(0, sample.offset + sample.resolutionMs - inferred) + ) + ); // If we already learned a clock offset, don't replace it with a lone, // materially different sample. Multiple agreeing anchors may update it @@ -1385,6 +1418,7 @@ } this.giftHistoryClockOffsetMs = inferred; + this.giftHistoryClockOffsetUncertaintyMs = offsetUncertaintyMs; return inferred; } From 86471cf54016f428262cc388f3dae237971e1009 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:48:07 +0100 Subject: [PATCH 083/130] fix(bonanza): persist sponsor clock uncertainty across crash recovery --- userscripts/giveaway/src/05-initialization.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index e365d882..90fb033e 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1391,6 +1391,8 @@ lastMsgId: Math.max(0, Math.floor(Number(window.__activeTracker.lastMsgId) || 0)), cursorInitialized: !!window.__activeTracker.cursorInitialized, giftHistoryClockOffsetMs: optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetMs), + giftHistoryClockOffsetUncertaintyMs: + optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetUncertaintyMs), giftHistoryInitialized: !!window.__activeTracker.giftHistoryInitialized, giftHistorySeenKeys: Array.from(window.__activeTracker.giftHistorySeenKeys || []).slice(-250), historyFallbackActive: !!window.__activeTracker.historyFallbackActive, @@ -2014,6 +2016,9 @@ giftHistoryClockOffsetMs: savedTracker ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetMs) : null, + giftHistoryClockOffsetUncertaintyMs: savedTracker + ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetUncertaintyMs) + : null, giftHistoryInitialized: savedTracker ? !!savedTracker.giftHistoryInitialized : false, giftHistorySeenKeys: savedTracker && Array.isArray(savedTracker.giftHistorySeenKeys) ? savedTracker.giftHistorySeenKeys From 117b4f6f39df0f6d91f3b55049049aadb0b760e0 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:48:50 +0100 Subject: [PATCH 084/130] test(bonanza): quarantine legacy saves without public-opening proof even when stale --- tests/bonanza-duration-recovery.test.mjs | 68 +++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs index 4e2ddf0c..f6f82c6e 100644 --- a/tests/bonanza-duration-recovery.test.mjs +++ b/tests/bonanza-duration-recovery.test.mjs @@ -32,7 +32,9 @@ function recoveryHarness({ ageMinutes, advertisedMinutes = 90, reminderCount = 1, - openingCheckpoint = null, + // Current verified snapshots may still contain legacy 90-minute values. + // Snapshots with no checkpoint must instead stay quarantined for manual review. + openingCheckpoint = { status: "confirmed" }, settlement = null, closingIntent = false, closingNoticeSent = false @@ -42,11 +44,13 @@ function recoveryHarness({ const clears = []; const warnings = []; const snap = { - startTime: openedAt, // Legacy snapshots predate giveawayData.openedAt. + startTime: openedAt, // Keep the backward-compatibility timestamp field. giveawayData: { host: "maghuro", rehearsalMode: false, endTs: originalEnd, + openedAt, + sponsorStartTs: openedAt, openingCheckpoint, reminderNum: reminderCount, reminderSchedule: [Math.round(advertisedMinutes / (reminderCount + 1) * MINUTE)], @@ -226,3 +230,63 @@ test("ancient uncertain opening retains recovery checkpoint without auto-settlem assert.equal(pending.giveawayData.endTs, h.cappedEnd); assert.equal(h.clears.length, 0, "Never erase a possibly-public opening without authoritative reconciliation"); }); + +test("pre-checkpoint active legacy snapshots never auto-resume or rewrite the deadline", () => { + for (const ageMinutes of [5, 30, 50, 110, 3 * 24 * 60]) { + const h = recoveryHarness({ + ageMinutes, + advertisedMinutes: 90, + openingCheckpoint: null + }); + const state = h.restore(); + assert.ok(state, "Old recoverable data must remain accessible for manual review"); + assert.equal(state.__legacyOpeningUnverified, true); + assert.equal(state.giveawayData.endTs, h.originalEnd, + "Cannot cap an old post-send timestamp as if it were the public opening"); + assert.equal(h.clears.length, 0, "Never erase unverified old giveaway data"); + assert.ok(h.warnings.some(w => w.includes("Unverified legacy opening"))); + } +}); + +test("pre-checkpoint committed and latched payouts stay intact but are not auto-resumed", () => { + for (const settlementState of [ + { settlement: { committed: true, phase: "settling" } }, + { closingIntent: true } + ]) { + const h = recoveryHarness({ + ageMinutes: 150, + advertisedMinutes: 90, + openingCheckpoint: null, + ...settlementState + }); + const snap = h.restore(); + assert.ok(snap); + assert.equal(snap.__legacyOpeningUnverified, true); + assert.equal(snap.giveawayData.endTs, h.originalEnd); + assert.equal(h.clears.length, 0); + } +}); + +test("malformed or incomplete confirmed proof also freezes legacy settlement", () => { + for (const proof of [{ status: "unknown" }, { status: "confirmed" }]) { + const h = recoveryHarness({ ageMinutes: 5, openingCheckpoint: proof }); + const snap = h.restore(); + if (proof.status === "confirmed") { + // The valid new-format fixture includes both openedAt and sponsorStartTs. + assert.equal(snap.__legacyOpeningUnverified, undefined); + } else { + assert.equal(snap.__legacyOpeningUnverified, true); + } + } +}); + +test("restore and new-start entry points reject old snapshots before any mutation", () => { + assert.match(initialization, /snap\.__legacyOpeningUnverified = true/); + const restore = initialization.slice( + initialization.indexOf("async function restoreGiveawayFromSnapshot("), + initialization.indexOf("// Validate the live DarkPeers identity/Main Chat context BEFORE claiming") + ); + assert.match(restore, /if \(snap\.__legacyOpeningUnverified === true \|\|/); + assert.match(restore, /return false;/); + assert.doesNotMatch(restore, /acquireTabLock\(/); +}); From 27f403707259456ca24a1c1e5468f0ce0a078c76 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:49:12 +0100 Subject: [PATCH 085/130] test(bonanza): validate calibrated clock error cannot inflate the sponsor pot --- tests/bonanza-opening-recovery.test.mjs | 58 +++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 1aa9ae96..c2915e03 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -248,7 +248,7 @@ test("recoverable uncertain opening blocks a second start from overwriting its s const startLock = lifecycle.indexOf("await acquireTabLock()", start); const safety = lifecycle.slice(start, startLock); assert.match(safety, /readActiveGiveawayRawForHost\(getLoggedInUsername\(\)/); - assert.match(safety, /openingCheckpoint\?\.status === "pending"/); + assert.match(safety, /!proof \|\| proof\.status !== "confirmed"/); assert.match(safety, /GIVEAWAY START BLOCKED/); }); @@ -438,8 +438,8 @@ test("calibrated Gift History excludes a coarse gift overlapping the sponsor-ope const tracker = sponsorHistoryFilterHarness({ calibrated: true }); const result = await tracker.filterHistoryRowsByWindow([ giftAt(2000, "before"), - giftAt(2001, "after"), - giftAt(2500, "later") + giftAt(3101, "after"), + giftAt(3500, "later") ], 2000.5, 4500); assert.equal(result.accepted.length, 2); assert.equal(result.accepted[0].historyKey, "after"); @@ -461,7 +461,7 @@ test("even without a closing timestamp, an overlapping pre-opening gift is not s tracker.data.endTs = null; const result = await tracker.filterHistoryRowsByWindow([ giftAt(1999, "old"), - giftAt(2001, "new") + giftAt(3101, "new") ], 2000.5, null); assert.equal(result.accepted.length, 1); assert.equal(result.accepted[0].historyKey, "new"); @@ -521,3 +521,53 @@ test("opening never falls back when the API is disabled or returns false before assert.equal(fallbackCalls, 0); } }); + +test("calibration fractional bias cannot turn a pre-opening gift into a sponsor", async () => { + const tracker = sponsorHistoryFilterHarness({ calibrated: true }); + // A calibration anchor at .750s produces offset -750ms for a coarse + // Gift History .000s bucket. Without uncertainty the gift falsely moves + // 750ms later and falls beyond the 3000.5ms opening boundary. + tracker.giftHistoryClockOffsetMs = -750; + tracker.giftHistoryClockOffsetUncertaintyMs = 1000; + const result = await tracker.filterHistoryRowsByWindow([ + giftAt(2500, "before"), + giftAt(4250, "after") + ], 3000.5, 4500); + assert.deepEqual(result.accepted.map(x => x.historyKey), ["after"]); +}); + +test("the inferred calibration error is propagated and conserved", () => { + const start = sponsorship.indexOf(" inferGiftHistoryClockOffset("); + const end = sponsorship.indexOf("\n matchGiftEventsToRows(", start); + assert.ok(start >= 0 && end > start); + const method = sponsorship.slice(start, end); + const context = vm.createContext({ + optionalFiniteNumber: x => x == null ? null : Number(x), + normalizeUserKey: x => String(x || "").toLowerCase(), + unit3dTimestampResolutionMs: () => 1000 + }); + vm.runInContext( + `class CalibrationTest { ${method} } this.CalibrationTest = CalibrationTest;`, + context + ); + const tracker = new context.CalibrationTest(); + tracker.giftHistoryClockOffsetMs = null; + const offset = tracker.inferGiftHistoryClockOffset( + [{ gifter: "sponsor", recipient: "host", rawAmount: 100, createdAtTs: 2750 }], + [{ sender: "sponsor", recipient: "host", amount: 100, createdAtAltTs: 2000, + rawTimestamp: "2026-10-10 11:00:02", timestampResolutionMs: 1000 }] + ); + assert.equal(offset, -750); + assert.ok(tracker.giftHistoryClockOffsetUncertaintyMs >= 1000); +}); + +test("clock-offset uncertainty survives sponsor snapshot and restore", () => { + const load = initialization; + const sponsorshipTrackerStart = sponsorship.slice( + sponsorship.indexOf(" class SponsorTracker {"), + sponsorship.indexOf(" /* ---- poll for any chat messages since last cursor ---- */") + ); + assert.match(sponsorshipTrackerStart, /giftHistoryClockOffsetUncertaintyMs = null/); + assert.match(load, /giftHistoryClockOffsetUncertaintyMs:\s*optionalFiniteNumber\(window\.__activeTracker\.giftHistoryClockOffsetUncertaintyMs\)/); + assert.match(load, /giftHistoryClockOffsetUncertaintyMs: savedTracker/); +}); From 504aeed6540e257f7340e6a253825a354247d91f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:49:50 +0100 Subject: [PATCH 086/130] docs(bonanza): mandate manual legacy recovery and calibration uncertainty --- AGENTS.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df1bb0a2..e559f128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,8 +76,19 @@ multiple userscripts, not just BONanza. use it for both live Gift History polling and reload reconciliation. - Leave unresolved opening checkpoints intact for later recovery; never overwrite them with a new giveaway or auto-settle a recovered ancient - opening beyond the one-hour expiry grace. Keep all BON-moving operations - blocked while publication or sponsor timing remains unverified. + opening beyond the one-hour expiry grace. **Pre-checkpoint legacy snapshots + without proven publication and a conservative pre-send opening time are + quarantined unchanged for MANUAL review**, including already-expired or + committed settlement state. Never cap their deadlines using a post-send + `startTime` or automatically resume entries and BON settlement. Never + overwrite such a preserved snapshot with a new start. +- A Gift History clock offset calibrated against precise notification/chat + events and second-precision history rows has **fractional calibration + uncertainty**. Persist an upper error bound with the inferred offset and + subtract it from the calibrated bucket start BEFORE checking + `sponsorStartTs`. This also applies to the boundary-evidence path and + across reloads. Keep all BON-moving operations blocked while publication + or sponsor timing remains unverified. - In **Silent Mode**, ordinary command replies (staff responses, errors, `!winners`, `!addbon` etc.) use private messages. **`!reminder` is NOT an ordinary reply:** it explicitly requests an announcement for everyone. From 44edb6d8699c9e58d99a127be7f7e266069c924d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:49:56 +0100 Subject: [PATCH 087/130] docs(bonanza): clarify immutable legacy snapshots and calibrated gift boundaries --- userscripts/giveaway/src/README.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 0472eb82..6ae4c82a 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -55,13 +55,23 @@ timestamp-resolution bucket** inside the original bounded POST attempt: overlapping same-second buckets and later identical posts remain ambiguous. If delivery is ambiguous, preserve it and retry verification on reload; even a non-OK opening POST response must NOT trigger a second chatbox -opening, since the server may have stored the original message. -do not silently discard the snapshot or perform settlement. - -A new giveaway must not overwrite a pending opening checkpoint. Recovered -opening checkpoints older than the normal settlement grace require manual -review rather than automatic BON transfers. Do not unify the two timestamps -or disable the fail-closed verification gates when changing the lifecycle. +opening, since the server may have stored the original message. Do not +silently discard the snapshot or perform settlement. + +A new giveaway must not overwrite a pending or legacy unverified snapshot. +Recovered opening checkpoints older than the normal settlement grace require +manual review rather than automatic BON transfers. Pre-checkpoint legacy +snapshots have neither verified public-opening evidence nor a conservative +pre-send timestamp: retain the ORIGINAL record for manual investigation, +without automatically capping a deadline from the later post-send timestamp, +resuming entrants, or moving BON, even if settlement was already committed. + +Clock calibration against exact notification events can shift the apparent +start of a second-precision Gift History bucket. Preserve the learned +calibration uncertainty in the snapshot, and apply the conservative EARLIEST +bucket start before checking the verified `sponsorStartTs`; this rule also +applies when reconstructing boundary chat evidence. Never unify the two +opening timestamps or bypass the fail-closed verification gates. Silent Mode routes normal host and staff command replies privately, including winner/pot adjustment messages. **`!reminder` is an explicit From e0a32b6390b45518d3174aad48298a183ab6d00a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:49:59 +0100 Subject: [PATCH 088/130] docs(bonanza): describe fail-closed legacy and sponsor calibration fixes --- userscripts/giveaway/src/00-preamble.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 38c588e8..057ebdf6 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -261,6 +261,10 @@ // Gift History timestamps also must start no earlier than the verified // sponsor opening boundary; HTTP error/5xx opening POSTs must never // trigger a second announcement via chatbox fallback. +// Legacy snapshots without verified opening evidence are retained only +// for manual recovery (no automatic 45-minute rewrite or BON payout). +// Coarse Gift History calibration uncertainty is tracked, saved across +// reloads, and subtracted before sponsor-start eligibility checks. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From 82c7716261e9c390980f5ab7af7546e3291ee3f5 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:50:22 +0100 Subject: [PATCH 089/130] build(bonanza): regenerate legacy quarantine and sponsor calibration hardening --- .../DarkPeers_BONanza_Giveaway.user.js | 119 ++++++++++++++++-- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 2459b281..2d4791ac 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -261,6 +261,10 @@ // Gift History timestamps also must start no earlier than the verified // sponsor opening boundary; HTTP error/5xx opening POSTs must never // trigger a second announcement via chatbox fallback. +// Legacy snapshots without verified opening evidence are retained only +// for manual recovery (no automatic 45-minute rewrite or BON payout). +// Coarse Gift History calibration uncertainty is tracked, saved across +// reloads, and subtracted before sponsor-start eligibility checks. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -3220,6 +3224,8 @@ body.host-panel-dragging * { lastMsgId: Math.max(0, Math.floor(Number(window.__activeTracker.lastMsgId) || 0)), cursorInitialized: !!window.__activeTracker.cursorInitialized, giftHistoryClockOffsetMs: optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetMs), + giftHistoryClockOffsetUncertaintyMs: + optionalFiniteNumber(window.__activeTracker.giftHistoryClockOffsetUncertaintyMs), giftHistoryInitialized: !!window.__activeTracker.giftHistoryInitialized, giftHistorySeenKeys: Array.from(window.__activeTracker.giftHistorySeenKeys || []).slice(-250), historyFallbackActive: !!window.__activeTracker.historyFallbackActive, @@ -3311,6 +3317,32 @@ body.host-panel-dragging * { return null; } + // Pre-checkpoint releases never proved that an opening reached + // Main Chat, and their startTime was recorded AFTER the send. + // Preserve the original raw snapshot, including old/expired and + // partially-paid records, but NEVER auto-resume or auto-settle it. + // Rewriting its 45-minute deadline from that late startTime would + // also violate the absolute public-opening cap. + const checkpoint = snap.giveawayData.openingCheckpoint; + if (!checkpoint || ( + checkpoint.status !== "pending" && + checkpoint.status !== "confirmed" + ) || ( + checkpoint.status === "confirmed" && + (!Number.isFinite(Number(snap.giveawayData.openedAt)) || + Number(snap.giveawayData.openedAt) <= 0 || + !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || + Number(snap.giveawayData.sponsorStartTs) < + Number(snap.giveawayData.openedAt)) + )) { + console.warn( + "[BON Giveaway] Unverified legacy opening retained for manual review; " + + "automatic resume, deadline migration and BON settlement are blocked." + ); + snap.__legacyOpeningUnverified = true; + return snap; + } + const endTs = Number(snap.giveawayData.endTs); if (!Number.isFinite(endTs) || endTs <= 0) { clearGiveawaySnapshot(); @@ -3472,6 +3504,28 @@ body.host-panel-dragging * { async function restoreGiveawayFromSnapshot(snap) { if (!snap || !snap.giveawayData) return false; + // Do not let pre-checkpoint snapshots enter any automatic restore path, + // even if an alternate caller bypasses loadGiveawaySnapshot(). Keep + // persistent entries and paid-gift ledgers untouched for manual review. + const proof = snap.giveawayData.openingCheckpoint; + if (snap.__legacyOpeningUnverified === true || + !proof || (proof.status !== "pending" && proof.status !== "confirmed") || + (proof.status === "confirmed" && + (!Number.isFinite(Number(snap.giveawayData.openedAt)) || + Number(snap.giveawayData.openedAt) <= 0 || + !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || + Number(snap.giveawayData.sponsorStartTs) < + Number(snap.giveawayData.openedAt)))) { + console.warn("[BON Giveaway] Legacy opening lacks verified publication proof; keeping original snapshot."); + window.alert( + "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: This older snapshot " + + "does not prove that its opening was published or when it began. " + + "Automatic entries, countdown changes and BON settlement are blocked. " + + "The original recovery state was preserved." + ); + return false; + } + // Validate the live DarkPeers identity/Main Chat context BEFORE claiming // ownership or touching the recoverable snapshot. A transient/wrong chat // context must never destroy or mutate persisted giveaway state. @@ -3795,6 +3849,9 @@ body.host-panel-dragging * { giftHistoryClockOffsetMs: savedTracker ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetMs) : null, + giftHistoryClockOffsetUncertaintyMs: savedTracker + ? optionalFiniteNumber(savedTracker.giftHistoryClockOffsetUncertaintyMs) + : null, giftHistoryInitialized: savedTracker ? !!savedTracker.giftHistoryInitialized : false, giftHistorySeenKeys: savedTracker && Array.isArray(savedTracker.giftHistorySeenKeys) ? savedTracker.giftHistorySeenKeys @@ -4111,11 +4168,17 @@ body.host-panel-dragging * { if (prior.raw) { try { const saved = JSON.parse(prior.raw); - if (saved?.giveawayData?.openingCheckpoint?.status === "pending") { + const proof = saved?.giveawayData?.openingCheckpoint; + if (saved?.giveawayData && (!proof || proof.status !== "confirmed" || + !Number.isFinite(Number(saved.giveawayData.openedAt)) || + Number(saved.giveawayData.openedAt) <= 0 || + !Number.isFinite(Number(saved.giveawayData.sponsorStartTs)) || + Number(saved.giveawayData.sponsorStartTs) < + Number(saved.giveawayData.openedAt))) { window.alert( - "GIVEAWAY START BLOCKED: A previous opening is awaiting " + - "Main Chat verification. Reload DarkPeers and recover it " + - "before starting another giveaway. Its saved state was preserved." + "GIVEAWAY START BLOCKED: A saved opening still requires " + + "verification or manual review. The original recovery " + + "snapshot must not be overwritten by a new giveaway." ); return; } @@ -5636,6 +5699,7 @@ body.host-panel-dragging * { lastMsgId = 0, cursorInitialized = false, giftHistoryClockOffsetMs = null, + giftHistoryClockOffsetUncertaintyMs = null, giftHistoryInitialized = false, giftHistorySeenKeys = [], historyFallbackActive = false, @@ -5648,6 +5712,12 @@ body.host-panel-dragging * { this.lastMsgId = Math.max(0, Math.floor(Number(lastMsgId) || 0)); // persisted API cursor this.cursorInitialized = !!cursorInitialized; this.giftHistoryClockOffsetMs = optionalFiniteNumber(giftHistoryClockOffsetMs); + // Earlier versions stored a point-estimate without its fractional + // calibration error. DarkPeers Gift History has second precision, + // so reserve at least one complete second of unknown offset. + this.giftHistoryClockOffsetUncertaintyMs = Math.max( + 1000, optionalFiniteNumber(giftHistoryClockOffsetUncertaintyMs) ?? 1000 + ); this.giftHistoryInitialized = !!giftHistoryInitialized; this.giftHistorySeenKeys = new Set( (Array.isArray(giftHistorySeenKeys) ? giftHistorySeenKeys : []) @@ -6087,7 +6157,8 @@ body.host-panel-dragging * { : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { - const start = wallTs - offset; + const start = wallTs - offset - + Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); // A gift bucket overlapping public opening is ambiguous: // only an interval STARTING at/after the confirmed sponsor // boundary can be safely attributed to this giveaway. @@ -6178,10 +6249,17 @@ body.host-panel-dragging * { : Number(item?.createdAtTs); if (offset !== null && Number.isFinite(wallTs)) { + // The learned offset subtracts an exact event time from + // coarse Gift History time. This shifts all bucket starts + // forward by the anchor event's fractional second (and, + // for clustered anchors, possibly more). Use the EARLIEST + // possible start, including stored calibration uncertainty. const eventTs = wallTs - offset; + const earliestEventTs = eventTs - + Math.max(1000, this.giftHistoryClockOffsetUncertaintyMs ?? 1000); // Entire Gift History timestamp bucket must be after // confirmed publication, not merely overlap its boundary. - const afterOpen = min === null || eventTs >= min; + const afterOpen = min === null || earliestEventTs >= min; if (!afterOpen) continue; if (max === null) { @@ -6565,21 +6643,27 @@ body.host-panel-dragging * { if (!Number.isFinite(historyWallTs)) continue; const offset = historyWallTs - Number(event.createdAtTs); - if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) samples.push(offset); + const resolutionMs = Math.max( + 1, Number(item?.timestampResolutionMs) || + unit3dTimestampResolutionMs(item?.rawTimestamp) + ); + if (Math.abs(offset) <= MAX_PLAUSIBLE_OFFSET_MS) { + samples.push({ offset, resolutionMs }); + } } if (!samples.length) return persisted; // Choose the densest 2-minute cluster, then its median. This rejects an // unrelated historical exact-value row without hardcoding any timezone. - samples.sort((a, b) => a - b); + samples.sort((a, b) => a.offset - b.offset); const CLUSTER_MS = 120_000; let best = []; for (let i = 0; i < samples.length; i++) { const cluster = []; for (let j = i; j < samples.length; j++) { - if (samples[j] - samples[i] > CLUSTER_MS) break; + if (samples[j].offset - samples[i].offset > CLUSTER_MS) break; cluster.push(samples[j]); } if (cluster.length > best.length) best = cluster; @@ -6588,8 +6672,20 @@ body.host-panel-dragging * { if (!best.length) return persisted; const mid = Math.floor(best.length / 2); const inferred = best.length % 2 - ? best[mid] - : Math.round((best[mid - 1] + best[mid]) / 2); + ? best[mid].offset + : Math.round((best[mid - 1].offset + best[mid].offset) / 2); + + // If the chosen offset is calibrated from a coarse timestamp, + // the true timezone offset can be up to one entire source bucket + // GREATER than the inferred point estimate. Include differences + // within the accepted anchor cluster, too. This upper error + // bound is persisted with the point estimate across page reloads. + const offsetUncertaintyMs = Math.max( + 1000, + ...best.map(sample => + Math.max(0, sample.offset + sample.resolutionMs - inferred) + ) + ); // If we already learned a clock offset, don't replace it with a lone, // materially different sample. Multiple agreeing anchors may update it @@ -6603,6 +6699,7 @@ body.host-panel-dragging * { } this.giftHistoryClockOffsetMs = inferred; + this.giftHistoryClockOffsetUncertaintyMs = offsetUncertaintyMs; return inferred; } From 9843ed7f93726af386ee2145254d863b8c3a8a12 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:51:04 +0100 Subject: [PATCH 090/130] test(bonanza): keep calibration regression fixture within sponsor closing window --- tests/bonanza-opening-recovery.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index c2915e03..1673cd64 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -529,10 +529,11 @@ test("calibration fractional bias cannot turn a pre-opening gift into a sponsor" // 750ms later and falls beyond the 3000.5ms opening boundary. tracker.giftHistoryClockOffsetMs = -750; tracker.giftHistoryClockOffsetUncertaintyMs = 1000; + tracker.data.endTs = 10000; const result = await tracker.filterHistoryRowsByWindow([ giftAt(2500, "before"), giftAt(4250, "after") - ], 3000.5, 4500); + ], 3000.5, 10000); assert.deepEqual(result.accepted.map(x => x.historyKey), ["after"]); }); From 23565f324945028004b5fb97c19f2cc34ef60568 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:51:41 +0100 Subject: [PATCH 091/130] test(bonanza): compare cross-realm VM sponsor result with native array --- tests/bonanza-opening-recovery.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 1673cd64..60a9f9a4 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -534,7 +534,7 @@ test("calibration fractional bias cannot turn a pre-opening gift into a sponsor" giftAt(2500, "before"), giftAt(4250, "after") ], 3000.5, 10000); - assert.deepEqual(result.accepted.map(x => x.historyKey), ["after"]); + assert.deepEqual(Array.from(result.accepted, x => x.historyKey), ["after"]); }); test("the inferred calibration error is propagated and conserved", () => { From 9ca2e445cbe41347daa7c16291c4790f9599dbb8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:46:30 +0100 Subject: [PATCH 092/130] fix(bonanza): recheck saved giveaway under exclusive lock before new opening --- userscripts/giveaway/src/06-lifecycle.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index c1d0f932..0c64addc 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -175,6 +175,26 @@ return; } + // The check above ran BEFORE ownership was acquired. Two tabs can + // both see empty storage; the second may acquire the lock only after + // the first has posted (or checkpointed) an ambiguous opening. Read + // storage AGAIN while owning the lock. Any remaining snapshot is + // recoverable state and must not be replaced by a second opening. + const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { + migrateLegacy: true + }); + if (savedAfterLock.raw || !savedAfterLock.storageKey) { + logEvent("Start blocked (post-lock recovery recheck)", + "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); + window.alert( + "GIVEAWAY START BLOCKED: A previous giveaway was saved while " + + "waiting for this tab's lock. Recover or resolve it before " + + "starting another. No new opening has been posted." + ); + releaseTabLock(); + return; + } + if (sponsorsInterval) { clearInterval(sponsorsInterval); sponsorsInterval = null; } if (observer) { observer.disconnect(); observer = null; } From 6b3003359a12bf7b233975b423eaf6a18ef94803 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:46:44 +0100 Subject: [PATCH 093/130] fix(bonanza): verify original confirmed opening message ID on reload --- userscripts/giveaway/src/06-lifecycle.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 0c64addc..93563ed1 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -11,7 +11,8 @@ // release a pending opening snapshot merely because sendMessage returned // false; an announced giveaway may already exist publicly. async function verifyPendingGiveawayOpening(checkpoint) { - if (!checkpoint || checkpoint.status !== "pending" || + if (!checkpoint || + (checkpoint.status !== "pending" && checkpoint.status !== "confirmed") || !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { return null; } @@ -54,6 +55,11 @@ for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); if (!Number.isSafeInteger(id) || id <= afterId) continue; + // A confirmed checkpoint must identify the SAME original + // announcement after reload. Never replace it with a later + // identical post, even inside the bounded send window. + if (checkpoint.status === "confirmed" && + id !== optionalFiniteNumber(checkpoint.messageId)) continue; const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); if (authorId !== senderId) continue; if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; From e77049cda78541fb40f80e3ed81590182242817c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:47:02 +0100 Subject: [PATCH 094/130] fix(bonanza): revalidate confirmed public openings before restore or payout --- userscripts/giveaway/src/05-initialization.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 90fb033e..33d73deb 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1741,6 +1741,33 @@ } try { + // Local storage alone is not authoritative evidence of a public + // opening. For a previously confirmed LIVE giveaway, prove that + // the exact original message ID/host/text/timestamp still exists + // in Main Chat before restoring entries, pot or settlement. The + // verifier checks the original bounded POST window and cursor. + const restoredCheckpoint = snap.giveawayData?.openingCheckpoint; + if (!REHEARSAL_MODE && restoredCheckpoint?.status === "confirmed") { + const verified = await verifyPendingGiveawayOpening(restoredCheckpoint); + const savedSponsorStart = optionalFiniteNumber( + snap.giveawayData.sponsorStartTs + ); + if (!verified || + verified.messageId !== optionalFiniteNumber(restoredCheckpoint.messageId) || + savedSponsorStart === null || + verified.sponsorStartTs !== savedSponsorStart) { + logEvent("Confirmed opening recovery deferred", + "Authoritative Main Chat could not reprove the exact saved opening and sponsor boundary."); + window.alert( + "GIVEAWAY RECOVERY PAUSED: The saved opening could not " + + "be verified again against Main Chat. No entries, " + + "sponsor updates or BON settlement will be resumed." + ); + releaseTabLock(); + return false; + } + } + // An opening may have reached Main Chat even if its POST response was // lost. It was durably checkpointed BEFORE sending; never resume // entries, sponsor accounting or settlement until the exact public From 6cab76bafb2dee21d133ec096659ec7d205b03d8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:47:39 +0100 Subject: [PATCH 095/130] fix(bonanza): fail closed on uncalibrated Gift History timestamps --- .../giveaway/src/09-sponsorship-polling.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/userscripts/giveaway/src/09-sponsorship-polling.js b/userscripts/giveaway/src/09-sponsorship-polling.js index 188e821b..b255f7ee 100644 --- a/userscripts/giveaway/src/09-sponsorship-polling.js +++ b/userscripts/giveaway/src/09-sponsorship-polling.js @@ -839,6 +839,26 @@ offset = await this.ensureGiftHistoryClockOffset(rows); } + // Gift History exposes site-local and browser/UTC interpretations. + // Without an authoritative calibration we CANNOT pick whichever + // interpretation falls after the public opening: the other one may + // place the same gift before it. Keep every row retryable, without + // modifying sponsor totals or marking gift-history keys as seen. + // The canonical settlement reconciliation treats retryable rows as + // a hard failure, so no BON payouts can proceed from this state. + if (min !== null && offset === null) { + logEvent( + "Sponsor accounting paused (clock uncalibrated)", + "Gift History time offset is unknown; deferred all gift rows instead of choosing an ambiguous timestamp interpretation." + ); + return { + accepted: [], + retryableKeys: new Set( + rows.map(item => item?.historyKey).filter(Boolean) + ) + }; + } + // Re-read the closing latch after the await above. If endGiveaway() // closed the window while this ordinary poll was already in flight, // this pass must immediately inherit that cutoff. From 4fdb3d9d5ace5db7dd65a5efb210fb03e2e88d5e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:48:26 +0100 Subject: [PATCH 096/130] test(bonanza): cover post-lock races, confirmed recovery and uncalibrated gifts --- tests/bonanza-opening-recovery.test.mjs | 73 +++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 60a9f9a4..d4b43b19 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -446,14 +446,24 @@ test("calibrated Gift History excludes a coarse gift overlapping the sponsor-ope assert.equal(result.accepted[1].historyKey, "later"); }); -test("fallback Gift History interpretations exclude overlapping opening buckets", async () => { +test("uncalibrated Gift History defers ALL gifts rather than guessing a timezone", async () => { const tracker = sponsorHistoryFilterHarness({ calibrated: false }); const result = await tracker.filterHistoryRowsByWindow([ giftAt(2000, "before"), - giftAt(2001, "after") + giftAt(3101, "apparently-after") ], 2000.5, 4500); - assert.equal(result.accepted.length, 1); - assert.equal(result.accepted[0].historyKey, "after"); + assert.equal(result.accepted.length, 0); + assert.deepEqual(Array.from(result.retryableKeys).sort(), ["apparently-after", "before"]); +}); + +test("uncalibrated interpretations remain unverified even with no closing cutoff", async () => { + const tracker = sponsorHistoryFilterHarness({ calibrated: false }); + tracker.data.endTs = null; + const result = await tracker.filterHistoryRowsByWindow([ + giftAt(3101, "unresolved") + ], 2000.5, null); + assert.equal(result.accepted.length, 0); + assert.deepEqual(Array.from(result.retryableKeys), ["unresolved"]); }); test("even without a closing timestamp, an overlapping pre-opening gift is not sponsored", async () => { @@ -572,3 +582,58 @@ test("clock-offset uncertainty survives sponsor snapshot and restore", () => { assert.match(load, /giftHistoryClockOffsetUncertaintyMs:\s*optionalFiniteNumber\(window\.__activeTracker\.giftHistoryClockOffsetUncertaintyMs\)/); assert.match(load, /giftHistoryClockOffsetUncertaintyMs: savedTracker/); }); + +test("starting a new giveaway repeats recovery snapshot check inside the acquired lock", () => { + const start = lifecycle.indexOf("async function startGiveaway()"); + const beforeLock = lifecycle.indexOf("const prior = readActiveGiveawayRawForHost(", start); + const locked = lifecycle.indexOf("if (!await acquireTabLock())", start); + const afterLock = lifecycle.indexOf("const savedAfterLock = readActiveGiveawayRawForHost(", locked); + const firstMutation = lifecycle.indexOf("if (sponsorsInterval)", afterLock); + const guard = lifecycle.slice(afterLock, firstMutation); + assert.ok(start >= 0 && beforeLock > start && locked > beforeLock); + assert.ok(afterLock > locked && firstMutation > afterLock); + assert.match(guard, /if \(savedAfterLock\.raw \|\| !savedAfterLock\.storageKey\)/); + assert.match(guard, /releaseTabLock\(\)/); + assert.match(guard, /return;/); +}); + +test("authoritatively confirmed opening must reverify identical original message ID", async () => { + const valid = createHarness({ records: [goodMessage()] }); + valid.checks.status = "confirmed"; + valid.checks.messageId = 101; + const result = await valid.run(); + assert.ok(result); + assert.equal(result.messageId, 101); + + const altered = createHarness({ records: [goodMessage({ id: 102 })] }); + altered.checks.status = "confirmed"; + altered.checks.messageId = 101; + assert.equal(await altered.run(), null); + + const missing = createHarness({ records: [goodMessage()] }); + missing.checks.status = "confirmed"; + assert.equal(await missing.run(), null); +}); + +test("confirmed restore verifies Main Chat before reloading entries or settlement", () => { + const begin = initialization.indexOf("async function restoreGiveawayFromSnapshot("); + const afterLock = initialization.indexOf("if (!await acquireTabLock())", begin); + const check = initialization.indexOf("restoredCheckpoint?.status === \"confirmed\"", afterLock); + const verify = initialization.indexOf("await verifyPendingGiveawayOpening(restoredCheckpoint)", check); + const restoreEntries = initialization.indexOf("// 1) Restore giveaway data", verify); + assert.ok(begin >= 0 && afterLock > begin && check > afterLock); + assert.ok(verify > check && restoreEntries > verify); + const guard = initialization.slice(check, restoreEntries); + assert.match(guard, /verified\.messageId !== optionalFiniteNumber\(restoredCheckpoint\.messageId\)/); + assert.match(guard, /verified\.sponsorStartTs !== savedSponsorStart/); + assert.match(guard, /releaseTabLock\(\)/); + assert.match(guard, /return false;/); +}); + +test("unverified history keys remain retryable across polling and block canonical payout", () => { + assert.match(sponsorship, /if \(min !== null && offset === null\)/); + assert.match(sponsorship, /retryableKeys: new Set\(/); + assert.match(sponsorship, /if \(!item\.historyKey \|\| retryableBoundaryKeys\.has\(item\.historyKey\)\) continue/); + assert.match(sponsorship, /if \(filtered\.retryableKeys\.size\)/); + assert.match(sponsorship, /Gift History reconciliation found timestamp-boundary rows/); +}); From a697a36ea72a1910126d3d0ba85231a00326c761 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:49:24 +0100 Subject: [PATCH 097/130] docs(bonanza): require post-lock recovery and safe uncalibrated gift behavior --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index e559f128..0db50d97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,17 @@ multiple userscripts, not just BONanza. Omit ambiguous boundary gifts rather than risking inflation of a real BON payout. Persist `sponsorStartTs` and use it for both live Gift History polling and reload reconciliation. +- After acquiring the exclusive Web Lock, read saved recovery storage AGAIN + before mutating state or posting. Another tab may have saved an uncertain + opening while waiting for the lock; do not overwrite any such state. +- Reverify every locally **confirmed** LIVE opening during restore against + the exact original Main Chat message ID, authenticated author, full + announcement text, bounded send window and sponsor boundary. If that + authoritative evidence is unavailable, preserve the snapshot without + enabling entries or BON transfers. +- With no calibrated Gift History offset, neither UTC nor browser-local + timestamps are trustworthy: defer ALL gifts as retryable and block + final canonical settlement until calibration succeeds. - Leave unresolved opening checkpoints intact for later recovery; never overwrite them with a new giveaway or auto-settle a recovered ancient opening beyond the one-hour expiry grace. **Pre-checkpoint legacy snapshots From c5cae0604335bff3e661f6143884c8792545c26c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:49:30 +0100 Subject: [PATCH 098/130] docs(bonanza): document strict concurrency, replay proof and clock gates --- userscripts/giveaway/src/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 6ae4c82a..c979aa1f 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -58,6 +58,17 @@ even a non-OK opening POST response must NOT trigger a second chatbox opening, since the server may have stored the original message. Do not silently discard the snapshot or perform settlement. +Recheck the saved state **after** acquiring the exclusive Web Lock; two +tabs may have seen empty storage before one posts an uncertain opening. +Reverify previously confirmed LIVE openings from authoritative Main Chat +on every restore, requiring the same original message ID, host, exact +text, send window and sponsor boundary. If absent or inconsistent, leave +the snapshot untouched and pause recovery, including BON settlement. + +Uncalibrated Gift History rows are NEVER eligible from a guessed local +or UTC interpretation. Keep their keys retryable until reliable clock +calibration succeeds, and fail final canonical reconciliation otherwise. + A new giveaway must not overwrite a pending or legacy unverified snapshot. Recovered opening checkpoints older than the normal settlement grace require manual review rather than automatic BON transfers. Pre-checkpoint legacy From 5a4af4eb33943e1382801b54c5385d4fdc305f67 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:49:33 +0100 Subject: [PATCH 099/130] docs(bonanza): record three newest review safety corrections --- userscripts/giveaway/src/00-preamble.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 057ebdf6..ab8f11ad 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -261,6 +261,10 @@ // Gift History timestamps also must start no earlier than the verified // sponsor opening boundary; HTTP error/5xx opening POSTs must never // trigger a second announcement via chatbox fallback. +// A second tab must recheck saved recovery AFTER acquiring its Web Lock. +// Confirmed LIVE openings are authoritatively rechecked on every restore. +// When Gift History calibration is unavailable, ALL ambiguous gift rows +// remain uncounted/retryable until reliable calibration succeeds. // Legacy snapshots without verified opening evidence are retained only // for manual recovery (no automatic 45-minute rewrite or BON payout). // Coarse Gift History calibration uncertainty is tracked, saved across From 56d948f76145c90c330064e48c62c4261c399c34 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:49:56 +0100 Subject: [PATCH 100/130] build(bonanza): regenerate userscript for secure lock, recovery and timestamps --- .../DarkPeers_BONanza_Giveaway.user.js | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 2d4791ac..f86d3622 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -261,6 +261,10 @@ // Gift History timestamps also must start no earlier than the verified // sponsor opening boundary; HTTP error/5xx opening POSTs must never // trigger a second announcement via chatbox fallback. +// A second tab must recheck saved recovery AFTER acquiring its Web Lock. +// Confirmed LIVE openings are authoritatively rechecked on every restore. +// When Gift History calibration is unavailable, ALL ambiguous gift rows +// remain uncounted/retryable until reliable calibration succeeds. // Legacy snapshots without verified opening evidence are retained only // for manual recovery (no automatic 45-minute rewrite or BON payout). // Coarse Gift History calibration uncertainty is tracked, saved across @@ -3574,6 +3578,33 @@ body.host-panel-dragging * { } try { + // Local storage alone is not authoritative evidence of a public + // opening. For a previously confirmed LIVE giveaway, prove that + // the exact original message ID/host/text/timestamp still exists + // in Main Chat before restoring entries, pot or settlement. The + // verifier checks the original bounded POST window and cursor. + const restoredCheckpoint = snap.giveawayData?.openingCheckpoint; + if (!REHEARSAL_MODE && restoredCheckpoint?.status === "confirmed") { + const verified = await verifyPendingGiveawayOpening(restoredCheckpoint); + const savedSponsorStart = optionalFiniteNumber( + snap.giveawayData.sponsorStartTs + ); + if (!verified || + verified.messageId !== optionalFiniteNumber(restoredCheckpoint.messageId) || + savedSponsorStart === null || + verified.sponsorStartTs !== savedSponsorStart) { + logEvent("Confirmed opening recovery deferred", + "Authoritative Main Chat could not reprove the exact saved opening and sponsor boundary."); + window.alert( + "GIVEAWAY RECOVERY PAUSED: The saved opening could not " + + "be verified again against Main Chat. No entries, " + + "sponsor updates or BON settlement will be resumed." + ); + releaseTabLock(); + return false; + } + } + // An opening may have reached Main Chat even if its POST response was // lost. It was durably checkpointed BEFORE sending; never resume // entries, sponsor accounting or settlement until the exact public @@ -4058,7 +4089,8 @@ body.host-panel-dragging * { // release a pending opening snapshot merely because sendMessage returned // false; an announced giveaway may already exist publicly. async function verifyPendingGiveawayOpening(checkpoint) { - if (!checkpoint || checkpoint.status !== "pending" || + if (!checkpoint || + (checkpoint.status !== "pending" && checkpoint.status !== "confirmed") || !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { return null; } @@ -4101,6 +4133,11 @@ body.host-panel-dragging * { for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); if (!Number.isSafeInteger(id) || id <= afterId) continue; + // A confirmed checkpoint must identify the SAME original + // announcement after reload. Never replace it with a later + // identical post, even inside the bounded send window. + if (checkpoint.status === "confirmed" && + id !== optionalFiniteNumber(checkpoint.messageId)) continue; const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); if (authorId !== senderId) continue; if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; @@ -4222,6 +4259,26 @@ body.host-panel-dragging * { return; } + // The check above ran BEFORE ownership was acquired. Two tabs can + // both see empty storage; the second may acquire the lock only after + // the first has posted (or checkpointed) an ambiguous opening. Read + // storage AGAIN while owning the lock. Any remaining snapshot is + // recoverable state and must not be replaced by a second opening. + const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { + migrateLegacy: true + }); + if (savedAfterLock.raw || !savedAfterLock.storageKey) { + logEvent("Start blocked (post-lock recovery recheck)", + "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); + window.alert( + "GIVEAWAY START BLOCKED: A previous giveaway was saved while " + + "waiting for this tab's lock. Recover or resolve it before " + + "starting another. No new opening has been posted." + ); + releaseTabLock(); + return; + } + if (sponsorsInterval) { clearInterval(sponsorsInterval); sponsorsInterval = null; } if (observer) { observer.disconnect(); observer = null; } @@ -6120,6 +6177,26 @@ body.host-panel-dragging * { offset = await this.ensureGiftHistoryClockOffset(rows); } + // Gift History exposes site-local and browser/UTC interpretations. + // Without an authoritative calibration we CANNOT pick whichever + // interpretation falls after the public opening: the other one may + // place the same gift before it. Keep every row retryable, without + // modifying sponsor totals or marking gift-history keys as seen. + // The canonical settlement reconciliation treats retryable rows as + // a hard failure, so no BON payouts can proceed from this state. + if (min !== null && offset === null) { + logEvent( + "Sponsor accounting paused (clock uncalibrated)", + "Gift History time offset is unknown; deferred all gift rows instead of choosing an ambiguous timestamp interpretation." + ); + return { + accepted: [], + retryableKeys: new Set( + rows.map(item => item?.historyKey).filter(Boolean) + ) + }; + } + // Re-read the closing latch after the await above. If endGiveaway() // closed the window while this ordinary poll was already in flight, // this pass must immediately inherit that cutoff. From 06c67b8f141bde080a61b6112649f20ee329679b Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:36:38 +0100 Subject: [PATCH 101/130] fix(bonanza): distinguish failed storage reads and never snapshot preflight giveaway --- userscripts/giveaway/src/05-initialization.js | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 33d73deb..b28013d4 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1075,11 +1075,11 @@ function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false }; + if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false, readOk: false }; try { const namespacedRaw = localStorage.getItem(storageKey); - if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false }; + if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false, readOk: true }; // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. // A legacy snapshot belonging to another account is deliberately left @@ -1087,24 +1087,28 @@ // overwrite that foreign recovery state. Rehearsal storage never // imports a live legacy snapshot. if (REHEARSAL_MODE) { - return { storageKey, raw: null, migratedLegacy: false }; + return { storageKey, raw: null, migratedLegacy: false, readOk: true }; } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false }; + if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); if (!legacyHostKey || legacyHostKey !== requestedHostKey) { - return { storageKey, raw: null, migratedLegacy: false }; + return { storageKey, raw: null, migratedLegacy: false, readOk: true }; } if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); } - return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy }; - } catch { - return { storageKey, raw: null, migratedLegacy: false }; + return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy, readOk: true }; + } catch (error) { + // A failed localStorage read (including corrupt legacy JSON) is + // NOT proof that the active recovery slot is empty. A new giveaway + // must not overwrite existing state when read integrity is unknown. + console.warn("[BON Giveaway] Active recovery storage read failed:", error); + return { storageKey, raw: null, migratedLegacy: false, readOk: false }; } } @@ -1328,7 +1332,13 @@ * Called at key mutation points (new entry, start, addbon, sponsor, time adjust). */ function snapshotGiveaway({ force = false, verifyWrite = false } = {}) { - if (!giveawayData || !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; + // Preflight state exists only in memory while balance, Gift History and + // chat cursor checks run. Never persist it as a legacy giveaway when + // pagehide/unload fires BEFORE the opening checkpoint is saved. + const proof = giveawayData?.openingCheckpoint; + if (!giveawayData || + !proof || (proof.status !== "pending" && proof.status !== "confirmed") || + !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; try { const snapshot = { giveawayData: { From ed1b884db8b0aa290cd33d27d57a78de00bf9dfa Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:36:54 +0100 Subject: [PATCH 102/130] fix(bonanza): fail closed on uncertain recovery reads and quarantined preflight --- userscripts/giveaway/src/06-lifecycle.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 93563ed1..0863a7f8 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -124,6 +124,11 @@ const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { migrateLegacy: true }); + // localStorage failures must block starts, never look like absence. + if (!prior.readOk || !prior.storageKey) { + window.alert("GIVEAWAY START BLOCKED: Cannot read recovery storage reliably. No opening was posted."); + return; + } if (prior.raw) { try { const saved = JSON.parse(prior.raw); @@ -189,7 +194,7 @@ const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { migrateLegacy: true }); - if (savedAfterLock.raw || !savedAfterLock.storageKey) { + if (!savedAfterLock.readOk || savedAfterLock.raw || !savedAfterLock.storageKey) { logEvent("Start blocked (post-lock recovery recheck)", "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); window.alert( @@ -315,6 +320,9 @@ updateRigToggleUI(); const currentBon = await getVerifiedHostBalance({ requireServer: true, maxAgeMs: 0 }); + // A pagehide while preflight was awaiting balance invalidates this + // document. Don't continue into the public opening workflow. + if (!ownsTabLock()) return; if (currentBon == null) { const startErr = "Unable to verify current BON balance."; @@ -401,6 +409,7 @@ giveawayData }); const sponsorBaselineReady = await tracker.bootstrapGiftHistory(); + if (!ownsTabLock()) return; if (!sponsorBaselineReady) { logEvent( "Start aborted (Gift History baseline)", From 5b276540c3af7bc6d25d7f4034f3abde5b1b8a4e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:37:25 +0100 Subject: [PATCH 103/130] test(bonanza): exercise recovery read failures and preflight snapshot blocking --- tests/bonanza-opening-recovery.test.mjs | 95 ++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index d4b43b19..8ad6ee64 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -592,7 +592,7 @@ test("starting a new giveaway repeats recovery snapshot check inside the acquire const guard = lifecycle.slice(afterLock, firstMutation); assert.ok(start >= 0 && beforeLock > start && locked > beforeLock); assert.ok(afterLock > locked && firstMutation > afterLock); - assert.match(guard, /if \(savedAfterLock\.raw \|\| !savedAfterLock\.storageKey\)/); + assert.match(guard, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.raw \|\| !savedAfterLock\.storageKey\)/); assert.match(guard, /releaseTabLock\(\)/); assert.match(guard, /return;/); }); @@ -637,3 +637,96 @@ test("unverified history keys remain retryable across polling and block canonica assert.match(sponsorship, /if \(filtered\.retryableKeys\.size\)/); assert.match(sponsorship, /Gift History reconciliation found timestamp-boundary rows/); }); + +function recoveryReadHarness({ namespaced = null, legacy = null, throwsOn = "" } = {}) { + const storage = new Map(); + if (namespaced !== null) storage.set("recovery::maghuro", namespaced); + if (legacy !== null) storage.set("recovery", legacy); + const events = []; + const ctx = vm.createContext({ + REHEARSAL_MODE: false, + LS_ACTIVE_GIVEAWAY_LEGACY: "recovery", + REHEARSAL_STORAGE_SUFFIX: "", + normalizeUserKey: s => String(s || "").toLowerCase(), + getLoggedInUsername: () => "maghuro", + getActiveGiveawayStorageKey: () => "recovery::maghuro", + localStorage: { + getItem: key => { + if (throwsOn === "read") throw Error("read denied"); + return storage.has(key) ? storage.get(key) : null; + }, + setItem: (key, value) => { + if (throwsOn === "write") throw Error("write denied"); + storage.set(key, value); + events.push("write:" + key); + }, + removeItem: key => { + if (throwsOn === "remove") throw Error("remove denied"); + storage.delete(key); + } + }, + console: { warn() {} } + }); + vm.runInContext([ + extract(initialization, "function readActiveGiveawayRawForHost(") + ].join("\n"), ctx); + return { read: (...args) => ctx.readActiveGiveawayRawForHost(...args), events }; +} + +test("recovery storage access failures never masquerade as empty storage", () => { + const denied = recoveryReadHarness({ throwsOn: "read" }).read("maghuro", { migrateLegacy: true }); + assert.equal(denied.readOk, false); + assert.equal(denied.raw, null); + + const malformed = recoveryReadHarness({ legacy: "{bad json" }).read("maghuro", { migrateLegacy: true }); + assert.equal(malformed.readOk, false); + assert.equal(malformed.raw, null); + + const failedWrite = recoveryReadHarness({ + legacy: JSON.stringify({ giveawayData: { host: "maghuro" } }), + throwsOn: "write" + }).read("maghuro", { migrateLegacy: true }); + assert.equal(failedWrite.readOk, false); +}); + +test("only positively established empty recovery storage allows start prechecks", () => { + const absent = recoveryReadHarness().read("maghuro", { migrateLegacy: true }); + assert.equal(absent.readOk, true); + assert.equal(absent.raw, null); + const existing = recoveryReadHarness({ + namespaced: JSON.stringify({ giveawayData: { host: "maghuro" } }) + }).read("maghuro", { migrateLegacy: true }); + assert.equal(existing.readOk, true); + assert.ok(existing.raw); + const start = lifecycle.slice( + lifecycle.indexOf("async function startGiveaway()"), + lifecycle.indexOf("if (sponsorsInterval)") + ); + assert.match(start, /if \(!prior\.readOk \|\| !prior\.storageKey\)/); + assert.match(start, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.raw/); +}); + +test("preflight state cannot be snapshotted by pagehide or beforeunload", () => { + const persistentWrites = []; + const sandbox = vm.createContext({ + giveawayData: { host: "maghuro", openedAt: null, endTs: 123456 }, + canMutateActiveGiveaway: () => true, + window: {}, + localStorage: { setItem: (...args) => persistentWrites.push(args) } + }); + vm.runInContext(extract(initialization, "function snapshotGiveaway("), sandbox); + assert.equal(sandbox.snapshotGiveaway({ force: true, verifyWrite: true }), false); + assert.equal(sandbox.snapshotGiveaway(), false); + assert.equal(persistentWrites.length, 0); + const start = lifecycle.slice( + lifecycle.indexOf("async function startGiveaway()"), + lifecycle.indexOf("async function resetGiveaway(") + ); + assert.match(start, /if \(!ownsTabLock\(\)\) return;/); + const hide = initialization.slice( + initialization.indexOf("function handleGiveawayPageHide("), + initialization.indexOf("function handleGiveawayPageShow(") + ); + assert.match(hide, /snapshotGiveaway\(\)/); + assert.match(initialization, /if \(!giveawayData \|\|[\s\S]*?!proof \|\|/); +}); From 960dc96d1d80076e072e0689e9a7d2fce56bc88f Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:16 +0100 Subject: [PATCH 104/130] fix(bonanza): preserve unreadable or malformed recovery snapshots instead of deleting them --- userscripts/giveaway/src/05-initialization.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index b28013d4..a2d30e06 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1476,6 +1476,10 @@ if (!loggedInHostKey) return null; const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: true }); + if (!stored.readOk) { + console.warn("[BON Giveaway] Recovery storage unreadable; leaving any existing state untouched."); + return null; + } if (!stored.raw) return null; const snap = JSON.parse(stored.raw); if (!snap || !snap.giveawayData) return null; @@ -1522,7 +1526,10 @@ const endTs = Number(snap.giveawayData.endTs); if (!Number.isFinite(endTs) || endTs <= 0) { - clearGiveawaySnapshot(); + // Invalid saved data can reflect an incomplete write or an + // in-progress payout. Preserve it for manual inspection; + // never erase potentially recoverable settlement evidence. + console.warn("[BON Giveaway] Invalid saved deadline; snapshot preserved for manual recovery."); return null; } @@ -1582,8 +1589,11 @@ // active-giveaway grace window. snap.__expiredAtLoad = overdueMs >= 0 || closingSettlementLatched; return snap; - } catch { - clearGiveawaySnapshot(); + } catch (error) { + // Malformed or temporarily unreadable snapshots must not be + // silently discarded; a subsequent clean read or manual analysis + // may be needed to prevent lost or duplicate BON payouts. + console.warn("[BON Giveaway] Unable to parse recovery snapshot; retaining stored data:", error); return null; } } From b013c98b4dd9bb64fe9362ea0496155df63426fc Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:38:52 +0100 Subject: [PATCH 105/130] docs(bonanza): define fail-closed storage reads and unsnapshotted preflight --- AGENTS.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0db50d97..e3bef270 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,13 +70,24 @@ multiple userscripts, not just BONanza. **Sponsor eligibility must NOT use that time.** It begins strictly after the independently verified publication timestamp bucket. Each Gift History timestamp bucket must START at/after `sponsorStartTs`, not - merely overlap it; this applies to calibrated and fallback parsing. + merely overlap it; uncalibrated history must be deferred altogether. Omit ambiguous boundary gifts rather than risking inflation of a real BON payout. Persist `sponsorStartTs` and use it for both live Gift History polling and reload reconciliation. - After acquiring the exclusive Web Lock, read saved recovery storage AGAIN before mutating state or posting. Another tab may have saved an uncertain opening while waiting for the lock; do not overwrite any such state. + The recovery reader reports `readOk` separately from `raw`: a denied + localStorage read, corrupt legacy JSON or migration failure MUST block the + start, never appear as empty storage. +- **Preflight is not an opening.** Before a durable `openingCheckpoint` + exists, the in-memory configuration MUST NOT be snapshotted by pagehide, + beforeunload or other background persistence. A BFCache handoff during + balance/history/cursor awaits must not generate a false legacy giveaway + or allow the abandoned tab to post. +- Malformed, unreadable or incomplete recovery data may contain settlement + evidence. Preserve its raw storage for manual investigation; a parse error + is not permission to delete it or start a replacement. - Reverify every locally **confirmed** LIVE opening during restore against the exact original Main Chat message ID, authenticated author, full announcement text, bounded send window and sponsor boundary. If that From 321955b324564b98d69e561649fd1267a463f296 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:39:01 +0100 Subject: [PATCH 106/130] docs(bonanza): document browser interruption and recovery preservation --- userscripts/giveaway/src/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index c979aa1f..105138e5 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -60,6 +60,12 @@ silently discard the snapshot or perform settlement. Recheck the saved state **after** acquiring the exclusive Web Lock; two tabs may have seen empty storage before one posts an uncertain opening. +Treat `readOk: false` as a hard stop: storage-read errors, malformed +legacy JSON and migration failures are NEVER equivalent to empty storage. +Snapshots are forbidden during balance/history/cursor preflight, before a +persisted opening checkpoint exists, including pagehide/BFCache/unload. +Keep malformed or unreadable recovery records intact for manual review +instead of deleting possible BON settlement evidence. Reverify previously confirmed LIVE openings from authoritative Main Chat on every restore, requiring the same original message ID, host, exact text, send window and sponsor boundary. If absent or inconsistent, leave From af86b7e961a46e655f317d65cb84db7c40c5568c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:39:03 +0100 Subject: [PATCH 107/130] docs(bonanza): record storage safety regression safeguards --- userscripts/giveaway/src/00-preamble.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index ab8f11ad..fce60c17 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -269,6 +269,10 @@ // for manual recovery (no automatic 45-minute rewrite or BON payout). // Coarse Gift History calibration uncertainty is tracked, saved across // reloads, and subtracted before sponsor-start eligibility checks. +// Recovery storage read failures block new giveaways even when raw data +// appears absent. Preflight before the persisted opening checkpoint is +// NEVER snapshotted, including on BFCache/pagehide. Corrupt recovery +// snapshots are preserved rather than silently deleted. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. From 2bd854d04a48036c1c939197aa577e9f6f3aae92 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:39:36 +0100 Subject: [PATCH 108/130] test(bonanza): preserve corrupt recovery data and distinguish failed storage reads --- tests/bonanza-duration-recovery.test.mjs | 37 +++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs index f6f82c6e..a19a20f3 100644 --- a/tests/bonanza-duration-recovery.test.mjs +++ b/tests/bonanza-duration-recovery.test.mjs @@ -70,7 +70,7 @@ function recoveryHarness({ REHEARSAL_MODE: false, normalizeUserKey: (value) => String(value || "").toLowerCase(), getLoggedInUsername: () => "maghuro", - readActiveGiveawayRawForHost: () => ({ raw: JSON.stringify(snap) }), + readActiveGiveawayRawForHost: () => ({ readOk: true, raw: JSON.stringify(snap) }), clearGiveawaySnapshot: () => { clears.push(true); }, console: { warn: (message) => warnings.push(message) } }); @@ -290,3 +290,38 @@ test("restore and new-start entry points reject old snapshots before any mutatio assert.match(restore, /return false;/); assert.doesNotMatch(restore, /acquireTabLock\(/); }); + +test("malformed recovery data is preserved without any silent deletion", () => { + const clears = []; + const runtime = vm.createContext({ + REHEARSAL_MODE: false, + MAX_GIVEAWAY_MINUTES: 45, + MAX_REMINDERS: 6, + EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS: 60 * MINUTE, + normalizeUserKey: x => String(x || "").toLowerCase(), + getLoggedInUsername: () => "maghuro", + readActiveGiveawayRawForHost: () => ({ + readOk: true, raw: "{malformed recovery" + }), + clearGiveawaySnapshot: () => clears.push(true), + console: { warn() {} } + }); + vm.runInContext(functions, runtime); + assert.equal(runtime.loadGiveawaySnapshot(), null); + assert.equal(clears.length, 0); +}); + +test("unreadable recovery storage cannot trigger deletion or auto-restore", () => { + const clears = []; + const runtime = vm.createContext({ + REHEARSAL_MODE: false, + normalizeUserKey: x => String(x || "").toLowerCase(), + getLoggedInUsername: () => "maghuro", + readActiveGiveawayRawForHost: () => ({ readOk: false, raw: null }), + clearGiveawaySnapshot: () => clears.push(true), + console: { warn() {} } + }); + vm.runInContext(functions, runtime); + assert.equal(runtime.loadGiveawaySnapshot(), null); + assert.equal(clears.length, 0); +}); From df012b7db87d9b1c44f5465a98dd3d793f22b270 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:39:58 +0100 Subject: [PATCH 109/130] build(bonanza): regenerate from complete fail-closed storage/preflight fixes --- .../DarkPeers_BONanza_Giveaway.user.js | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index f86d3622..43db9973 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -269,6 +269,10 @@ // for manual recovery (no automatic 45-minute rewrite or BON payout). // Coarse Gift History calibration uncertainty is tracked, saved across // reloads, and subtracted before sponsor-start eligibility checks. +// Recovery storage read failures block new giveaways even when raw data +// appears absent. Preflight before the persisted opening checkpoint is +// NEVER snapshotted, including on BFCache/pagehide. Corrupt recovery +// snapshots are preserved rather than silently deleted. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. // Further development and maintenance by Maghuro & M.A.E.S.T.R.O. @@ -2912,11 +2916,11 @@ body.host-panel-dragging * { function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false }; + if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false, readOk: false }; try { const namespacedRaw = localStorage.getItem(storageKey); - if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false }; + if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false, readOk: true }; // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. // A legacy snapshot belonging to another account is deliberately left @@ -2924,24 +2928,28 @@ body.host-panel-dragging * { // overwrite that foreign recovery state. Rehearsal storage never // imports a live legacy snapshot. if (REHEARSAL_MODE) { - return { storageKey, raw: null, migratedLegacy: false }; + return { storageKey, raw: null, migratedLegacy: false, readOk: true }; } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false }; + if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); if (!legacyHostKey || legacyHostKey !== requestedHostKey) { - return { storageKey, raw: null, migratedLegacy: false }; + return { storageKey, raw: null, migratedLegacy: false, readOk: true }; } if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); } - return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy }; - } catch { - return { storageKey, raw: null, migratedLegacy: false }; + return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy, readOk: true }; + } catch (error) { + // A failed localStorage read (including corrupt legacy JSON) is + // NOT proof that the active recovery slot is empty. A new giveaway + // must not overwrite existing state when read integrity is unknown. + console.warn("[BON Giveaway] Active recovery storage read failed:", error); + return { storageKey, raw: null, migratedLegacy: false, readOk: false }; } } @@ -3165,7 +3173,13 @@ body.host-panel-dragging * { * Called at key mutation points (new entry, start, addbon, sponsor, time adjust). */ function snapshotGiveaway({ force = false, verifyWrite = false } = {}) { - if (!giveawayData || !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; + // Preflight state exists only in memory while balance, Gift History and + // chat cursor checks run. Never persist it as a legacy giveaway when + // pagehide/unload fires BEFORE the opening checkpoint is saved. + const proof = giveawayData?.openingCheckpoint; + if (!giveawayData || + !proof || (proof.status !== "pending" && proof.status !== "confirmed") || + !canMutateActiveGiveaway() || (giveawayData.__ending && !force)) return false; try { const snapshot = { giveawayData: { @@ -3303,6 +3317,10 @@ body.host-panel-dragging * { if (!loggedInHostKey) return null; const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: true }); + if (!stored.readOk) { + console.warn("[BON Giveaway] Recovery storage unreadable; leaving any existing state untouched."); + return null; + } if (!stored.raw) return null; const snap = JSON.parse(stored.raw); if (!snap || !snap.giveawayData) return null; @@ -3349,7 +3367,10 @@ body.host-panel-dragging * { const endTs = Number(snap.giveawayData.endTs); if (!Number.isFinite(endTs) || endTs <= 0) { - clearGiveawaySnapshot(); + // Invalid saved data can reflect an incomplete write or an + // in-progress payout. Preserve it for manual inspection; + // never erase potentially recoverable settlement evidence. + console.warn("[BON Giveaway] Invalid saved deadline; snapshot preserved for manual recovery."); return null; } @@ -3409,8 +3430,11 @@ body.host-panel-dragging * { // active-giveaway grace window. snap.__expiredAtLoad = overdueMs >= 0 || closingSettlementLatched; return snap; - } catch { - clearGiveawaySnapshot(); + } catch (error) { + // Malformed or temporarily unreadable snapshots must not be + // silently discarded; a subsequent clean read or manual analysis + // may be needed to prevent lost or duplicate BON payouts. + console.warn("[BON Giveaway] Unable to parse recovery snapshot; retaining stored data:", error); return null; } } @@ -4202,6 +4226,11 @@ body.host-panel-dragging * { const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { migrateLegacy: true }); + // localStorage failures must block starts, never look like absence. + if (!prior.readOk || !prior.storageKey) { + window.alert("GIVEAWAY START BLOCKED: Cannot read recovery storage reliably. No opening was posted."); + return; + } if (prior.raw) { try { const saved = JSON.parse(prior.raw); @@ -4267,7 +4296,7 @@ body.host-panel-dragging * { const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { migrateLegacy: true }); - if (savedAfterLock.raw || !savedAfterLock.storageKey) { + if (!savedAfterLock.readOk || savedAfterLock.raw || !savedAfterLock.storageKey) { logEvent("Start blocked (post-lock recovery recheck)", "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); window.alert( @@ -4393,6 +4422,9 @@ body.host-panel-dragging * { updateRigToggleUI(); const currentBon = await getVerifiedHostBalance({ requireServer: true, maxAgeMs: 0 }); + // A pagehide while preflight was awaiting balance invalidates this + // document. Don't continue into the public opening workflow. + if (!ownsTabLock()) return; if (currentBon == null) { const startErr = "Unable to verify current BON balance."; @@ -4479,6 +4511,7 @@ body.host-panel-dragging * { giveawayData }); const sponsorBaselineReady = await tracker.bootstrapGiftHistory(); + if (!ownsTabLock()) return; if (!sponsorBaselineReady) { logEvent( "Start aborted (Gift History baseline)", From 7ca46583b8bc40f8556f8c44fbfd1b912cb1b644 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:40:48 +0100 Subject: [PATCH 110/130] fix(bonanza): treat empty-but-present recovery keys as occupied unsafe state --- userscripts/giveaway/src/05-initialization.js | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index a2d30e06..3e02c35b 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1075,11 +1075,15 @@ function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false, readOk: false }; + if (!requestedHostKey || !storageKey) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; try { const namespacedRaw = localStorage.getItem(storageKey); - if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false, readOk: true }; + // Empty but present storage is corrupt/recoverable, NOT vacant. + if (namespacedRaw !== null) return { + storageKey, raw: namespacedRaw, hasStoredState: true, + migratedLegacy: false, readOk: true + }; // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. // A legacy snapshot belonging to another account is deliberately left @@ -1087,28 +1091,31 @@ // overwrite that foreign recovery state. Rehearsal storage never // imports a live legacy snapshot. if (REHEARSAL_MODE) { - return { storageKey, raw: null, migratedLegacy: false, readOk: true }; + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false, readOk: true }; + if (!legacyRaw) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); if (!legacyHostKey || legacyHostKey !== requestedHostKey) { - return { storageKey, raw: null, migratedLegacy: false, readOk: true }; + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); } - return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy, readOk: true }; + return { + storageKey, raw: legacyRaw, hasStoredState: true, + migratedLegacy: !!migrateLegacy, readOk: true + }; } catch (error) { // A failed localStorage read (including corrupt legacy JSON) is // NOT proof that the active recovery slot is empty. A new giveaway // must not overwrite existing state when read integrity is unknown. console.warn("[BON Giveaway] Active recovery storage read failed:", error); - return { storageKey, raw: null, migratedLegacy: false, readOk: false }; + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; } } From d8b7cd42889e5f2c7093e91f927ca34ec01fca11 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:40:57 +0100 Subject: [PATCH 111/130] fix(bonanza): reject occupied recovery slots even when stored text is empty --- userscripts/giveaway/src/06-lifecycle.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 0863a7f8..a1bc7d06 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -129,7 +129,7 @@ window.alert("GIVEAWAY START BLOCKED: Cannot read recovery storage reliably. No opening was posted."); return; } - if (prior.raw) { + if (prior.hasStoredState) { try { const saved = JSON.parse(prior.raw); const proof = saved?.giveawayData?.openingCheckpoint; @@ -194,7 +194,7 @@ const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { migrateLegacy: true }); - if (!savedAfterLock.readOk || savedAfterLock.raw || !savedAfterLock.storageKey) { + if (!savedAfterLock.readOk || savedAfterLock.hasStoredState || !savedAfterLock.storageKey) { logEvent("Start blocked (post-lock recovery recheck)", "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); window.alert( From 506decf914fa74d42645db3bfd17d01b4702a249 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:41:14 +0100 Subject: [PATCH 112/130] test(bonanza): protect empty corrupt recovery keys from overwrite --- tests/bonanza-opening-recovery.test.mjs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 8ad6ee64..cce80c5b 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -592,7 +592,7 @@ test("starting a new giveaway repeats recovery snapshot check inside the acquire const guard = lifecycle.slice(afterLock, firstMutation); assert.ok(start >= 0 && beforeLock > start && locked > beforeLock); assert.ok(afterLock > locked && firstMutation > afterLock); - assert.match(guard, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.raw \|\| !savedAfterLock\.storageKey\)/); + assert.match(guard, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.hasStoredState \|\| !savedAfterLock\.storageKey\)/); assert.match(guard, /releaseTabLock\(\)/); assert.match(guard, /return;/); }); @@ -703,7 +703,7 @@ test("only positively established empty recovery storage allows start prechecks" lifecycle.indexOf("if (sponsorsInterval)") ); assert.match(start, /if \(!prior\.readOk \|\| !prior\.storageKey\)/); - assert.match(start, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.raw/); + assert.match(start, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.hasStoredState/); }); test("preflight state cannot be snapshotted by pagehide or beforeunload", () => { @@ -730,3 +730,19 @@ test("preflight state cannot be snapshotted by pagehide or beforeunload", () => assert.match(hide, /snapshotGiveaway\(\)/); assert.match(initialization, /if \(!giveawayData \|\|[\s\S]*?!proof \|\|/); }); + +test("empty but present recovery key is occupied and blocks a new opening", () => { + const empty = recoveryReadHarness({ namespaced: "" }).read("maghuro", { migrateLegacy: true }); + assert.equal(empty.readOk, true); + assert.equal(empty.hasStoredState, true); + assert.equal(empty.raw, ""); + const trulyEmpty = recoveryReadHarness().read("maghuro", { migrateLegacy: true }); + assert.equal(trulyEmpty.readOk, true); + assert.equal(trulyEmpty.hasStoredState, false); + const start = lifecycle.slice( + lifecycle.indexOf("async function startGiveaway()"), + lifecycle.indexOf("if (sponsorsInterval)") + ); + assert.match(start, /if \(prior\.hasStoredState\)/); + assert.match(start, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.hasStoredState/); +}); From dbd16dc69004a7ab2a9d76846901ea09cd831089 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:41:35 +0100 Subject: [PATCH 113/130] fix(bonanza): keep pre-lock recovery checks read-only and serialize legacy migration --- userscripts/giveaway/src/06-lifecycle.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index a1bc7d06..bf273f9f 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -121,8 +121,10 @@ // Never overwrite a recoverable opening whose public POST was ambiguous. // The pending snapshot is the only path to reconcile entrants/sponsors // after a reload. A second start could otherwise destroy that evidence. + // Pre-lock validation is read-only. Any migration of a legacy key + // happens only after exclusive ownership has been acquired. const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { - migrateLegacy: true + migrateLegacy: false }); // localStorage failures must block starts, never look like absence. if (!prior.readOk || !prior.storageKey) { From 5c300d6092440b81d469ca7197aa94deb8fdb238 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:41:52 +0100 Subject: [PATCH 114/130] docs(bonanza): clarify occupied recovery keys and serialized migration --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e3bef270..bc3b3b21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,9 @@ multiple userscripts, not just BONanza. opening while waiting for the lock; do not overwrite any such state. The recovery reader reports `readOk` separately from `raw`: a denied localStorage read, corrupt legacy JSON or migration failure MUST block the - start, never appear as empty storage. + start, never appear as empty storage. An empty STRING in an existing + recovery slot is still an occupied (corrupt) record. Keep pre-lock reads + read-only, performing any legacy migration under exclusive ownership. - **Preflight is not an opening.** Before a durable `openingCheckpoint` exists, the in-memory configuration MUST NOT be snapshotted by pagehide, beforeunload or other background persistence. A BFCache handoff during From e8b32b7d9bb23ac1abe5e6cb3946ae58cccf9b76 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:41:59 +0100 Subject: [PATCH 115/130] docs(bonanza): explain pre-lock read-only recovery invariant --- userscripts/giveaway/src/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 105138e5..35db04fa 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -62,6 +62,8 @@ Recheck the saved state **after** acquiring the exclusive Web Lock; two tabs may have seen empty storage before one posts an uncertain opening. Treat `readOk: false` as a hard stop: storage-read errors, malformed legacy JSON and migration failures are NEVER equivalent to empty storage. +Even an empty string in an existing recovery slot is occupied, not vacant. +Pre-lock checks only READ; any legacy-key migration happens after locking. Snapshots are forbidden during balance/history/cursor preflight, before a persisted opening checkpoint exists, including pagehide/BFCache/unload. Keep malformed or unreadable recovery records intact for manual review From 25a70fbee48807366db7a6cd9791ccf71a146e46 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:42:02 +0100 Subject: [PATCH 116/130] docs(bonanza): mention empty corrupt state and migration safety --- userscripts/giveaway/src/00-preamble.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index fce60c17..75f7149d 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -270,7 +270,9 @@ // Coarse Gift History calibration uncertainty is tracked, saved across // reloads, and subtracted before sponsor-start eligibility checks. // Recovery storage read failures block new giveaways even when raw data -// appears absent. Preflight before the persisted opening checkpoint is +// appears absent; even empty-but-present recovery keys are occupied. +// Before the exclusive Web Lock, reading must not migrate legacy keys. +// Preflight before the persisted opening checkpoint is // NEVER snapshotted, including on BFCache/pagehide. Corrupt recovery // snapshots are preserved rather than silently deleted. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. From 01d62cd24255f15240cf392d0e2da5ab7befe61b Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:42:23 +0100 Subject: [PATCH 117/130] build(bonanza): synchronize full bundle after recovery storage audit --- .../DarkPeers_BONanza_Giveaway.user.js | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 43db9973..d3a28015 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -270,7 +270,9 @@ // Coarse Gift History calibration uncertainty is tracked, saved across // reloads, and subtracted before sponsor-start eligibility checks. // Recovery storage read failures block new giveaways even when raw data -// appears absent. Preflight before the persisted opening checkpoint is +// appears absent; even empty-but-present recovery keys are occupied. +// Before the exclusive Web Lock, reading must not migrate legacy keys. +// Preflight before the persisted opening checkpoint is // NEVER snapshotted, including on BFCache/pagehide. Corrupt recovery // snapshots are preserved rather than silently deleted. //// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff. @@ -2916,11 +2918,15 @@ body.host-panel-dragging * { function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) return { storageKey, raw: null, migratedLegacy: false, readOk: false }; + if (!requestedHostKey || !storageKey) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; try { const namespacedRaw = localStorage.getItem(storageKey); - if (namespacedRaw) return { storageKey, raw: namespacedRaw, migratedLegacy: false, readOk: true }; + // Empty but present storage is corrupt/recoverable, NOT vacant. + if (namespacedRaw !== null) return { + storageKey, raw: namespacedRaw, hasStoredState: true, + migratedLegacy: false, readOk: true + }; // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. // A legacy snapshot belonging to another account is deliberately left @@ -2928,28 +2934,31 @@ body.host-panel-dragging * { // overwrite that foreign recovery state. Rehearsal storage never // imports a live legacy snapshot. if (REHEARSAL_MODE) { - return { storageKey, raw: null, migratedLegacy: false, readOk: true }; + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (!legacyRaw) return { storageKey, raw: null, migratedLegacy: false, readOk: true }; + if (!legacyRaw) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); if (!legacyHostKey || legacyHostKey !== requestedHostKey) { - return { storageKey, raw: null, migratedLegacy: false, readOk: true }; + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); } - return { storageKey, raw: legacyRaw, migratedLegacy: !!migrateLegacy, readOk: true }; + return { + storageKey, raw: legacyRaw, hasStoredState: true, + migratedLegacy: !!migrateLegacy, readOk: true + }; } catch (error) { // A failed localStorage read (including corrupt legacy JSON) is // NOT proof that the active recovery slot is empty. A new giveaway // must not overwrite existing state when read integrity is unknown. console.warn("[BON Giveaway] Active recovery storage read failed:", error); - return { storageKey, raw: null, migratedLegacy: false, readOk: false }; + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; } } @@ -4223,15 +4232,17 @@ body.host-panel-dragging * { // Never overwrite a recoverable opening whose public POST was ambiguous. // The pending snapshot is the only path to reconcile entrants/sponsors // after a reload. A second start could otherwise destroy that evidence. + // Pre-lock validation is read-only. Any migration of a legacy key + // happens only after exclusive ownership has been acquired. const prior = readActiveGiveawayRawForHost(getLoggedInUsername(), { - migrateLegacy: true + migrateLegacy: false }); // localStorage failures must block starts, never look like absence. if (!prior.readOk || !prior.storageKey) { window.alert("GIVEAWAY START BLOCKED: Cannot read recovery storage reliably. No opening was posted."); return; } - if (prior.raw) { + if (prior.hasStoredState) { try { const saved = JSON.parse(prior.raw); const proof = saved?.giveawayData?.openingCheckpoint; @@ -4296,7 +4307,7 @@ body.host-panel-dragging * { const savedAfterLock = readActiveGiveawayRawForHost(getLoggedInUsername(), { migrateLegacy: true }); - if (!savedAfterLock.readOk || savedAfterLock.raw || !savedAfterLock.storageKey) { + if (!savedAfterLock.readOk || savedAfterLock.hasStoredState || !savedAfterLock.storageKey) { logEvent("Start blocked (post-lock recovery recheck)", "Saved giveaway state appeared while awaiting the exclusive lock; no new opening was posted."); window.alert( From 1a01d40903b505e97fda263d6202140275c1adf7 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:10:10 +0100 Subject: [PATCH 118/130] fix(bonanza): reload recovery under restore lock and reject empty legacy key --- userscripts/giveaway/src/05-initialization.js | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 3e02c35b..130781ca 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1094,7 +1094,9 @@ return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (!legacyRaw) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; + // null means absent; even "" is an occupied/corrupt recovery slot. + // Never use truthiness when deciding whether BON recovery exists. + if (legacyRaw === null) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); @@ -1482,7 +1484,9 @@ const loggedInHostKey = normalizeUserKey(getLoggedInUsername()); if (!loggedInHostKey) return null; - const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: true }); + // UI bootstrap runs BEFORE Web Lock acquisition: never migrate or + // remove legacy keys here. Only the post-lock restore read may do so. + const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: false }); if (!stored.readOk) { console.warn("[BON Giveaway] Recovery storage unreadable; leaving any existing state untouched."); return null; @@ -1768,6 +1772,34 @@ } try { + // The caller's snap was read before awaiting the Web Lock. + // Another tab may have committed/persisted payout state while we + // were queued, then released its lock. Never restore that stale + // object and overwrite newer payout/checkpoint information. + // Refresh storage and re-apply all current restore validation + // WHILE holding exclusive ownership. + const lockedRead = readActiveGiveawayRawForHost(restoreHost, { + migrateLegacy: true + }); + if (!ownsTabLock() || !lockedRead.readOk || + !lockedRead.hasStoredState || !lockedRead.raw) { + console.warn("[BON Giveaway] Restore blocked: recovery storage changed or could not be read under lock."); + releaseTabLock(); + return false; + } + const lockedSnapshot = loadGiveawaySnapshot(); + if (!ownsTabLock() || !lockedSnapshot || + lockedSnapshot.__legacyOpeningUnverified === true || + !lockedSnapshot.giveawayData || + normalizeUserKey(lockedSnapshot.giveawayData.host) !== + normalizeUserKey(restoreHost) || + (lockedSnapshot.giveawayData.rehearsalMode === true) !== REHEARSAL_MODE) { + console.warn("[BON Giveaway] Restore blocked: locked snapshot is absent, unverified or belongs to another mode/host."); + releaseTabLock(); + return false; + } + snap = lockedSnapshot; + // Local storage alone is not authoritative evidence of a public // opening. For a previously confirmed LIVE giveaway, prove that // the exact original message ID/host/text/timestamp still exists From 48f80f97485adcc21bd46c97be18dca0b4cd5c1c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:10:46 +0100 Subject: [PATCH 119/130] test(bonanza): cover locked restore freshness, empty legacy key and read-only discovery --- tests/bonanza-opening-recovery.test.mjs | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index cce80c5b..74c40ed4 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -746,3 +746,63 @@ test("empty but present recovery key is occupied and blocks a new opening", () = assert.match(start, /if \(prior\.hasStoredState\)/); assert.match(start, /if \(!savedAfterLock\.readOk \|\| savedAfterLock\.hasStoredState/); }); + +test("empty legacy key is occupied, unreadable and never treated as vacant", () => { + const empty = recoveryReadHarness({ legacy: "" }).read("maghuro", { migrateLegacy: false }); + assert.equal(empty.readOk, false, "Corrupt present legacy key cannot permit a new start"); + assert.equal(empty.raw, null); + const afterLock = recoveryReadHarness({ legacy: "" }).read("maghuro", { migrateLegacy: true }); + assert.equal(afterLock.readOk, false); + assert.equal(afterLock.raw, null); +}); + +test("initial legacy discovery is strictly read-only; migration waits for lock", () => { + const raw = JSON.stringify({ giveawayData: { host: "maghuro" } }); + const h = recoveryReadHarness({ legacy: raw }); + const before = h.read("maghuro", { migrateLegacy: false }); + assert.equal(before.readOk, true); + assert.equal(before.hasStoredState, true); + assert.equal(before.raw, raw); + assert.deepEqual(h.events, []); + const after = h.read("maghuro", { migrateLegacy: true }); + assert.equal(after.readOk, true); + assert.equal(after.migratedLegacy, true); + assert.deepEqual(h.events, ["write:recovery::maghuro"]); + const loadStart = initialization.indexOf("function loadGiveawaySnapshot()"); + const loadEnd = initialization.indexOf("function clearSponsorAccountingRecoveryTimer()", loadStart); + const loader = initialization.slice(loadStart, loadEnd); + assert.match(loader, /migrateLegacy: false/); + assert.doesNotMatch(loader, /migrateLegacy: true/); +}); + +test("restore discards pre-lock object and freshly validates durable state under exclusive ownership", () => { + const start = initialization.indexOf("async function restoreGiveawayFromSnapshot("); + const acquire = initialization.indexOf("if (!await acquireTabLock())", start); + const read = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost(", acquire); + const validateRead = initialization.indexOf("!lockedRead.readOk", read); + const fresh = initialization.indexOf("const lockedSnapshot = loadGiveawaySnapshot()", read); + const freshness = initialization.indexOf("snap = lockedSnapshot;", fresh); + const verified = initialization.indexOf("const restoredCheckpoint = snap.giveawayData?.openingCheckpoint", freshness); + const hydrate = initialization.indexOf("// 1) Restore giveaway data", verified); + assert.ok(start >= 0 && acquire > start && read > acquire); + assert.ok(validateRead > read && fresh > validateRead && freshness > fresh); + assert.ok(verified > freshness && hydrate > verified); + const guard = initialization.slice(read, freshness); + assert.match(guard, /migrateLegacy: true/); + assert.match(guard, /!lockedRead.hasStoredState/); + assert.match(guard, /!ownsTabLock\(\)/); + assert.match(guard, /releaseTabLock\(\)/); + assert.match(guard, /lockedSnapshot.__legacyOpeningUnverified === true/); +}); + +test("fresh restore rejects deleted or corrupted recovery even when caller holds old snapshot", () => { + const begin = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost("); + const end = initialization.indexOf("snap = lockedSnapshot;", begin); + const body = initialization.slice(begin, end); + assert.match(body, /!lockedRead.readOk/); + assert.match(body, /!lockedRead.hasStoredState/); + assert.match(body, /!lockedRead.raw/); + assert.match(body, /!lockedSnapshot/); + assert.match(body, /normalizeUserKey\(lockedSnapshot.giveawayData.host\)/); + assert.match(body, /lockedSnapshot.giveawayData.rehearsalMode === true/); +}); From 4131d34661a1199f59cbf2263946546d969fbc37 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:11:15 +0100 Subject: [PATCH 120/130] fix(bonanza): keep pre-lock recovery loading read-only, quarantine expired state --- userscripts/giveaway/src/05-initialization.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 130781ca..f0cdfce2 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1589,9 +1589,13 @@ !closingSettlementLatched && snap.giveawayData?.openingCheckpoint?.status !== "pending" ) { - console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); - clearGiveawaySnapshot(); - return null; + // This loader also runs BEFORE acquiring the Web Lock. + // Deleting a stale recovery slot here could erase another + // tab's in-progress settlement or manual recovery evidence. + // Preserve it and block auto-resume, never clear it on read. + console.warn("[BON Giveaway] Expired active snapshot preserved for manual review; automatic settlement grace exceeded."); + snap.__expiredBeyondGrace = true; + return snap; } // Once settlement is committed OR the closing latch has been persisted, @@ -1707,6 +1711,7 @@ // persistent entries and paid-gift ledgers untouched for manual review. const proof = snap.giveawayData.openingCheckpoint; if (snap.__legacyOpeningUnverified === true || + snap.__expiredBeyondGrace === true || !proof || (proof.status !== "pending" && proof.status !== "confirmed") || (proof.status === "confirmed" && (!Number.isFinite(Number(snap.giveawayData.openedAt)) || @@ -1790,6 +1795,7 @@ const lockedSnapshot = loadGiveawaySnapshot(); if (!ownsTabLock() || !lockedSnapshot || lockedSnapshot.__legacyOpeningUnverified === true || + lockedSnapshot.__expiredBeyondGrace === true || !lockedSnapshot.giveawayData || normalizeUserKey(lockedSnapshot.giveawayData.host) !== normalizeUserKey(restoreHost) || From 5b4d670ebcd2d594538b6d2e0ab39cba7859bb4e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:11:29 +0100 Subject: [PATCH 121/130] test(bonanza): ensure expired snapshots remain read-only before restore lock --- tests/bonanza-duration-recovery.test.mjs | 36 +++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs index a19a20f3..c64a1783 100644 --- a/tests/bonanza-duration-recovery.test.mjs +++ b/tests/bonanza-duration-recovery.test.mjs @@ -86,15 +86,19 @@ test("stale legacy giveaway is not resurrected when capped deadline exceeds reco // Under the old 90-minute deadline this ended 20 minutes ago and was // recoverable; its compliant 45-minute deadline was already 65 minutes ago. const h = recoveryHarness({ ageMinutes: 110 }); - assert.equal(h.restore(), null); - assert.equal(h.clears.length, 1); - assert.ok(h.warnings.some(w => w.includes("Discarding stale expired"))); + const state = h.restore(); + assert.ok(state, "Expired data is preserved for manual review"); + assert.equal(state.__expiredBeyondGrace, true); + assert.equal(h.clears.length, 0); + assert.ok(h.warnings.some(w => w.includes("preserved for manual review"))); }); test("long-abandoned giveaway cannot settle unexpectedly after a code upgrade", () => { const h = recoveryHarness({ ageMinutes: 3 * 24 * 60, advertisedMinutes: 120 }); - assert.equal(h.restore(), null); - assert.equal(h.clears.length, 1); + const old = h.restore(); + assert.ok(old); + assert.equal(old.__expiredBeyondGrace, true); + assert.equal(h.clears.length, 0); }); test("recoverable legacy snapshot is capped and recognized as already expired", () => { @@ -121,8 +125,10 @@ test("the ordinary expiry grace boundary still applies to the capped deadline", assert.equal(withinGrace.clears.length, 0); const afterGrace = recoveryHarness({ ageMinutes: 105 + 1 / MINUTE }); - assert.equal(afterGrace.restore(), null); - assert.equal(afterGrace.clears.length, 1); + const expired = afterGrace.restore(); + assert.ok(expired); + assert.equal(expired.__expiredBeyondGrace, true); + assert.equal(afterGrace.clears.length, 0); }); test("committed settlement remains recoverable without rewriting its deadline", () => { @@ -325,3 +331,19 @@ test("unreadable recovery storage cannot trigger deletion or auto-restore", () = assert.equal(runtime.loadGiveawaySnapshot(), null); assert.equal(clears.length, 0); }); + +test("pre-lock snapshot loading never deletes expired recovery data", () => { + const h = recoveryHarness({ ageMinutes: 180, advertisedMinutes: 90 }); + const preserved = h.restore(); + assert.ok(preserved); + assert.equal(preserved.__expiredBeyondGrace, true); + assert.equal(h.clears.length, 0); + const loader = initialization.slice( + initialization.indexOf("function loadGiveawaySnapshot("), + initialization.indexOf("function clearSponsorAccountingRecoveryTimer(") + ); + assert.doesNotMatch(loader, /clearGiveawaySnapshot\(/); + assert.match(loader, /migrateLegacy: false/); + assert.match(initialization, /snap\.__expiredBeyondGrace === true/); + assert.match(initialization, /lockedSnapshot.__expiredBeyondGrace === true/); +}); From b4bc589c4012e30f9b4acd4029045cb0999e8c66 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:12:01 +0100 Subject: [PATCH 122/130] docs(bonanza): document locked restore refresh and read-only recovery discovery --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index bc3b3b21..4b432a43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,11 +77,19 @@ multiple userscripts, not just BONanza. - After acquiring the exclusive Web Lock, read saved recovery storage AGAIN before mutating state or posting. Another tab may have saved an uncertain opening while waiting for the lock; do not overwrite any such state. + **Restores must also re-read and revalidate the COMPLETE latest snapshot + under that lock**, discarding the pre-lock in-memory object: another tab + could have persisted payout/settlement progress while ownership was queued. + Never replace a newer committed settlement with stale recovery data. The recovery reader reports `readOk` separately from `raw`: a denied localStorage read, corrupt legacy JSON or migration failure MUST block the start, never appear as empty storage. An empty STRING in an existing recovery slot is still an occupied (corrupt) record. Keep pre-lock reads read-only, performing any legacy migration under exclusive ownership. + A legacy key containing an empty string is PRESENT, not absent: test + `getItem() === null` to prove absence. `loadGiveawaySnapshot()` must never + delete, migrate, or alter recovery storage before acquiring the Web Lock; + stale/expired data needs manual review, not speculative cleanup. - **Preflight is not an opening.** Before a durable `openingCheckpoint` exists, the in-memory configuration MUST NOT be snapshotted by pagehide, beforeunload or other background persistence. A BFCache handoff during From 4dde6cab4886307286b51d07a49346f7e10164e0 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:12:04 +0100 Subject: [PATCH 123/130] docs(bonanza): document locked restore refresh and read-only recovery discovery --- userscripts/giveaway/src/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 35db04fa..b05ac125 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -60,10 +60,17 @@ silently discard the snapshot or perform settlement. Recheck the saved state **after** acquiring the exclusive Web Lock; two tabs may have seen empty storage before one posts an uncertain opening. +For restoration, discard the earlier loaded object and re-read/revalidate +the latest complete persisted giveaway under the lock before restoring any +entries, sponsors or settlement. This prevents a waiting tab from replacing +the other tab's newly committed payout checkpoint. Treat `readOk: false` as a hard stop: storage-read errors, malformed legacy JSON and migration failures are NEVER equivalent to empty storage. Even an empty string in an existing recovery slot is occupied, not vacant. Pre-lock checks only READ; any legacy-key migration happens after locking. +An empty legacy value is occupied/corrupt (only `null` means absent). +The initial snapshot loader must not clear even expired state: preserve it +for manual review instead of altering shared recovery before locking. Snapshots are forbidden during balance/history/cursor preflight, before a persisted opening checkpoint exists, including pagehide/BFCache/unload. Keep malformed or unreadable recovery records intact for manual review From c998a343d276b996ec44b514af92c8350eb8a3f8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:12:25 +0100 Subject: [PATCH 124/130] build(bonanza): synchronize generated script after locked recovery hardening --- .../DarkPeers_BONanza_Giveaway.user.js | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index d3a28015..5d185367 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2937,7 +2937,9 @@ body.host-panel-dragging * { return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - if (!legacyRaw) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; + // null means absent; even "" is an occupied/corrupt recovery slot. + // Never use truthiness when deciding whether BON recovery exists. + if (legacyRaw === null) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); @@ -3325,7 +3327,9 @@ body.host-panel-dragging * { const loggedInHostKey = normalizeUserKey(getLoggedInUsername()); if (!loggedInHostKey) return null; - const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: true }); + // UI bootstrap runs BEFORE Web Lock acquisition: never migrate or + // remove legacy keys here. Only the post-lock restore read may do so. + const stored = readActiveGiveawayRawForHost(loggedInHostKey, { migrateLegacy: false }); if (!stored.readOk) { console.warn("[BON Giveaway] Recovery storage unreadable; leaving any existing state untouched."); return null; @@ -3428,9 +3432,13 @@ body.host-panel-dragging * { !closingSettlementLatched && snap.giveawayData?.openingCheckpoint?.status !== "pending" ) { - console.warn("[BON Giveaway] Discarding stale expired active snapshot; automatic settlement grace exceeded."); - clearGiveawaySnapshot(); - return null; + // This loader also runs BEFORE acquiring the Web Lock. + // Deleting a stale recovery slot here could erase another + // tab's in-progress settlement or manual recovery evidence. + // Preserve it and block auto-resume, never clear it on read. + console.warn("[BON Giveaway] Expired active snapshot preserved for manual review; automatic settlement grace exceeded."); + snap.__expiredBeyondGrace = true; + return snap; } // Once settlement is committed OR the closing latch has been persisted, @@ -3546,6 +3554,7 @@ body.host-panel-dragging * { // persistent entries and paid-gift ledgers untouched for manual review. const proof = snap.giveawayData.openingCheckpoint; if (snap.__legacyOpeningUnverified === true || + snap.__expiredBeyondGrace === true || !proof || (proof.status !== "pending" && proof.status !== "confirmed") || (proof.status === "confirmed" && (!Number.isFinite(Number(snap.giveawayData.openedAt)) || @@ -3611,6 +3620,35 @@ body.host-panel-dragging * { } try { + // The caller's snap was read before awaiting the Web Lock. + // Another tab may have committed/persisted payout state while we + // were queued, then released its lock. Never restore that stale + // object and overwrite newer payout/checkpoint information. + // Refresh storage and re-apply all current restore validation + // WHILE holding exclusive ownership. + const lockedRead = readActiveGiveawayRawForHost(restoreHost, { + migrateLegacy: true + }); + if (!ownsTabLock() || !lockedRead.readOk || + !lockedRead.hasStoredState || !lockedRead.raw) { + console.warn("[BON Giveaway] Restore blocked: recovery storage changed or could not be read under lock."); + releaseTabLock(); + return false; + } + const lockedSnapshot = loadGiveawaySnapshot(); + if (!ownsTabLock() || !lockedSnapshot || + lockedSnapshot.__legacyOpeningUnverified === true || + lockedSnapshot.__expiredBeyondGrace === true || + !lockedSnapshot.giveawayData || + normalizeUserKey(lockedSnapshot.giveawayData.host) !== + normalizeUserKey(restoreHost) || + (lockedSnapshot.giveawayData.rehearsalMode === true) !== REHEARSAL_MODE) { + console.warn("[BON Giveaway] Restore blocked: locked snapshot is absent, unverified or belongs to another mode/host."); + releaseTabLock(); + return false; + } + snap = lockedSnapshot; + // Local storage alone is not authoritative evidence of a public // opening. For a previously confirmed LIVE giveaway, prove that // the exact original message ID/host/text/timestamp still exists From fcb68b8735151d71bc25497e36ea3560abeca339 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:41:39 +0100 Subject: [PATCH 125/130] fix(bonanza): fail closed on hostless legacy state and restore under lock --- AGENTS.md | 9 +++- tests/bonanza-opening-recovery.test.mjs | 48 +++++++++++++++++ .../DarkPeers_BONanza_Giveaway.user.js | 52 +++++++++---------- userscripts/giveaway/src/05-initialization.js | 52 +++++++++---------- userscripts/giveaway/src/README.md | 7 ++- 5 files changed, 110 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b432a43..24f49a7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,9 @@ multiple userscripts, not just BONanza. **Restores must also re-read and revalidate the COMPLETE latest snapshot under that lock**, discarding the pre-lock in-memory object: another tab could have persisted payout/settlement progress while ownership was queued. + Pre-lock `__expiredBeyondGrace`, `__legacyOpeningUnverified`, proof and + host data are stale hints, NEVER grounds for rejecting before the lock. + Classify only the latest post-lock snapshot. Never replace a newer committed settlement with stale recovery data. The recovery reader reports `readOk` separately from `raw`: a denied localStorage read, corrupt legacy JSON or migration failure MUST block the @@ -87,7 +90,11 @@ multiple userscripts, not just BONanza. recovery slot is still an occupied (corrupt) record. Keep pre-lock reads read-only, performing any legacy migration under exclusive ownership. A legacy key containing an empty string is PRESENT, not absent: test - `getItem() === null` to prove absence. `loadGiveawaySnapshot()` must never + `getItem() === null` to prove absence. Even valid JSON without an + identifiable host (such as `{}` or `{"giveawayData":{}}`) remains an + occupied, unidentified record. Fail closed and preserve it for manual + review: ONLY a positively identified foreign host may be ignored. + `loadGiveawaySnapshot()` must never delete, migrate, or alter recovery storage before acquiring the Web Lock; stale/expired data needs manual review, not speculative cleanup. - **Preflight is not an opening.** Before a durable `openingCheckpoint` diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 74c40ed4..9dfa4822 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -806,3 +806,51 @@ test("fresh restore rejects deleted or corrupted recovery even when caller holds assert.match(body, /normalizeUserKey\(lockedSnapshot.giveawayData.host\)/); assert.match(body, /lockedSnapshot.giveawayData.rehearsalMode === true/); }); + +test("valid but hostless legacy records remain occupied and block new starts", () => { + const examples = [ + "{}", '{"giveawayData":{}}', '{"giveawayData":{"host":""}}', + '{"giveawayData":{"host":null}}', "null", '"unexpected"' + ]; + for (const raw of examples) { + for (const migrateLegacy of [false, true]) { + const h = recoveryReadHarness({ legacy: raw }); + const state = h.read("maghuro", { migrateLegacy }); + assert.equal(state.readOk, false, `Unidentified record must fail closed: ${raw}`); + assert.equal(state.hasStoredState, true); + assert.equal(state.raw, raw); + assert.equal(state.migratedLegacy, false); + assert.deepEqual(h.events, [], "Unidentified state must not be migrated"); + } + } + const foreignRaw = JSON.stringify({ giveawayData: { host: "different-host" } }); + for (const migrateLegacy of [false, true]) { + const h = recoveryReadHarness({ legacy: foreignRaw }); + const state = h.read("maghuro", { migrateLegacy }); + assert.equal(state.readOk, true); + assert.equal(state.hasStoredState, false); + assert.equal(state.raw, null); + assert.deepEqual(h.events, []); + } +}); + +test("restore rejects stale recovery flags only after refreshing under exclusive lock", () => { + const start = initialization.indexOf("async function restoreGiveawayFromSnapshot("); + const lock = initialization.indexOf("if (!await acquireTabLock())", start); + const read = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost(", lock); + const fresh = initialization.indexOf("const lockedSnapshot = loadGiveawaySnapshot()", read); + const replace = initialization.indexOf("snap = lockedSnapshot;", fresh); + const verify = initialization.indexOf("const restoredCheckpoint = snap.giveawayData?.openingCheckpoint", replace); + assert.ok(start >= 0 && lock > start && read > lock && fresh > read); + assert.ok(replace > fresh && verify > replace); + const prelock = initialization.slice(start, lock); + assert.doesNotMatch(prelock, + /__expiredBeyondGrace|__legacyOpeningUnverified|openingCheckpoint|restoreExpectedHost|snap\.giveawayData/); + const lockedGuard = initialization.slice(fresh, replace); + assert.match(lockedGuard, /lockedSnapshot.__expiredBeyondGrace === true/); + assert.match(lockedGuard, /lockedSnapshot.__legacyOpeningUnverified === true/); + assert.match(lockedGuard, /normalizeUserKey\(lockedSnapshot.giveawayData.host\)/); + assert.match(lockedGuard, /lockedSnapshot.giveawayData.rehearsalMode === true/); + assert.match(lockedGuard, /releaseTabLock\(\)/); + assert.match(lockedGuard, /return false;/); +}); diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 5d185367..1749137d 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2943,7 +2943,17 @@ body.host-panel-dragging * { const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); - if (!legacyHostKey || legacyHostKey !== requestedHostKey) { + if (!legacyHostKey) { + // Missing host identity is NOT proof of empty recovery storage. + // This may contain real BON settlement evidence: keep its bytes. + console.warn("[BON Giveaway] Hostless legacy recovery is occupied; manual review required."); + return { + storageKey, raw: legacyRaw, hasStoredState: true, + migratedLegacy: false, readOk: false + }; + } + if (legacyHostKey !== requestedHostKey) { + // Only a positively identified different host may be ignored. return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } @@ -3547,44 +3557,22 @@ body.host-panel-dragging * { } async function restoreGiveawayFromSnapshot(snap) { - if (!snap || !snap.giveawayData) return false; - - // Do not let pre-checkpoint snapshots enter any automatic restore path, - // even if an alternate caller bypasses loadGiveawaySnapshot(). Keep - // persistent entries and paid-gift ledgers untouched for manual review. - const proof = snap.giveawayData.openingCheckpoint; - if (snap.__legacyOpeningUnverified === true || - snap.__expiredBeyondGrace === true || - !proof || (proof.status !== "pending" && proof.status !== "confirmed") || - (proof.status === "confirmed" && - (!Number.isFinite(Number(snap.giveawayData.openedAt)) || - Number(snap.giveawayData.openedAt) <= 0 || - !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || - Number(snap.giveawayData.sponsorStartTs) < - Number(snap.giveawayData.openedAt)))) { - console.warn("[BON Giveaway] Legacy opening lacks verified publication proof; keeping original snapshot."); - window.alert( - "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: This older snapshot " + - "does not prove that its opening was published or when it began. " + - "Automatic entries, countdown changes and BON settlement are blocked. " + - "The original recovery state was preserved." - ); - return false; - } + // The earlier snapshot is only a discovery hint. Another tab may + // persist a newer settlement while we await the Web Lock. Classify + // recovery status, expiry, checkpoint and host ONLY after rereading + // the latest saved state under exclusive ownership. + if (!snap) return false; // Validate the live DarkPeers identity/Main Chat context BEFORE claiming // ownership or touching the recoverable snapshot. A transient/wrong chat // context must never destroy or mutate persisted giveaway state. cacheChatContext(); const restoreHost = getLoggedInUsername(); - const restoreExpectedHost = String(snap.giveawayData.host || "").trim(); const restoreUserId = Math.floor(Number(OT_USER_ID)); const restorePublicRoom = Math.floor(Number(OT_CHATROOM_ID)); const restoreExpectedRoom = Math.floor(Number(DARKPEERS_MAIN_CHATROOM_ID)); const restoreContextOk = !!restoreHost && - !!restoreExpectedHost && - normalizeUserKey(restoreHost) === normalizeUserKey(restoreExpectedHost) && Number.isFinite(restoreUserId) && restoreUserId > 0 && Number.isFinite(restorePublicRoom) && @@ -3644,6 +3632,14 @@ body.host-panel-dragging * { normalizeUserKey(restoreHost) || (lockedSnapshot.giveawayData.rehearsalMode === true) !== REHEARSAL_MODE) { console.warn("[BON Giveaway] Restore blocked: locked snapshot is absent, unverified or belongs to another mode/host."); + if (lockedSnapshot?.__legacyOpeningUnverified === true || + lockedSnapshot?.__expiredBeyondGrace === true) { + window.alert( + "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: The latest saved state " + + "cannot safely resume entries or BON settlement. " + + "The original recovery data has been preserved." + ); + } releaseTabLock(); return false; } diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index f0cdfce2..00301f2e 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1100,7 +1100,17 @@ const legacy = JSON.parse(legacyRaw); const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); - if (!legacyHostKey || legacyHostKey !== requestedHostKey) { + if (!legacyHostKey) { + // Missing host identity is NOT proof of empty recovery storage. + // This may contain real BON settlement evidence: keep its bytes. + console.warn("[BON Giveaway] Hostless legacy recovery is occupied; manual review required."); + return { + storageKey, raw: legacyRaw, hasStoredState: true, + migratedLegacy: false, readOk: false + }; + } + if (legacyHostKey !== requestedHostKey) { + // Only a positively identified different host may be ignored. return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; } @@ -1704,44 +1714,22 @@ } async function restoreGiveawayFromSnapshot(snap) { - if (!snap || !snap.giveawayData) return false; - - // Do not let pre-checkpoint snapshots enter any automatic restore path, - // even if an alternate caller bypasses loadGiveawaySnapshot(). Keep - // persistent entries and paid-gift ledgers untouched for manual review. - const proof = snap.giveawayData.openingCheckpoint; - if (snap.__legacyOpeningUnverified === true || - snap.__expiredBeyondGrace === true || - !proof || (proof.status !== "pending" && proof.status !== "confirmed") || - (proof.status === "confirmed" && - (!Number.isFinite(Number(snap.giveawayData.openedAt)) || - Number(snap.giveawayData.openedAt) <= 0 || - !Number.isFinite(Number(snap.giveawayData.sponsorStartTs)) || - Number(snap.giveawayData.sponsorStartTs) < - Number(snap.giveawayData.openedAt)))) { - console.warn("[BON Giveaway] Legacy opening lacks verified publication proof; keeping original snapshot."); - window.alert( - "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: This older snapshot " + - "does not prove that its opening was published or when it began. " + - "Automatic entries, countdown changes and BON settlement are blocked. " + - "The original recovery state was preserved." - ); - return false; - } + // The earlier snapshot is only a discovery hint. Another tab may + // persist a newer settlement while we await the Web Lock. Classify + // recovery status, expiry, checkpoint and host ONLY after rereading + // the latest saved state under exclusive ownership. + if (!snap) return false; // Validate the live DarkPeers identity/Main Chat context BEFORE claiming // ownership or touching the recoverable snapshot. A transient/wrong chat // context must never destroy or mutate persisted giveaway state. cacheChatContext(); const restoreHost = getLoggedInUsername(); - const restoreExpectedHost = String(snap.giveawayData.host || "").trim(); const restoreUserId = Math.floor(Number(OT_USER_ID)); const restorePublicRoom = Math.floor(Number(OT_CHATROOM_ID)); const restoreExpectedRoom = Math.floor(Number(DARKPEERS_MAIN_CHATROOM_ID)); const restoreContextOk = !!restoreHost && - !!restoreExpectedHost && - normalizeUserKey(restoreHost) === normalizeUserKey(restoreExpectedHost) && Number.isFinite(restoreUserId) && restoreUserId > 0 && Number.isFinite(restorePublicRoom) && @@ -1801,6 +1789,14 @@ normalizeUserKey(restoreHost) || (lockedSnapshot.giveawayData.rehearsalMode === true) !== REHEARSAL_MODE) { console.warn("[BON Giveaway] Restore blocked: locked snapshot is absent, unverified or belongs to another mode/host."); + if (lockedSnapshot?.__legacyOpeningUnverified === true || + lockedSnapshot?.__expiredBeyondGrace === true) { + window.alert( + "GIVEAWAY RECOVERY REQUIRES MANUAL REVIEW: The latest saved state " + + "cannot safely resume entries or BON settlement. " + + "The original recovery data has been preserved." + ); + } releaseTabLock(); return false; } diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index b05ac125..5722bda5 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -63,12 +63,17 @@ tabs may have seen empty storage before one posts an uncertain opening. For restoration, discard the earlier loaded object and re-read/revalidate the latest complete persisted giveaway under the lock before restoring any entries, sponsors or settlement. This prevents a waiting tab from replacing -the other tab's newly committed payout checkpoint. +the other tab's newly committed payout checkpoint. A pre-lock expired flag, +unverified-opening flag or saved host may already be stale; do not reject on +those before the post-lock read, which is authoritative. Treat `readOk: false` as a hard stop: storage-read errors, malformed legacy JSON and migration failures are NEVER equivalent to empty storage. Even an empty string in an existing recovery slot is occupied, not vacant. Pre-lock checks only READ; any legacy-key migration happens after locking. An empty legacy value is occupied/corrupt (only `null` means absent). +Valid legacy JSON without a usable host (`{}`, `{"giveawayData":{}}`, etc.) +is likewise occupied and unidentified: block new live openings, retain the +original bytes, and require manual review. A known DIFFERENT host may be ignored. The initial snapshot loader must not clear even expired state: preserve it for manual review instead of altering shared recovery before locking. Snapshots are forbidden during balance/history/cursor preflight, before a From 34ce04dc73bb5493877bfb32cb5c8bbe1ec6de27 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:43:19 +0100 Subject: [PATCH 126/130] test(bonanza): update locked recovery safety assertions and diagnostic --- tests/bonanza-duration-recovery.test.mjs | 23 +++++++++++-------- .../DarkPeers_BONanza_Giveaway.user.js | 1 - userscripts/giveaway/src/05-initialization.js | 1 - 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/bonanza-duration-recovery.test.mjs b/tests/bonanza-duration-recovery.test.mjs index c64a1783..84eebd8a 100644 --- a/tests/bonanza-duration-recovery.test.mjs +++ b/tests/bonanza-duration-recovery.test.mjs @@ -286,15 +286,18 @@ test("malformed or incomplete confirmed proof also freezes legacy settlement", ( } }); -test("restore and new-start entry points reject old snapshots before any mutation", () => { +test("legacy recovery is quarantined using the locked snapshot before any mutation", () => { assert.match(initialization, /snap\.__legacyOpeningUnverified = true/); - const restore = initialization.slice( - initialization.indexOf("async function restoreGiveawayFromSnapshot("), - initialization.indexOf("// Validate the live DarkPeers identity/Main Chat context BEFORE claiming") - ); - assert.match(restore, /if \(snap\.__legacyOpeningUnverified === true \|\|/); - assert.match(restore, /return false;/); - assert.doesNotMatch(restore, /acquireTabLock\(/); + const restoreStart = initialization.indexOf("async function restoreGiveawayFromSnapshot("); + const lock = initialization.indexOf("if (!await acquireTabLock())", restoreStart); + const loaded = initialization.indexOf("const lockedSnapshot = loadGiveawaySnapshot()", lock); + const hydrate = initialization.indexOf("// 1) Restore giveaway data", loaded); + assert.ok(restoreStart >= 0 && lock > restoreStart && loaded > lock && hydrate > loaded); + assert.doesNotMatch(initialization.slice(restoreStart, lock), /snap\.__legacyOpeningUnverified/); + const guard = initialization.slice(loaded, hydrate); + assert.match(guard, /lockedSnapshot.__legacyOpeningUnverified === true/); + assert.match(guard, /releaseTabLock\(\)/); + assert.match(guard, /return false;/); }); test("malformed recovery data is preserved without any silent deletion", () => { @@ -344,6 +347,8 @@ test("pre-lock snapshot loading never deletes expired recovery data", () => { ); assert.doesNotMatch(loader, /clearGiveawaySnapshot\(/); assert.match(loader, /migrateLegacy: false/); - assert.match(initialization, /snap\.__expiredBeyondGrace === true/); + const restoreStart = initialization.indexOf("async function restoreGiveawayFromSnapshot("); + const lock = initialization.indexOf("if (!await acquireTabLock())", restoreStart); + assert.doesNotMatch(initialization.slice(restoreStart, lock), /snap\.__expiredBeyondGrace === true/); assert.match(initialization, /lockedSnapshot.__expiredBeyondGrace === true/); }); diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 1749137d..069aa26d 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -3584,7 +3584,6 @@ body.host-panel-dragging * { "[BON Giveaway] Restore deferred: DarkPeers identity/Main Chat context is not trustworthy.", { restoreHost, - expectedHost: restoreExpectedHost, userId: restoreUserId, publicRoom: restorePublicRoom, expectedRoom: restoreExpectedRoom, diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 00301f2e..fc395619 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1741,7 +1741,6 @@ "[BON Giveaway] Restore deferred: DarkPeers identity/Main Chat context is not trustworthy.", { restoreHost, - expectedHost: restoreExpectedHost, userId: restoreUserId, publicRoom: restorePublicRoom, expectedRoom: restoreExpectedRoom, From 906fcfcdc85fbdd03196168136212b373b88e42d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:46:27 +0100 Subject: [PATCH 127/130] fix(bonanza): reject nonstring legacy recovery host identities --- tests/bonanza-opening-recovery.test.mjs | 5 ++++- userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js | 6 +++++- userscripts/giveaway/src/05-initialization.js | 6 +++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 9dfa4822..647af586 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -810,7 +810,10 @@ test("fresh restore rejects deleted or corrupted recovery even when caller holds test("valid but hostless legacy records remain occupied and block new starts", () => { const examples = [ "{}", '{"giveawayData":{}}', '{"giveawayData":{"host":""}}', - '{"giveawayData":{"host":null}}', "null", '"unexpected"' + '{"giveawayData":{"host":null}}', "null", '"unexpected"', + '{"giveawayData":{"host":[]}}', '{"giveawayData":{"host":["maghuro"]}}', + '{"giveawayData":{"host":{}}}', '{"giveawayData":{"host":{"user":"maghuro"}}}', + '{"giveawayData":{"host":123}}', '{"giveawayData":{"host":true}}' ]; for (const raw of examples) { for (const migrateLegacy of [false, true]) { diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 069aa26d..d0ee6bf7 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2942,7 +2942,11 @@ body.host-panel-dragging * { if (legacyRaw === null) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); - const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); + // Never coerce an object/array/number into a plausible username: + // legacy recovery ownership requires an actual saved username. + const legacyHost = legacy?.giveawayData?.host; + const legacyHostKey = typeof legacyHost === "string" + ? normalizeUserKey(legacyHost) : ""; if (!legacyHostKey) { // Missing host identity is NOT proof of empty recovery storage. // This may contain real BON settlement evidence: keep its bytes. diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index fc395619..39d4757e 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1099,7 +1099,11 @@ if (legacyRaw === null) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; const legacy = JSON.parse(legacyRaw); - const legacyHostKey = normalizeUserKey(legacy?.giveawayData?.host); + // Never coerce an object/array/number into a plausible username: + // legacy recovery ownership requires an actual saved username. + const legacyHost = legacy?.giveawayData?.host; + const legacyHostKey = typeof legacyHost === "string" + ? normalizeUserKey(legacyHost) : ""; if (!legacyHostKey) { // Missing host identity is NOT proof of empty recovery storage. // This may contain real BON settlement evidence: keep its bytes. From 42861041f27c50c88856850eb6c7316edff1be3a Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:49:05 +0100 Subject: [PATCH 128/130] fix(bonanza): reconcile legacy and namespaced recovery before settlement --- AGENTS.md | 5 ++ tests/bonanza-opening-recovery.test.mjs | 48 ++++++++++++ .../DarkPeers_BONanza_Giveaway.user.js | 73 ++++++++++++------- userscripts/giveaway/src/05-initialization.js | 73 ++++++++++++------- userscripts/giveaway/src/README.md | 5 ++ 5 files changed, 152 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 24f49a7c..c84579c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,11 @@ multiple userscripts, not just BONanza. identifiable host (such as `{}` or `{"giveawayData":{}}`) remains an occupied, unidentified record. Fail closed and preserve it for manual review: ONLY a positively identified foreign host may be ignored. + ALWAYS inspect the old legacy slot even when namespaced recovery exists. + Corrupt, unidentified or different SAME-HOST snapshots must block automatic + recovery/settlement; identical same-host records are safe to read, as is + a positively identified different host. Never delete a legacy record until + an exclusive-lock migration has been written and read back successfully. `loadGiveawaySnapshot()` must never delete, migrate, or alter recovery storage before acquiring the Web Lock; stale/expired data needs manual review, not speculative cleanup. diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 647af586..3f781e13 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -857,3 +857,51 @@ test("restore rejects stale recovery flags only after refreshing under exclusive assert.match(lockedGuard, /releaseTabLock\(\)/); assert.match(lockedGuard, /return false;/); }); + +test("coexisting namespaced recovery rejects corrupt and inconsistent legacy evidence", () => { + const current = JSON.stringify({ + giveawayData: { host: "maghuro", settlement: { committed: true, ledger: ["already-paid"] } } + }); + const conflicting = [ + "", "{invalid", "{}", '{"giveawayData":{}}', + '{"giveawayData":{"host":[]}}', + JSON.stringify({ giveawayData: { host: "maghuro", settlement: { committed: false } } }) + ]; + for (const legacy of conflicting) { + for (const migrateLegacy of [false, true]) { + const h = recoveryReadHarness({ namespaced: current, legacy }); + const state = h.read("maghuro", { migrateLegacy }); + assert.equal(state.readOk, false, "Unresolved old evidence cannot enable real settlement"); + assert.equal(state.hasStoredState, true); + assert.equal(state.migratedLegacy, false); + assert.deepEqual(h.events, [], "Keep both records intact for manual review"); + } + } +}); + +test("byte-identical same-host recovery copies are accepted without mutation", () => { + const identical = JSON.stringify({ giveawayData: { host: "maghuro" }, entries: [7, 8] }); + for (const migrateLegacy of [false, true]) { + const h = recoveryReadHarness({ namespaced: identical, legacy: identical }); + const state = h.read("maghuro", { migrateLegacy }); + assert.equal(state.readOk, true); + assert.equal(state.hasStoredState, true); + assert.equal(state.raw, identical); + assert.equal(state.migratedLegacy, false); + assert.deepEqual(h.events, []); + } +}); + +test("positively foreign legacy evidence does not mask valid namespaced recovery", () => { + const current = JSON.stringify({ giveawayData: { host: "maghuro" } }); + const legacy = JSON.stringify({ giveawayData: { host: "another-host" } }); + for (const migrateLegacy of [false, true]) { + const h = recoveryReadHarness({ namespaced: current, legacy }); + const state = h.read("maghuro", { migrateLegacy }); + assert.equal(state.readOk, true); + assert.equal(state.hasStoredState, true); + assert.equal(state.raw, current); + assert.equal(state.migratedLegacy, false); + assert.deepEqual(h.events, []); + } +}); diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index d0ee6bf7..17523fb8 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2918,51 +2918,71 @@ body.host-panel-dragging * { function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; + if (!requestedHostKey || !storageKey) { + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; + } + // Namespaced recovery never proves the old hostname-wide slot empty. + // Both slots may contain distinct settlement evidence for real BON. + let hasStoredState = false; try { const namespacedRaw = localStorage.getItem(storageKey); - // Empty but present storage is corrupt/recoverable, NOT vacant. - if (namespacedRaw !== null) return { - storageKey, raw: namespacedRaw, hasStoredState: true, + const hasNamespacedState = namespacedRaw !== null; + hasStoredState = hasNamespacedState; + const namespacedResult = { + storageKey, raw: namespacedRaw, hasStoredState: hasNamespacedState, migratedLegacy: false, readOk: true }; - // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. - // A legacy snapshot belonging to another account is deliberately left - // untouched; the current account now has its own namespace and cannot - // overwrite that foreign recovery state. Rehearsal storage never - // imports a live legacy snapshot. - if (REHEARSAL_MODE) { - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; - } + // Rehearsal is isolated from the LIVE legacy recovery key. + if (REHEARSAL_MODE) return namespacedResult; + const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - // null means absent; even "" is an occupied/corrupt recovery slot. - // Never use truthiness when deciding whether BON recovery exists. - if (legacyRaw === null) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; + // Only null means absent, even if the current namespace is occupied. + if (legacyRaw === null) return namespacedResult; + hasStoredState = true; const legacy = JSON.parse(legacyRaw); - // Never coerce an object/array/number into a plausible username: - // legacy recovery ownership requires an actual saved username. const legacyHost = legacy?.giveawayData?.host; + // Reject non-string host identities rather than coercing a + // malicious/broken array, object or number into a username. const legacyHostKey = typeof legacyHost === "string" ? normalizeUserKey(legacyHost) : ""; + if (!legacyHostKey) { - // Missing host identity is NOT proof of empty recovery storage. - // This may contain real BON settlement evidence: keep its bytes. - console.warn("[BON Giveaway] Hostless legacy recovery is occupied; manual review required."); + console.warn("[BON Giveaway] Unidentified legacy recovery is occupied; manual review required."); return { storageKey, raw: legacyRaw, hasStoredState: true, migratedLegacy: false, readOk: false }; } + if (legacyHostKey !== requestedHostKey) { - // Only a positively identified different host may be ignored. - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; + // Only a positively identified different host can be ignored. + return namespacedResult; + } + + if (hasNamespacedState) { + // Same host is NOT the same settlement. A stale copy can + // omit already-paid ledger entries. Never auto-restore while + // conflicting same-host records are present. + if (namespacedRaw !== legacyRaw) { + console.warn("[BON Giveaway] Conflicting same-host recovery slots; manual review required."); + return { + storageKey, raw: namespacedRaw, hasStoredState: true, + migratedLegacy: false, readOk: false + }; + } + return namespacedResult; } + // Only the locked restore path migrates the single identified + // legacy record. Never delete it before read-back confirmation. if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); + if (localStorage.getItem(storageKey) !== legacyRaw) { + throw new Error("Legacy migration read-back mismatch"); + } localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); } return { @@ -2970,11 +2990,12 @@ body.host-panel-dragging * { migratedLegacy: !!migrateLegacy, readOk: true }; } catch (error) { - // A failed localStorage read (including corrupt legacy JSON) is - // NOT proof that the active recovery slot is empty. A new giveaway - // must not overwrite existing state when read integrity is unknown. + // Storage or JSON failure is NOT evidence of vacant recovery. console.warn("[BON Giveaway] Active recovery storage read failed:", error); - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; + return { + storageKey, raw: null, hasStoredState, + migratedLegacy: false, readOk: false + }; } } diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 39d4757e..a7068c04 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1075,51 +1075,71 @@ function readActiveGiveawayRawForHost(hostName = "", { migrateLegacy = false } = {}) { const requestedHostKey = normalizeUserKey(hostName || getLoggedInUsername()); const storageKey = getActiveGiveawayStorageKey(requestedHostKey); - if (!requestedHostKey || !storageKey) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; + if (!requestedHostKey || !storageKey) { + return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; + } + // Namespaced recovery never proves the old hostname-wide slot empty. + // Both slots may contain distinct settlement evidence for real BON. + let hasStoredState = false; try { const namespacedRaw = localStorage.getItem(storageKey); - // Empty but present storage is corrupt/recoverable, NOT vacant. - if (namespacedRaw !== null) return { - storageKey, raw: namespacedRaw, hasStoredState: true, + const hasNamespacedState = namespacedRaw !== null; + hasStoredState = hasNamespacedState; + const namespacedResult = { + storageKey, raw: namespacedRaw, hasStoredState: hasNamespacedState, migratedLegacy: false, readOk: true }; - // Backwards-compatible migration from pre-v1.3.24 hostname-wide storage. - // A legacy snapshot belonging to another account is deliberately left - // untouched; the current account now has its own namespace and cannot - // overwrite that foreign recovery state. Rehearsal storage never - // imports a live legacy snapshot. - if (REHEARSAL_MODE) { - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; - } + // Rehearsal is isolated from the LIVE legacy recovery key. + if (REHEARSAL_MODE) return namespacedResult; + const legacyRaw = localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY); - // null means absent; even "" is an occupied/corrupt recovery slot. - // Never use truthiness when deciding whether BON recovery exists. - if (legacyRaw === null) return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; + // Only null means absent, even if the current namespace is occupied. + if (legacyRaw === null) return namespacedResult; + hasStoredState = true; const legacy = JSON.parse(legacyRaw); - // Never coerce an object/array/number into a plausible username: - // legacy recovery ownership requires an actual saved username. const legacyHost = legacy?.giveawayData?.host; + // Reject non-string host identities rather than coercing a + // malicious/broken array, object or number into a username. const legacyHostKey = typeof legacyHost === "string" ? normalizeUserKey(legacyHost) : ""; + if (!legacyHostKey) { - // Missing host identity is NOT proof of empty recovery storage. - // This may contain real BON settlement evidence: keep its bytes. - console.warn("[BON Giveaway] Hostless legacy recovery is occupied; manual review required."); + console.warn("[BON Giveaway] Unidentified legacy recovery is occupied; manual review required."); return { storageKey, raw: legacyRaw, hasStoredState: true, migratedLegacy: false, readOk: false }; } + if (legacyHostKey !== requestedHostKey) { - // Only a positively identified different host may be ignored. - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: true }; + // Only a positively identified different host can be ignored. + return namespacedResult; } + if (hasNamespacedState) { + // Same host is NOT the same settlement. A stale copy can + // omit already-paid ledger entries. Never auto-restore while + // conflicting same-host records are present. + if (namespacedRaw !== legacyRaw) { + console.warn("[BON Giveaway] Conflicting same-host recovery slots; manual review required."); + return { + storageKey, raw: namespacedRaw, hasStoredState: true, + migratedLegacy: false, readOk: false + }; + } + return namespacedResult; + } + + // Only the locked restore path migrates the single identified + // legacy record. Never delete it before read-back confirmation. if (migrateLegacy) { localStorage.setItem(storageKey, legacyRaw); + if (localStorage.getItem(storageKey) !== legacyRaw) { + throw new Error("Legacy migration read-back mismatch"); + } localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); } return { @@ -1127,11 +1147,12 @@ migratedLegacy: !!migrateLegacy, readOk: true }; } catch (error) { - // A failed localStorage read (including corrupt legacy JSON) is - // NOT proof that the active recovery slot is empty. A new giveaway - // must not overwrite existing state when read integrity is unknown. + // Storage or JSON failure is NOT evidence of vacant recovery. console.warn("[BON Giveaway] Active recovery storage read failed:", error); - return { storageKey, raw: null, hasStoredState: false, migratedLegacy: false, readOk: false }; + return { + storageKey, raw: null, hasStoredState, + migratedLegacy: false, readOk: false + }; } } diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 5722bda5..a7608869 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -74,6 +74,11 @@ An empty legacy value is occupied/corrupt (only `null` means absent). Valid legacy JSON without a usable host (`{}`, `{"giveawayData":{}}`, etc.) is likewise occupied and unidentified: block new live openings, retain the original bytes, and require manual review. A known DIFFERENT host may be ignored. +Even with an existing namespaced snapshot, always inspect the legacy slot: +block any malformed/unidentified record or inconsistent same-host settlement +state (only byte-identical duplicates are safe). Keep both records for manual +investigation rather than trusting an older payout ledger. Migrations require +an exclusive lock and successful read-back before removing legacy storage. The initial snapshot loader must not clear even expired state: preserve it for manual review instead of altering shared recovery before locking. Snapshots are forbidden during balance/history/cursor preflight, before a From 0ed1335b06b52f7a043694cb69544bde0e979e3d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:54:21 +0100 Subject: [PATCH 129/130] fix(bonanza): retire verified duplicate legacy state before checkpoint update --- AGENTS.md | 8 ++- tests/bonanza-opening-recovery.test.mjs | 64 +++++++++++++++---- .../DarkPeers_BONanza_Giveaway.user.js | 14 ++++ userscripts/giveaway/src/05-initialization.js | 14 ++++ userscripts/giveaway/src/README.md | 10 ++- 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c84579c4..5777d51c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,8 +97,12 @@ multiple userscripts, not just BONanza. ALWAYS inspect the old legacy slot even when namespaced recovery exists. Corrupt, unidentified or different SAME-HOST snapshots must block automatic recovery/settlement; identical same-host records are safe to read, as is - a positively identified different host. Never delete a legacy record until - an exclusive-lock migration has been written and read back successfully. + a positively identified different host. On post-lock recovery, first + verify byte-identical same-host snapshots, then retire their duplicate + legacy slot with before/after read-back checks BEFORE writing a new + confirmed opening checkpoint. If cleanup fails, abort without payout; + pre-lock discovery must remain read-only. Never delete a legacy record + until its namespaced copy has been verified durably. `loadGiveawaySnapshot()` must never delete, migrate, or alter recovery storage before acquiring the Web Lock; stale/expired data needs manual review, not speculative cleanup. diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 3f781e13..6f744595 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -640,6 +640,7 @@ test("unverified history keys remain retryable across polling and block canonica function recoveryReadHarness({ namespaced = null, legacy = null, throwsOn = "" } = {}) { const storage = new Map(); + const readCounts = new Map(); if (namespaced !== null) storage.set("recovery::maghuro", namespaced); if (legacy !== null) storage.set("recovery", legacy); const events = []; @@ -653,6 +654,9 @@ function recoveryReadHarness({ namespaced = null, legacy = null, throwsOn = "" } localStorage: { getItem: key => { if (throwsOn === "read") throw Error("read denied"); + const count = (readCounts.get(key) || 0) + 1; + readCounts.set(key, count); + if (throwsOn === "verify" && key === "recovery::maghuro" && count > 1) throw Error("read-back denied"); return storage.has(key) ? storage.get(key) : null; }, setItem: (key, value) => { @@ -662,7 +666,7 @@ function recoveryReadHarness({ namespaced = null, legacy = null, throwsOn = "" } }, removeItem: key => { if (throwsOn === "remove") throw Error("remove denied"); - storage.delete(key); + if (throwsOn !== "remove-noop") storage.delete(key); } }, console: { warn() {} } @@ -670,7 +674,7 @@ function recoveryReadHarness({ namespaced = null, legacy = null, throwsOn = "" } vm.runInContext([ extract(initialization, "function readActiveGiveawayRawForHost(") ].join("\n"), ctx); - return { read: (...args) => ctx.readActiveGiveawayRawForHost(...args), events }; + return { read: (...args) => ctx.readActiveGiveawayRawForHost(...args), events, storage }; } test("recovery storage access failures never masquerade as empty storage", () => { @@ -879,19 +883,57 @@ test("coexisting namespaced recovery rejects corrupt and inconsistent legacy evi } }); -test("byte-identical same-host recovery copies are accepted without mutation", () => { - const identical = JSON.stringify({ giveawayData: { host: "maghuro" }, entries: [7, 8] }); - for (const migrateLegacy of [false, true]) { - const h = recoveryReadHarness({ namespaced: identical, legacy: identical }); - const state = h.read("maghuro", { migrateLegacy }); - assert.equal(state.readOk, true); +test("duplicate same-host recovery remains read-only until exclusive-locked migration", () => { + const identical = JSON.stringify({ giveawayData: { host: "maghuro", openingCheckpoint: { status: "pending" } } }); + const h = recoveryReadHarness({ namespaced: identical, legacy: identical }); + const pre = h.read("maghuro", { migrateLegacy: false }); + assert.equal(pre.readOk, true); + assert.equal(pre.raw, identical); + assert.equal(h.storage.get("recovery"), identical); + assert.deepEqual(h.events, [], "Pre-lock discovery cannot modify legacy recovery"); + + const locked = h.read("maghuro", { migrateLegacy: true }); + assert.equal(locked.readOk, true); + assert.equal(locked.raw, identical); + assert.equal(locked.migratedLegacy, false); + assert.equal(h.storage.get("recovery::maghuro"), identical); + assert.equal(h.storage.has("recovery"), false, "Retire redundant legacy before confirming the opening"); + + // An immediate crash after the pending-to-confirmed checkpoint update + // must not strand recovery against a stale pending legacy slot. + const confirmed = JSON.stringify({ giveawayData: { host: "maghuro", openingCheckpoint: { status: "confirmed" } } }); + h.storage.set("recovery::maghuro", confirmed); + const restarted = h.read("maghuro", { migrateLegacy: false }); + assert.equal(restarted.readOk, true); + assert.equal(restarted.raw, confirmed); + assert.equal(restarted.hasStoredState, true); +}); + +test("duplicate cleanup failures fail closed without overwriting verified recovery", () => { + const identical = JSON.stringify({ giveawayData: { host: "maghuro" } }); + for (const throwsOn of ["remove", "verify", "remove-noop"]) { + const h = recoveryReadHarness({ namespaced: identical, legacy: identical, throwsOn }); + const state = h.read("maghuro", { migrateLegacy: true }); + assert.equal(state.readOk, false, `Cleanup must fail closed: ${throwsOn}`); assert.equal(state.hasStoredState, true); - assert.equal(state.raw, identical); - assert.equal(state.migratedLegacy, false); - assert.deepEqual(h.events, []); + assert.equal(h.storage.get("recovery::maghuro"), identical); + assert.equal(h.storage.get("recovery"), identical); } }); +test("duplicate cleanup precedes recovered pending-opening confirmation", () => { + const reader = initialization.slice( + initialization.indexOf("function readActiveGiveawayRawForHost("), + initialization.indexOf("/** Read and validate the shared cross-tab lock.") + ); + assert.match(reader, /if \(migrateLegacy\) \{/); + assert.match(reader, /Duplicate legacy recovery cleanup not confirmed/); + const restoreStart = initialization.indexOf("async function restoreGiveawayFromSnapshot("); + const lockedRead = initialization.indexOf("const lockedRead = readActiveGiveawayRawForHost(", restoreStart); + const update = initialization.indexOf("localStorage.setItem(storageKey, verifiedRaw)", lockedRead); + assert.ok(update > lockedRead, "Cleanup is required during locked read before the confirmed checkpoint write"); +}); + test("positively foreign legacy evidence does not mask valid namespaced recovery", () => { const current = JSON.stringify({ giveawayData: { host: "maghuro" } }); const legacy = JSON.stringify({ giveawayData: { host: "another-host" } }); diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 17523fb8..2ffe5ca3 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2973,6 +2973,20 @@ body.host-panel-dragging * { migratedLegacy: false, readOk: false }; } + if (migrateLegacy) { + // Both records were byte-identical. Under the exclusive + // restore lock, retire the redundant old copy BEFORE + // a pending -> confirmed write can make it conflict. + // Verify BOTH values are unchanged before deletion. + if (localStorage.getItem(storageKey) !== namespacedRaw || + localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { + throw new Error("Duplicate recovery state changed before cleanup"); + } + localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); + if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { + throw new Error("Duplicate legacy recovery cleanup not confirmed"); + } + } return namespacedResult; } diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index a7068c04..55848d25 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1130,6 +1130,20 @@ migratedLegacy: false, readOk: false }; } + if (migrateLegacy) { + // Both records were byte-identical. Under the exclusive + // restore lock, retire the redundant old copy BEFORE + // a pending -> confirmed write can make it conflict. + // Verify BOTH values are unchanged before deletion. + if (localStorage.getItem(storageKey) !== namespacedRaw || + localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { + throw new Error("Duplicate recovery state changed before cleanup"); + } + localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); + if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { + throw new Error("Duplicate legacy recovery cleanup not confirmed"); + } + } return namespacedResult; } diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index a7608869..17359b0d 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -76,9 +76,13 @@ is likewise occupied and unidentified: block new live openings, retain the original bytes, and require manual review. A known DIFFERENT host may be ignored. Even with an existing namespaced snapshot, always inspect the legacy slot: block any malformed/unidentified record or inconsistent same-host settlement -state (only byte-identical duplicates are safe). Keep both records for manual -investigation rather than trusting an older payout ledger. Migrations require -an exclusive lock and successful read-back before removing legacy storage. +state (only byte-identical duplicates are safe). Keep conflicting records +for manual investigation rather than trusting an older payout ledger. +After acquiring the exclusive lock, verify and retire a redundant identical +legacy slot BEFORE changing the namespaced opening checkpoint, with before- +and after-deletion read-back checks. Otherwise a crash could strand recovery. +A cleanup failure blocks automatic restoration. Single-slot migrations also +require verified writes before removing legacy storage. The initial snapshot loader must not clear even expired state: preserve it for manual review instead of altering shared recovery before locking. Snapshots are forbidden during balance/history/cursor preflight, before a From f8831d44efef76f4160571062f21b15432891cdb Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:11:03 +0100 Subject: [PATCH 130/130] fix(bonanza): verify legacy removal after single-slot recovery migration --- AGENTS.md | 8 ++- tests/bonanza-opening-recovery.test.mjs | 52 +++++++++++++++++++ .../DarkPeers_BONanza_Giveaway.user.js | 10 ++++ userscripts/giveaway/src/05-initialization.js | 10 ++++ userscripts/giveaway/src/README.md | 4 +- 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5777d51c..9a167cd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,8 +101,12 @@ multiple userscripts, not just BONanza. verify byte-identical same-host snapshots, then retire their duplicate legacy slot with before/after read-back checks BEFORE writing a new confirmed opening checkpoint. If cleanup fails, abort without payout; - pre-lock discovery must remain read-only. Never delete a legacy record - until its namespaced copy has been verified durably. + pre-lock discovery must remain read-only. Also verify legacy removal + succeeded after migrating a single old-format record: a successful + namespaced write does not prove the old key was deleted. Abort restore + if deletion is denied or silently ignored, before a pending opening + is confirmed. Never delete a legacy record until its namespaced copy + has been verified durably. `loadGiveawaySnapshot()` must never delete, migrate, or alter recovery storage before acquiring the Web Lock; stale/expired data needs manual review, not speculative cleanup. diff --git a/tests/bonanza-opening-recovery.test.mjs b/tests/bonanza-opening-recovery.test.mjs index 6f744595..67c1a6ce 100644 --- a/tests/bonanza-opening-recovery.test.mjs +++ b/tests/bonanza-opening-recovery.test.mjs @@ -883,6 +883,58 @@ test("coexisting namespaced recovery rejects corrupt and inconsistent legacy evi } }); +test("single legacy slot migration verifies cleanup and survives an immediate post-confirmation reload", () => { + const pending = JSON.stringify({ + giveawayData: { host: "maghuro", openingCheckpoint: { status: "pending" } } + }); + const h = recoveryReadHarness({ legacy: pending }); + const discovery = h.read("maghuro", { migrateLegacy: false }); + assert.equal(discovery.readOk, true); + assert.equal(discovery.raw, pending); + assert.equal(h.storage.has("recovery::maghuro"), false, "Discovery cannot migrate"); + assert.equal(h.storage.get("recovery"), pending, "Discovery cannot delete"); + + const migrated = h.read("maghuro", { migrateLegacy: true }); + assert.equal(migrated.readOk, true); + assert.equal(migrated.migratedLegacy, true); + assert.equal(migrated.raw, pending); + assert.equal(h.storage.get("recovery::maghuro"), pending, "Namespaced checkpoint must survive"); + assert.equal(h.storage.has("recovery"), false, "Migration must remove the old key"); + + // An interrupted restore after writing the confirmed opening has no + // leftover legacy record against which to conflict on the next load. + const confirmed = JSON.stringify({ + giveawayData: { host: "maghuro", openingCheckpoint: { status: "confirmed" } } + }); + h.storage.set("recovery::maghuro", confirmed); + const restarted = h.read("maghuro", { migrateLegacy: false }); + assert.equal(restarted.readOk, true); + assert.equal(restarted.raw, confirmed); +}); + +test("single legacy slot migration fails closed on denied or silent removal", () => { + const pending = JSON.stringify({ + giveawayData: { host: "maghuro", openingCheckpoint: { status: "pending" } } + }); + for (const failure of ["remove", "remove-noop"]) { + const h = recoveryReadHarness({ legacy: pending, throwsOn: failure }); + const result = h.read("maghuro", { migrateLegacy: true }); + assert.equal(result.readOk, false, `Restore must abort when legacy deletion fails: ${failure}`); + assert.equal(result.hasStoredState, true); + assert.equal(result.migratedLegacy, false); + assert.equal(h.storage.get("recovery"), pending, "Preserve legacy evidence"); + assert.equal(h.storage.get("recovery::maghuro"), pending, "Preserve migrated checkpoint"); + assert.equal(h.storage.has("recovery"), true, "Do not advance confirmation while old slot remains"); + + // A subsequent successful migration can retire these two identical + // copies instead of losing the original pending ledger state. + const retry = recoveryReadHarness({ namespaced: pending, legacy: pending }); + const recovered = retry.read("maghuro", { migrateLegacy: true }); + assert.equal(recovered.readOk, true); + assert.equal(retry.storage.has("recovery"), false); + } +}); + test("duplicate same-host recovery remains read-only until exclusive-locked migration", () => { const identical = JSON.stringify({ giveawayData: { host: "maghuro", openingCheckpoint: { status: "pending" } } }); const h = recoveryReadHarness({ namespaced: identical, legacy: identical }); diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 2ffe5ca3..863b14d6 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2997,7 +2997,17 @@ body.host-panel-dragging * { if (localStorage.getItem(storageKey) !== legacyRaw) { throw new Error("Legacy migration read-back mismatch"); } + // A successful write alone is not a completed migration. + // Confirm the legacy key really disappeared BEFORE any + // checkpoint mutation can diverge the two same-host copies. + // If removal is denied or silently ignored, fail closed. + if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { + throw new Error("Legacy recovery changed before removal"); + } localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); + if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { + throw new Error("Single-slot legacy migration cleanup not confirmed"); + } } return { storageKey, raw: legacyRaw, hasStoredState: true, diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 55848d25..43cca3aa 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1154,7 +1154,17 @@ if (localStorage.getItem(storageKey) !== legacyRaw) { throw new Error("Legacy migration read-back mismatch"); } + // A successful write alone is not a completed migration. + // Confirm the legacy key really disappeared BEFORE any + // checkpoint mutation can diverge the two same-host copies. + // If removal is denied or silently ignored, fail closed. + if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== legacyRaw) { + throw new Error("Legacy recovery changed before removal"); + } localStorage.removeItem(LS_ACTIVE_GIVEAWAY_LEGACY); + if (localStorage.getItem(LS_ACTIVE_GIVEAWAY_LEGACY) !== null) { + throw new Error("Single-slot legacy migration cleanup not confirmed"); + } } return { storageKey, raw: legacyRaw, hasStoredState: true, diff --git a/userscripts/giveaway/src/README.md b/userscripts/giveaway/src/README.md index 17359b0d..24053cde 100644 --- a/userscripts/giveaway/src/README.md +++ b/userscripts/giveaway/src/README.md @@ -82,7 +82,9 @@ After acquiring the exclusive lock, verify and retire a redundant identical legacy slot BEFORE changing the namespaced opening checkpoint, with before- and after-deletion read-back checks. Otherwise a crash could strand recovery. A cleanup failure blocks automatic restoration. Single-slot migrations also -require verified writes before removing legacy storage. +require verified namespaced writes, an unchanged legacy source, and a +confirmed legacy deletion before returning success. Denied or silent +removeItem failures block restore before opening confirmation. The initial snapshot loader must not clear even expired state: preserve it for manual review instead of altering shared recovery before locking. Snapshots are forbidden during balance/history/cursor preflight, before a