AkurAI Build
Menu

popagent

public

Latest 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,
    };
  },
};