contact messages: browser-side PoW solving in the contact panel

The bridge routes {type: pow} frames to the panel. contact-main solves
each fresh challenge in the background with the backend-served easypow
solver module (imported by URL from a variable so Rollup leaves it
dynamic), aborting stale solves on replacement challenges. Sending
awaits the current solution and burns it with the message; a
'required' rejection re-solves and retries the message once.

Composer and ContactPanel need no changes: uploads stay ungated, and
the send path just awaits the in-flight solve.
This commit is contained in:
LeoVasanko committed 2026-09-27 06:14:04 +00:00
1 parent ec35bc71e8
commit 0adcd0bf90
2 files changed
+58 -5

No files matched your search

+54 -4
View File
@@ -31,6 +31,55 @@ function markSeen() {
refreshBadges()
}
// PoW (easypow, docs/messages.md): the server issues a fresh single-use
// challenge with every registration/resync and after every accepted
// message; one solution burns one message. The solver module is served
// by the backend from the easypow package, so both sides always match.
const SOLVER_URL = '/_contact/easypow.js' // backend-served; kept in a variable so Rollup leaves the import dynamic
let bridge = null
let token = ''
let solverPromise = null
let powSolution = null // Promise<string> solving the current challenge
let powAborter = null
let unsent = null // last message text, retried once if its solution was rejected
// The solution for the currently outstanding challenge; rides out a
// challenge replacement (reconnect) by continuing with the new solve.
async function currentSolution() {
for (;;) {
const p = powSolution
if (!p) return ''
try {
return await p
} catch {
if (p === powSolution) return '' // failed without a replacement
}
}
}
async function sendMessage(md) {
unsent = md
const pow = await currentSolution()
if (pow) bridge.send({ token, msg: md, pow })
}
async function onPow(msg) {
powAborter?.abort() // superseded challenge: stop the stale solve
const ac = (powAborter = new AbortController())
solverPromise ??= import(/* @vite-ignore */ SOLVER_URL)
const { solve } = await solverPromise
powSolution = solve(msg.challenge, ac.signal)
powSolution.catch(() => {}) // aborts are expected
// A rejected send (stale or already burned solution) is retried once
// the fresh challenge is solved; a second rejection drops it.
if (msg.required && unsent) {
const md = unsent
unsent = null
const pow = await currentSolution()
if (pow) bridge.send({ token, msg: md, pow })
}
}
// A "new reply" dot on every contact trigger button while the panel is
// closed and the newest admin message is younger than the last look.
// Called on thread pushes and (from pagerite.js) after navigations,
@@ -49,13 +98,14 @@ function onThread(thread) {
else refreshBadges()
}
export function open(bridge) {
export function open(br) {
bridge = br
if (!app) {
const token = ensureToken()
token = ensureToken()
app = createApp(ContactPanel, {
state,
token,
onSend: (md) => bridge.send({ token, msg: md }),
onSend: (md) => sendMessage(md),
onClose: () => {
state.open = false
refreshBadges()
@@ -67,7 +117,7 @@ export function open(bridge) {
app.mount(el)
// Registration doubles as the resync: the server pushes the thread
// state back, and pagerite.js re-registers on every socket reconnect.
bridge.register(token, onThread)
bridge.register(token, onThread, onPow)
}
state.open = true
markSeen()
+4 -1
View File
@@ -643,6 +643,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
return;
}
if (msg?.type === "thread") contactListener(msg);
else if (msg?.type === "pow") contactPowListener?.(msg);
else if (msg?.type === "contact-error") console.warn("contact:", msg.detail);
};
ws.onclose = () => {
@@ -893,10 +894,12 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
let contactMod = null;
let contactToken = "";
let contactListener = null;
let contactPowListener = null;
const contactBridge = {
register(token, onThread) {
register(token, onThread, onPow) {
contactToken = token;
contactListener = onThread;
contactPowListener = onPow;
contactBridge.send({ token });
},
send(msg) {