Move the action from Deno to Node.js and Vite+
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 58s

This commit is contained in:
Danny Kim
2026-10-16 02:20:49 +09:00
parent 9b55e98d8c
commit 8f23da2b42
45 changed files with 5664 additions and 1731 deletions
+58 -58
View File
@@ -1,4 +1,6 @@
import { parse } from "jsr:@std/yaml@1";
import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts";
import type { BotProfile } from "./profiles.ts";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
@@ -7,40 +9,40 @@ const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
opencode: "openrouter/qwen/qwen3.8-27b",
};
export type BotCredentials = {
export interface BotCredentials {
reviewerToken: string;
profiles: BotProfile[];
};
}
function decodeBase64Url(value: string): Uint8Array {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0);
}
function encodeBase64Url(value: Uint8Array): string {
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join("");
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
async function importMasterKey(encoded: string, usage: KeyUsage): Promise<CryptoKey> {
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> {
const bytes = decodeBase64Url(encoded);
if (bytes.length !== 32) {
throw new Error("master-key must be 32 bytes encoded as base64url");
}
return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
}
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(":");
if (version !== "v1" || !encodedNonce || !encodedCiphertext || extra) {
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":");
if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") {
throw new Error("credential must use v1:<nonce>:<ciphertext>");
}
const nonce = decodeBase64Url(encodedNonce);
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes");
const plaintext = await crypto.subtle.decrypt(
const plaintext = await webcrypto.subtle.decrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"),
buffer(decodeBase64Url(encodedCiphertext)),
@@ -51,10 +53,10 @@ export async function decryptCredential(encrypted: string, masterKey: string): P
export async function encryptCredential(
plaintext: string,
masterKey: string,
nonce: Uint8Array = crypto.getRandomValues(new Uint8Array(12)),
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
): Promise<string> {
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes");
const ciphertext = await crypto.subtle.encrypt(
const ciphertext = await webcrypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"),
new TextEncoder().encode(plaintext),
@@ -62,59 +64,57 @@ export async function encryptCredential(
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
}
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
const value: unknown = parse(text);
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("credentials must be a mapping");
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> {
const at = `profiles[${index}]`;
if (!isMapping(entry)) {
throw new TypeError(`${at}: expected a mapping`);
}
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
for (const key of Object.keys(entry)) {
if (key !== "type" && key !== "model" && key !== "token") {
throw new Error(`${at}.${key}: unknown field`);
}
}
const { type, model, token } = entry;
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
}
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
if (token !== undefined && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
...(typeof token === "string" && {
token: await decryptCredential(token, masterKey),
}),
};
}
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
const value: unknown = JSON.parse(text);
if (!isMapping(value)) {
throw new TypeError("credentials must be a mapping");
}
for (const key of Object.keys(value)) {
if (key !== "reviewer-token" && key !== "profiles") {
throw new Error(`${key}: unknown field`);
}
}
if (typeof record["reviewer-token"] !== "string") {
throw new Error("reviewer-token: expected an encrypted credential");
const { "reviewer-token": reviewerToken, profiles } = value;
if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential");
}
if (!Array.isArray(record.profiles) || record.profiles.length === 0) {
if (!Array.isArray(profiles) || profiles.length === 0) {
throw new Error("profiles must contain at least one profile");
}
const profiles = await Promise.all(
record.profiles.map(async (entry: unknown, index): Promise<BotProfile> => {
const at = `profiles[${index}]`;
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
throw new Error(`${at}: expected a mapping`);
}
const profile = entry as Record<string, unknown>;
for (const key of Object.keys(profile)) {
if (key !== "type" && key !== "model" && key !== "token") {
throw new Error(`${at}.${key}: unknown field`);
}
}
const { type, model, token } = profile;
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
}
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
if (token !== undefined && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
...(typeof token === "string" && {
token: await decryptCredential(token, masterKey),
}),
};
}),
const parsed = await Promise.all(
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
);
return {
reviewerToken: await decryptCredential(record["reviewer-token"], masterKey),
profiles,
};
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
}