Skip to content

Commit 0c934df

Browse files
authored
Merge pull request #62 from weissaufschwarz/feature/drizzle-extension-storage
implement drizzle pg extension storage
2 parents fa405a4 + fe160e3 commit 0c934df

File tree

11 files changed

+4237
-2963
lines changed

11 files changed

+4237
-2963
lines changed

.changeset/puny-moons-fail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@weissaufschwarz/mitthooks-drizzle": minor
3+
---
4+
5+
implemented drizzle postgres extension storage
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require("@repo/eslint-config/eslint.config");
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
dist
2+
.prettierrc
3+
tsconfig.json
4+
src/examples/**/*
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"semi": true,
3+
"singleQuote": false,
4+
"quoteProps": "as-needed",
5+
"trailingComma": "all",
6+
"bracketSpacing": true,
7+
"arrowParens": "always",
8+
"proseWrap": "always",
9+
"tabWidth": 4
10+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# mitthooks-drizzle
2+
3+
[Drizzle ORM](https://orm.drizzle.team/) integration for the mitthooks SDK.
4+
This package provides a PostgreSQL-based `ExtensionStorage` implementation using Drizzle ORM with built-in encryption support for sensitive data.
5+
6+
## Features
7+
8+
- PostgreSQL-based storage implementation for mitthooks extension instances
9+
- Built-in encryption for sensitive data (secrets) using AES-256-GCM
10+
- Type-safe database schema using Drizzle ORM
11+
- Automatic timestamp management
12+
- Support for all extension lifecycle operations (install, update, rotate secret, uninstall)
13+
- Extensible schema - add custom columns to the extension instance table
14+
15+
## Why Does This Package Include Schema Builders?
16+
17+
PostgreSQL is a schema-based relational database, which means table structures must be explicitly defined before data can be stored. Unlike schemaless databases like MongoDB where you can simply store JSON documents without predefined structure, PostgreSQL requires:
18+
19+
- Defined column types (varchar, text, boolean, etc.)
20+
- Constraints and relationships
21+
- Indexes and keys
22+
- Migration management
23+
24+
This package provides schema builder functions (`buildExtensionInstanceTable`, `buildEncryptedTextColumn`) rather than managing its own separate database connection or schema. This design allows you to:
25+
26+
- **Integrate seamlessly** - Add the extension instance table to your existing Drizzle schema alongside your application's tables
27+
- **Customize encryption** - Configure your own encryption keys based on your security requirements
28+
- **Extend the schema** - Add custom columns to track additional data specific to your use case
29+
- **Control migrations** - Manage the extension instance table through your application's migration workflow
30+
- **Type safety** - Benefit from Drizzle's type-safe query builder across your entire schema
31+
32+
This approach treats extension storage as a first-class part of your application's database schema, giving you the flexibility and control that Drizzle ORM provides while ensuring everything works together cohesively.
33+
34+
## Installation
35+
36+
```bash
37+
npm install @weissaufschwarz/mitthooks-drizzle drizzle-orm pg
38+
```
39+
40+
Note: `drizzle-orm` and `pg` are peer dependencies and must be installed separately.
41+
42+
## Usage
43+
44+
### 1. Define your database schema
45+
46+
Create a schema file (e.g., `schema.ts`) that exports the extension instance table:
47+
48+
```typescript
49+
import { buildEncryptedTextColumn, buildEncryptionKey } from "@weissaufschwarz/mitthooks-drizzle/pg/encryption";
50+
import { buildExtensionInstanceTable } from "@weissaufschwarz/mitthooks-drizzle/pg/schema";
51+
52+
// Export the context enum for use in your schema
53+
export { context } from "@weissaufschwarz/mitthooks-drizzle/pg/schema";
54+
55+
// Build the encryption key from your environment variables
56+
const encryptionKey = buildEncryptionKey(
57+
process.env.ENCRYPTION_MASTER_PASSWORD!,
58+
process.env.ENCRYPTION_SALT!
59+
);
60+
61+
// Build the encrypted text column
62+
const encryptedTextColumn = buildEncryptedTextColumn(encryptionKey);
63+
64+
// Export the extension instances table
65+
export const extensionInstances = buildExtensionInstanceTable(encryptedTextColumn);
66+
```
67+
68+
The `buildExtensionInstanceTable` function creates a table with the following columns:
69+
- `id` - Unique identifier (varchar, 36 chars, primary key)
70+
- `contextId` - Context identifier, either projectId or customerId (varchar, 36 chars)
71+
- `context` - Context type enum ("customer" or "project")
72+
- `active` - Whether the extension instance is active (boolean)
73+
- `variantKey` - Optional variant key (text)
74+
- `consentedScopes` - Array of consented scopes (text array)
75+
- `secret` - Encrypted secret (text, encrypted using AES-256-GCM)
76+
- `createdAt` - Creation timestamp (automatic)
77+
- `updatedAt` - Last update timestamp (automatic)
78+
79+
#### Extending the schema with custom columns
80+
81+
You can add custom columns to the extension instance table by passing them as the second parameter to `buildExtensionInstanceTable`:
82+
83+
```typescript
84+
import { text, integer } from "drizzle-orm/pg-core";
85+
import { buildEncryptedTextColumn, buildEncryptionKey } from "@weissaufschwarz/mitthooks-drizzle/pg/encryption";
86+
import { buildExtensionInstanceTable } from "@weissaufschwarz/mitthooks-drizzle/pg/schema";
87+
88+
export const extensionInstances = buildExtensionInstanceTable(
89+
buildEncryptedTextColumn(
90+
buildEncryptionKey(
91+
process.env.ENCRYPTION_MASTER_PASSWORD!,
92+
process.env.ENCRYPTION_SALT!
93+
)
94+
),
95+
{
96+
customField: text("custom_field"),
97+
counter: integer("counter").default(0),
98+
}
99+
);
100+
```
101+
102+
**Important:** Custom columns must be nullable or have default values, as the webhook handlers do not set these fields when managing extension instances.
103+
104+
### 2. Initialize the extension storage
105+
106+
Use the `PgExtensionStorage` class with your Drizzle database instance:
107+
108+
```typescript
109+
import { drizzle } from "drizzle-orm/node-postgres";
110+
import { Pool } from "pg";
111+
import { PgExtensionStorage } from "@weissaufschwarz/mitthooks-drizzle/pg";
112+
import { CombinedWebhookHandlerFactory } from "@weissaufschwarz/mitthooks/factory/combined";
113+
import * as schema from "./schema";
114+
115+
// Create a PostgreSQL connection pool
116+
const pool = new Pool({
117+
connectionString: process.env.DATABASE_URL,
118+
});
119+
120+
// Create a Drizzle database instance
121+
const db = drizzle(pool, {
122+
schema,
123+
});
124+
125+
// Create the extension storage
126+
const extensionStorage = new PgExtensionStorage(db, schema.extensionInstances);
127+
128+
// Use it with the mitthooks webhook handler
129+
const combinedHandler = new CombinedWebhookHandlerFactory(
130+
extensionStorage,
131+
process.env.EXTENSION_ID!,
132+
).build();
133+
```
134+
135+
## Encryption
136+
137+
The package uses AES-256-GCM encryption for storing sensitive data (secrets). The encryption key is derived from a master password and salt using the scrypt key derivation function.
138+
139+
### Environment Variables
140+
141+
It is strongly recommended to provide the master password and salt via environment variables:
142+
143+
- Master password for encryption key derivation
144+
- Salt for encryption key derivation
145+
146+
The environment variable names can be chosen freely based on your project's conventions.
147+
148+
**Important:** Once set, these values must never be changed. Changing either the master password or salt will make it impossible to decrypt existing encrypted data in your database.
149+
150+
Make sure to keep these values secret and never commit them to version control.
151+
152+
#### Generating Secure Values
153+
154+
You can generate secure random values for the master password and salt using Node.js:
155+
156+
```bash
157+
# Generate a secure master password (32 bytes, base64 encoded)
158+
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
159+
160+
# Generate a secure salt (16 bytes, base64 encoded)
161+
node -e "console.log(require('crypto').randomBytes(16).toString('base64'))"
162+
```
163+
164+
Store these values in your environment configuration (e.g., `.env` file for local development, secrets manager for production).
165+
166+
### Encryption Details
167+
168+
- Algorithm: AES-256-GCM
169+
- Key derivation: scrypt with custom salt
170+
- Initialization vector: 12 bytes (randomly generated for each encryption)
171+
- Authentication tag: Included for data integrity verification
172+
- Storage format: `base64(iv):base64(encrypted):base64(tag)`
173+
174+
## Database Migration
175+
176+
Make sure to run database migrations to create the `extension_instance` table before using this package. The table schema can be generated using Drizzle Kit:
177+
178+
```bash
179+
npx drizzle-kit generate
180+
npx drizzle-kit migrate
181+
182+
# or for empty databases
183+
npx drizzle-kit push
184+
```
185+
186+
Refer to the [Drizzle ORM documentation](https://orm.drizzle.team/docs/overview) for more information on migrations.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@weissaufschwarz/mitthooks-drizzle",
3+
"version": "0.0.0",
4+
"private": false,
5+
"type": "module",
6+
"exports": {
7+
"./*.js": "./dist/*.js",
8+
"./*": "./dist/*.js"
9+
},
10+
"publishConfig": {
11+
"registry": "https://registry.npmjs.org"
12+
},
13+
"repository": {
14+
"type": "git",
15+
"url": "ssh://git@github.com/weissaufschwarz/mitthooks.git",
16+
"directory": "packages/mitthooks-drizzle"
17+
},
18+
"scripts": {
19+
"build": "tsc -p tsconfig.json",
20+
"test": "vitest run",
21+
"lint": "eslint 'src/**/*.ts' --config .eslintrc.cjs",
22+
"format": "prettier --write -c ./.prettierrc '**/*.{ts,yaml,yml,json,md}'"
23+
},
24+
"dependencies": {
25+
"@weissaufschwarz/mitthooks": "workspace:*"
26+
},
27+
"devDependencies": {
28+
"@types/node": "^22.7.3",
29+
"typescript": "^5.6.2",
30+
"vitest": "^3.2.0",
31+
"@repo/eslint-config": "workspace:*"
32+
},
33+
"peerDependencies": {
34+
"drizzle-orm": "^0.44.7",
35+
"pg": "^8.16.3"
36+
}
37+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { customType } from "drizzle-orm/pg-core";
2+
import {
3+
createCipheriv,
4+
createDecipheriv,
5+
randomBytes,
6+
scryptSync,
7+
} from "node:crypto";
8+
9+
const algorithm = "aes-256-gcm";
10+
const IV_LEN = 12;
11+
12+
export function buildEncryptionKey(
13+
masterPassword: string,
14+
salt: string,
15+
): Buffer {
16+
return scryptSync(masterPassword, salt, 32);
17+
}
18+
19+
export const buildEncryptedTextColumn = (encryptionKey: Buffer) => {
20+
return customType<{ data: string }>({
21+
dataType() {
22+
return "text";
23+
},
24+
fromDriver(value: unknown) {
25+
if (typeof value !== "string")
26+
throw new Error("Invalid encrypted value");
27+
28+
const [ivB64, encB64, tagB64] = value.split(":");
29+
30+
if (!ivB64 || !encB64 || !tagB64) {
31+
throw new Error("Invalid encrypted value");
32+
}
33+
34+
const iv = Buffer.from(ivB64, "base64");
35+
const encrypted = Buffer.from(encB64, "base64");
36+
const tag = Buffer.from(tagB64, "base64");
37+
38+
const decipher = createDecipheriv(algorithm, encryptionKey, iv);
39+
decipher.setAuthTag(tag);
40+
41+
const decrypted = Buffer.concat([
42+
decipher.update(encrypted),
43+
decipher.final(),
44+
]);
45+
return decrypted.toString("utf8");
46+
},
47+
toDriver(value: string) {
48+
const initializationVektor = randomBytes(IV_LEN);
49+
const cipher = createCipheriv(
50+
algorithm,
51+
encryptionKey,
52+
initializationVektor,
53+
);
54+
55+
const encrypted = Buffer.concat([
56+
cipher.update(value, "utf8"),
57+
cipher.final(),
58+
]);
59+
const tag = cipher.getAuthTag();
60+
61+
return [initializationVektor, encrypted, tag]
62+
.map((buf) => buf.toString("base64"))
63+
.join(":");
64+
},
65+
});
66+
};
67+
68+
export type EncryptedTextColumn = ReturnType<typeof buildEncryptedTextColumn>;

0 commit comments

Comments
 (0)