feat: server: storage hooks! released server@3.0.0

This commit is contained in:
Tiago
2025-08-01 11:57:12 +01:00
parent 5106fb5735
commit 5ff81e72e1
9 changed files with 741 additions and 362 deletions
+53
View File
@@ -0,0 +1,53 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "cap-demo",
"dependencies": {
"@cap.js/server": "^1.0.13",
"elysia": "^1.3.4",
},
},
},
"packages": {
"@cap.js/server": ["@cap.js/server@1.0.15", "", { "dependencies": { "@types/node": "^22.14.1", "typescript": "^5.8.3" } }, "sha512-mcDbHAL4ar5O4gnDE+62ZPeXhLXxd1KKHzJCsZfn81HY8Fgo5ousxYCnzhsLfki5sH6sO6jpNtS4MZXlKDq+fA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.35", "", {}, "sha512-C6ypdODf2VZkgRT6sFM8E1F8vR+HcffniX0Kp8MsU8PIfrlXbNCBz0jzj17GjdmjTx1OtZzdH8+iALL21UjF5A=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/node": ["@types/node@22.15.31", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"elysia": ["elysia@1.3.4", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-kAfM3Zwovy3z255IZgTKVxBw91HbgKhYl3TqrGRdZqqr+Fd+4eKOfvxgaKij22+MZLczPzIHtscAmvfpI3+q/A=="],
"exact-mirror": ["exact-mirror@0.1.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-wFCPCDLmHbKGUb8TOi/IS7jLsgR8WVDGtDK3CzcB4Guf/weq7G+I+DkXiRSZfbemBFOxOINKpraM6ml78vo8Zw=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"strtok3": ["strtok3@10.3.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-3JWEZM6mfix/GCJBBUrkA8p2Id2pBkyTkVCJKto55w080QBKZ+8R171fGrbiSp+yMO/u6F8/yUh7K4V9K+YCnw=="],
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"uint8array-extras": ["uint8array-extras@1.4.0", "", {}, "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cap demo</title>
<style>
body {
padding: 0px;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0px;
flex-direction: column;
}
button {
margin-top: 10px;
font-family: system, -apple-system, "BlinkMacSystemFont",
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
background-color: rgba(20, 20, 25);
border: 1px solid rgba(60, 60, 70);
border-radius: 12px;
color: #ffffff;
cursor: pointer;
font-size: 14px;
padding: 12px 16px;
text-align: center;
transition: transform 0.2s, background-color 0.2s, border-color 0.2s;
width: fit-content;
}
button:focus {
outline: 0;
}
button:hover {
background-color: rgba(40, 40, 40);
border: 1px solid rgba(100, 100, 120);
}
button:active {
transform: scale(0.98);
}
.source {
color: black;
font-family: system, -apple-system, "BlinkMacSystemFont",
".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI",
"Helvetica Neue", "Lucida Grande", "Ubuntu", "arial", sans-serif;
margin-top: 10px;
font-size: 13px;
}
</style>
</head>
<body>
<cap-widget
id="cap"
onsolve="console.log(`Token: ${event.detail.token}`)"
data-cap-api-endpoint="/api/"
data-cap-hidden-field-name="test-endpoint"
></cap-widget>
<cap-widget
id="floating"
onsolve="console.log(`Token: ${event.detail.token}`)"
data-cap-api-endpoint="/api/"
></cap-widget>
<button data-cap-floating="#floating" data-cap-floating-position="bottom">
Trigger floating mode
</button>
<a
href="https://github.com/tiagorangel1/cap/tree/main/demo"
target="_blank"
class="source"
>Source code</a
>
</body>
<!--
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget/cap-floating.min.js"></script>
-->
<script src="/cap.js"></script>
<script src="/cap-floating.js"></script>
</html>
+124
View File
@@ -0,0 +1,124 @@
import { Database } from "bun:sqlite";
import fs from "node:fs/promises";
import { Elysia, file } from "elysia";
import Cap from "../../server/index.js";
const db = new Database("./.data/cap.db");
db.exec(`
CREATE TABLE IF NOT EXISTS challenges (
token TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
key TEXT PRIMARY KEY,
expires INTEGER NOT NULL
);
`);
const cap = new Cap({
storage: {
challenges: {
store: async (token, challengeData) => {
db.prepare(
"INSERT OR REPLACE INTO challenges (token, data, expires) VALUES (?, ?, ?)",
).run(
token,
JSON.stringify(challengeData.challenge),
challengeData.expires,
);
},
read: async (token) => {
const row = db
.prepare(
"SELECT data, expires FROM challenges WHERE token = ? AND expires > ?",
)
.get(token, Date.now());
return row
? { challenge: JSON.parse(row.data), expires: row.expires }
: null;
},
delete: async (token) => {
db.prepare("DELETE FROM challenges WHERE token = ?").run(token);
},
listExpired: async () => {
const rows = db
.prepare("SELECT token FROM challenges WHERE expires <= ?")
.all(Date.now());
return rows.map((row) => row.token);
},
},
tokens: {
store: async (tokenKey, expires) => {
db.prepare(
"INSERT OR REPLACE INTO tokens (key, expires) VALUES (?, ?)",
).run(tokenKey, expires);
},
get: async (tokenKey) => {
const row = db
.prepare("SELECT expires FROM tokens WHERE key = ? AND expires > ?")
.get(tokenKey, Date.now());
return row ? row.expires : null;
},
delete: async (tokenKey) => {
db.prepare("DELETE FROM tokens WHERE key = ?").run(tokenKey);
},
listExpired: async () => {
const rows = db
.prepare("SELECT key FROM tokens WHERE expires <= ?")
.all(Date.now());
return rows.map((row) => row.key);
},
},
},
});
const app = new Elysia();
app.get("/", () => file("./index.html"));
app.get("/cap.js", async ({ set }) => {
// in the newest version, the worker is injected into the main file
// by a build script. since we don't have a build script here,
// we'll need to run a minimal build ourselves.
const main = await fs.readFile("../../widget/src/src/cap.js", "utf-8");
const worker = await fs.readFile("../../widget/src/src/worker.js", "utf-8");
const bundle = main.replace("%%workerScript%%", worker);
set.headers = {
"Content-Type": "application/javascript",
};
return bundle;
});
app.get("/cap-floating.js", () => file("../../widget/src/src/cap-floating.js"));
app.post("/api/challenge", () => cap.createChallenge());
app.post("/api/redeem", async ({ body, set }) => {
const { token, solutions } = body;
if (!token || !solutions) {
set.status = 400;
return { success: false };
}
const answer = await cap.redeemChallenge({ token, solutions });
console.log("new challenge redeemed", {
...answer,
isValid: (await cap.validateToken(answer.token)).success,
});
return answer;
});
app.listen(3000);
console.log("Server is running on http://localhost:3000");
+17
View File
@@ -0,0 +1,17 @@
{
"name": "cap-demo",
"description": "Cap demo",
"license": "Apache-2.0",
"author": "Tiago Rangel",
"type": "module",
"main": "index.js",
"scripts": {
"start": "bun run index.js",
"dev": "bun run --watch index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@cap.js/server": "^1.0.13",
"elysia": "^1.3.4"
}
}
+3 -3
View File
@@ -1,8 +1,6 @@
# Community libraries
Want to use Cap without the standalone server and with a different language? Here are some community-maintained libraries that might help:
**Warning:** These libraries are community-maintained and not officially supported or actively monitored for security by Cap. We can't guarantee their quality, security, or compatibility. If you want to add a library, feel free to open a pull request!
Want to use Cap without the standalone server and with a different language? Here are some community-maintained libraries that might help. If you want to add a library, feel free to open a pull request!
## Widgets
@@ -18,6 +16,8 @@ These are wrappers around Cap's widget. They're usually not required as the defa
## Server
**Warning:** These libraries are community-maintained and not officially supported or actively monitored for security by Cap. We can't guarantee their quality, security, or compatibility. They also might not support newer features such as storage hooks or seeded challenges.
### Java
- **[luckygc/cap-server](https://github.com/luckygc/cap-server)**: Replacement of wuhunyu's Java server that fixes [an important issue](https://github.com/tiagorangel1/cap/issues/69#issuecomment-3079407189)
+140 -296
View File
@@ -1,6 +1,6 @@
# @cap.js/server
`@cap.js/server` is Cap's server-side library. It helps you create and validate challenges for your users. Start by installing it using bun (recommended), npm, or pnpm:
`@cap.js/server` is Cap's server-side library for creating and validating challenges. Install it using your preferred package manager:
::: code-group
@@ -18,142 +18,123 @@ pnpm i @cap.js/server
:::
## Example code
## Getting started
::: code-group
The best way to use Cap is with **storage hooks** that connect to your database. Here's a simple example with SQLite:
```js [Elysia]
import { Elysia } from "elysia";
```js
import Cap from "@cap.js/server";
import { Database } from "bun:sqlite";
const db = new Database("cap.db");
db.exec(`
CREATE TABLE IF NOT EXISTS challenges (
token TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
key TEXT PRIMARY KEY,
expires INTEGER NOT NULL
);
`);
const cap = new Cap({
tokens_store_path: ".data/tokensList.json",
});
storage: {
challenges: {
store: async (token, challengeData) => {
db.prepare(
"INSERT OR REPLACE INTO challenges (token, data, expires) VALUES (?, ?, ?)"
).run(token, JSON.stringify(challengeData), challengeData.expires);
},
read: async (token) => {
const row = db
.prepare(
"SELECT data, expires FROM challenges WHERE token = ? AND expires > ?"
)
.get(token, Date.now());
new Elysia()
.post("/api/challenge", () => {
return cap.createChallenge();
})
return row
? { challenge: JSON.parse(row.data), expires: row.expires }
: null;
},
delete: async (token) => {
db.prepare("DELETE FROM challenges WHERE token = ?").run(token);
},
listExpired: async () => {
const rows = db
.prepare("SELECT token FROM challenges WHERE expires <= ?")
.all(Date.now());
.post("/api/redeem", async ({ body, set }) => {
const { token, solutions } = body;
if (!token || !solutions) {
set.status = 400;
return { success: false };
}
return await cap.redeemChallenge({ token, solutions });
})
.listen(3000);
console.log(`🦊 Elysia is running at http://localhost:3000`);
```
```js [Fastify]
import Fastify from "fastify";
import Cap from "@cap.js/server";
const fastify = Fastify();
const cap = new Cap({
tokens_store_path: ".data/tokensList.json",
});
fastify.post("/api/challenge", (req, res) => {
res.send(cap.createChallenge());
});
fastify.post("/api/redeem", async (req, res) => {
const { token, solutions } = req.body;
if (!token || !solutions) {
return res.code(400).send({ success: false });
}
res.send(await cap.redeemChallenge({ token, solutions }));
});
fastify.listen({ port: 3000, host: "0.0.0.0" }).then(() => {
console.log("Server is running on http://localhost:3000");
});
```
```js [Bun.serve]
import Cap from "@cap.js/server";
const cap = new Cap({
tokens_store_path: ".data/tokensList.json",
});
Bun.serve({
port: 3000,
routes: {
"/api/challenge": {
POST: () => {
return Response.json(cap.createChallenge());
return rows.map((row) => row.token);
},
},
"/api/redeem": {
POST: async (req) => {
const body = await req.json();
const { token, solutions } = body;
tokens: {
store: async (tokenKey, expires) => {
db.prepare(
"INSERT OR REPLACE INTO tokens (key, expires) VALUES (?, ?)"
).run(tokenKey, expires);
},
get: async (tokenKey) => {
const row = db
.prepare("SELECT expires FROM tokens WHERE key = ? AND expires > ?")
.get(tokenKey, Date.now());
if (!token || !solutions) {
return Response.json({ success: false }, { status: 400 });
}
return row ? row.expires : null;
},
delete: async (tokenKey) => {
db.prepare("DELETE FROM tokens WHERE key = ?").run(tokenKey);
},
listExpired: async () => {
const rows = db
.prepare("SELECT key FROM tokens WHERE expires <= ?")
.all(Date.now());
return Response.json(await cap.redeemChallenge({ token, solutions }));
return rows.map((row) => row.key);
},
},
},
});
console.log(`Server running at http://localhost:3000`);
export default cap;
```
```js [hono]
import { Hono } from "hono";
import Cap from "@cap.js/server";
Now, you can connect this to your backend to expose the routes needed for the widget:
const app = new Hono();
const cap = new Cap({
tokens_store_path: ".data/tokensList.json",
});
::: code-group
app.post("/api/challenge", (c) => {
return c.json(cap.createChallenge());
});
```js [Elysia]
import { Elysia } from "elysia";
import cap from "...";
app.post("/api/redeem", async (c) => {
const { token, solutions } = await c.req.json();
if (!token || !solutions) {
return c.json({ success: false }, 400);
}
return c.json(await cap.redeemChallenge({ token, solutions }));
});
export default {
port: 3000,
fetch: app.fetch,
};
new Elysia()
.post("/cap/challenge", async () => {
return await cap.createChallenge();
})
.post("/cap/redeem", async ({ body, set }) => {
const { token, solutions } = body;
if (!token || !solutions) {
set.status = 400;
return { success: false };
}
return await cap.redeemChallenge({ token, solutions });
})
.listen(3000);
```
```js [Express]
import express from "express";
import Cap from "@cap.js/server";
import cap from "...";
const app = express();
app.use(express.json());
const cap = new Cap({
tokens_store_path: ".data/tokensList.json",
app.post("/cap/challenge", async (req, res) => {
res.json(await cap.createChallenge());
});
app.post("/api/challenge", (req, res) => {
res.json(cap.createChallenge());
});
app.post("/api/redeem", async (req, res) => {
app.post("/cap/redeem", async (req, res) => {
const { token, solutions } = req.body;
if (!token || !solutions) {
return res.status(400).json({ success: false });
@@ -161,47 +142,75 @@ app.post("/api/redeem", async (req, res) => {
res.json(await cap.redeemChallenge({ token, solutions }));
});
app.listen(3000, () => {
console.log("Listening on port 3000");
app.listen(3000);
```
```js [Fastify]
import Fastify from "fastify";
import cap from "...";
const fastify = Fastify();
fastify.post("/cap/challenge", async (req, res) => {
res.send(await cap.createChallenge());
});
fastify.post("/cap/redeem", async (req, res) => {
const { token, solutions } = req.body;
if (!token || !solutions) {
return res.code(400).send({ success: false });
}
res.send(await cap.redeemChallenge({ token, solutions }));
});
fastify.listen({ port: 3000 });
```
:::
Note that these are the simplest examples possible. You should adapt them to your needs. Usually, you'll want to:
In this example, the Cap API is at `/cap/` — set that in your widget as `data-cap-api-endpoint` ([see widget docs](./widget.md)).
- Use an actual database such as SQLite or Redis for storing tokens and challenges — in this example, we're using a very simple JSON db + memory access.
- Some kind of actual ratelimiting.
Both of these are done for you in the [Standalone server](./standalone/index.md), but here's also [an example with everything for a simple Elysia app](#full-example-with-elysia).
Then, you can verify the CAPTCHA tokens on your server by calling the `await cap.validateToken("<token>")` method. Example:
When someone completes the CAPTCHA and sends the token back to your backend, you can validate the token and proceed with your logic.
```js
const { success } = await cap.validateToken("9363220f..."); // [!code highlight]
const { success } = await cap.validateToken("...");
if (success) {
console.log("Valid token");
} else {
console.log("Invalid token");
}
if (!success) throw new Error("invalid cap token");
// ...your logic
```
## Supported methods and arguments
The following methods are supported:
## Methods and arguments
#### `new Cap({ ... })`
Creates a new Cap instance.
**Arguments**
```json
{
// used for json keyval storage. storage hooks are recommended instead
"tokens_store_path": ".data/tokensList.json",
// disables all filesystem operations, usually used along editing the state. storage hooks are recommended instead
"noFSState": false,
"disableAutoCleanup": false,
"storage": {
"challenges": {
"store": "async (token, challengeData) => {}",
"read": "async (token) => {}",
"delete": "async (token) => {}",
"listExpired": "async () => []"
},
"tokens": {
"store": "async (tokenKey, expires) => {}",
"get": "async (tokenKey) => {}",
"delete": "async (tokenKey) => {}",
"listExpired": "async () => []"
}
},
"state": {
"challengesList": {},
"tokensList": {}
@@ -211,7 +220,7 @@ Creates a new Cap instance.
You can always access or set the options of the `Cap` class by accessing or modifying the `cap.config` object.
#### `cap.createChallenge({ ... })`
#### `await cap.createChallenge({ ... })`
**Arguments**
@@ -224,9 +233,7 @@ You can always access or set the options of the `Cap` class by accessing or modi
}
```
**Response:** `{ challenge, expires }`
> if `noFSState` is set to `true`, the state will be stored in memory only. You can use this together with setting `config.state` to use a custom db such as redis for storing tokens. Added by [#16](https://github.com/tiagorangel1/cap/pull/16)
**Response:** `{ challenge, token, expires }`
#### `cap.redeemChallenge({ ... })`
@@ -251,169 +258,6 @@ You can always access or set the options of the `Cap` class by accessing or modi
**Response:** `{ success }`
## Full example with Elysia
#### `await cap.cleanup()`
```js
import { Elysia, t } from "elysia";
import Cap from "@cap.js/server";
import { cors } from "@elysiajs/cors";
import { rateLimit } from "elysia-rate-limit";
import { Database } from "bun:sqlite";
// make sure this is a real file!
const db = new Database("./.data/cap.sqlite");
db.query(
`create table if not exists challenges (
token string not null,
data string not null,
expires integer not null,
primary key (token)
)`
).run();
db.query(
`create table if not exists tokens (
token string not null,
expires integer not null,
primary key (token)
)`
).run();
const insertChallengeQuery = db.query(`
INSERT INTO challenges (token, data, expires)
VALUES (?, ?, ?)
`);
const getChallengeQuery = db.query(`
SELECT * FROM challenges WHERE token = ?
`);
const deleteChallengeQuery = db.query(`
DELETE FROM challenges WHERE token = ?
`);
const insertTokenQuery = db.query(`
INSERT INTO tokens (token, expires)
VALUES (?, ?)
`);
const getTokenQuery = db.query(`
SELECT * FROM tokens WHERE token = ?
`);
const deleteTokenQuery = db.query(`
DELETE FROM tokens WHERE token = ?
`);
const challengeCount = 50;
export const capServer = new Elysia({
prefix: "/cap",
})
.use(
rateLimit({
scoping: "scoped",
max: 45,
duration: 5_000,
})
)
.use(
cors({
origin: ["http://localhost:3000", "..."],
methods: ["POST"],
})
)
.post("/challenge", () => {
const cap = new Cap({
noFSState: true,
});
const challenge = cap.createChallenge({
challengeCount,
});
insertChallengeQuery.run(
challenge.token,
Object.values(challenge.challenge).join(","),
challenge.expires
);
return challenge;
})
.post(
"/redeem",
async ({ body, set }) => {
const challenge = getChallengeQuery.get(body.token);
if (!challenge) {
set.status = 404;
return { error: "Challenge not found" };
}
try {
deleteChallengeQuery.run(body.token);
} catch (e) {
set.status = 404;
return { error: "Challenge not found or already redeemed/expired" };
}
const cap = new Cap({
noFSState: true,
state: {
challengesList: {
[challenge.token]: {
challenge: {
c: challenge.data.split(",")[0],
s: challenge.data.split(",")[1],
d: challenge.data.split(",")[2],
},
expires: challenge.expires,
},
},
},
});
const { success, token, expires } = await cap.redeemChallenge(body);
if (!success) {
set.status = 403;
return { error: "Invalid solution" };
}
insertTokenQuery.run(token, expires);
return {
success: true,
token,
expires,
};
},
{
body: t.Object({
token: t.String(),
solutions: t.Array(t.Number()),
}),
}
);
export const validateToken = async (_token) => {
const token = getTokenQuery.get(_token);
if (!token) {
return { error: "Token not found" };
}
if (token.expires < Math.floor(Date.now() / 1000)) {
deleteTokenQuery.run(_token);
return { error: "Token expired" };
}
deleteTokenQuery.run(_token);
return { success: true };
};
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
db.query(`DELETE FROM challenges WHERE expires < ?`).run(now);
db.query(`DELETE FROM tokens WHERE expires < ?`).run(now);
}, 60000);
```
Cleans up all expired challenges and tokens. This is usually done for you by default.
+98 -8
View File
@@ -11,14 +11,36 @@ declare class Cap extends EventEmitter<[never]> {
constructor(configObj?: Partial<CapConfig>);
/** @type {Promise<void>|null} */
_cleanupPromise: Promise<void> | null;
/** @type {Required<CapConfig>} */
config: Required<CapConfig>;
/** @type {number} */
_lastCleanup: number;
/** @type {CapConfig} */
config: CapConfig;
/**
* Performs cleanup if enough time has passed since last cleanup
* @private
* @returns {Promise<void>}
*/
private _lazyCleanup;
/**
* Retrieves challenge data from storage
* @private
* @param {string} token - Challenge token
* @returns {Promise<ChallengeData|null>} Challenge data or null if not found
*/
private _getChallenge;
/**
* Deletes challenge from storage
* @private
* @param {string} token - Challenge token
* @returns {Promise<void>}
*/
private _deleteChallenge;
/**
* Generates a new challenge
* @param {ChallengeConfig} [conf] - Challenge configuration
* @returns {{ challenge: {c: number, s: number, d: number}, token?: string, expires: number }} Challenge data
* @returns {Promise<{ challenge: {c: number, s: number, d: number}, token?: string, expires: number }>} Challenge data
*/
createChallenge(conf?: ChallengeConfig): {
createChallenge(conf?: ChallengeConfig): Promise<{
challenge: {
c: number;
s: number;
@@ -26,7 +48,7 @@ declare class Cap extends EventEmitter<[never]> {
};
token?: string;
expires: number;
};
}>;
/**
* Redeems a challenge solution in exchange for a token
* @param {Solution} param0 - Challenge solution data
@@ -38,6 +60,20 @@ declare class Cap extends EventEmitter<[never]> {
token?: string;
expires?: number;
}>;
/**
* Retrieves token expiration from storage
* @private
* @param {string} tokenKey - Token key
* @returns {Promise<number|null>} Token expiration or null if not found
*/
private _getToken;
/**
* Deletes token from storage
* @private
* @param {string} tokenKey - Token key
* @returns {Promise<void>}
*/
private _deleteToken;
/**
* Validates a token
* @param {string} token - The token to validate
@@ -54,9 +90,9 @@ declare class Cap extends EventEmitter<[never]> {
*/
private _loadTokens;
/**
* Removes expired tokens and challenges from memory
* Removes expired tokens and challenges from memory and storage
* @private
* @returns {boolean} - True if any tokens were changed/removed
* @returns {Promise<boolean>} - True if any tokens were changed/removed
*/
private _cleanExpiredTokens;
/**
@@ -72,7 +108,7 @@ declare class Cap extends EventEmitter<[never]> {
cleanup(): Promise<void>;
}
declare namespace Cap {
export { Crypto, FsPromises, PathLike, ChallengeTuple, ChallengeData, ChallengeState, ChallengeConfig, TokenConfig, Solution, CapConfig };
export { Crypto, FsPromises, PathLike, ChallengeTuple, ChallengeData, ChallengeState, ChallengeConfig, TokenConfig, Solution, ChallengeStorage, TokenStorage, StorageHooks, CapConfig };
}
import { EventEmitter } from "events";
type Crypto = typeof import("node:crypto");
@@ -141,6 +177,52 @@ type Solution = {
*/
solutions: number[];
};
type ChallengeStorage = {
/**
* - Store challenge data
*/
store: (arg0: string, arg1: ChallengeData) => Promise<void>;
/**
* - Retrieve challenge data
*/
read: (arg0: string) => Promise<ChallengeData | null>;
/**
* - Delete challenge data
*/
delete: (arg0: string) => Promise<void>;
/**
* - List expired challenge tokens
*/
listExpired: () => Promise<string[]>;
};
type TokenStorage = {
/**
* - Store token with expiration
*/
store: (arg0: string, arg1: number) => Promise<void>;
/**
* - Retrieve token expiration
*/
get: (arg0: string) => Promise<number | null>;
/**
* - Delete token
*/
delete: (arg0: string) => Promise<void>;
/**
* - List expired token keys
*/
listExpired: () => Promise<string[]>;
};
type StorageHooks = {
/**
* - Challenge storage hooks
*/
challenges?: ChallengeStorage | undefined;
/**
* - Token storage hooks
*/
tokens?: TokenStorage | undefined;
};
type CapConfig = {
/**
* - Path to store the tokens file
@@ -154,4 +236,12 @@ type CapConfig = {
* - Whether to disable the state file
*/
noFSState: boolean;
/**
* - Whether to disable automatic cleanup of expired tokens and challenges
*/
disableAutoCleanup?: boolean | undefined;
/**
* - Custom storage hooks for challenges and tokens
*/
storage?: StorageHooks | undefined;
};
+214 -54
View File
@@ -47,18 +47,42 @@
* @property {number[]} solutions - Array of challenge solutions
*/
/**
* @typedef {Object} ChallengeStorage
* @property {function(string, ChallengeData): Promise<void>} store - Store challenge data
* @property {function(string): Promise<ChallengeData|null>} read - Retrieve challenge data
* @property {function(string): Promise<void>} delete - Delete challenge data
* @property {function(): Promise<string[]>} listExpired - List expired challenge tokens
*/
/**
* @typedef {Object} TokenStorage
* @property {function(string, number): Promise<void>} store - Store token with expiration
* @property {function(string): Promise<number|null>} get - Retrieve token expiration
* @property {function(string): Promise<void>} delete - Delete token
* @property {function(): Promise<string[]>} listExpired - List expired token keys
*/
/**
* @typedef {Object} StorageHooks
* @property {ChallengeStorage} [challenges] - Challenge storage hooks
* @property {TokenStorage} [tokens] - Token storage hooks
*/
/**
* @typedef {Object} CapConfig
* @property {string} tokens_store_path - Path to store the tokens file
* @property {ChallengeState} state - State configuration
* @property {boolean} noFSState - Whether to disable the state file
* @property {boolean} [disableAutoCleanup] - Whether to disable automatic cleanup of expired tokens and challenges
* @property {StorageHooks} [storage] - Custom storage hooks for challenges and tokens
*/
/** @type {typeof import('node:crypto')} */
const crypto = require("crypto");
const crypto = require("node:crypto");
/** @type {typeof import('node:fs/promises')} */
const fs = require("node:fs/promises");
const { EventEmitter } = require("events");
const { EventEmitter } = require("node:events");
const DEFAULT_TOKENS_STORE = ".data/tokensList.json";
@@ -109,7 +133,10 @@ class Cap extends EventEmitter {
/** @type {Promise<void>|null} */
_cleanupPromise;
/** @type {Required<CapConfig>} */
/** @type {number} */
_lastCleanup;
/** @type {CapConfig} */
config;
/**
@@ -119,7 +146,8 @@ class Cap extends EventEmitter {
constructor(configObj) {
super();
this._cleanupPromise = null;
/** @type {Required<CapConfig>} */
this._lastCleanup = 0;
/** @type {CapConfig} */
this.config = {
tokens_store_path: DEFAULT_TOKENS_STORE,
noFSState: false,
@@ -130,7 +158,7 @@ class Cap extends EventEmitter {
...configObj,
};
if (!this.config.noFSState) {
if (!this.config.noFSState && !this.config.storage?.tokens) {
this._loadTokens().catch(() => {});
}
@@ -145,29 +173,80 @@ class Cap extends EventEmitter {
});
}
/**
* Performs cleanup if enough time has passed since last cleanup
* @private
* @returns {Promise<void>}
*/
async _lazyCleanup() {
if (this.config.disableAutoCleanup) return;
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (now - this._lastCleanup > fiveMinutes) {
await this._cleanExpiredTokens();
this._lastCleanup = now;
}
}
/**
* Retrieves challenge data from storage
* @private
* @param {string} token - Challenge token
* @returns {Promise<ChallengeData|null>} Challenge data or null if not found
*/
async _getChallenge(token) {
if (this.config.storage?.challenges?.read) {
return (await this.config.storage.challenges.read(token)) || null;
}
return this.config.state.challengesList[token] || null;
}
/**
* Deletes challenge from storage
* @private
* @param {string} token - Challenge token
* @returns {Promise<void>}
*/
async _deleteChallenge(token) {
if (this.config.storage?.challenges?.delete) {
await this.config.storage.challenges.delete(token);
} else {
delete this.config.state.challengesList[token];
}
}
/**
* Generates a new challenge
* @param {ChallengeConfig} [conf] - Challenge configuration
* @returns {{ challenge: {c: number, s: number, d: number}, token?: string, expires: number }} Challenge data
* @returns {Promise<{ challenge: {c: number, s: number, d: number}, token?: string, expires: number }>} Challenge data
*/
createChallenge(conf) {
this._cleanExpiredTokens();
async createChallenge(conf) {
await this._lazyCleanup();
/** @type {{c: number, s: number, d: number}} */
const challenge = {
c: conf?.challengeCount || 50,
s: conf?.challengeSize || 32,
d: conf?.challengeDifficulty || 4,
c: conf?.challengeCount ?? 50,
s: conf?.challengeSize ?? 32,
d: conf?.challengeDifficulty ?? 4,
};
const token = crypto.randomBytes(25).toString("hex");
const expires = Date.now() + (conf?.expiresMs || 600000);
const expires = Date.now() + (conf?.expiresMs ?? 600000);
if (conf && conf.store === false) {
return { challenge, expires };
}
this.config.state.challengesList[token] = { expires, challenge };
const challengeData = { expires, challenge };
if (this.config.storage?.challenges?.store) {
await this.config.storage.challenges.store(token, challengeData);
} else {
this.config.state.challengesList[token] = challengeData;
}
return { challenge, token, expires };
}
@@ -187,16 +266,15 @@ class Cap extends EventEmitter {
return { success: false, message: "Invalid body" };
}
this._cleanExpiredTokens();
await this._lazyCleanup();
const challengeData = await this._getChallenge(token);
await this._deleteChallenge(token);
const challengeData = this.config.state.challengesList[token];
if (!challengeData || challengeData.expires < Date.now()) {
delete this.config.state.challengesList[token];
return { success: false, message: "Challenge expired" };
return { success: false, message: "Challenge invalid or expired" };
}
delete this.config.state.challengesList[token];
let i = 0;
const challenges = Array.from({ length: challengeData.challenge.c }, () => {
@@ -225,21 +303,63 @@ class Cap extends EventEmitter {
const expires = Date.now() + 20 * 60 * 1000;
const hash = crypto.createHash("sha256").update(vertoken).digest("hex");
const id = crypto.randomBytes(8).toString("hex");
const tokenKey = `${id}:${hash}`;
if (this?.config?.state?.tokensList)
this.config.state.tokensList[`${id}:${hash}`] = expires;
if (this.config.storage?.tokens?.store) {
await this.config.storage.tokens.store(tokenKey, expires);
} else {
if (this?.config?.state?.tokensList) {
this.config.state.tokensList[tokenKey] = expires;
}
if (!this.config.noFSState) {
await fs.writeFile(
this.config.tokens_store_path,
JSON.stringify(this.config.state.tokensList),
"utf8",
);
if (!this.config.noFSState) {
await fs.writeFile(
this.config.tokens_store_path,
JSON.stringify(this.config.state.tokensList),
"utf8",
);
}
}
return { success: true, token: `${id}:${vertoken}`, expires };
}
/**
* Retrieves token expiration from storage
* @private
* @param {string} tokenKey - Token key
* @returns {Promise<number|null>} Token expiration or null if not found
*/
async _getToken(tokenKey) {
if (this.config.storage?.tokens?.get) {
return await this.config.storage.tokens.get(tokenKey);
}
return this.config.state.tokensList[tokenKey] || null;
}
/**
* Deletes token from storage
* @private
* @param {string} tokenKey - Token key
* @returns {Promise<void>}
*/
async _deleteToken(tokenKey) {
if (this.config.storage?.tokens?.delete) {
await this.config.storage.tokens.delete(tokenKey);
} else {
delete this.config.state.tokensList[tokenKey];
if (!this.config.noFSState) {
await fs.writeFile(
this.config.tokens_store_path,
JSON.stringify(this.config.state.tokensList),
"utf8",
);
}
}
}
/**
* Validates a token
* @param {string} token - The token to validate
@@ -247,25 +367,27 @@ class Cap extends EventEmitter {
* @returns {Promise<{success: boolean}>}
*/
async validateToken(token, conf) {
this._cleanExpiredTokens();
await this._lazyCleanup();
const [id, vertoken] = token.split(":");
if (!token || typeof token !== "string") {
return { success: false };
}
const parts = token.split(":");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return { success: false };
}
const [id, vertoken] = parts;
const hash = crypto.createHash("sha256").update(vertoken).digest("hex");
const key = `${id}:${hash}`;
await this._waitForTokensList();
if (this.config.state.tokensList[key]) {
const tokenExpires = await this._getToken(key);
if (tokenExpires && tokenExpires > Date.now()) {
if (!conf?.keepToken) {
delete this.config.state.tokensList[key];
}
if (!this.config.noFSState) {
await fs.writeFile(
this.config.tokens_store_path,
JSON.stringify(this.config.state.tokensList),
"utf8",
);
await this._deleteToken(key);
}
return { success: true };
@@ -285,6 +407,7 @@ class Cap extends EventEmitter {
.split("/")
.slice(0, -1)
.join("/");
if (dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
@@ -293,9 +416,9 @@ class Cap extends EventEmitter {
await fs.access(this.config.tokens_store_path);
const data = await fs.readFile(this.config.tokens_store_path, "utf-8");
this.config.state.tokensList = JSON.parse(data) || {};
this._cleanExpiredTokens();
await this._lazyCleanup();
} catch {
console.warn(`[cap] Tokens file not found, creating a new empty one`);
console.warn(`[cap] tokens file not found, creating a new empty one`);
await fs.writeFile(this.config.tokens_store_path, "{}", "utf-8");
this.config.state.tokensList = {};
}
@@ -308,25 +431,58 @@ class Cap extends EventEmitter {
}
/**
* Removes expired tokens and challenges from memory
* Removes expired tokens and challenges from memory and storage
* @private
* @returns {boolean} - True if any tokens were changed/removed
* @returns {Promise<boolean>} - True if any tokens were changed/removed
*/
_cleanExpiredTokens() {
async _cleanExpiredTokens() {
const now = Date.now();
let tokensChanged = false;
for (const k in this.config.state.challengesList) {
if (this.config.state.challengesList[k].expires < now) {
delete this.config.state.challengesList[k];
}
if (this.config.storage?.challenges?.listExpired) {
const expiredChallenges =
await this.config.storage.challenges.listExpired();
await Promise.all(
expiredChallenges.map(async (token) => {
await this._deleteChallenge(token);
}),
);
} else if (!this.config.storage?.challenges) {
const expired = Object.entries(this.config.state.challengesList)
.filter(([_, v]) => v.expires < now)
.map(([k]) => k);
await Promise.all(
expired.map(async (k) => {
await this._deleteChallenge(k);
}),
);
} else {
console.warn(
"[cap] challenge storage hooks provided but no listExpired, couldn't delete expired challenges",
);
}
for (const k in this.config.state.tokensList) {
if (this.config.state.tokensList[k] < now) {
delete this.config.state.tokensList[k];
tokensChanged = true;
if (this.config.storage?.tokens?.listExpired) {
const expiredTokens = await this.config.storage.tokens.listExpired();
await Promise.all(
expiredTokens.map(async (tokenKey) => {
await this._deleteToken(tokenKey);
tokensChanged = true;
}),
);
} else if (!this.config.storage?.tokens) {
for (const k in this.config.state.tokensList) {
if (this.config.state.tokensList[k] < now) {
await this._deleteToken(k);
tokensChanged = true;
}
}
} else {
console.warn(
"[cap] token storage hooks provided but no listExpired, couldn't delete expired tokens",
);
}
return tokensChanged;
@@ -357,9 +513,13 @@ class Cap extends EventEmitter {
if (this._cleanupPromise) return this._cleanupPromise;
this._cleanupPromise = (async () => {
const tokensChanged = this._cleanExpiredTokens();
const tokensChanged = await this._cleanExpiredTokens();
if (tokensChanged) {
if (
tokensChanged &&
!this.config.noFSState &&
!this.config.storage?.tokens?.store
) {
await fs.writeFile(
this.config.tokens_store_path,
JSON.stringify(this.config.state.tokensList),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cap.js/server",
"version": "2.0.0",
"version": "3.0.0",
"author": "Tiago",
"repository": {
"type": "git",