server: add types

This commit is contained in:
Tiago
2025-04-24 16:01:37 +01:00
parent d09d7e6ab5
commit 94711b1b84
9 changed files with 481 additions and 198 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "@cap.js/server",
"dependencies": {
"@types/node": "^22.14.1",
"typescript": "^5.8.3",
},
},
},
"packages": {
"@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}
+149
View File
@@ -0,0 +1,149 @@
export = Cap;
/**
* Main Cap class
* @extends EventEmitter
*/
declare class Cap extends EventEmitter<[never]> {
/**
* Creates a new Cap instance
* @param {Partial<CapConfig>} [configObj] - Configuration object
*/
constructor(configObj?: Partial<CapConfig>);
/** @type {Promise<void>|null} */
_cleanupPromise: Promise<void> | null;
/** @type {Required<CapConfig>} */
config: Required<CapConfig>;
/**
* Generates a new challenge
* @param {ChallengeConfig} [conf] - Challenge configuration
* @returns {{ challenge: Array<ChallengeTuple>, token?: string, expires: number }} Challenge data
*/
createChallenge(conf?: ChallengeConfig): {
challenge: Array<ChallengeTuple>;
token?: string;
expires: number;
};
/**
* Redeems a challenge solution in exchange for a token
* @param {Solution} param0 - Challenge solution data
* @returns {Promise<{success: boolean, message?: string, token?: string, expires?: number}>}
*/
redeemChallenge({ token, solutions }: Solution): Promise<{
success: boolean;
message?: string;
token?: string;
expires?: number;
}>;
/**
* Validates a token
* @param {string} token - The token to validate
* @param {TokenConfig} [conf] - Validation configuration
* @returns {Promise<{success: boolean}>}
*/
validateToken(token: string, conf?: TokenConfig): Promise<{
success: boolean;
}>;
/**
* Loads tokens from the storage file
* @private
* @returns {Promise<void>}
*/
private _loadTokens;
/**
* Removes expired tokens and challenges from memory
* @private
* @returns {boolean} - True if any tokens were changed/removed
*/
private _cleanExpiredTokens;
/**
* Waits for the tokens list to be initialized
* @private
* @returns {Promise<void>}
*/
private _waitForTokensList;
/**
* Cleans up expired tokens and syncs state
* @returns {Promise<void>}
*/
cleanup(): Promise<void>;
}
declare namespace Cap {
export { Crypto, FsPromises, PathLike, ChallengeTuple, ChallengeData, ChallengeState, ChallengeConfig, TokenConfig, Solution, CapConfig };
}
import { EventEmitter } from "events";
type Crypto = typeof import("node:crypto");
type FsPromises = typeof import("node:fs/promises");
type PathLike = import("fs").PathLike;
type ChallengeTuple = [string, string];
type ChallengeData = {
/**
* - Array of [salt, target] tuples
*/
challenge: Array<ChallengeTuple>;
/**
* - Expiration timestamp
*/
expires: number;
/**
* - Challenge token
*/
token: string;
};
type ChallengeState = {
/**
* - Map of challenge tokens to challenge data
*/
challengesList: Record<string, ChallengeData>;
/**
* - Map of token hashes to expiration timestamps
*/
tokensList: Record<string, number>;
};
type ChallengeConfig = {
/**
* - Number of challenges to generate
*/
challengeCount?: number | undefined;
/**
* - Size of each challenge in bytes
*/
challengeSize?: number | undefined;
/**
* - Difficulty level of the challenge
*/
challengeDifficulty?: number | undefined;
/**
* - Time in milliseconds until the challenge expires
*/
expiresMs?: number | undefined;
/**
* - Whether to store the challenge in memory
*/
store?: boolean | undefined;
};
type TokenConfig = {
/**
* - Whether to keep the token after validation
*/
keepToken?: boolean | undefined;
};
type Solution = {
/**
* - The challenge token
*/
token: string;
/**
* - Array of [salt, target, solution] tuples
*/
solutions: Array<[string, string, string]>;
};
type CapConfig = {
/**
* - Path to store the tokens file
*/
tokens_store_path: string;
/**
* - State configuration
*/
state: ChallengeState;
};
+277 -183
View File
@@ -1,228 +1,322 @@
// @ts-check
/// <reference lib="dom" />
/// <reference types="node" />
/**
* @typedef {import('node:crypto')} Crypto
* @typedef {import('node:fs/promises')} FsPromises
* @typedef {import('fs').PathLike} PathLike
*/
/**
* @typedef {[string, string]} ChallengeTuple
*/
/**
* @typedef {Object} ChallengeData
* @property {Array<ChallengeTuple>} challenge - Array of [salt, target] tuples
* @property {number} expires - Expiration timestamp
* @property {string} token - Challenge token
*/
/**
* @typedef {Object} ChallengeState
* @property {Record<string, ChallengeData>} challengesList - Map of challenge tokens to challenge data
* @property {Record<string, number>} tokensList - Map of token hashes to expiration timestamps
*/
/**
* @typedef {Object} ChallengeConfig
* @property {number} [challengeCount=18] - Number of challenges to generate
* @property {number} [challengeSize=32] - Size of each challenge in bytes
* @property {number} [challengeDifficulty=4] - Difficulty level of the challenge
* @property {number} [expiresMs=600000] - Time in milliseconds until the challenge expires
* @property {boolean} [store=true] - Whether to store the challenge in memory
*/
/**
* @typedef {Object} TokenConfig
* @property {boolean} [keepToken] - Whether to keep the token after validation
*/
/**
* @typedef {Object} Solution
* @property {string} token - The challenge token
* @property {Array<[string, string, string]>} solutions - Array of [salt, target, solution] tuples
*/
/**
* @typedef {Object} CapConfig
* @property {string} tokens_store_path - Path to store the tokens file
* @property {ChallengeState} state - State configuration
*/
/** @type {typeof import('node:crypto')} */
const crypto = require("crypto");
/** @type {typeof import('node:fs/promises')} */
const fs = require("fs/promises");
const { EventEmitter } = require("events");
const DEFAULT_TOKENS_STORE = ".data/tokensList.json";
(function (define) {
define(function (require, exports, module) {
class Cap extends EventEmitter {
constructor(configObj) {
super();
this._cleanupPromise = null;
this.config = {
tokens_store_path: DEFAULT_TOKENS_STORE,
state: {
challengesList: {},
tokensList: {},
},
...configObj,
};
/**
* Main Cap class
* @extends EventEmitter
*/
class Cap extends EventEmitter {
/** @type {Promise<void>|null} */
_cleanupPromise;
this._loadTokens().catch(() => { });
/** @type {Required<CapConfig>} */
config;
process.on("beforeExit", () => this.cleanup());
/**
* Creates a new Cap instance
* @param {Partial<CapConfig>} [configObj] - Configuration object
*/
constructor(configObj) {
super();
this._cleanupPromise = null;
/** @type {Required<CapConfig>} */
this.config = {
tokens_store_path: DEFAULT_TOKENS_STORE,
state: {
challengesList: {},
tokensList: {},
},
...configObj,
};
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => {
process.once(signal, () => {
this.cleanup()
.then(() => process.exit(0))
.catch(() => process.exit(1));
});
});
}
this._loadTokens().catch(() => {});
async _loadTokens() {
try {
const dirPath = this.config.tokens_store_path.split('/').slice(0, -1).join('/');
if (dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
process.on("beforeExit", () => this.cleanup());
try {
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();
} catch {
console.log(
`[cap] Tokens file not found, creating a new empty one`
);
await fs.writeFile(this.config.tokens_store_path, "{}", "utf-8");
this.config.state.tokensList = {};
}
} catch (error) {
console.log(
`[cap] Couldn't load or write tokens file, using empty state`
);
this.config.state.tokensList = {};
}
}
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => {
process.once(signal, () => {
this.cleanup()
.then(() => process.exit(0))
.catch(() => process.exit(1));
});
});
}
_cleanExpiredTokens() {
const now = Date.now();
let tokensChanged = false;
/**
* Generates a new challenge
* @param {ChallengeConfig} [conf] - Challenge configuration
* @returns {{ challenge: Array<ChallengeTuple>, token?: string, expires: number }} Challenge data
*/
createChallenge(conf) {
this._cleanExpiredTokens();
for (const k in this.config.state.challengesList) {
if (this.config.state.challengesList[k].expires < now) {
delete this.config.state.challengesList[k];
}
}
/** @type {Array<ChallengeTuple>} */
const challenges = Array.from(
{ length: (conf && conf.challengeCount) || 18 },
() =>
/** @type {ChallengeTuple} */ ([
crypto
.randomBytes(Math.ceil(((conf && conf.challengeSize) || 32) / 2))
.toString("hex")
.slice(0, (conf && conf.challengeSize) || 32),
crypto
.randomBytes(
Math.ceil(((conf && conf.challengeDifficulty) || 4) / 2)
)
.toString("hex")
.slice(0, (conf && conf.challengeDifficulty) || 4),
])
);
for (const k in this.config.state.tokensList) {
if (this.config.state.tokensList[k] < now) {
delete this.config.state.tokensList[k];
tokensChanged = true;
}
}
const token = crypto.randomBytes(25).toString("hex");
const expires = Date.now() + ((conf && conf.expiresMs) || 600000);
return tokensChanged;
}
if (conf && conf.store === false) {
return { challenge: challenges, expires };
}
_waitForTokensList() {
return new Promise((resolve) => {
const l = () => {
if (this.config.state.tokensList) {
return resolve();
}
setTimeout(l, 10);
};
l();
});
}
this.config.state.challengesList[token] = {
challenge: challenges,
expires,
token,
};
async cleanup() {
if (this._cleanupPromise) return this._cleanupPromise;
return { challenge: challenges, token, expires };
}
this._cleanupPromise = (async () => {
const tokensChanged = this._cleanExpiredTokens();
/**
* Redeems a challenge solution in exchange for a token
* @param {Solution} param0 - Challenge solution data
* @returns {Promise<{success: boolean, message?: string, token?: string, expires?: number}>}
*/
async redeemChallenge({ token, solutions }) {
this._cleanExpiredTokens();
if (tokensChanged) {
await fs.writeFile(
this.config.tokens_store_path,
JSON.stringify(this.config.state.tokensList),
"utf8"
);
}
})();
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 this._cleanupPromise;
}
delete this.config.state.challengesList[token];
createChallenge(conf) {
this._cleanExpiredTokens();
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)
);
});
const challenges = Array.from(
{ length: (conf && conf.challengeCount) || 18 },
() => [
crypto
.randomBytes(Math.ceil(((conf && conf.challengeSize) || 32) / 2))
.toString("hex")
.slice(0, (conf && conf.challengeSize) || 32),
crypto
.randomBytes(
Math.ceil(((conf && conf.challengeDifficulty) || 4) / 2)
)
.toString("hex")
.slice(0, (conf && conf.challengeDifficulty) || 4),
]
);
if (!isValid) return { success: false, message: "Invalid solution" };
const token = crypto.randomBytes(25).toString("hex");
const expires = Date.now() + ((conf && conf.expiresMs) || 600000);
const vertoken = crypto.randomBytes(15).toString("hex");
const expires = Date.now() + 20 * 60 * 1000;
const hash = crypto.createHash("sha256").update(vertoken).digest("hex");
const id = crypto.randomBytes(8).toString("hex");
if (conf && conf.store === false) {
return { challenge: challenges, expires };
}
this.config.state.tokensList[`${id}:${hash}`] = expires;
this.config.state.challengesList[token] = {
challenge: challenges,
expires,
token,
};
await fs.writeFile(
this.config.tokens_store_path,
JSON.stringify(this.config.state.tokensList),
"utf8"
);
return { challenge: challenges, token, expires };
}
return { success: true, token: `${id}:${vertoken}`, expires };
}
async redeemChallenge({ token, solutions }) {
this._cleanExpiredTokens();
/**
* Validates a token
* @param {string} token - The token to validate
* @param {TokenConfig} [conf] - Validation configuration
* @returns {Promise<{success: boolean}>}
*/
async validateToken(token, conf) {
this._cleanExpiredTokens();
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" };
}
const [id, vertoken] = token.split(":");
const hash = crypto.createHash("sha256").update(vertoken).digest("hex");
const key = `${id}:${hash}`;
delete this.config.state.challengesList[token];
await this._waitForTokensList();
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, message: "Invalid solution" };
const vertoken = crypto.randomBytes(15).toString("hex");
const expires = Date.now() + 20 * 60 * 1000;
const hash = crypto.createHash("sha256").update(vertoken).digest("hex");
const id = crypto.randomBytes(8).toString("hex");
this.config.state.tokensList[`${id}:${hash}`] = expires;
if (this.config.state.tokensList[key]) {
if (conf && 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, token: `${id}:${vertoken}`, expires };
}
async validateToken(token, conf) {
return { success: true };
}
return { success: false };
}
/**
* Loads tokens from the storage file
* @private
* @returns {Promise<void>}
*/
async _loadTokens() {
try {
const dirPath = this.config.tokens_store_path
.split("/")
.slice(0, -1)
.join("/");
if (dirPath) {
await fs.mkdir(dirPath, { recursive: true });
}
try {
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();
} catch {
console.log(`[cap] Tokens file not found, creating a new empty one`);
await fs.writeFile(this.config.tokens_store_path, "{}", "utf-8");
this.config.state.tokensList = {};
}
} catch (error) {
console.log(
`[cap] Couldn't load or write tokens file, using empty state`
);
this.config.state.tokensList = {};
}
}
const [id, vertoken] = token.split(":");
const hash = crypto.createHash("sha256").update(vertoken).digest("hex");
const key = `${id}:${hash}`;
/**
* Removes expired tokens and challenges from memory
* @private
* @returns {boolean} - True if any tokens were changed/removed
*/
_cleanExpiredTokens() {
const now = Date.now();
let tokensChanged = false;
await this._waitForTokensList();
if (this.config.state.tokensList[key]) {
if (conf && 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 };
for (const k in this.config.state.challengesList) {
if (this.config.state.challengesList[k].expires < now) {
delete this.config.state.challengesList[k];
}
}
return Cap;
});
})(
typeof module === "object" && module.exports && typeof define !== "function"
? function (factory) {
module.exports = factory(require, exports, module);
for (const k in this.config.state.tokensList) {
if (this.config.state.tokensList[k] < now) {
delete this.config.state.tokensList[k];
tokensChanged = true;
}
}
: define
);
return tokensChanged;
}
/**
* Waits for the tokens list to be initialized
* @private
* @returns {Promise<void>}
*/
_waitForTokensList() {
return new Promise((resolve) => {
const l = () => {
if (this.config.state.tokensList) {
return resolve();
}
setTimeout(l, 10);
};
l();
});
}
/**
* Cleans up expired tokens and syncs state
* @returns {Promise<void>}
*/
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;
}
}
/** @type {typeof Cap} */
module.exports = Cap;
+8 -3
View File
@@ -1,12 +1,13 @@
{
"name": "@cap.js/server",
"version": "1.0.8",
"author": "Tiago Rangel",
"version": "1.0.9",
"author": "Tiago",
"repository": {
"type": "git",
"url": "git+https://github.com/tiagorangel1/cap.git"
},
"main": "index.js",
"types": "index.d.ts",
"bugs": {
"url": "https://github.com/tiagorangel1/cap/issues"
},
@@ -227,5 +228,9 @@
"test": "echo \"Error: no test specified\" && exit 1",
"npm:publish": "sudo npm publish --access public"
},
"type": "commonjs"
"type": "commonjs",
"dependencies": {
"@types/node": "^22.14.1",
"typescript": "^5.8.3"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"checkJs": true,
"allowJs": true,
"declaration": true,
"emitDeclarationOnly": true,
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"types": ["node"],
"outDir": "."
},
"include": ["./index.js"],
"exclude": []
}
+7 -6
View File
@@ -77,8 +77,8 @@
padding: 6px 10px;
border-radius: 6px;
border: none;
color: black;
background: white;
color: white;
background: #0076D8;
background-origin: border-box;
user-select: none;
-webkit-user-select: none;
@@ -90,9 +90,9 @@
}
button.btn:focus {
box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2),
box-shadow: inset 0px 0.8px 0px -0.25px rgba(0, 118, 216, 0.2),
0px 0.5px 1.5px rgba(54, 122, 246, 0.25),
0px 0px 0px 3.5px rgba(255, 255, 255, 0.5);
0px 0px 0px 3.5px rgba(0, 118, 216, 0.5);
outline: 0;
}
@@ -188,13 +188,14 @@
border-radius: 8px;
border: none;
padding: 10px 14px;
background-color: #2a2a31;
border: 1px solid rgba(255, 255, 255, 0.17);
background-color: transparent;
color: inherit;
}
.key-creation-form input:focus {
outline: none;
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.5);
box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.2);
}
.key-creation-form input:disabled {
+4 -4
View File
@@ -116,10 +116,10 @@ createKeyBtn.addEventListener("click", async () => {
<p>Make sure to copy your secret key — it's required to validate tokens and you won't be able to see it again.</p>
<div class="key-creation-form">
<label for="public-key">Key ID</label>
<input type="text" id="public-key" value="${publicKey}" readonly style="opacity: 1"/>
<input type="text" id="public-key" value="${publicKey}" readonly style="opacity: 1;user-select: all;"/>
</div><div class="key-creation-form">
<label for="private-key">Secret key</label>
<input type="text" id="private-key" value="${privateKey}" readonly style="opacity: 1"/>
<input type="text" id="private-key" value="${privateKey}" readonly style="opacity: 1;user-select: all;"/>
</div>
</div>
`;
@@ -314,10 +314,10 @@ const reloadKeysList = async () => {
<p>Make sure to copy your new secret key — it's required to validate tokens and you won't be able to see it again.</p>
<div class="key-creation-form">
<label for="public-key">Key ID</label>
<input type="text" id="public-key" value="${key.publicKey}" readonly style="opacity: 1"/>
<input type="text" id="public-key" value="${key.publicKey}" readonly style="opacity: 1;user-select: all;"/>
</div><div class="key-creation-form">
<label for="private-key">Secret key</label>
<input type="text" id="private-key" value="${privateKey}" readonly style="opacity: 1"/>
<input type="text" id="private-key" value="${privateKey}" readonly style="opacity: 1;user-select: all;"/>
</div>
</div>
`;
+1 -1
View File
@@ -162,7 +162,7 @@
placeholder="••••••••••••••"
class="password"
/>
<button title="Show/hide password" class="showhide-password">
<button title="Show/hide password" class="showhide-password" type="button">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
+1 -1
View File
@@ -93,7 +93,7 @@ const auth = new Elysia({
rateLimit({
scoping: "scoped",
count: 10,
duration: "15s",
duration: 15000,
})
)
.post("/", async ({ body, cookie, set }) => {