Menu
popagent
publicLatest change 1ce5ab5f17eefb3cef070f9d98b28f2bb1e5c879 - Secure agent secrets behind origin-bound browser use by AkurAI Build
import { SQL } from "bun";
import { retryableInit } from "./retryable-init";
import type { AgentSecret, AgentSecretInput, AgentSecretUpdateInput } from "./api-types";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is required");
export const SECRET_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/;
export const MAX_SECRET_NAME_CHARACTERS = 128;
export const MAX_SECRET_VALUE_CHARACTERS = 32_768;
export function normalizeSecretName(value: string): string {
const name = value.trim();
if (
name.length < 1
|| name.length > MAX_SECRET_NAME_CHARACTERS
|| !SECRET_NAME_PATTERN.test(name)
) {
throw new Error("Secret name must use 1-128 letters, digits, dots, slashes, underscores, or hyphens");
}
return name;
}
export function normalizeSecretOrigin(value: string): string {
const input = value.trim();
if (input.includes("*")) throw new Error("Secret destination must be one exact origin");
let url: URL;
try {
url = new URL(input);
} catch {
throw new Error("Secret destination must be a valid HTTPS origin");
}
if (url.protocol !== "https:") throw new Error("Secret destination must use HTTPS");
if (url.username || url.password) throw new Error("Secret destination cannot contain credentials");
if (url.pathname !== "/" || url.search || url.hash) {
throw new Error("Secret destination must be an origin without a path, query, or fragment");
}
return url.origin;
}
function normalizeSecretValue(value: string): string {
if (value.length < 1 || value.length > MAX_SECRET_VALUE_CHARACTERS) {
throw new Error("Secret value must contain 1-32768 characters");
}
return value;
}
const sql = new SQL(connectionString);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const ensureSchema = retryableInit(async () => {
await sql`
CREATE TABLE IF NOT EXISTS popagent_secrets (
resource_id TEXT NOT NULL,
name TEXT NOT NULL,
ciphertext BYTEA NOT NULL,
iv BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (resource_id, name)
)
`;
await sql.unsafe(await Bun.file(new URL("./secret-bindings.sql", import.meta.url)).text());
});
async function encryptionKey() {
const encoded = process.env.POPAGENT_SECRET_KEY;
if (!encoded) throw new Error("POPAGENT_SECRET_KEY is required to use secret tools");
const bytes = Uint8Array.fromBase64(encoded);
if (bytes.byteLength !== 32) throw new Error("POPAGENT_SECRET_KEY must be a base64-encoded 32-byte key");
return crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]);
}
function additionalData(resourceId: string, name: string) {
return encoder.encode(`${resourceId}\0${name}`);
}
async function encrypt(resourceId: string, name: string, value: string) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: additionalData(resourceId, name) },
await encryptionKey(),
encoder.encode(value),
);
return { ciphertext: new Uint8Array(ciphertext), iv };
}
async function decrypt(resourceId: string, name: string, ciphertext: Uint8Array, iv: Uint8Array) {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv as Uint8Array<ArrayBuffer>, additionalData: additionalData(resourceId, name) },
await encryptionKey(),
ciphertext as Uint8Array<ArrayBuffer>,
);
return decoder.decode(plaintext);
}
export const secrets = {
async store(resourceId: string, name: string, value: string) {
await ensureSchema();
const encrypted = await encrypt(resourceId, name, value);
const rows = await sql`
INSERT INTO popagent_secrets (resource_id, name, ciphertext, iv)
VALUES (${resourceId}, ${name}, ${encrypted.ciphertext}, ${encrypted.iv})
ON CONFLICT (resource_id, name) DO NOTHING
RETURNING name
`;
return rows.length === 1;
},
async recall(resourceId: string, name: string) {
await ensureSchema();
const rows = await sql`
SELECT ciphertext, iv
FROM popagent_secrets
WHERE resource_id = ${resourceId} AND name = ${name}
` as Array<{ ciphertext: Uint8Array; iv: Uint8Array }>;
const row = rows[0];
if (!row) return undefined;
return decrypt(resourceId, name, row.ciphertext, row.iv);
},
async delete(resourceId: string, name: string) {
await ensureSchema();
const rows = await sql`
DELETE FROM popagent_secrets
WHERE resource_id = ${resourceId} AND name = ${name}
RETURNING name
`;
return rows.length === 1;
},
async update(resourceId: string, name: string, value: string) {
await ensureSchema();
const encrypted = await encrypt(resourceId, name, value);
const rows = await sql`
UPDATE popagent_secrets
SET ciphertext = ${encrypted.ciphertext}, iv = ${encrypted.iv}, updated_at = NOW()
WHERE resource_id = ${resourceId} AND name = ${name}
RETURNING name
`;
return rows.length === 1;
},
};
export const agentSecrets = {
async list(resourceId: string): Promise<AgentSecret[]> {
await ensureSchema();
return await sql`
SELECT
secret_name AS "name",
allowed_origin AS "allowedOrigin",
created_at::text AS "createdAt",
updated_at::text AS "updatedAt"
FROM popagent_secret_bindings
WHERE resource_id = ${resourceId}
ORDER BY secret_name
` as AgentSecret[];
},
async create(resourceId: string, input: AgentSecretInput): Promise<AgentSecret | undefined> {
const name = normalizeSecretName(input.name);
const value = normalizeSecretValue(input.value);
const allowedOrigin = normalizeSecretOrigin(input.allowedOrigin);
const encrypted = await encrypt(resourceId, name, value);
await ensureSchema();
const rows = await sql`
WITH inserted_secret AS (
INSERT INTO popagent_secrets (resource_id, name, ciphertext, iv)
VALUES (${resourceId}, ${name}, ${encrypted.ciphertext}, ${encrypted.iv})
ON CONFLICT (resource_id, name) DO NOTHING
RETURNING resource_id, name
)
INSERT INTO popagent_secret_bindings (resource_id, secret_name, allowed_origin)
SELECT resource_id, name, ${allowedOrigin}
FROM inserted_secret
RETURNING
secret_name AS "name",
allowed_origin AS "allowedOrigin",
created_at::text AS "createdAt",
updated_at::text AS "updatedAt"
` as AgentSecret[];
return rows[0];
},
async update(
resourceId: string,
rawName: string,
input: AgentSecretUpdateInput,
): Promise<AgentSecret | undefined> {
const name = normalizeSecretName(rawName);
if (input.value === undefined && input.allowedOrigin === undefined) {
throw new Error("Secret update must include a value or allowed origin");
}
const value = input.value === undefined ? undefined : normalizeSecretValue(input.value);
const allowedOrigin = input.allowedOrigin === undefined
? undefined
: normalizeSecretOrigin(input.allowedOrigin);
await ensureSchema();
const bindings = await sql`
SELECT 1
FROM popagent_secret_bindings
WHERE resource_id = ${resourceId} AND secret_name = ${name}
`;
if (!bindings.length) return undefined;
if (value !== undefined) {
const updated = await secrets.update(resourceId, name, value);
if (!updated) return undefined;
}
const rows = allowedOrigin === undefined
? await sql`
UPDATE popagent_secret_bindings
SET updated_at = NOW()
WHERE resource_id = ${resourceId} AND secret_name = ${name}
RETURNING
secret_name AS "name",
allowed_origin AS "allowedOrigin",
created_at::text AS "createdAt",
updated_at::text AS "updatedAt"
`
: await sql`
UPDATE popagent_secret_bindings
SET allowed_origin = ${allowedOrigin}, updated_at = NOW()
WHERE resource_id = ${resourceId} AND secret_name = ${name}
RETURNING
secret_name AS "name",
allowed_origin AS "allowedOrigin",
created_at::text AS "createdAt",
updated_at::text AS "updatedAt"
`;
return (rows as AgentSecret[])[0];
},
async delete(resourceId: string, rawName: string): Promise<boolean> {
return secrets.delete(resourceId, normalizeSecretName(rawName));
},
async recallBound(
resourceId: string,
rawName: string,
): Promise<{ name: string; value: string; allowedOrigin: string } | undefined> {
const name = normalizeSecretName(rawName);
await ensureSchema();
const rows = await sql`
SELECT secret.ciphertext, secret.iv, binding.allowed_origin AS "allowedOrigin"
FROM popagent_secrets AS secret
JOIN popagent_secret_bindings AS binding
ON binding.resource_id = secret.resource_id
AND binding.secret_name = secret.name
WHERE secret.resource_id = ${resourceId}
AND secret.name = ${name}
AND binding.allowed_origin IS NOT NULL
` as Array<{ ciphertext: Uint8Array; iv: Uint8Array; allowedOrigin: string }>;
const row = rows[0];
if (!row) return undefined;
return {
name,
value: await decrypt(resourceId, name, row.ciphertext, row.iv),
allowedOrigin: row.allowedOrigin,
};
},
};