feat(backend): add DB persistence layer (#79)

* feat(backend): add DB persistence layer

Replace raw pg Pool with Drizzle ORM backed by Bun.sql.
Add per-user source configuration table (user_sources).
Migrate Better Auth to drizzle-adapter.
Add AES-256-GCM credential encryption.

Co-authored-by: Ona <no-reply@ona.com>

* fix(backend): set updatedAt explicitly in all mutations

onConflictDoUpdate bypasses Drizzle's $onUpdate hook.
Set updatedAt explicitly in all mutation methods.

Co-authored-by: Ona <no-reply@ona.com>

* fix(backend): add composite index on user_sources

Add (user_id, enabled) index for the enabled() query path.

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-03-16 01:30:02 +00:00
committed by GitHub
parent 9ac88d921c
commit 61c1ade631
29 changed files with 2034 additions and 115 deletions

View File

@@ -0,0 +1,62 @@
import { randomBytes } from "node:crypto"
import { describe, expect, test } from "bun:test"
import { CredentialEncryptor } from "./crypto.ts"
const TEST_KEY = randomBytes(32).toString("base64")
describe("CredentialEncryptor", () => {
const encryptor = new CredentialEncryptor(TEST_KEY)
test("round-trip with simple string", () => {
const plaintext = "hello world"
const encrypted = encryptor.encrypt(plaintext)
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
})
test("round-trip with JSON credentials", () => {
const credentials = JSON.stringify({
accessToken: "ya29.a0AfH6SMB...",
refreshToken: "1//0dx...",
expiresAt: "2025-12-01T00:00:00Z",
})
const encrypted = encryptor.encrypt(credentials)
expect(encryptor.decrypt(encrypted)).toBe(credentials)
})
test("round-trip with empty string", () => {
const encrypted = encryptor.encrypt("")
expect(encryptor.decrypt(encrypted)).toBe("")
})
test("round-trip with unicode", () => {
const plaintext = "日本語テスト 🔐"
const encrypted = encryptor.encrypt(plaintext)
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
})
test("each encryption produces different ciphertext (unique IV)", () => {
const plaintext = "same input"
const a = encryptor.encrypt(plaintext)
const b = encryptor.encrypt(plaintext)
expect(a).not.toEqual(b)
expect(encryptor.decrypt(a)).toBe(plaintext)
expect(encryptor.decrypt(b)).toBe(plaintext)
})
test("tampered ciphertext throws", () => {
const encrypted = encryptor.encrypt("secret")
encrypted[13]! ^= 0xff
expect(() => encryptor.decrypt(encrypted)).toThrow()
})
test("truncated data throws", () => {
expect(() => encryptor.decrypt(Buffer.alloc(10))).toThrow("Encrypted data is too short")
})
test("throws when key is wrong length", () => {
expect(() => new CredentialEncryptor(Buffer.from("too-short").toString("base64"))).toThrow(
"must be 32 bytes",
)
})
})

View File

@@ -0,0 +1,60 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
const ALGORITHM = "aes-256-gcm"
const IV_LENGTH = 12
const AUTH_TAG_LENGTH = 16
/**
* AES-256-GCM encryption for credential storage.
*
* Caches the parsed key on construction to avoid repeated
* env reads and Buffer allocations.
*/
export class CredentialEncryptor {
private readonly key: Buffer
constructor(base64Key: string) {
const key = Buffer.from(base64Key, "base64")
if (key.length !== 32) {
throw new Error(
`Encryption key must be 32 bytes (got ${key.length}). Generate with: openssl rand -base64 32`,
)
}
this.key = key
}
/**
* Encrypts plaintext using AES-256-GCM.
*
* Output format: [12-byte IV][ciphertext][16-byte auth tag]
*/
encrypt(plaintext: string): Buffer {
const iv = randomBytes(IV_LENGTH)
const cipher = createCipheriv(ALGORITHM, this.key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])
const authTag = cipher.getAuthTag()
return Buffer.concat([iv, encrypted, authTag])
}
/**
* Decrypts a buffer produced by `encrypt`.
*
* Expects format: [12-byte IV][ciphertext][16-byte auth tag]
*/
decrypt(data: Buffer): string {
if (data.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Encrypted data is too short")
}
const iv = data.subarray(0, IV_LENGTH)
const authTag = data.subarray(data.length - AUTH_TAG_LENGTH)
const ciphertext = data.subarray(IV_LENGTH, data.length - AUTH_TAG_LENGTH)
const decipher = createDecipheriv(ALGORITHM, this.key, iv)
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
}
}