Skip to content

Commit ecab5d3

Browse files
AINATIVEM-42 add AES-256-GCM token encryption
- generate src/crypto/encrypt.ts with encrypt/decrypt using node:crypto - wire CryptoStep into NodeProjectBuilder, add ENCRYPTION_KEY to .env.example - widen access_token/refresh_token columns to TEXT (varchar(768) too small for ciphertext) - encrypt on write, decrypt on read in tokenRepository for all db drivers - add RUN mkdir -p /app/data && chown in Dockerfile for sqlite volume permissions - replace docker compose with docker-compose in CLI next steps - add USER node to generated Dockerfile
1 parent dc2f6ca commit ecab5d3

9 files changed

Lines changed: 246 additions & 23 deletions

File tree

src/cli.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Next steps:
99
cd test-app
1010
cp .env.example .env
1111
# fill in PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET
12-
docker compose up`);
12+
docker-compose up`);
1313
});
1414
});
1515

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function nextStepLines(options: NextStepOptions): string[] {
1616
`cd ${options.nameOrPath}`,
1717
'cp .env.example .env',
1818
'# fill in PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET',
19-
'docker compose up',
19+
'docker-compose up',
2020
];
2121

2222
return ['', 'Next steps:', ...steps.map((s) => ` ${s}`)];

src/generators/node/crypto.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
2+
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join, dirname, resolve } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
8+
9+
describe('generateCrypto', () => {
10+
let tmpDir: string;
11+
12+
beforeEach(async () => {
13+
tmpDir = await mkdtemp(join(tmpdir(), 'cpa-crypto-test-'));
14+
});
15+
16+
afterEach(async () => {
17+
await rm(tmpDir, { recursive: true, force: true });
18+
});
19+
20+
it('generates src/crypto/encrypt.ts', async () => {
21+
const { generateCrypto } = await import('./crypto.js');
22+
await generateCrypto(tmpDir);
23+
const content = await readFile(join(tmpDir, 'src/crypto/encrypt.ts'), 'utf8');
24+
expect(content).toContain("from 'node:crypto'");
25+
expect(content).toContain('aes-256-gcm');
26+
expect(content).toContain('ENCRYPTION_KEY');
27+
expect(content).toContain('export function encrypt');
28+
expect(content).toContain('export function decrypt');
29+
expect(content).toContain('randomBytes(12)');
30+
expect(content).toContain('getAuthTag');
31+
expect(content).toContain('base64url');
32+
});
33+
34+
it('generated encrypt/decrypt round-trips correctly', async () => {
35+
const { generateCrypto } = await import('./crypto.js');
36+
await generateCrypto(tmpDir);
37+
38+
const runner = join(tmpDir, 'test-runner.ts');
39+
await writeFile(
40+
runner,
41+
`
42+
import { encrypt, decrypt } from '${join(tmpDir, 'src/crypto/encrypt.ts')}';
43+
process.env.ENCRYPTION_KEY = 'a'.repeat(64);
44+
const plain = 'hello-token-value';
45+
const enc = encrypt(plain);
46+
const dec = decrypt(enc);
47+
if (dec !== plain) throw new Error(\`expected \${plain}, got \${dec}\`);
48+
const parts = enc.split('.');
49+
if (parts.length !== 3) throw new Error('expected 3 parts');
50+
console.log('ok');
51+
`,
52+
);
53+
const { execSync } = await import('node:child_process');
54+
const out = execSync(`./node_modules/.bin/tsx ${runner}`, {
55+
cwd: repoRoot,
56+
})
57+
.toString()
58+
.trim();
59+
expect(out).toBe('ok');
60+
});
61+
});

src/generators/node/crypto.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import dedent from 'dedent';
2+
import { join } from 'node:path';
3+
import { writeFile } from '../../utils/writeFile.js';
4+
5+
export async function generateCrypto(outputDir: string): Promise<void> {
6+
await writeFile(
7+
join(outputDir, 'src/crypto/encrypt.ts'),
8+
dedent`
9+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
10+
11+
const ALGORITHM = 'aes-256-gcm';
12+
13+
function getKey(): Buffer {
14+
const key = process.env.ENCRYPTION_KEY;
15+
if (!key) throw new Error('ENCRYPTION_KEY is required');
16+
const buf = Buffer.from(key, 'hex');
17+
if (buf.length !== 32) throw new Error('ENCRYPTION_KEY must be a 64-char hex string (32 bytes)');
18+
return buf;
19+
}
20+
21+
export function encrypt(plaintext: string): string {
22+
const iv = randomBytes(12);
23+
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
24+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
25+
const authTag = cipher.getAuthTag();
26+
return \`\${iv.toString('base64url')}.\${authTag.toString('base64url')}.\${ciphertext.toString('base64url')}\`;
27+
}
28+
29+
export function decrypt(ciphertext: string): string {
30+
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
31+
if (!ivB64 || !authTagB64 || !dataB64) throw new Error('Invalid ciphertext format');
32+
const decipher = createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivB64, 'base64url'));
33+
decipher.setAuthTag(Buffer.from(authTagB64, 'base64url'));
34+
return Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64url')), decipher.final()]).toString('utf8');
35+
}
36+
`,
37+
);
38+
}

src/generators/node/database.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,4 +387,78 @@ describe('generateDatabase — tokenRepository.ts', () => {
387387
expect(compose).toContain('depends_on:');
388388
expect(compose).toContain('condition: service_healthy');
389389
});
390+
391+
it('postgres schema uses text for access_token and refresh_token', async () => {
392+
const { generateDatabase } = await import('./database.js');
393+
await generateDatabase(tmpDir, pgOptions);
394+
const schema = await read('src/database/schema.ts');
395+
expect(schema).not.toContain("varchar('access_token'");
396+
expect(schema).not.toContain("varchar('refresh_token'");
397+
expect(schema).toContain("text('access_token')");
398+
expect(schema).toContain("text('refresh_token')");
399+
});
400+
401+
it('postgres migration uses TEXT for access_token and refresh_token', async () => {
402+
const { generateDatabase } = await import('./database.js');
403+
await generateDatabase(tmpDir, pgOptions);
404+
const migration = await read('src/database/migrations/0000_init.sql');
405+
expect(migration).toMatch(/"access_token" TEXT/);
406+
expect(migration).toMatch(/"refresh_token" TEXT/);
407+
});
408+
409+
it('tokenRepository imports encrypt and decrypt', async () => {
410+
const { generateDatabase } = await import('./database.js');
411+
await generateDatabase(tmpDir, pgOptions);
412+
const repo = await read('src/database/tokenRepository.ts');
413+
expect(repo).toContain("from '../crypto/encrypt.js'");
414+
expect(repo).toContain('encrypt(token.access_token)');
415+
expect(repo).toContain('encrypt(token.refresh_token)');
416+
expect(repo).toContain('decrypt(row.accessToken)');
417+
expect(repo).toContain('decrypt(row.refreshToken)');
418+
});
419+
420+
it('mysql schema uses text for access_token and refresh_token', async () => {
421+
const { generateDatabase } = await import('./database.js');
422+
await generateDatabase(tmpDir, mysqlOptions);
423+
const schema = await read('src/database/schema.ts');
424+
expect(schema).not.toContain("varchar('access_token'");
425+
expect(schema).not.toContain("varchar('refresh_token'");
426+
expect(schema).toContain("text('access_token')");
427+
expect(schema).toContain("text('refresh_token')");
428+
});
429+
430+
it('mysql migration uses TEXT for access_token and refresh_token', async () => {
431+
const { generateDatabase } = await import('./database.js');
432+
await generateDatabase(tmpDir, mysqlOptions);
433+
const migration = await read('src/database/migrations/0000_init.sql');
434+
expect(migration).toMatch(/`access_token` TEXT/);
435+
expect(migration).toMatch(/`refresh_token` TEXT/);
436+
});
437+
438+
it('mysql tokenRepository imports encrypt and decrypt', async () => {
439+
const { generateDatabase } = await import('./database.js');
440+
await generateDatabase(tmpDir, mysqlOptions);
441+
const repo = await read('src/database/tokenRepository.ts');
442+
expect(repo).toContain("from '../crypto/encrypt.js'");
443+
expect(repo).toContain('encrypt(token.access_token)');
444+
expect(repo).toContain('encrypt(token.refresh_token)');
445+
expect(repo).toContain('decrypt(row.accessToken)');
446+
expect(repo).toContain('decrypt(row.refreshToken)');
447+
});
448+
});
449+
450+
describe('generateDatabase — Dockerfile', () => {
451+
it('generates Dockerfile with USER node when no app extensions', async () => {
452+
const { generateDatabase } = await import('./database.js');
453+
await generateDatabase(tmpDir, sqliteOptions);
454+
expect(await exists(join(tmpDir, 'Dockerfile'))).toBe(true);
455+
const content = await read('Dockerfile');
456+
expect(content).toContain('FROM node:24-alpine');
457+
expect(content).toContain('WORKDIR /app');
458+
expect(content).toContain('COPY package*.json ./');
459+
expect(content).toContain('RUN npm install');
460+
expect(content).toContain('COPY . .');
461+
expect(content).toContain('RUN mkdir -p /app/data && chown -R node:node /app/data');
462+
expect(content).toContain('USER node');
463+
});
390464
});

src/generators/node/database.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ function schemaContent(database: GeneratorOptions['database']): string {
3636
{
3737
pipedriveCompanyId: integer('pipedrive_company_id').notNull(),
3838
pipedriveUserId: integer('pipedrive_user_id').notNull(),
39-
accessToken: varchar('access_token', { length: 768 }).notNull(),
40-
refreshToken: varchar('refresh_token', { length: 768 }).notNull(),
39+
accessToken: text('access_token').notNull(),
40+
refreshToken: text('refresh_token').notNull(),
4141
tokenType: varchar('token_type', { length: 50 }).notNull().default('bearer'),
4242
accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(),
4343
refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(),
@@ -62,8 +62,8 @@ function schemaContent(database: GeneratorOptions['database']): string {
6262
{
6363
pipedriveCompanyId: int('pipedrive_company_id').notNull(),
6464
pipedriveUserId: int('pipedrive_user_id').notNull(),
65-
accessToken: varchar('access_token', { length: 768 }).notNull(),
66-
refreshToken: varchar('refresh_token', { length: 768 }).notNull(),
65+
accessToken: text('access_token').notNull(),
66+
refreshToken: text('refresh_token').notNull(),
6767
tokenType: varchar('token_type', { length: 50 }).notNull().default('bearer'),
6868
accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(),
6969
refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(),
@@ -196,8 +196,8 @@ function migrationSqlContent(database: GeneratorOptions['database']): string {
196196
CREATE TABLE IF NOT EXISTS "pipedrive_tokens" (
197197
"pipedrive_company_id" INTEGER NOT NULL,
198198
"pipedrive_user_id" INTEGER NOT NULL,
199-
"access_token" VARCHAR(768) NOT NULL,
200-
"refresh_token" VARCHAR(768) NOT NULL,
199+
"access_token" TEXT NOT NULL,
200+
"refresh_token" TEXT NOT NULL,
201201
"token_type" VARCHAR(50) NOT NULL DEFAULT 'bearer',
202202
"access_token_expires_at" TIMESTAMP NOT NULL,
203203
"refresh_token_expires_at" TIMESTAMP NOT NULL,
@@ -215,8 +215,8 @@ function migrationSqlContent(database: GeneratorOptions['database']): string {
215215
CREATE TABLE IF NOT EXISTS \`pipedrive_tokens\` (
216216
\`pipedrive_company_id\` INT NOT NULL,
217217
\`pipedrive_user_id\` INT NOT NULL,
218-
\`access_token\` VARCHAR(768) NOT NULL,
219-
\`refresh_token\` VARCHAR(768) NOT NULL,
218+
\`access_token\` TEXT NOT NULL,
219+
\`refresh_token\` TEXT NOT NULL,
220220
\`token_type\` VARCHAR(50) NOT NULL DEFAULT 'bearer',
221221
\`access_token_expires_at\` TIMESTAMP NOT NULL,
222222
\`refresh_token_expires_at\` TIMESTAMP NOT NULL,
@@ -258,15 +258,16 @@ function tokenRepositoryContent(database: GeneratorOptions['database']): string
258258
import type { TokenResponse } from 'pipedrive/v2';
259259
import { db } from './index.js';
260260
import { pipedriveTokens } from './schema.js';
261+
import { encrypt, decrypt } from '../crypto/encrypt.js';
261262
262263
const REFRESH_TOKEN_TTL_MS = 60 * 24 * 60 * 60 * 1000;
263264
264265
export type StoredToken = { companyId: number; userId: number; token: TokenResponse };
265266
266267
function toTokenResponse(row: typeof pipedriveTokens.$inferSelect): TokenResponse {
267268
return {
268-
access_token: row.accessToken,
269-
refresh_token: row.refreshToken,
269+
access_token: decrypt(row.accessToken),
270+
refresh_token: decrypt(row.refreshToken),
270271
token_type: row.tokenType,
271272
expires_in: Math.max(0, Math.floor((row.accessTokenExpiresAt.getTime() - Date.now()) / 1000)),
272273
scope: row.scope ?? '',
@@ -304,8 +305,8 @@ function tokenRepositoryContent(database: GeneratorOptions['database']): string
304305
.values({
305306
pipedriveCompanyId: companyId,
306307
pipedriveUserId: userId,
307-
accessToken: token.access_token,
308-
refreshToken: token.refresh_token,
308+
accessToken: encrypt(token.access_token),
309+
refreshToken: encrypt(token.refresh_token),
309310
tokenType: token.token_type,
310311
accessTokenExpiresAt,
311312
refreshTokenExpiresAt,
@@ -316,8 +317,8 @@ function tokenRepositoryContent(database: GeneratorOptions['database']): string
316317
})
317318
.onDuplicateKeyUpdate({
318319
set: {
319-
accessToken: token.access_token,
320-
refreshToken: token.refresh_token,
320+
accessToken: encrypt(token.access_token),
321+
refreshToken: encrypt(token.refresh_token),
321322
tokenType: token.token_type,
322323
accessTokenExpiresAt,
323324
refreshTokenExpiresAt,
@@ -335,15 +336,16 @@ function tokenRepositoryContent(database: GeneratorOptions['database']): string
335336
import type { TokenResponse } from 'pipedrive/v2';
336337
import { db } from './index.js';
337338
import { pipedriveTokens } from './schema.js';
339+
import { encrypt, decrypt } from '../crypto/encrypt.js';
338340
339341
const REFRESH_TOKEN_TTL_MS = 60 * 24 * 60 * 60 * 1000;
340342
341343
export type StoredToken = { companyId: number; userId: number; token: TokenResponse };
342344
343345
function toTokenResponse(row: typeof pipedriveTokens.$inferSelect): TokenResponse {
344346
return {
345-
access_token: row.accessToken,
346-
refresh_token: row.refreshToken,
347+
access_token: decrypt(row.accessToken),
348+
refresh_token: decrypt(row.refreshToken),
347349
token_type: row.tokenType,
348350
expires_in: Math.max(0, Math.floor((row.accessTokenExpiresAt.getTime() - Date.now()) / 1000)),
349351
scope: row.scope ?? '',
@@ -381,8 +383,8 @@ function tokenRepositoryContent(database: GeneratorOptions['database']): string
381383
.values({
382384
pipedriveCompanyId: companyId,
383385
pipedriveUserId: userId,
384-
accessToken: token.access_token,
385-
refreshToken: token.refresh_token,
386+
accessToken: encrypt(token.access_token),
387+
refreshToken: encrypt(token.refresh_token),
386388
tokenType: token.token_type,
387389
accessTokenExpiresAt,
388390
refreshTokenExpiresAt,
@@ -394,8 +396,8 @@ function tokenRepositoryContent(database: GeneratorOptions['database']): string
394396
.onConflictDoUpdate({
395397
target: [pipedriveTokens.pipedriveCompanyId, pipedriveTokens.pipedriveUserId],
396398
set: {
397-
accessToken: token.access_token,
398-
refreshToken: token.refresh_token,
399+
accessToken: encrypt(token.access_token),
400+
refreshToken: encrypt(token.refresh_token),
399401
tokenType: token.token_type,
400402
accessTokenExpiresAt,
401403
refreshTokenExpiresAt,
@@ -794,6 +796,8 @@ async function generateDockerfile(outputDir: string): Promise<void> {
794796
COPY package*.json ./
795797
RUN npm install
796798
COPY . .
799+
RUN mkdir -p /app/data && chown -R node:node /app/data
800+
USER node
797801
`,
798802
);
799803
}

src/generators/node/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const nodeGenerator: Generator = {
55
async generate(outputDir: string, options: GeneratorOptions): Promise<void> {
66
await new NodeProjectBuilder(outputDir, options)
77
.addDatabase()
8+
.addCrypto()
89
.addOAuth()
910
.addApp()
1011
.when(options.appExtensions.length > 0, (b) => b.addAppExtensions())

src/generators/node/projectBuilder.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { readFile, rm } from 'node:fs/promises';
2+
import { readFile, rm, mkdtemp } from 'node:fs/promises';
33
import { join } from 'node:path';
44
import { tmpdir } from 'node:os';
55
import type { GeneratorOptions } from '../interface.js';
@@ -166,4 +166,37 @@ describe('NodeProjectBuilder', () => {
166166

167167
await rm(tmpDir, { recursive: true, force: true });
168168
});
169+
170+
it('generates ENCRYPTION_KEY in .env.example', async () => {
171+
const outputDir = await mkdtemp(join(tmpdir(), 'cpa-builder-crypto-'));
172+
try {
173+
const builder = new NodeProjectBuilder(outputDir, {
174+
projectName: 'test',
175+
database: 'sqlite',
176+
appExtensions: [],
177+
});
178+
await builder.addEnvExample().build();
179+
const env = await readFile(join(outputDir, '.env.example'), 'utf8');
180+
expect(env).toContain('ENCRYPTION_KEY=');
181+
expect(env).toContain('openssl rand -hex 32');
182+
} finally {
183+
await rm(outputDir, { recursive: true, force: true });
184+
}
185+
});
186+
187+
it('generates src/crypto/encrypt.ts via addCrypto', async () => {
188+
const outputDir = await mkdtemp(join(tmpdir(), 'cpa-builder-crypto-'));
189+
try {
190+
const builder = new NodeProjectBuilder(outputDir, {
191+
projectName: 'test',
192+
database: 'sqlite',
193+
appExtensions: [],
194+
});
195+
await builder.addCrypto().build();
196+
const content = await readFile(join(outputDir, 'src/crypto/encrypt.ts'), 'utf8');
197+
expect(content).toContain('aes-256-gcm');
198+
} finally {
199+
await rm(outputDir, { recursive: true, force: true });
200+
}
201+
});
169202
});

0 commit comments

Comments
 (0)