From 1e843c4f9c81c85f9dae824ca49ba5f56db069c3 Mon Sep 17 00:00:00 2001
From: Tiago Rangel
Date: Mon, 13 Jan 2025 20:01:26 +0000
Subject: [PATCH] A lot of changes to how cap works. Note: the capdotjs npm
library has now been deprecated
---
.gitignore | 3 +-
build.js | 4 +-
demo.js | 191 ---------------------------
docs/guide/demo.md | 2 +
docs/guide/effectiveness.md | 2 +-
docs/guide/floating.md | 28 +---
docs/guide/index.md | 83 ++++++------
docs/index.md | 2 +-
package.json | 11 +-
server/README.md | 1 +
server/index.js | 192 ++++++++++++++++++++++++++++
server/package.json | 35 +++++
server/test.js | 78 +++++++++++
widget/README.md | 1 +
{lib => widget}/cap-floating.min.js | 0
{lib => widget}/cap.min.js | 0
widget/package.json | 32 +++++
{src => widget/src}/cap-floating.js | 0
{src => widget/src}/cap.js | 0
{lib => widget}/wasm-hashes.min.js | 0
20 files changed, 403 insertions(+), 262 deletions(-)
delete mode 100644 demo.js
create mode 100644 server/README.md
create mode 100644 server/index.js
create mode 100644 server/package.json
create mode 100644 server/test.js
create mode 100644 widget/README.md
rename {lib => widget}/cap-floating.min.js (100%)
rename {lib => widget}/cap.min.js (100%)
create mode 100644 widget/package.json
rename {src => widget/src}/cap-floating.js (100%)
rename {src => widget/src}/cap.js (100%)
rename {lib => widget}/wasm-hashes.min.js (100%)
diff --git a/.gitignore b/.gitignore
index 9329e7d..93d41c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.old
build/Release
bun.lockb
package-lock.json
@@ -89,4 +90,4 @@ docs/.vitepress/dist
.DS_Store
.vscode
.data
-node_modules
\ No newline at end of file
+node_modules
diff --git a/build.js b/build.js
index e214a71..2964cca 100644
--- a/build.js
+++ b/build.js
@@ -3,8 +3,8 @@ const babel = require("@babel/core");
const { minify } = require("terser");
const paths = [
- ["./src/cap.js", "./lib/cap.min.js"],
- ["./src/cap-floating.js", "./lib/cap-floating.min.js"],
+ ["./widget/src/cap.js", "./widget/cap.min.js"],
+ ["./widget/src/cap-floating.js", "./widget/cap-floating.min.js"],
];
paths.forEach(async function ([inpath, outpath]) {
diff --git a/demo.js b/demo.js
deleted file mode 100644
index b0f8443..0000000
--- a/demo.js
+++ /dev/null
@@ -1,191 +0,0 @@
-const fs = require("fs");
-const Koa = require("koa");
-const path = require("path");
-const crypto = require("crypto");
-const json = require("koa-json");
-const mount = require("koa-mount");
-const Router = require("@koa/router");
-const compress = require("koa-compress");
-const ratelimit = require("koa-ratelimit");
-const bodyParser = require("koa-bodyparser");
-
-const fsp = fs.promises;
-
-const app = new Koa();
-const router = new Router();
-
-app.proxy = true;
-
-const serve = require("koa-static");
-app.use(mount("/docs", serve(path.join(__dirname, "/docs/.vitepress/dist/"))));
-
-const TOKENS_PATH = path.join(__dirname, ".data", "tokensList.json");
-const state = {
- challengesList: {},
- tokensList: {},
- requestCounts: {}
-};
-
-app.use(bodyParser());
-app.use(json());
-app.use(compress());
-
-app.use(async (ctx, next) => {
- const start = Date.now();
- await next();
- const ms = Date.now() - start;
- ctx.set("X-Response-Time", `${ms}ms`);
-});
-
-const db = new Map();
-
-const createRateLimit = () =>
- ratelimit({
- driver: "memory",
- db: db,
- duration: 60 * 1000,
- max: 30,
- errorMessage: "Too many requests, please try again later.",
- });
-
-const nid = function (l) {
- return crypto
- .randomBytes(Math.ceil(l / 2))
- .toString("hex")
- .slice(0, l);
-};
-
-router.post("/api/challenge", createRateLimit(), async (ctx) => {
- state.requestCounts[ctx.ip] = {
- counts: (state.requestCounts[ctx.ip]?.counts + 1) || 1,
- timestamp: Date.now()
- };
- const config = { challengeCount: 18, challengeSize: 32, challengeDifficulty: 4 };
- const challenges = Array.from({ length: config.challengeCount }, () => [
- nid(config.challengeSize),
- nid(config.challengeDifficulty),
- ]);
- const token = crypto.randomBytes(25).toString("hex");
- const expires = Date.now() + 600000;
-
- state.challengesList[token] = {
- challenge: challenges,
- expires,
- ip: ctx.ip,
- };
-
- ctx.body = { challenge: challenges, token, expires };
-});
-
-router.post("/api/redeem", createRateLimit(), async (ctx) => {
- const { token, solutions } = ctx.request.body;
- const challengeData = state.challengesList[token];
- delete state.challengesList[token];
-
- if (
- !challengeData ||
- challengeData.expires < Date.now() ||
- !(challengeData.ip === ctx.ip)
- ) {
- ctx.body = { success: false };
- return;
- }
-
- const isValid = challengeData.challenge.every(([salt, target]) => {
- const solution = solutions.find(([s, t]) => s === salt && t === target);
- return (
- solution &&
- crypto
- .createHash("sha256")
- .update(salt + solution[2])
- .digest("hex")
- .startsWith(target)
- );
- });
-
- if (!isValid) {
- ctx.body = { success: false };
- return;
- }
-
- const encodeBigInt = (num, base = 62) => {
- const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
- let res = "", n = BigInt(num);
- while (n > 0) (res = chars[Number(n % BigInt(base))] + res), (n /= BigInt(base));
- return res || "0";
- };
-
- const vertoken = crypto.randomBytes(15).toString("hex");
- const expires = Date.now() + 20 * 60 * 1000;
- const hash = encodeBigInt(Bun.hash(vertoken));
- const id = crypto.randomBytes(8).toString("hex");
-
- state.tokensList[`${id}:${hash}`] = expires;
-
- await fsp.writeFile(TOKENS_PATH, JSON.stringify(state.tokensList), "utf8");
-
- ctx.body = { success: true, token: `${id}:${vertoken}`, expires };
-});
-
-router.post("/api/validate", createRateLimit(), async (ctx) => {
- const encodeBigInt = (num, base = 62) => {
- const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
- let res = "", n = BigInt(num);
- while (n > 0) (res = chars[Number(n % BigInt(base))] + res), (n /= BigInt(base));
- return res || "0";
- };
-
- const { token } = ctx.request.body;
- const [id, vertoken] = token.split(":");
- const hash = encodeBigInt(Bun.hash(vertoken));
-
- if (state.tokensList[`${id}:${hash}`]) {
- delete state.tokensList[`${id}:${hash}`];
- ctx.body = { success: true };
- } else {
- ctx.body = { success: false };
- }
-})
-
-router.get("/", async (ctx) => {
- ctx.redirect("/docs");
-});
-
-setInterval(async () => {
- const now = Date.now();
- let tokensChanged = false;
-
- for (const k in state.challengesList) {
- if (state.challengesList[k].expires < now) delete state.challengesList[k];
- }
-
- for (const k in state.tokensList) {
- if (state.tokensList[k] < now) {
- delete state.tokensList[k];
- tokensChanged = true;
- }
- }
-
- if (tokensChanged) {
- await fsp.writeFile(TOKENS_PATH, JSON.stringify(state.tokensList), "utf8");
- }
-}, 1000);
-
-setInterval(function () {
- const now = Date.now();
-
- Object.entries(state.requestCounts).forEach(([ip, { counts, timestamp }]) => {
- if (now - timestamp > 30 * 60 * 1000) {
- delete state.requestCounts[ip];
- }
- });
-}, 1000);
-
-state.tokensList = JSON.parse(fs.readFileSync(TOKENS_PATH, "utf-8").toString()) || {};
-
-app.use(router.routes()).use(router.allowedMethods());
-
-app.listen(process.env.PORT || 3000, () => {
- console.log("Server running on http://localhost:3000");
- require("./build.js");
-});
diff --git a/docs/guide/demo.md b/docs/guide/demo.md
index 1848e2e..2fea82e 100644
--- a/docs/guide/demo.md
+++ b/docs/guide/demo.md
@@ -1,5 +1,7 @@
# Demo
+**Note:** Please note that the demo implementation is still experimental! Don't consider it to be representative of the stability or behavior of Cap overall.
+
### Normal button:
diff --git a/docs/guide/effectiveness.md b/docs/guide/effectiveness.md
index 71d2573..a2ec41f 100644
--- a/docs/guide/effectiveness.md
+++ b/docs/guide/effectiveness.md
@@ -16,7 +16,7 @@ Cap is fully compliant with GDPR and CCPA. It doesn't use cookies or track you i
## Security
-- IP addresses are only stored in-memory
+- IP addresses are not stored by default
- Requests are stored in-memory in make sure they are not tampered with (hashed tokens only, this is stored in .data/tokensList.json by default)
- Confirmation tokens reset after 20 minutes
- Challenges are only valid for 10 minutes
diff --git a/docs/guide/floating.md b/docs/guide/floating.md
index ea263e1..5f4a24b 100644
--- a/docs/guide/floating.md
+++ b/docs/guide/floating.md
@@ -3,39 +3,17 @@
Cap can automatically hide the captcha until the form is submitted. To use this feature, add the `data-cap-floating` attribute to the Cap widget with the query selector of the `cap-widget` element you want to use.
```html
-
+
```
You'll also need to import both the Cap library and the floating mode script from JSDelivr:
```html{2}
-
-
+
+
```
-::: details Other CDNs
-While it is recommended to use JSDelivr, you can also import Cap from other CDNs like unpkg or GitHub.
-
-##### unpkg
-
-```html
-
-```
-
-##### jsdelivr + GitHub
-
-```html
-
-```
-
-##### GitHub
-
-```html
-
-```
-:::
-
> [!NOTE]
> You'll not need to re-import the main Cap library if you've already done it.
diff --git a/docs/guide/index.md b/docs/guide/index.md
index c6f666a..f0918af 100644
--- a/docs/guide/index.md
+++ b/docs/guide/index.md
@@ -7,41 +7,67 @@ Cap is a library designed for safeguarding against spam and abuse by utilizing a
## Adding the Cap widget
-Cap is built to be straightforward and requires no API tokens for setup. Start by importing the Cap library:
+### Server-side
+Cap is fully self-hosted, so you'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute. This is easy since we've already pre-made a library to help you generate and validate challenges for you.
-```html
-
+Start by installing it using npm or bun:
+
+```
+npm i @cap.js/server
```
-::: details Other CDNs
-While it is recommended to use JSDelivr, you can also import Cap from other CDNs like unpkg or GitHub.
+Now, you'll need to change your server code to add the routes that Cap needs to work. Here's an example with Express.js:
-##### unpkg
+```js
+const express = require('express');
+const Cap = require('./index.js');
-```html
-
+const app = express();
+app.use(express.json());
+
+const cap = new Cap({
+ tokens_store_path: '.data/tokensList.json' // make sure this file has already been created and added to your gitignore
+});
+
+app.post('/api/challenge', (req, res) => {
+ res.json(cap.createChallenge({
+ challengeCount: 2,
+ challengeSize: 16,
+ challengeDifficulty: 3,
+ expiresMs: 300000
+ }));
+});
+
+app.post('/api/redeem', async (req, res) => {
+ const { token, solutions } = req.body;
+ if (!token || !solutions) {
+ return res.status(400).json({ success: false });
+ }
+ res.json(await cap.redeemChallenge({ token, solutions }));
+});
+
+app.listen(3000, () => {
+ console.log('Listening on port 3000');
+})
```
-##### jsdelivr + GitHub
+It should be easy to adapt this to work with other frameworks such as Hono.
+
+### Client-side
+
+Cap's widget is really easy to add. Start by adding it from a CDN:
```html
-
+
```
-##### GitHub
-
-```html
-
-```
-:::
-
Next, add the `` component to your HTML.
```html
-
+
```
-**Note:** You'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute.
+**Note:** You'll need to start a server with the Cap API running at the same URL as specified in the `data-api-endpoint` attribute. In the server-side example we provided, the `data-api-endpoint` attribute is set to `/api`. You can change this by replacing every `app.post('/api/...', ...)` to `app.post('//...', ...)`.
Then, in your JavaScript, listen for the `solve` event to capture the token when generated:
@@ -60,21 +86,6 @@ Alternatively, you can use `onsolve=""` directly within the widget or wrap the w
## Server-Side Validation
-Once you have the token, verify it server-side with a simple POST request:
+Once the token is generated and captured, you can use it later to validate the user's identity. This is typically done by sending the token to a server-side endpoint that uses the Cap API for validation.
-```http
-POST /validate
-Content-Type: application/json
-
-{
- "token": "xxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
-}
-```
-
-The server response will confirm the validity and immediately invalidate the token:
-
-```json
-{
- "success": true
-}
-```
\ No newline at end of file
+To do this, you'll only need to call `await cap.validateToken("...")`. This will return `{ success: Boolean }`. Note that the token will immediately be deleted after this. To prevent this, use `await cap.validateToken("...", { keepToken: true })`.
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
index 31248a9..27cbf82 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,7 +8,7 @@ hero:
tagline: "Cap is the open-source & modern ReCAPTCHA alternative designed for speed. Lightweight, powerful, private and secure."
actions:
- theme: brand
- text: Quickstart
+ text: Guide
link: /guide
- theme: alt
text: Demo
diff --git a/package.json b/package.json
index 2cbd6a3..3a936d6 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,6 @@
{
- "name": "capdotjs",
- "version": "0.0.4",
+ "name": "cap",
"author": "Tiago Rangel",
- "main": "lib/cap.min.js",
"description": "A modern, lightning-quick PoW captcha",
"keywords": [
"captcha",
@@ -14,12 +12,15 @@
"node"
],
"scripts": {
- "demo": "bun run demo.js",
"build": "bun run build.js",
"test": "echo \"Error: no test specified\" && exit 1",
+
"docs:dev": "cd docs && bun run docs:dev",
"docs:build": "cd docs && bun run docs:build",
- "docs:preview": "cd docs && bun run docs:preview"
+ "docs:preview": "cd docs && bun run docs:preview",
+
+ "widget:publish": "cd widget && sudo npm publish --access public",
+ "server:publish": "cd server && sudo npm publish --access public"
},
"dependencies": {
"@babel/core": "^7.25.8",
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..853a3e2
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1 @@
+# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap)
\ No newline at end of file
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..6c8c352
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,192 @@
+const crypto = require("crypto");
+const fs = require("fs/promises");
+const { EventEmitter } = require("events");
+
+const encodeBigInt = (num, base = 62) => {
+ const chars =
+ "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ let res = "",
+ n = BigInt(num);
+ while (n > 0) {
+ res = chars[Number(n % BigInt(base))] + res;
+ n /= BigInt(base);
+ }
+ return res || "0";
+};
+
+class Cap extends EventEmitter {
+ config = {
+ tokens_store_path: ".data/tokensList.json",
+ state: {
+ challengesList: {},
+ tokensList: {},
+ },
+ };
+
+ #cleanupPromise = null;
+
+ constructor(configObj) {
+ super();
+ this.config = { ...this.config, ...configObj };
+
+ this.#loadTokens().catch(() => {});
+
+ process.on("beforeExit", () => this.cleanup());
+
+ ["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => {
+ process.once(signal, () => {
+ this.cleanup()
+ .then(() => process.exit(0))
+ .catch(() => process.exit(1));
+ });
+ });
+ }
+
+ async #loadTokens() {
+ try {
+ const data = await fs.readFile(this.config.tokens_store_path, "utf-8");
+ this.config.state.tokensList = JSON.parse(data) || {};
+ this.#cleanExpiredTokens();
+ } catch (error) {
+ this.config.state.tokensList = {};
+ }
+ }
+
+ #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];
+ }
+ }
+
+ for (const k in this.config.state.tokensList) {
+ if (this.config.state.tokensList[k] < now) {
+ delete this.config.state.tokensList[k];
+ tokensChanged = true;
+ }
+ }
+
+ return tokensChanged;
+ }
+
+ async cleanup() {
+ if (this.#cleanupPromise) return this.#cleanupPromise;
+
+ this.#cleanupPromise = (async () => {
+ const tokensChanged = this.#cleanExpiredTokens();
+
+ if (tokensChanged) {
+ await fs.writeFile(
+ this.config.tokens_store_path,
+ JSON.stringify(this.config.state.tokensList),
+ "utf8"
+ );
+ }
+ })();
+
+ return this.#cleanupPromise;
+ }
+
+ createChallenge(conf) {
+ this.#cleanExpiredTokens();
+
+ const challenges = Array.from(
+ { length: conf?.challengeCount || 18 },
+ () => [
+ crypto
+ .randomBytes(Math.ceil((conf?.challengeSize || 32) / 2))
+ .toString("hex")
+ .slice(0, conf?.challengeSize || 32),
+ crypto
+ .randomBytes(Math.ceil((conf?.challengeDifficulty || 4) / 2))
+ .toString("hex")
+ .slice(0, conf?.challengeDifficulty || 4),
+ ]
+ );
+
+ const token = crypto.randomBytes(25).toString("hex");
+ const expires = Date.now() + (conf?.expiresMs || 600000);
+
+ if (conf?.store === false) {
+ return { challenge: challenges, expires };
+ }
+
+ this.config.state.challengesList[token] = {
+ challenge: challenges,
+ expires,
+ token,
+ };
+
+ return { challenge: challenges, token, expires };
+ }
+
+ async redeemChallenge({ token, solutions }) {
+ this.#cleanExpiredTokens();
+
+ const challengeData = this.config.state.challengesList[token];
+ if (!challengeData || challengeData.expires < Date.now()) {
+ delete this.config.state.challengesList[token];
+ return { success: false };
+ }
+
+ delete this.config.state.challengesList[token];
+
+ const isValid = challengeData.challenge.every(([salt, target]) => {
+ const solution = solutions.find(([s, t]) => s === salt && t === target);
+ return (
+ solution &&
+ crypto
+ .createHash("sha256")
+ .update(salt + solution[2])
+ .digest("hex")
+ .startsWith(target)
+ );
+ });
+
+ if (!isValid) return { success: false };
+
+ const vertoken = crypto.randomBytes(15).toString("hex");
+ const expires = Date.now() + 20 * 60 * 1000;
+ const hash = encodeBigInt(Bun.hash(vertoken));
+ const id = crypto.randomBytes(8).toString("hex");
+
+ this.config.state.tokensList[`${id}:${hash}`] = expires;
+
+ await fs.writeFile(
+ this.config.tokens_store_path,
+ JSON.stringify(this.config.state.tokensList),
+ "utf8"
+ );
+
+ return { success: true, token: `${id}:${vertoken}`, expires };
+ }
+
+ async validateToken(token, conf) {
+ this.#cleanExpiredTokens();
+
+ const [id, vertoken] = token.split(":");
+ const hash = encodeBigInt(Bun.hash(vertoken));
+ const key = `${id}:${hash}`;
+
+ if (this.config.state.tokensList[key]) {
+ if (conf.keepToken) {
+ delete this.config.state.tokensList[key];
+
+ await fs.writeFile(
+ this.config.tokens_store_path,
+ JSON.stringify(this.config.state.tokensList),
+ "utf8"
+ );
+ }
+
+ return { success: true };
+ }
+
+ return { success: false };
+ }
+}
+
+module.exports = Cap;
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..572273b
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@cap.js/server",
+ "version": "1.0.1",
+ "description": "Cap server-side helper",
+ "keywords": [
+ "captcha",
+ "pow",
+ "crypto",
+ "encryption",
+ "recaptcha",
+ "bun",
+ "js",
+ "node",
+ "hcaptcha"
+ ],
+ "homepage": "https://github.com/tiagorangel1/cap#readme",
+ "bugs": {
+ "url": "https://github.com/tiagorangel1/cap/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/tiagorangel1/cap.git"
+ },
+ "license": "AGPL-3.0",
+ "author": "Tiago Rangel",
+ "type": "commonjs",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "npm:publish": "sudo npm publish --access public"
+ },
+ "dependencies": {
+ "express": "^4.21.2"
+ }
+}
diff --git a/server/test.js b/server/test.js
new file mode 100644
index 0000000..5ec36f3
--- /dev/null
+++ b/server/test.js
@@ -0,0 +1,78 @@
+const express = require('express');
+const crypto = require('crypto');
+const fs = require('fs').promises;
+const Cap = require('./index.js');
+
+const app = express();
+app.use(express.json());
+
+const cap = new Cap({
+ tokens_store_path: '.data/tokensList.json'
+});
+
+function solveChallenge(salt, target) {
+ let nonce = 0;
+ while (true) {
+ if (crypto.createHash('sha256').update(salt + nonce.toString()).digest('hex').startsWith(target)) {
+ return nonce.toString();
+ }
+ nonce++;
+ }
+}
+
+app.post('/api/challenge', (req, res) => {
+ res.json(cap.createChallenge({
+ challengeCount: 2,
+ challengeSize: 16,
+ challengeDifficulty: 3,
+ expiresMs: 300000
+ }));
+});
+
+app.post('/api/redeem', async (req, res) => {
+ const { token, solutions } = req.body;
+ if (!token || !solutions) {
+ return res.status(400).json({ success: false });
+ }
+ res.json(await cap.redeemChallenge({ token, solutions }));
+});
+
+app.post('/api/validate', async (req, res) => {
+ const { token } = req.body;
+ if (!token) {
+ return res.status(400).json({ success: false });
+ }
+ res.json(await cap.validateToken({ token }));
+});
+
+async function test() {
+ const { challenge, token } = await fetch('http://localhost:3000/api/challenge', {
+ method: 'POST'
+ }).then(r => r.json());
+
+ const solutions = challenge.map(([salt, target]) => [salt, target, solveChallenge(salt, target)]);
+ const { success, token: verToken } = await fetch('http://localhost:3000/api/redeem', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, solutions })
+ }).then(r => r.json());
+
+ if (!success || !verToken) {
+ throw new Error('Failed to redeem challenge');
+ }
+
+ const validation = await fetch('http://localhost:3000/api/validate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: verToken })
+ }).then(r => r.json());
+
+ if (!validation.success) {
+ throw new Error('Failed to validate token');
+ }
+
+ console.log('All tests passed!');
+ process.exit(0);
+}
+
+app.listen(3000, test);
\ No newline at end of file
diff --git a/widget/README.md b/widget/README.md
new file mode 100644
index 0000000..853a3e2
--- /dev/null
+++ b/widget/README.md
@@ -0,0 +1 @@
+# Please see [github.com/tiagorangel1/cap](https://github.com/tiagorangel1/cap)
\ No newline at end of file
diff --git a/lib/cap-floating.min.js b/widget/cap-floating.min.js
similarity index 100%
rename from lib/cap-floating.min.js
rename to widget/cap-floating.min.js
diff --git a/lib/cap.min.js b/widget/cap.min.js
similarity index 100%
rename from lib/cap.min.js
rename to widget/cap.min.js
diff --git a/widget/package.json b/widget/package.json
new file mode 100644
index 0000000..85736fb
--- /dev/null
+++ b/widget/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@cap.js/widget",
+ "version": "0.0.2",
+ "description": "Cap widget",
+ "keywords": [
+ "captcha",
+ "pow",
+ "crypto",
+ "encryption",
+ "recaptcha",
+ "bun",
+ "js",
+ "node",
+ "hcaptcha"
+ ],
+ "homepage": "https://github.com/tiagorangel1/cap#readme",
+ "bugs": {
+ "url": "https://github.com/tiagorangel1/cap/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/tiagorangel1/cap.git"
+ },
+ "license": "AGPL-3.0",
+ "author": "Tiago Rangel",
+ "type": "commonjs",
+ "main": "cap.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "npm:publish": "sudo npm publish --access public"
+ }
+}
diff --git a/src/cap-floating.js b/widget/src/cap-floating.js
similarity index 100%
rename from src/cap-floating.js
rename to widget/src/cap-floating.js
diff --git a/src/cap.js b/widget/src/cap.js
similarity index 100%
rename from src/cap.js
rename to widget/src/cap.js
diff --git a/lib/wasm-hashes.min.js b/widget/wasm-hashes.min.js
similarity index 100%
rename from lib/wasm-hashes.min.js
rename to widget/wasm-hashes.min.js