import { createHash, randomBytes } from "node:crypto";
import { db, transaction } from "./db";
import { getUserById } from "./users";
export const TOKEN_PREFIX = "vec_";
/** Prefix of tokens issued before the rename to Vectrum; still accepted. */
const LEGACY_TOKEN_PREFIX = "mng_";
export type TokenPermission = "read" | "write";
export type ApiToken = {
id: number;
userId: number;
name: string;
hint: string;
permission: TokenPermission;
allRepos: boolean;
repos: { id: number; fullName: string }[];
expiresAt: number | null;
expired: boolean;
createdAt: number;
lastUsedAt: number | null;
};
type TokenRow = {
id: number;
user_id: number;
name: string;
hint: string;
permission: TokenPermission;
all_repos: number;
expires_at: number | null;
created_at: number;
last_used_at: number | null;
};
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
function tokenRepos(tokenId: number) {
return db
.prepare(
`SELECT r.id, u.username || '/' || r.name AS fullName FROM api_token_repos t
JOIN repos r ON r.id = t.repo_id JOIN users u ON u.id = r.owner_id WHERE t.token_id = ? ORDER BY fullName`,
)
.all(tokenId) as { id: number; fullName: string }[];
}
function toToken(r: TokenRow): ApiToken {
return {
id: r.id,
userId: r.user_id,
name: r.name,
hint: r.hint,
permission: r.permission,
allRepos: r.all_repos === 1,
repos: r.all_repos === 1 ? [] : tokenRepos(r.id),
expiresAt: r.expires_at,
expired: r.expires_at !== null && r.expires_at < Date.now(),
createdAt: r.created_at,
lastUsedAt: r.last_used_at,
};
}
export class TokenError extends Error {}
/** Creates a token and returns its secret value; only its hash is stored. */
export function createToken(
userId: number,
input: { name: string; permission: TokenPermission; expiresInDays: number | null; repoIds: number[] | null },
) {
const name = input.name.trim().slice(0, 100);
if (!name) throw new TokenError("Give your token a name so you can recognize it later.");
if (input.repoIds && input.repoIds.length === 0) throw new TokenError("Select at least one repository.");
const secret = `${TOKEN_PREFIX}${randomBytes(30).toString("base64url")}`;
const now = Date.now();
transaction(() => {
const res = db
.prepare(
`INSERT INTO api_tokens (user_id, name, token_hash, hint, permission, all_repos, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
userId,
name,
sha256(secret),
secret.slice(-4),
input.permission,
input.repoIds ? 0 : 1,
input.expiresInDays ? now + input.expiresInDays * 86400e3 : null,
now,
);
for (const repoId of input.repoIds ?? []) {
db.prepare("INSERT INTO api_token_repos (token_id, repo_id) VALUES (?, ?)").run(res.lastInsertRowid, repoId);
}
});
return secret;
}
export function listTokens(userId: number) {
return (db.prepare("SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC").all(userId) as TokenRow[]).map(
toToken,
);
}
export function deleteToken(userId: number, tokenId: number) {
db.prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?").run(tokenId, userId);
}
/** Resolves a secret token to its owner, or null if unknown or expired. */
export function authenticateToken(secret: string) {
if (!secret.startsWith(TOKEN_PREFIX) && !secret.startsWith(LEGACY_TOKEN_PREFIX)) return null;
const row = db.prepare("SELECT * FROM api_tokens WHERE token_hash = ?").get(sha256(secret)) as TokenRow | undefined;
if (!row || (row.expires_at && row.expires_at < Date.now())) return null;
const user = getUserById(row.user_id);
if (!user) return null;
db.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run(Date.now(), row.id);
return { user, token: toToken(row) };
}
export function tokenCovers(token: ApiToken, repoId: number, needWrite: boolean) {
if (needWrite && token.permission !== "write") return false;
return token.allRepos || token.repos.some((r) => r.id === repoId);
}