diff --git a/.gitignore b/.gitignore
index 5459546..5510722 100644
--- a/.gitignore
+++ b/.gitignore
@@ -93,7 +93,4 @@ node_modules
debug/
target/
**/*.rs.bk
-*.pdb
-
-
-standalone-v2-beta/**
+*.pdb
\ No newline at end of file
diff --git a/assets/standalone_v1/Dockerfile b/assets/standalone_v1/Dockerfile
new file mode 100644
index 0000000..f72e975
--- /dev/null
+++ b/assets/standalone_v1/Dockerfile
@@ -0,0 +1,29 @@
+FROM oven/bun:1 AS base
+WORKDIR /usr/src/app
+
+FROM base AS install
+RUN mkdir -p /temp/dev
+COPY package.json bun.lock /temp/dev/
+RUN cd /temp/dev && bun install
+
+FROM base AS prod
+RUN mkdir -p /temp/prod
+COPY package.json bun.lock /temp/prod/
+RUN cd /temp/prod && bun install
+
+FROM base AS prerelease
+COPY --from=install /temp/dev/node_modules node_modules
+COPY . .
+
+FROM base AS release
+COPY --from=prod /temp/prod/node_modules node_modules
+COPY --from=prerelease /usr/src/app .
+WORKDIR /usr/src/app
+
+RUN mkdir -p /usr/src/app/.data && chown -R bun:bun /usr/src/app/.data
+
+EXPOSE 3000/tcp
+
+USER bun
+
+ENTRYPOINT [ "bun", "run", "./src/index.js" ]
\ No newline at end of file
diff --git a/assets/standalone_v1/bun.lock b/assets/standalone_v1/bun.lock
new file mode 100644
index 0000000..a08968c
--- /dev/null
+++ b/assets/standalone_v1/bun.lock
@@ -0,0 +1,79 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "app",
+ "dependencies": {
+ "@cap.js/server": "^1.0.13",
+ "@elysiajs/cors": "^1.3.3",
+ "@elysiajs/static": "^1.3.0",
+ "elysia": "^1.3.1",
+ "elysia-rate-limit": "^4.3.0",
+ },
+ "devDependencies": {
+ "bun-types": "latest",
+ },
+ },
+ },
+ "packages": {
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+ "@cap.js/server": ["@cap.js/server@1.0.13", "", { "dependencies": { "@types/node": "^22.14.1", "typescript": "^5.8.3" } }, "sha512-JcEd/XdqheDSaoBdW0YK4o2lpxlFiCipcUcvgr8ZHscprYVXOTpk7fpagiXqJAJSr68dwyaYk+h8WBv42R4QTw=="],
+
+ "@elysiajs/cors": ["@elysiajs/cors@1.3.3", "", { "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-mYIU6PyMM6xIJuj7d27Vt0/wuzVKIEnFPjcvlkyd7t/m9xspAG37cwNjFxVOnyvY43oOd2I/oW2DB85utXpA2Q=="],
+
+ "@elysiajs/static": ["@elysiajs/static@1.3.0", "", { "dependencies": { "node-cache": "^5.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-7mWlj2U/AZvH27IfRKqpUjDP1W9ZRldF9NmdnatFEtx0AOy7YYgyk0rt5hXrH6wPcR//2gO2Qy+k5rwswpEhJA=="],
+
+ "@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="],
+
+ "@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.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="],
+
+ "bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
+
+ "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
+
+ "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
+
+ "debug": ["debug@4.3.4", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ=="],
+
+ "elysia": ["elysia@1.3.1", "", { "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-En41P6cDHcHtQ0nvfsn9ayB+8ahQJqG1nzvPX8FVZjOriFK/RtZPQBtXMfZDq/AsVIk7JFZGFEtAVEmztNJVhQ=="],
+
+ "elysia-rate-limit": ["elysia-rate-limit@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "5.2.0", "debug": "4.3.4" }, "peerDependencies": { "elysia": ">= 1.0.0" } }, "sha512-TCquBhMqUK+Wce+2RoaIk6byhJ6KMtO316PYp4Mc1GKkBH7qKPQH7Y5qVLauBkzx69wBYgFwMrtuz8yToG3JtQ=="],
+
+ "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@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
+
+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
+ "ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
+
+ "node-cache": ["node-cache@5.1.2", "", { "dependencies": { "clone": "2.x" } }, "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg=="],
+
+ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
+
+ "peek-readable": ["peek-readable@7.0.0", "", {}, "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ=="],
+
+ "strtok3": ["strtok3@10.2.2", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^7.0.0" } }, "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg=="],
+
+ "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=="],
+
+ "@tokenizer/inflate/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
+
+ "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ }
+}
diff --git a/assets/standalone_v1/package.json b/assets/standalone_v1/package.json
new file mode 100644
index 0000000..2588bb1
--- /dev/null
+++ b/assets/standalone_v1/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "cap-standalone",
+ "version": "1.0.17",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "dev": "bun run --watch src/index.js",
+ "docker:publish": "docker buildx build --platform linux/amd64,linux/arm64 -t tiago2/cap:latest . --push"
+ },
+ "keywords": [
+ "security",
+ "detection",
+ "prevention",
+ "defense",
+ "protection",
+ "anti-abuse",
+ "anti-automation",
+ "anti-bot",
+ "anti-ddos",
+ "anti-dos",
+ "anti-exploitation",
+ "anti-spam",
+ "anti-scraping",
+ "attack-mitigation",
+ "protocol",
+ "client-server",
+ "computational-puzzle",
+ "complexity",
+ "puzzle",
+ "crypto",
+ "cryptographic",
+ "algorithm",
+ "cybersecurity",
+ "ddos",
+ "hashcash",
+ "hcaptcha",
+ "captcha-alternative",
+ "verification",
+ "invisible",
+ "pow",
+ "proof-of-work",
+ "recaptcha",
+ "spam",
+ "bots",
+ "filtering",
+ "turing-test"
+ ],
+ "dependencies": {
+ "@cap.js/server": "^1.0.13",
+ "@elysiajs/cors": "^1.3.3",
+ "@elysiajs/static": "^1.3.0",
+ "elysia": "^1.3.1",
+ "elysia-rate-limit": "^4.3.0"
+ },
+ "devDependencies": {
+ "bun-types": "latest"
+ },
+ "module": "src/index.js"
+}
diff --git a/assets/standalone_v1/public/index.html b/assets/standalone_v1/public/index.html
new file mode 100644
index 0000000..223cd4f
--- /dev/null
+++ b/assets/standalone_v1/public/index.html
@@ -0,0 +1,326 @@
+
+
+
+
+
+ Cap
+
+
+
+
+
+
+
+
Your keys
+
+
+
+
+
+
+
diff --git a/standalone/public/index.js b/assets/standalone_v1/public/index.js
similarity index 100%
rename from standalone/public/index.js
rename to assets/standalone_v1/public/index.js
diff --git a/standalone/public/lock.html b/assets/standalone_v1/public/lock.html
similarity index 100%
rename from standalone/public/lock.html
rename to assets/standalone_v1/public/lock.html
diff --git a/standalone/public/logo.png b/assets/standalone_v1/public/logo.png
similarity index 100%
rename from standalone/public/logo.png
rename to assets/standalone_v1/public/logo.png
diff --git a/standalone/public/test.html b/assets/standalone_v1/public/test.html
similarity index 100%
rename from standalone/public/test.html
rename to assets/standalone_v1/public/test.html
diff --git a/assets/standalone_v1/src/index.js b/assets/standalone_v1/src/index.js
new file mode 100644
index 0000000..67f43b8
--- /dev/null
+++ b/assets/standalone_v1/src/index.js
@@ -0,0 +1,449 @@
+import { Elysia, file, NotFoundError } from "elysia";
+import { staticPlugin } from "@elysiajs/static";
+import { timingSafeEqual } from "node:crypto";
+import { cors } from "@elysiajs/cors";
+import { rateLimit } from "elysia-rate-limit";
+import Cap from "@cap.js/server";
+import crypto from "crypto";
+import fs from "fs/promises";
+import path from "path";
+
+const ADMIN_KEY = process.env.ADMIN_KEY?.trim();
+const dataDir = "./.data";
+const keysStorePath = path.join(dataDir, "keys.json");
+
+function generateAdminKeyHint() {
+ return `\nWe've generated this one for you to use: \n\n${crypto
+ .randomBytes(30)
+ .toString("hex")}\n\nThen, restart this process.\n\n`;
+}
+
+if (!ADMIN_KEY) {
+ console.error(
+ `\nNo admin key has been set. Make sure to set it using the \n\`ADMIN_KEY\` environment variable.${generateAdminKeyHint()}`
+ );
+ process.exit(1);
+}
+
+if (ADMIN_KEY === "your_secret_key") {
+ console.error(
+ `\nDon't leave the admin key as default! Make sure to set it \nusing the \`ADMIN_KEY\` environment variable.${generateAdminKeyHint()}`
+ );
+ process.exit(1);
+}
+
+if (ADMIN_KEY.length < 20) {
+ console.warn(
+ `\n${"*".repeat(
+ 60
+ )}\n\nThe admin key you're using is quite short. We recommend \nusing a longer one.${generateAdminKeyHint()}${"*".repeat(
+ 60
+ )}\n`
+ );
+}
+
+let keys = [];
+const capInstances = new Map();
+
+const updateCache = async () => {
+ let cacheConfig = {};
+
+ try {
+ cacheConfig = JSON.parse(
+ await fs.readFile(path.join(dataDir, "assets-cache.json"), "utf-8")
+ );
+ } catch {}
+
+ const lastUpdate = cacheConfig["lastUpdate"] || 0;
+ const currentTime = Date.now();
+ const updateInterval = 1000 * 60 * 60 * 24; // 1 day
+
+ if (!(currentTime - lastUpdate > updateInterval)) return;
+
+ const CACHE_HOST = process.env.CACHE_HOST || "https://cdn.jsdelivr.net";
+ if (CACHE_HOST === "disable") return;
+
+ const WIDGET_VERSION = process.env.WIDGET_VERSION || "latest";
+ const WASM_VERSION = process.env.WASM_VERSION || "latest";
+
+ try {
+ const [widgetSource, floatingSource, wasmSource, wasmLoaderSource] =
+ await Promise.all([
+ fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}`).then((r) => r.text()),
+ fetch(`${CACHE_HOST}/npm/@cap.js/widget@${WIDGET_VERSION}/cap-floating.min.js`).then(
+ (r) => r.text()
+ ),
+ fetch(`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm_bg.wasm`).then(
+ (r) => r.arrayBuffer()
+ ),
+ fetch(`${CACHE_HOST}/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm.min.js`).then(
+ (r) => r.text()
+ ),
+ ]);
+
+ cacheConfig["lastUpdate"] = currentTime;
+ await fs.writeFile(
+ path.join(dataDir, "assets-cache.json"),
+ JSON.stringify(cacheConfig)
+ );
+
+ await fs.writeFile(path.join(dataDir, "assets-widget.js"), widgetSource);
+ await fs.writeFile(
+ path.join(dataDir, "assets-floating.js"),
+ floatingSource
+ );
+ await fs.writeFile(
+ path.join(dataDir, "assets-cap_wasm_bg.wasm"),
+ Buffer.from(wasmSource)
+ );
+ await fs.writeFile(
+ path.join(dataDir, "assets-cap_wasm.js"),
+ wasmLoaderSource
+ );
+
+ console.log("[asset server] updated assets cache");
+ } catch (e) {
+ console.error(
+ "[asset server] error updating assets cache, trying to load them might fail:",
+ e
+ );
+ }
+};
+
+const init = async () => {
+ try {
+ await fs.mkdir(dataDir, { recursive: true });
+
+ const data = await fs.readFile(keysStorePath, "utf-8");
+ keys = JSON.parse(data);
+ capInstances.clear();
+
+ keys.forEach((key) => {
+ capInstances.set(
+ key.publicKey,
+ new Cap({
+ tokens_store_path: path.join(dataDir, `tokens-${key.publicKey}.json`),
+ })
+ );
+ });
+ } catch {
+ await fs.writeFile(keysStorePath, "[]");
+ keys = [];
+ capInstances.clear();
+ }
+
+ await updateCache();
+};
+
+const saveKeys = async () => {
+ await fs.writeFile(keysStorePath, JSON.stringify(keys));
+};
+
+const getCapInstance = (publicKey) => {
+ if (!capInstances.has(publicKey) || !capInstances.get(publicKey)) {
+ const keyData = keys.find((k) => k.publicKey === publicKey);
+ if (keyData) {
+ capInstances.set(
+ publicKey,
+ new Cap({
+ tokens_store_path: path.join(dataDir, `tokens-${publicKey}.json`),
+ })
+ );
+ }
+ }
+ return capInstances.get(publicKey);
+};
+
+const auth = new Elysia({
+ prefix: "/internal/auth",
+})
+ .use(
+ rateLimit({
+ scoping: "scoped",
+ count: 10,
+ duration: 15000,
+ })
+ )
+ .post("/", async ({ body, cookie, set }) => {
+ try {
+ if (
+ !body?.password ||
+ !timingSafeEqual(Buffer.from(body.password), Buffer.from(ADMIN_KEY))
+ ) {
+ set.status = 401;
+ return { success: false };
+ }
+ } catch {
+ set.status = 500;
+ return { success: false };
+ }
+
+ cookie["cap-admin-key"].set({
+ value: await Bun.password.hash(body.password),
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 86400 * 7,
+ });
+
+ return { success: true };
+ })
+ .get("/logout", ({ cookie, redirect }) => {
+ cookie["cap-admin-key"].remove();
+ return redirect("/");
+ });
+
+const internal = new Elysia({ prefix: "/internal" })
+ .onBeforeHandle(async ({ cookie, set }) => {
+ const authCookie = cookie["cap-admin-key"]?.value;
+ if (!authCookie || !(await Bun.password.verify(ADMIN_KEY, authCookie))) {
+ set.status = 401;
+ return {
+ success: false,
+ message: "Unauthorized",
+ };
+ }
+ })
+ .use(
+ rateLimit({
+ scoping: "scoped",
+ number: 60,
+ duration: 10000,
+ })
+ )
+ .post(
+ "/createKey",
+ async ({
+ body: {
+ keyName,
+ challengesCount = 18,
+ challengeSize = 32,
+ challengeDifficulty = 4,
+ expiresMs = 600000,
+ },
+ set,
+ }) => {
+ if (!keyName?.trim()) {
+ set.status = 400;
+ return { success: false, message: "Key name is required" };
+ }
+
+ const publicKey = crypto.randomBytes(6).toString("hex");
+ const privateKey = crypto.randomBytes(25).toString("hex");
+ const privateKeyHash = await Bun.password.hash(privateKey);
+
+ const newKey = {
+ name: keyName,
+ publicKey,
+ privateKey: privateKeyHash,
+ challengesCount: Number(challengesCount),
+ challengeSize: Number(challengeSize),
+ challengeDifficulty: Number(challengeDifficulty),
+ expiresMs: Number(expiresMs),
+ };
+
+ keys.push(newKey);
+ await saveKeys();
+
+ getCapInstance(publicKey);
+
+ set.status = 201;
+ return {
+ success: true,
+ publicKey,
+ privateKey,
+ };
+ }
+ )
+ .post(
+ "/editKey",
+ async ({
+ body: {
+ publicKey,
+ keyName,
+ challengesCount,
+ challengeSize,
+ challengeDifficulty,
+ expiresMs,
+ },
+ set,
+ }) => {
+ const keyIndex = keys.findIndex((key) => key.publicKey === publicKey);
+
+ if (keyIndex === -1) {
+ set.status = 404;
+ return { success: false, message: "Key not found" };
+ }
+
+ if (!keyName.trim()) {
+ set.status = 400;
+ return { success: false, message: "Key name is required" };
+ }
+
+ const existingKey = keys[keyIndex];
+ keys[keyIndex] = {
+ ...existingKey,
+ name: keyName,
+ challengesCount: Number(challengesCount),
+ challengeSize: Number(challengeSize),
+ challengeDifficulty: Number(challengeDifficulty),
+ expiresMs: Number(expiresMs),
+ };
+
+ await saveKeys();
+
+ return { success: true };
+ }
+ )
+ .get("/listKeys", async () => {
+ return {
+ keys: keys.map(({ privateKey, ...rest }) => rest),
+ };
+ })
+ .post("/deleteKey", async ({ body: { publicKey }, set }) => {
+ const initialLength = keys.length;
+ keys = keys.filter((key) => key.publicKey !== publicKey);
+
+ if (keys.length === initialLength) {
+ set.status = 404;
+ return { success: false, message: "Key not found" };
+ }
+
+ await saveKeys();
+
+ const tokenFilePath = path.join(dataDir, `tokens-${publicKey}.json`);
+ try {
+ await fs.unlink(tokenFilePath);
+ } catch (error) {
+ if (error.code !== "ENOENT") {
+ console.warn(
+ `Could not delete token file ${tokenFilePath}:`,
+ error.message
+ );
+ }
+ }
+
+ capInstances.delete(publicKey);
+
+ set.status = 200;
+ return { success: true };
+ })
+ .post("/rotateKey", async ({ body: { publicKey }, set }) => {
+ const keyIndex = keys.findIndex((key) => key.publicKey === publicKey);
+
+ if (keyIndex === -1) {
+ set.status = 404;
+ return { success: false, message: "Key not found" };
+ }
+
+ const newPrivateKey = crypto.randomBytes(25).toString("hex");
+ const newPrivateKeyHash = await Bun.password.hash(newPrivateKey);
+
+ keys[keyIndex].privateKey = newPrivateKeyHash;
+
+ await saveKeys();
+
+ return { success: true, privateKey: newPrivateKey };
+ });
+
+const api = new Elysia({ prefix: "/:key" })
+ .use(
+ cors({
+ origin: process.env.CORS_ORIGIN || true,
+ })
+ )
+ .use(
+ rateLimit({
+ scoping: "scoped",
+ number: 80,
+ duration: 1000,
+ })
+ )
+ .derive(({ params }) => {
+ const keyData = keys.find((k) => k.publicKey === params.key);
+ if (!keyData) {
+ throw new NotFoundError("Key not found");
+ }
+ return { keyData, capInstance: getCapInstance(params.key) };
+ })
+ .post("/api/challenge", async ({ keyData, capInstance }) => {
+ return await capInstance.createChallenge({
+ challengeSize: keyData.challengeSize,
+ challengeDifficulty: keyData.challengeDifficulty,
+ challengeCount: keyData.challengesCount,
+ expiresMs: keyData.expiresMs,
+ });
+ })
+ .post("/api/redeem", async ({ body, set, capInstance }) => {
+ const { token, solutions } = body;
+
+ if (!token || !solutions) {
+ set.status = 400;
+ return { success: false, message: "Missing solutions and/or token" };
+ }
+
+ return await capInstance.redeemChallenge({ token, solutions });
+ })
+ .post("/siteverify", async ({ body, set, keyData, capInstance }) => {
+ const { secret, response } = body;
+
+ if (!secret || !response) {
+ set.status = 400;
+ return { success: false, message: "Missing secret or/and response" };
+ }
+
+ if (!(await Bun.password.verify(secret, keyData.privateKey))) {
+ set.status = 400;
+ return { success: false, message: "Invalid secret" };
+ }
+
+ return await capInstance.validateToken(response, {
+ keepToken: false,
+ });
+ });
+
+const assetsServer = new Elysia({ prefix: "/assets" })
+ .use(
+ cors({
+ origin: process.env.CORS_ORIGIN || true,
+ methods: ["GET"],
+ })
+ )
+ .onBeforeHandle(({ set }) => {
+ set.headers["Cache-Control"] = "max-age=31536000, immutable";
+ })
+ .get("/widget.js", ({ set }) => {
+ set.headers["Content-Type"] = "text/javascript";
+ return file(path.join(dataDir, "assets-widget.js"));
+ })
+ .get("/floating.js", ({ set }) => {
+ set.headers["Content-Type"] = "text/javascript";
+ return file(path.join(dataDir, "assets-floating.js"));
+ })
+ .get("/cap_wasm_bg.wasm", ({ set }) => {
+ set.headers["Content-Type"] = "application/wasm";
+ return file(path.join(dataDir, "assets-cap_wasm_bg.wasm"));
+ })
+ .get("/cap_wasm.js", ({ set }) => {
+ set.headers["Content-Type"] = "text/javascript";
+ return file(path.join(dataDir, "assets-cap_wasm.js"));
+ });
+
+new Elysia()
+ .use(staticPlugin())
+ .use(auth)
+ .use(internal)
+ .use(api)
+ .use(assetsServer)
+ .get("/", async ({ cookie }) => {
+ const authCookie = cookie["cap-admin-key"]?.value;
+ const isAuthed =
+ authCookie && (await Bun.password.verify(ADMIN_KEY, authCookie));
+
+ return file(isAuthed ? "./public/index.html" : "./public/lock.html");
+ })
+ .listen(3000);
+
+console.log(`Cap standalone running on http://localhost:3000`);
+init();
diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs
index 2b200e5..79ad0fc 100644
--- a/docs/.vitepress/config.mjs
+++ b/docs/.vitepress/config.mjs
@@ -152,15 +152,6 @@ export default withMermaid({
{ text: "Quickstart", link: "/guide/index.md" },
{ text: "Feature comparison", link: "/guide/alternatives.md" },
{ text: "Community libraries", link: "/guide/community.md" },
- {
- text: "Modes",
- collapsed: false,
- items: [
- { text: "Standalone mode", link: "/guide/standalone.md" },
- { text: "Floating mode", link: "/guide/floating.md" },
- { text: "Invisible mode", link: "/guide/invisible.md" },
- ],
- },
{
text: "Libraries",
collapsed: false,
@@ -171,6 +162,25 @@ export default withMermaid({
{ text: "CLI", link: "/guide/cli.md" },
],
},
+ {
+ text: "Standalone",
+ collapsed: true,
+ items: [
+ { text: "About", link: "/guide/standalone/index.md" },
+ { text: "Installation", link: "/guide/standalone/installation.md" },
+ { text: "Usage", link: "/guide/standalone/usage.md" },
+ { text: "API", link: "/guide/standalone/api.md" },
+ { text: "Options", link: "/guide/standalone/options.md" },
+ ],
+ },
+ {
+ text: "Modes",
+ collapsed: true,
+ items: [
+ { text: "Floating mode", link: "/guide/floating.md" },
+ { text: "Invisible mode", link: "/guide/invisible.md" },
+ ],
+ },
{
text: "Checkpoint",
collapsed: true,
diff --git a/docs/guide/index.md b/docs/guide/index.md
index 8071d4e..d7feb9a 100644
--- a/docs/guide/index.md
+++ b/docs/guide/index.md
@@ -23,6 +23,8 @@ Cap consists mainly of the **widget** (can be used invisibly) and **server** (yo
This guide details how to use the usual setup. You can find guides on using the [Standalone server](./standalone.md), [M2M solver](./solver.md), and [checkpoint middleware](./middleware/index.md) in their respective sections.
+We highly recommend checking out the [Standalone mode](./standalone/index.md) as it's complete, fast, simple to set up, and works with any language that can make HTTP requests. It also includes a dashboard, API key support, and more.
+
## Client-side
Start by adding importing the Cap widget library from a CDN:
@@ -243,9 +245,11 @@ app.listen(3000, () => {
:::
-::: warning
-These example codes don't have ratelimiting for simplicity. Make sure to add proper ratelimiting to your endpoints to prevent abuse.
-:::
+Note that these are the simplest examples possible. You should adapt them to your needs. Usually, you'll want to:
+
+- Use an actual database such as SQLite or Redis for storing tokens and challenges. This is done for you in the [Standalone server](./standalone/index.md)
+
+- Add some kind of actual ratelimiting. This is also done for you in the [Standalone server](./standalone/index.md)
### Token validation
diff --git a/docs/guide/middleware/demo.png b/docs/guide/middleware/demo.png
deleted file mode 100644
index 531a40b..0000000
Binary files a/docs/guide/middleware/demo.png and /dev/null differ
diff --git a/docs/guide/middleware/index.md b/docs/guide/middleware/index.md
index fabb8e2..b1b592d 100644
--- a/docs/guide/middleware/index.md
+++ b/docs/guide/middleware/index.md
@@ -4,8 +4,4 @@ Cap's Checkpoints (previously known as middlewares) allow you to replicate Cloud
They're extremely simple to set up and use, with you only having to add a few lines of code to your server, unlike moving your entire website over to Cloudflare. Note that this is kind of a nuclear solution, as it _will_ also impact good bots such as search engine crawlers.
-
-
-## How do Checkpoints work?
-
-Checkpoints work by intercepting requests to your server and checking if the user has a valid `__cap_clearance` cookie. If the cookie is present and valid, the request is allowed to proceed. If not, the user is redirected to a challenge page.
\ No newline at end of file
+
diff --git a/docs/guide/standalone.md b/docs/guide/standalone.md
index 9510c5c..8bb5689 100644
--- a/docs/guide/standalone.md
+++ b/docs/guide/standalone.md
@@ -1,5 +1,9 @@
# Standalone server
+> [!WARNING]
+> **You are viewing the docs for a legacy version of Cap's Standalone server.** It is no longer actively maintained and will only receive critical security updates. While v1 is still tagged and pullable, certain commands below might not work anymore.
+> [New docs](standalone/index.md)
+
## Installation
Cap Standalone is a self-hosted version of Cap's backend that allows you to spin up a server to validate and create challenges so you can use it with languages other than JS.
diff --git a/docs/guide/standalone/api.md b/docs/guide/standalone/api.md
new file mode 100644
index 0000000..1342488
--- /dev/null
+++ b/docs/guide/standalone/api.md
@@ -0,0 +1,13 @@
+# API
+
+Standalone mode offers a simple API for creating, viewing, and managing keys and sessions. First, login to your Cap Standalone dashboard and get an API key from **Settings** → **API Keys**. Give it a name and tap "Create".
+
+Once your key is created, save it somewhere safe, as you won't be able to see it again.
+
+Now, you can use this key to make API requests to your Standalone server. For each request you make, you'll need to include the `Authorization` header with your API key, like this:
+
+```http
+Authorization: Bot YOUR_API_KEY
+```
+
+You can see a list of all available API endpoints and their required bodies by going to `http://localhost:3000/swagger`
diff --git a/docs/guide/standalone/index.md b/docs/guide/standalone/index.md
new file mode 100644
index 0000000..5ac7ffb
--- /dev/null
+++ b/docs/guide/standalone/index.md
@@ -0,0 +1,9 @@
+# Cap Standalone
+
+Cap Standalone is a self-hosted version of Cap's backend that allows you to spin up a server to validate and create challenges so you can use it with languages other than JS.
+
+It's simple yet amazingly powerful, allowing you to use Cap in any language that can make HTTP requests. It's mostly compatible with reCAPTCHA and hCaptcha's siteverify enpoints, so you can use it as a drop-in replacement for them.
+
+It also offers API key support, a built-in assets server, a dashboard with statistics, and more. To get started, follow [the instructions to install it on your server](installation.md).
+
+
diff --git a/docs/guide/standalone/installation.md b/docs/guide/standalone/installation.md
new file mode 100644
index 0000000..19e4d30
--- /dev/null
+++ b/docs/guide/standalone/installation.md
@@ -0,0 +1,32 @@
+# Installation
+
+### Requirements
+
+You'll need to have [Docker Engine 20.10 or higher](https://docs.docker.com/get-docker/) installed on your server. Both `x86_64` (amd64) and `arm64` architectures are supported.
+
+---
+
+Run the following command to pull the Cap Standalone Docker image from Docker Hub:
+
+```bash
+docker pull tiago2/cap:latest
+```
+
+Then, to run the server, use the following command:
+
+```bash
+docker run -d \
+ -p 3000:3000 \
+ -v cap-data:/usr/src/app/.data \
+ -e ADMIN_KEY=your_secret_password \
+ --name cap-standalone \
+ tiago2/cap:latest
+```
+
+Make sure to replace `your_secret_password` with a strong password, as anyone with it will be able to log into the dashboard and create keys. It'll need to be at least 30 characters long.
+
+Then, you can access the dashboard at `http://localhost:3000`, log in, and create a key. You'll get a site key and a secret key which you'll be able to use on your widget.
+
+You'll also need to make the server publicly accessible from the internet, as the widget needs to be able to reach it.
+
+Done! Now, read the [usage guide](/guide/standalone/usage.md) to learn how to use it.
\ No newline at end of file
diff --git a/docs/guide/standalone/options.md b/docs/guide/standalone/options.md
new file mode 100644
index 0000000..be9baaa
--- /dev/null
+++ b/docs/guide/standalone/options.md
@@ -0,0 +1,38 @@
+# Options
+
+## CORS
+
+You can change the default CORS settings for redeeming and generating challenges by setting the `CORS_ORIGIN` environment variable when running the server. This defaults to `*`, which allows all origins.
+
+## Asset server
+
+The asset server is disabled by default. You can enable it by setting the `ENABLE_ASSETS_SERVER` environment variable to `true`. This will serve the assets from the `/assets` endpoint.
+
+Then, make sure to set `WIDGET_VERSION` and `WASM_VERSION` to the correct version of the widget and WASM files you want to serve. This defaults to `latest`, which will serve the latest version of the widget and WASM files, although these are not recommended in production as they might serve breaking changes.
+
+Your assets will be served from the following paths:
+
+- `/assets/widget.js`
+- `/assets/floating.js`
+- `/assets/cap_wasm_bg.wasm`
+- `/assets/cap_wasm.js`
+
+You can use these in your app by setting the widget's script source to the appropriate path, like this:
+
+```html
+
+```
+
+For the floating mode, use:
+
+```html
+
+```
+
+And by setting `window.CAP_CUSTOM_WASM_URL` to the path of the `cap_wasm_bg.wasm` file, like this:
+
+```js
+window.CAP_CUSTOM_WASM_URL = "https:///assets/cap_wasm_bg.wasm";
+```
+
+By default, these fetch from `process.env.CACHE_HOST` (which defaults to `https://cdn.jsdelivr.net`). You can change this by setting the `CACHE_HOST` env variable when running the server.
diff --git a/docs/guide/standalone/usage.md b/docs/guide/standalone/usage.md
new file mode 100644
index 0000000..b00f6d0
--- /dev/null
+++ b/docs/guide/standalone/usage.md
@@ -0,0 +1,73 @@
+# Usage
+
+First, make sure Cap Standalone is installed and running by following [the installation guide](installation.md).
+
+Then, you can get to using Cap Standalone:
+
+### Client-side
+
+Let's configure your widget to use your self-hosted Cap Standalone server. To do this, set the widget's API endpoint option to:
+
+```
+https:////
+```
+
+Make sure to replace:
+
+- ``: The actual URL where your Cap Standalone instance is running. This URL must be publicly accessible from the internet.
+- ``: Your site key from this dashboard.
+
+Example:
+
+```html
+
+```
+
+### Server-side
+
+After a user completes the CAPTCHA on your site, your backend needs to verify their token using this server's API.
+
+You can do this by sending a `POST` request from your server to the following endpoint:
+
+```
+https:////siteverify
+```
+
+Your request needs to include the following data:
+
+- `secret`: Your key secret from this dashboard. This is **not** the admin key, but rather your site key's secret.
+
+- `response`: The CAPTCHA token generated by the widget on the client-side
+
+Example using `curl`:
+
+```bash
+curl "https:////siteverify" \
+ -X POST \
+ -H "Content-Type: application/json" \
+ -d '{ "secret": "", "response": "" }'
+```
+
+The response should look like this:
+
+```json
+{
+ "success": true
+}
+```
+
+Or, if the captcha token is invalid or expired, it will return:
+
+```json
+{
+ "success": false
+}
+```
+
+If `success` is true, you can proceed with your app logic.
+
+### Client-side library storage
+
+To learn more about using the client-side library, check out the [guide on it](options.md#asset-server).
diff --git a/docs/package.json b/docs/package.json
index 8037aee..4a00ade 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -1,7 +1,7 @@
{
"scripts": {
- "start": "vitepress dev",
- "dev": "vitepress dev",
+ "start": "bunx --bun vitepress dev",
+ "dev": "bunx --bun vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview"
},
diff --git a/docs/public/checkpoints_screenshot.png b/docs/public/checkpoints_screenshot.png
new file mode 100644
index 0000000..10e080c
Binary files /dev/null and b/docs/public/checkpoints_screenshot.png differ
diff --git a/docs/public/standalone_screenshot.png b/docs/public/standalone_screenshot.png
new file mode 100644
index 0000000..16b4b1e
Binary files /dev/null and b/docs/public/standalone_screenshot.png differ
diff --git a/standalone/.env.example b/standalone/.env.example
new file mode 100644
index 0000000..cb24536
--- /dev/null
+++ b/standalone/.env.example
@@ -0,0 +1 @@
+ADMIN_KEY=
\ No newline at end of file
diff --git a/standalone/bun.lock b/standalone/bun.lock
index a08968c..c9f44d0 100644
--- a/standalone/bun.lock
+++ b/standalone/bun.lock
@@ -7,6 +7,7 @@
"@cap.js/server": "^1.0.13",
"@elysiajs/cors": "^1.3.3",
"@elysiajs/static": "^1.3.0",
+ "@elysiajs/swagger": "^1.3.0",
"elysia": "^1.3.1",
"elysia-rate-limit": "^4.3.0",
},
@@ -24,6 +25,14 @@
"@elysiajs/static": ["@elysiajs/static@1.3.0", "", { "dependencies": { "node-cache": "^5.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-7mWlj2U/AZvH27IfRKqpUjDP1W9ZRldF9NmdnatFEtx0AOy7YYgyk0rt5hXrH6wPcR//2gO2Qy+k5rwswpEhJA=="],
+ "@elysiajs/swagger": ["@elysiajs/swagger@1.3.0", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-0fo3FWkDRPNYpowJvLz3jBHe9bFe6gruZUyf+feKvUEEMG9ZHptO1jolSoPE0ffFw1BgN1/wMsP19p4GRXKdfg=="],
+
+ "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
+
+ "@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="],
+
+ "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="],
+
"@sinclair/typebox": ["@sinclair/typebox@0.34.33", "", {}, "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g=="],
"@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=="],
@@ -32,6 +41,8 @@
"@types/node": ["@types/node@22.15.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A=="],
+ "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
+
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@@ -52,28 +63,44 @@
"file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
+ "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
+
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
+ "nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
+
"node-cache": ["node-cache@5.1.2", "", { "dependencies": { "clone": "2.x" } }, "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
+ "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
+
"peek-readable": ["peek-readable@7.0.0", "", {}, "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ=="],
"strtok3": ["strtok3@10.2.2", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^7.0.0" } }, "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg=="],
"token-types": ["token-types@6.0.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA=="],
+ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
+
"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=="],
+ "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
+
+ "zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="],
+
+ "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
+
"@tokenizer/inflate/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
+ "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
+
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
}
}
diff --git a/standalone/package.json b/standalone/package.json
index 2588bb1..d299971 100644
--- a/standalone/package.json
+++ b/standalone/package.json
@@ -1,6 +1,6 @@
{
"name": "cap-standalone",
- "version": "1.0.17",
+ "version": "2.0.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.js",
@@ -48,6 +48,7 @@
"@cap.js/server": "^1.0.13",
"@elysiajs/cors": "^1.3.3",
"@elysiajs/static": "^1.3.0",
+ "@elysiajs/swagger": "^1.3.0",
"elysia": "^1.3.1",
"elysia-rate-limit": "^4.3.0"
},
diff --git a/standalone/public/index.css b/standalone/public/index.css
new file mode 100644
index 0000000..6ae8aab
--- /dev/null
+++ b/standalone/public/index.css
@@ -0,0 +1,745 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ background-color: #181818;
+ color: white;
+ display: flex;
+ max-height: 100vh;
+ height: 100vh;
+ margin: 0px;
+ padding: 0px;
+ flex-direction: column;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
+}
+
+nav {
+ padding: 0.8em 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding-bottom: 0px;
+ user-select: none;
+}
+
+nav .logo {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ border: none;
+ font-family: inherit;
+ font-size: inherit;
+ cursor: pointer;
+ background-color: transparent;
+ padding: 0px;
+ margin-right: auto;
+ transition: opacity 0.2s;
+}
+
+nav .logo img {
+ width: 28px;
+ height: 28px;
+ margin-right: 8px;
+}
+
+nav .logo p {
+ margin: 0px;
+ color: rgb(248, 248, 248);
+ opacity: 0;
+ margin-left: -5px;
+ transition: margin-left 0.2s, opacity 0.2s;
+ pointer-events: none;
+ filter: blur(1px);
+}
+
+nav .logo:hover p {
+ opacity: 1;
+ margin-left: 0px;
+ filter: none;
+ pointer-events: all;
+}
+
+nav .logo:active {
+ opacity: 0.6;
+}
+
+nav .settings {
+ font-size: inherit;
+ cursor: pointer;
+ background-color: transparent;
+ padding: 0px;
+ border: none;
+ color: rgb(248, 248, 248);
+ opacity: 0.6;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: opacity 0.2s;
+}
+
+nav .settings:hover {
+ opacity: 1;
+}
+
+nav .logout {
+ font-family: inherit;
+ margin-left: auto;
+ font-size: inherit;
+ cursor: pointer;
+ background-color: transparent;
+ padding: 0px;
+ border: none;
+ color: rgb(248, 248, 248);
+ opacity: 0.6;
+ transition: opacity 0.2s;
+}
+
+nav .logout:hover {
+ opacity: 1;
+}
+
+.state-loading {
+ max-height: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 0.8em;
+}
+
+.state-loading svg {
+ width: 30px;
+ height: 30px;
+}
+.state-homepage {
+ max-width: 520px;
+ flex-direction: column;
+ margin: 2em auto;
+ width: 100%;
+ padding: 0px 0.5rem;
+}
+
+.state-homepage h1 {
+ font-size: 1.5em;
+ font-weight: 600;
+}
+
+.state-homepage .keys-top {
+ border: 1px solid #1f1f1f;
+ border-radius: 8px;
+ background-color: #161616;
+ height: 40px;
+ display: flex;
+}
+
+.state-homepage .keys-top input {
+ font-size: 15px;
+ font-family: inherit;
+ background-color: transparent;
+ color: inherit;
+ border: none;
+ height: 100%;
+ width: 100%;
+ padding-left: 0.8em;
+}
+
+.state-homepage .keys-top input:focus {
+ outline: none;
+}
+
+.state-homepage .keys-top:has(input:focus) {
+ border: 1px solid #3f3f3f;
+}
+
+.state-homepage .keys-top .create-key {
+ width: fit-content;
+ display: flex;
+ align-items: center;
+ margin: 4px;
+ border-radius: 4px;
+ border: none;
+ background-color: #0076d8;
+ color: white;
+ font-family: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+ padding: 0px 6px;
+ transition: transform 0.2s, opacity 0.2s;
+}
+
+.state-homepage .keys-top .create-key svg {
+ width: 18px;
+ height: 18px;
+ margin-right: 4px;
+}
+
+.state-homepage .keys-top .create-key:hover {
+ opacity: 0.8;
+}
+
+.state-homepage .keys-top .create-key:active {
+ transform: scale(0.98);
+}
+
+.keys-list.no-keys p {
+ text-align: center;
+ opacity: 0.8;
+ font-size: 15px;
+ margin-top: 20px;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ animation: fadeIn 0.15s ease-in-out;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.modal-content {
+ background-color: #282828;
+ border-radius: 10px;
+ max-width: 400px;
+ width: 90%;
+ color: white;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
+ animation: scaleIn 0.2s ease-in-out;
+}
+
+@keyframes scaleIn {
+ from {
+ transform: scale(0.98);
+ }
+ to {
+ transform: scale(1);
+ }
+}
+
+.modal-content header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1em;
+ border-bottom: 1px solid #323232;
+ padding: 12px 16px;
+}
+
+.modal-content header h2 {
+ margin: 0;
+ font-size: 17px;
+ font-weight: 500;
+}
+
+.modal-content header .close-button {
+ background: transparent;
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 0px;
+}
+
+.modal .content {
+ padding: 12px 16px;
+ padding-top: 0px;
+}
+
+.modal-content input[type="text"] {
+ width: 100%;
+ padding: 0.6em 0.7em;
+ margin-top: 8px;
+ border-radius: 8px;
+ font-size: 15px;
+ color: white;
+ border: 1px solid #e3e4e810;
+ background-color: #e3e4e807;
+ font-family: inherit;
+ margin-bottom: 8px;
+ text-overflow: ellipsis;
+ transition: box-shadow 0.15s;
+}
+
+.modal-content input[type="text"]::focus {
+ text-overflow: unset;
+}
+
+.modal-content input[type="text"]:focus {
+ box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
+ outline: none;
+}
+
+.modal-content label {
+ font-weight: 500;
+ font-size: 14px;
+}
+
+.modal-content button.primary {
+ border: none;
+ border-radius: 8px;
+ background-color: white;
+ color: black;
+ font-size: 15px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-family: inherit;
+ height: 36px;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 16px;
+ width: calc(100% - 32px);
+ margin-top: 0px;
+ transition: opacity 0.2s;
+}
+
+.modal-content button.primary.secondary {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+.modal-content button.primary:hover {
+ opacity: 0.8;
+}
+
+.modal-content button.primary:active {
+ opacity: 0.5;
+}
+
+.modal-content button.primary:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.key-created-header {
+ display: flex;
+ gap: 11px;
+ margin-bottom: 2px;
+}
+
+.key-created-header svg {
+ width: 40px;
+ height: 40px;
+}
+
+.key-created-header h3 {
+ margin-top: 10px;
+ font-weight: 500;
+}
+
+.modal-content p.small-text {
+ line-height: 1.4;
+ font-size: 14px;
+ max-width: 90%;
+ opacity: 0.9;
+ margin-top: 2px;
+ margin-bottom: 4px;
+}
+
+.keys-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.keys-list .key {
+ border: 1px solid #1f1f1f;
+ border-radius: 8px;
+ background-color: #161616;
+ display: flex;
+ gap: 6px;
+ height: 68px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.key .key-text {
+ padding-left: 14px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 100%;
+}
+
+.key .key-text h2 {
+ font-size: 17px;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ margin: 0px;
+}
+
+.key .key-text p {
+ margin-bottom: 0px;
+ margin-top: 4px;
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.5);
+ font-weight: 300;
+}
+
+.key .key-text p b {
+ color: rgba(255, 255, 255, 0.7);
+ font-weight: 500;
+}
+
+.key .key-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-right: 14px;
+}
+
+.key .key-actions button {
+ width: 38px;
+ height: 38px;
+ border-radius: 100px;
+ border: none;
+ background: transparent;
+ color: white;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.key:focus,
+.key:active,
+.key:has(button:focus) {
+ box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
+}
+
+.state-key-page {
+ flex-direction: column;
+ max-width: 800px;
+ width: 100%;
+ margin: 2em auto;
+ padding: 0px 0.5rem;
+ padding-bottom: 4em;
+}
+
+.state-key-page .chart-container {
+ width: 100%;
+ height: 380px;
+
+ border: 1px solid #e3e4e810;
+ background-color: #e3e4e807;
+ padding: 15px;
+ border-radius: 12px;
+}
+
+.state-key-page .topbar {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ margin-bottom: 18px;
+}
+
+.state-key-page .topbar .left {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+
+.state-key-page .topbar .left h1 {
+ font-size: 23px;
+ margin: 0px;
+ font-weight: 500;
+}
+
+.state-key-page .topbar .left button {
+ border: none;
+ border-radius: 6px;
+ color: rgba(255, 255, 255, 0.85);
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ padding: 0.4em 0.6em;
+ font-family: inherit;
+ cursor: pointer;
+
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.state-key-page .topbar .left button:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.state-key-page .topbar .left button:active {
+ opacity: 0.6;
+}
+
+.state-key-page .topbar .right {
+ margin-left: auto;
+}
+
+.state-key-page .time-duration {
+ border: none;
+ border-radius: 6px;
+ color: white;
+ background-color: transparent;
+ padding: 0px;
+ font-family: inherit;
+ cursor: pointer;
+}
+
+.state-key-page .time-duration:focus {
+ outline: none;
+}
+
+.state-key-page h2 {
+ font-size: 18px;
+ margin: 0px;
+ font-weight: 600;
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.danger-zone-buttons button {
+ color: white;
+ background-color: rgba(255, 255, 255, 0.05);
+ font-family: inherit;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ padding: 0.5em 1em;
+ cursor: pointer;
+}
+
+.danger-zone-buttons button:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.danger-zone-buttons button:focus {
+ box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
+ outline: none;
+}
+
+.danger-zone-buttons button:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.config input {
+ width: 100%;
+ padding: 0.6em 0.7em;
+ margin-top: 8px;
+ border-radius: 8px;
+ font-size: 15px;
+ color: white;
+ border: 1px solid #e3e4e810;
+ background-color: #e3e4e807;
+ font-family: inherit;
+ margin-bottom: 8px;
+ text-overflow: ellipsis;
+ max-width: 300px;
+ display: block;
+}
+
+.config input::focus {
+ text-overflow: unset;
+}
+
+.config input:focus {
+ box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
+ outline: none;
+}
+
+.config label {
+ font-weight: 500;
+ font-size: 14px;
+}
+
+.config .config-item {
+ margin-bottom: 16px;
+}
+
+.config button {
+ border: none;
+ border-radius: 8px;
+ background-color: white;
+ color: black;
+ font-size: 15px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-family: inherit;
+ height: 36px;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0px 16px;
+}
+
+.config button:hover {
+ opacity: 0.8;
+}
+
+.config button:focus {
+ box-shadow: 0px 0px 0px 3px rgba(255, 255, 255, 0.37);
+}
+
+.config button:disabled {
+ opacity: 0.4;
+ pointer-events: none;
+}
+
+.keypage-back {
+ display: flex;
+ align-items: center;
+ border: none;
+ padding: 0px;
+ background-color: transparent;
+ color: white;
+ font-family: inherit;
+ font-size: 16px;
+ margin-bottom: 10px;
+ opacity: 0.63;
+ margin-left: -6px;
+ cursor: pointer;
+ width: fit-content;
+ transition: opacity 0.2s;
+}
+
+.keypage-back:hover {
+ opacity: 1;
+}
+
+.keypage-back:active {
+ opacity: 0.58;
+}
+
+.keypage-back svg {
+ transition: margin-left 0.2s, margin-right 0.2s;
+}
+
+.keypage-back:hover svg {
+ margin-left: -2px;
+ margin-right: 2px;
+}
+
+.state-settings {
+ flex-direction: column;
+ max-width: 800px;
+ width: 100%;
+ margin: 2em auto;
+ padding: 0px 0.5rem;
+ padding-bottom: 4em;
+}
+
+.state-settings {
+ flex-direction: column;
+ max-width: 700px;
+ width: 100%;
+ margin: 2em auto;
+ padding: 0px 0.5rem;
+ padding-bottom: 4em;
+}
+
+.state-settings h1 {
+ margin-top: 0px;
+ font-size: 27px;
+ margin-bottom: 0px;
+}
+
+.state-settings h2 {
+ font-weight: 500;
+ font-size: 22px;
+ margin-top: 14px;
+}
+
+.sessions-list, .apikeys-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.sessions-list .session, .apikeys-list .apikey {
+ border: 1px solid #1f1f1f;
+ border-radius: 8px;
+ background-color: #161616;
+ display: flex;
+ gap: 6px;
+ height: 80px;
+ align-items: center;
+}
+
+.session .session-info, .apikey .apikey-info {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 12px 16px;
+}
+
+.session-token, .apikey-token {
+ margin-bottom: 5px;
+ font-weight: 500;
+}
+
+.session-expires, .apikey-created {
+ opacity: 0.93;
+ font-size: 14px;
+}
+
+.session .session-delete-button, .apikey .apikey-delete-button {
+ border-radius: 6px;
+ color: rgba(255, 255, 255, 0.85);
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ padding: 0.4em 0.6em;
+ font-family: inherit;
+ cursor: pointer;
+ margin-left: auto;
+ margin-right: 16px;
+}
+
+.session .session-delete-button:hover, .apikey .apikey-delete-button:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.session .session-delete-button:active, .apikey .apikey-delete-button:active {
+ opacity: 0.6;
+}
+
+.apikeys-title {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.apikeys-title button {
+ border: none;
+ border-radius: 6px;
+ background-color: #0076d8;
+ color: white;
+ padding: 0.4em 0.6em;
+ font-family: inherit;
+ cursor: pointer;
+ margin-left: auto;
+ margin-right: 16px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: opacity 0.2s;
+}
+
+.apikeys-title button:hover {
+ opacity: 0.8;
+}
+
+.apikeys-title button:active {
+ opacity: 0.5;
+}
diff --git a/standalone/public/index.html b/standalone/public/index.html
index 223cd4f..c28dd5d 100644
--- a/standalone/public/index.html
+++ b/standalone/public/index.html
@@ -3,324 +3,195 @@
- Cap
-
-
+
+ Cap Standalone
-
-
-
Your keys
-