tenant-secrets.ts113 lines · main
1import { newId } from '@briven/shared';
2import { and, eq } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { tenantSecrets } from '../db/schema.js';
6
7import {
8 decryptTenantSecret,
9 encryptTenantSecret,
10 type TenantService,
11} from './tenant-secret-store.js';
12
13/**
14 * Persistence layer for per-tenant encrypted secrets (OAuth client secrets,
15 * mittera API keys, webhook signing keys). The crypto lives in
16 * `tenant-secret-store.ts` (HKDF-SHA256 per-tenant key + AES-256-GCM); this
17 * file is the store-it / read-it helper around it, backed by the
18 * control-plane `tenant_secrets` table.
19 *
20 * Identity is the (projectId, service, name) triple — the same namespace the
21 * encryption is scoped to. `service` ('auth' | 'pay') keeps the two briven
22 * services' secrets isolated without separate tables. Plaintext never lands
23 * in the database and `hasTenantSecret` never decrypts.
24 */
25
26// Re-export so callers can type their `service` argument without reaching
27// into the crypto primitive directly.
28export type { TenantService } from './tenant-secret-store.js';
29
30/**
31 * Store (or overwrite) a secret. Encrypts the plaintext via
32 * `encryptTenantSecret`, then UPSERTs keyed by (projectId, service, name).
33 * `createdBy` is recorded on insert only — an overwrite leaves the original
34 * actor in place and just refreshes `encryptedValue` + `updatedAt`.
35 *
36 * Control plane is Postgres 17, so `onConflictDoUpdate` is available (unlike
37 * the DoltGres data plane which needs a manual insert-then-update emulation).
38 */
39export async function setTenantSecret(
40 projectId: string,
41 service: TenantService,
42 name: string,
43 plaintext: string,
44 createdBy?: string | null,
45): Promise<void> {
46 const db = getDb();
47 const encryptedValue = encryptTenantSecret({ service, projectId, plaintext });
48 await db
49 .insert(tenantSecrets)
50 .values({
51 id: newId('tsec'),
52 projectId,
53 service,
54 name,
55 encryptedValue,
56 createdBy: createdBy ?? null,
57 })
58 .onConflictDoUpdate({
59 target: [tenantSecrets.projectId, tenantSecrets.service, tenantSecrets.name],
60 set: { encryptedValue, updatedAt: new Date() },
61 });
62}
63
64/**
65 * Read and decrypt a secret. Returns the plaintext, or `null` when no row
66 * exists for the (projectId, service, name) triple.
67 */
68export async function getTenantSecret(
69 projectId: string,
70 service: TenantService,
71 name: string,
72): Promise<string | null> {
73 const db = getDb();
74 const [row] = await db
75 .select()
76 .from(tenantSecrets)
77 .where(
78 and(
79 eq(tenantSecrets.projectId, projectId),
80 eq(tenantSecrets.service, service),
81 eq(tenantSecrets.name, name),
82 ),
83 )
84 .limit(1);
85 if (!row) return null;
86 return decryptTenantSecret({ service, projectId, ciphertext: row.encryptedValue });
87}
88
89/**
90 * Presence check only — returns whether a secret exists for the
91 * (projectId, service, name) triple. NEVER reads or decrypts the
92 * ciphertext, so it's safe on a hot path that only needs the "is it
93 * configured?" answer.
94 */
95export async function hasTenantSecret(
96 projectId: string,
97 service: TenantService,
98 name: string,
99): Promise<boolean> {
100 const db = getDb();
101 const [row] = await db
102 .select({ id: tenantSecrets.id })
103 .from(tenantSecrets)
104 .where(
105 and(
106 eq(tenantSecrets.projectId, projectId),
107 eq(tenantSecrets.service, service),
108 eq(tenantSecrets.name, name),
109 ),
110 )
111 .limit(1);
112 return row !== undefined;
113}