proof-of-work reactions for your blogs



proof-of-work reactions for your Svelte blogs

demo: pow-reaction.pages.dev
1. You generate a challenge which consists of a. difficulty, b. number of rounds
2. You generate a unique random string of characters for each round called id
3. User now has to find a hash so that hash(id + nonce) -> translated to binary (000111010101011) starts from difficulty number of consecutive zeroes by iterating nonce starting from 0 and until they find the hash
4. They send their solutions (nonces) back with the challenge signed by you (to retrieve parameters for captcha and keep this lib stateless)
5. All you have to do is verify their solutions by checking if hash(id + nonce) with their provided nonce -> translated to binary really starts from difficulty number of consecutive zeroes
Add progressively increasing difficulty with each subsequent request, and you get a pretty good stateless, privacy friendly rate limiter.
Not only this is a secure way of stopping flood but also a fair way for users to express their reaction. More reactions = more time to spend = those who appreciate the page's content more will send more reactions.
Couple of qurks:
- Instead of setting difficulty to 100 and rounds to 1, set difficulty to 1 and rounds to 100
- more rounds = more equal average time of solving the challenge
- more rounds = real progress bar for user
- more rounds = run several workers to seek solution in parallel
- Instead of mining on single core, use WebWorkers
- web workers run in a separate thread = no UI freezes
- several web workers = several times faster to find all solutions
- use navigator.hardwareConcurrency which is supported by every browser
NPM:
```
bun add pow-reaction
JSR is blocked (see #1)
In your Svelte UI component (client-side):
`svelte
`
In your server side initialize PowReaction class (lib/server/reactions.ts):
`ts
import { PowReaction } from 'pow-reaction';
// load from process.env or something, it should be 32 bytes long
const secret = new TextEncoder().encode('HESOYAM_HESOYAM_HESOYAM_HESOYAM!');
type ClientParams = { ip: string; pageId: string };
export const reaction = new PowReaction
// secret is used to cryptographically sign challenge
secret,
// reaction can be any string, emoji or enum value
reaction: '😘',
// optional but decreases the chance of dehashing client params in case of database breach
clientParamsSalt: 'my-app-name',
difficulty: {
// how many ms (1/1000 of a second) should be checked when generating a challenge
windowMs: 1000 * 60,
// min. starting difficulty, optional, defaults to 4
minDifficulty: 4,
// floor(challenges generated in last windowMs * multiplier) = number of leading zero bytes in the challengeclientId
multiplier: 1,
async getEntries({ clientId, since }) {
// return number of entries in your persistant storage
// a client with has been added to itsince
// starting from DateclientId
},
async putEntry({ clientId }) {
// put an entry for client with to yournew Date()
// persistant storage and assign current
// date to the entrychallengeId
}
},
// how long should a signed challenge be valid
// this is mainly to prevent bots from requesting a lot of challenges
// in advance, easily solving, and then submitting in batch.
// too small values will cause low-end devices not to be able
// to submit solutions within this time frame
// optional, defaults to 60000 (60 seconds)
ttl: 1000 * 60,
async isRedeemed({ challengeId }) {
// return whether the was submitted previouslychallengeId
},
async setRedeemed({ challengeId }) {
// put the successfully submitted `
}
});
In your server challenge generator API handler (POST /api/reactions/challenge):
`ts
import z from 'zod';
import { json } from '@sveltejs/kit';
import { reaction } from '$lib/server/reactions';
export async function POST({ request }) {
const body = await z
.object({
reaction: z.literal('😘')
})
.safeParseAsync(await request.json());
if (!body.success) {
return json({ success: false }, { status: 400 });
}
// get from headers or event.getClientAddress(), see https://github.com/sveltejs/kit/pull/4289
const ip = '1.2.3.4';
// by passing pageId you're binding this challenge to this page
// meaning the solution won't work for other pages but also that
// the rate limit of this challenge will only count for this page
const client = { ip, pageId: '/demo' };
const challenge = await reaction.getChallenge(client);
return json({ challenge });
}
`
In your server solution submitter API handler (POST /api/reactions):
`ts
import z from 'zod';
import { json } from '@sveltejs/kit';
import { reaction } from '$lib/server/reactions';
export async function POST({ request }) {
const body = await z
.object({
reaction: z.literal('😘'),
challenge: z.string().min(1),
solutions: z.array(z.number().int().nonnegative())
})
.safeParseAsync(await request.json());
if (!body.success) {
return json({ success: false }, { status: 400 });
}
const { challenge, solutions } = body.data;
// get from headers or event.getClientAddress(), see https://github.com/sveltejs/kit/pull/4289
const ip = '1.2.3.4';
// client must be exactly the same as in getChallenge
const client = { ip, pageId: '/demo' };
const success = await reaction.verifySolution({ challenge, solutions }, client);
if (success) {
// increase number of reactions by +1 in your database
}
return json({ success });
}
`
> [!IMPORTANT]
> Vite deps optimizer does not work with WebWorkers (see vitejs/vite#11672, vitejs/vite#15547, vitejs/vite#15618). You must add pow-reaction to optimizeDeps.exclude in your vite.config.ts in order for WebWorker to load:`
>
> ts`
> optimizeDeps: {
> exclude: ['pow-reaction'];
> }
>
| CSS variable | Description | Default |
| ----------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| --reaction-button-text-color | Value text color | rgba(0, 0, 0, 0.6), rgba(212, 212, 212, 0.6) when (prefers-color-scheme: dark) |--reaction-button-highlight-color
| | Button highlight color (focus & circular progress bar) | rgba(0, 0, 0, 0.1), rgba(161, 161, 161, 0.3) when (prefers-color-scheme: dark)` |
You can find example & demo source code in src/routes directory.
Demo works with Cloudflare Pages and Cloudflare KV for IP rate limiting.
Tested in Firefox 142, Chrome 139, Safari macOS 18.5, Safari iOS 18.0, Tor Browser 14.5.6
Thanks to Paul Miller for the amazing noble project!
And thanks to Pilcrow for the awesome Oslo project!