feat(notelett): encrypt note body with @bytelyst/field-encrypt

- Add field-encrypt dependency + config env vars (FIELD_ENCRYPT_*)
- Create backend/src/lib/field-encrypt.ts encryptor singleton
- Update notes repository: encrypt body on create/update, decrypt on read
- Backward-compatible: isEncryptedField guard handles plaintext during migration
- All 86 tests passing
This commit is contained in:
saravanakumardb1 2026-03-21 09:29:44 -07:00
parent d12c5bfa02
commit e85cfeb0f1
5 changed files with 102 additions and 5 deletions

View File

@ -19,6 +19,7 @@
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
"@bytelyst/fastify-auth": "file:../../learning_ai_common_plat/packages/fastify-auth",
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
"@bytelyst/field-encrypt": "file:../../learning_ai_common_plat/packages/field-encrypt",
"@bytelyst/logger": "file:../../learning_ai_common_plat/packages/logger",
"fastify": "^5.2.1",
"jose": "^6.0.8",
@ -160,6 +161,30 @@
}
}
},
"../../learning_ai_common_plat/packages/field-encrypt": {
"name": "@bytelyst/field-encrypt",
"version": "0.1.0",
"dependencies": {
"@bytelyst/errors": "workspace:*"
},
"devDependencies": {
"vitest": "^3.0.0",
"zod": "^3.24.0"
},
"peerDependencies": {
"@azure/identity": ">=4.0.0",
"@azure/keyvault-keys": ">=4.8.0",
"zod": ">=3.22.0"
},
"peerDependenciesMeta": {
"@azure/identity": {
"optional": true
},
"@azure/keyvault-keys": {
"optional": true
}
}
},
"../../learning_ai_common_plat/packages/logger": {
"name": "@bytelyst/logger",
"version": "0.1.0"
@ -449,6 +474,10 @@
"resolved": "../../learning_ai_common_plat/packages/fastify-core",
"link": true
},
"node_modules/@bytelyst/field-encrypt": {
"resolved": "../../learning_ai_common_plat/packages/field-encrypt",
"link": true
},
"node_modules/@bytelyst/logger": {
"resolved": "../../learning_ai_common_plat/packages/logger",
"link": true

View File

@ -22,6 +22,7 @@
"@bytelyst/backend-flags": "file:../../learning_ai_common_plat/packages/backend-flags",
"@bytelyst/backend-telemetry": "file:../../learning_ai_common_plat/packages/backend-telemetry",
"@bytelyst/errors": "file:../../learning_ai_common_plat/packages/errors",
"@bytelyst/field-encrypt": "file:../../learning_ai_common_plat/packages/field-encrypt",
"@bytelyst/fastify-auth": "file:../../learning_ai_common_plat/packages/fastify-auth",
"@bytelyst/fastify-core": "file:../../learning_ai_common_plat/packages/fastify-core",
"@bytelyst/logger": "file:../../learning_ai_common_plat/packages/logger",

View File

@ -14,6 +14,11 @@ const envSchema = baseBackendConfigSchema.extend({
MCP_SERVER_URL: z.string().default('http://localhost:4007'),
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false),
// ── Field Encryption (@bytelyst/field-encrypt) ──
FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['akv', 'env', 'memory']).default('memory'),
FIELD_ENCRYPT_KEY: z.string().default(''),
FIELD_ENCRYPT_MEK_NAME: z.string().default('notelett-mek'),
AZURE_KEYVAULT_URL: z.string().default(''),
});
export const config = envSchema.parse(process.env);

View File

@ -0,0 +1,26 @@
/**
* Field encryption singleton for NoteLett backend.
*/
import { createFieldEncryptor, type FieldEncryptor } from '@bytelyst/field-encrypt';
import { config } from './config.js';
let _encryptor: FieldEncryptor | null = null;
export function getEncryptor(): FieldEncryptor {
if (_encryptor) return _encryptor;
_encryptor = createFieldEncryptor({
keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER,
encryptionKey: config.FIELD_ENCRYPT_KEY || undefined,
keyVaultUrl: config.AZURE_KEYVAULT_URL || undefined,
mekName: config.FIELD_ENCRYPT_MEK_NAME || undefined,
});
return _encryptor;
}
/** @internal — for testing only. */
export function _resetEncryptor(): void {
_encryptor = null;
}

View File

@ -1,11 +1,38 @@
import { getCollection } from '../../lib/datastore.js';
import { getEncryptor } from '../../lib/field-encrypt.js';
import { isEncryptedField, type EncryptedField } from '@bytelyst/field-encrypt';
import type { NoteDoc, ListNotesQuery } from './types.js';
import type { FilterMap } from '@bytelyst/datastore';
const ENCRYPT_CONTEXT = 'notes';
function collection() {
return getCollection<NoteDoc>('notes', '/workspaceId');
}
/** Encrypt body before storage. */
async function encryptFields(doc: NoteDoc): Promise<NoteDoc> {
const enc = getEncryptor();
const ctx = { userId: doc.userId, context: ENCRYPT_CONTEXT };
const encrypted = await enc.encrypt(doc.body, ctx);
return { ...doc, body: encrypted as unknown as string };
}
/** Decrypt body after read. Handles plaintext (pre-migration) gracefully. */
async function decryptFields(doc: NoteDoc): Promise<NoteDoc> {
const enc = getEncryptor();
const ctx = { userId: doc.userId, context: ENCRYPT_CONTEXT };
let body = doc.body;
if (isEncryptedField(body)) {
body = await enc.decrypt(body as unknown as EncryptedField, ctx);
}
return { ...doc, body };
}
async function decryptBatch(docs: NoteDoc[]): Promise<NoteDoc[]> {
return Promise.all(docs.map(decryptFields));
}
export async function listNotes(
userId: string,
productId: string,
@ -26,7 +53,7 @@ export async function listNotes(
limit: query.limit,
});
return { items, total };
return { items: await decryptBatch(items), total };
}
export async function countNotesByWorkspaces(
@ -43,11 +70,15 @@ export async function countNotesByWorkspaces(
}
export async function getNote(id: string, workspaceId: string): Promise<NoteDoc | null> {
return collection().findById(id, workspaceId);
const doc = await collection().findById(id, workspaceId);
if (!doc) return null;
return decryptFields(doc);
}
export async function createNote(doc: NoteDoc): Promise<NoteDoc> {
return collection().create(doc);
const encrypted = await encryptFields(doc);
const created = await collection().create(encrypted);
return decryptFields(created);
}
export async function updateNote(
@ -60,8 +91,11 @@ export async function updateNote(
return null;
}
// Decrypt existing body first so merge works correctly
const decryptedExisting = await decryptFields(existing);
const merged: NoteDoc = {
...existing,
...decryptedExisting,
...updates,
id: existing.id,
workspaceId: existing.workspaceId,
@ -69,5 +103,7 @@ export async function updateNote(
productId: existing.productId,
};
return collection().upsert(merged);
const encrypted = await encryptFields(merged);
const result = await collection().upsert(encrypted);
return decryptFields(result);
}