diff --git a/backend/src/lib/encrypted-fields.ts b/backend/src/lib/encrypted-fields.ts new file mode 100644 index 0000000..7ea060a --- /dev/null +++ b/backend/src/lib/encrypted-fields.ts @@ -0,0 +1,25 @@ +import { isEncryptedField, type EncryptedField } from '@bytelyst/field-encrypt'; +import { getEncryptor } from './field-encrypt.js'; + +export async function encryptTextField( + value: string | undefined, + userId: string, + context: string, +): Promise { + if (value === undefined) return undefined; + if (isEncryptedField(value)) return value as unknown as string; + + const encrypted = await getEncryptor().encrypt(value, { userId, context }); + return encrypted as unknown as string; +} + +export async function decryptTextField( + value: string | undefined, + userId: string, + context: string, +): Promise { + if (value === undefined) return undefined; + if (!isEncryptedField(value)) return value; + + return getEncryptor().decrypt(value as unknown as EncryptedField, { userId, context }); +} diff --git a/backend/src/lib/encryption-coverage.test.ts b/backend/src/lib/encryption-coverage.test.ts new file mode 100644 index 0000000..3c7992e --- /dev/null +++ b/backend/src/lib/encryption-coverage.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { isEncryptedField } from '@bytelyst/field-encrypt'; +import { getCollection } from './datastore.js'; +import { _resetEncryptor } from './field-encrypt.js'; +import { resetMemoryDatastore } from '../test-helpers.js'; +import { createNoteArtifact, getNoteArtifact } from '../modules/note-artifacts/repository.js'; +import type { NoteArtifactDoc } from '../modules/note-artifacts/types.js'; +import { createPromptTemplate, getPromptTemplate } from '../modules/note-prompts/repository.js'; +import type { PromptTemplateDoc } from '../modules/note-prompts/types.js'; +import { + createNoteAgentAction, + getNoteAgentAction, + updateNoteAgentAction, +} from '../modules/note-agent-actions/repository.js'; +import type { NoteAgentActionDoc } from '../modules/note-agent-actions/types.js'; + +describe('field encryption coverage', () => { + beforeEach(() => { + resetMemoryDatastore(); + _resetEncryptor(); + }); + + it('encrypts sensitive artifact metadata while returning plaintext to callers', async () => { + const created = await createNoteArtifact({ + id: 'artifact-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + noteId: 'note-1', + artifactType: 'summary', + title: 'Private summary', + description: 'Sensitive extracted summary', + blobPath: 'notelett/user-1/private-summary.md', + contentType: 'text/markdown', + createdAt: '2026-05-05T00:00:00.000Z', + createdBy: 'user-1', + updatedAt: '2026-05-05T00:00:00.000Z', + updatedBy: 'user-1', + }); + + expect(created.title).toBe('Private summary'); + expect(created.description).toBe('Sensitive extracted summary'); + + const raw = await getCollection('note_artifacts', '/workspaceId').findById('artifact-1', 'ws-1'); + expect(isEncryptedField(raw?.title)).toBe(true); + expect(isEncryptedField(raw?.description)).toBe(true); + expect(isEncryptedField(raw?.blobPath)).toBe(true); + + const fetched = await getNoteArtifact('artifact-1', 'ws-1'); + expect(fetched).toMatchObject({ + title: 'Private summary', + description: 'Sensitive extracted summary', + blobPath: 'notelett/user-1/private-summary.md', + }); + }); + + it('encrypts custom prompt content while preserving slug lookup', async () => { + const created = await createPromptTemplate('user-1', { + slug: 'private-template', + name: 'Private Template', + description: 'Internal reasoning instructions', + systemPrompt: 'Use confidential house style.', + userPromptTemplate: 'Rewrite {{note}} for leadership.', + inputType: 'text', + outputType: 'new_note', + category: 'transform', + requiresApproval: true, + }); + + expect(created.systemPrompt).toBe('Use confidential house style.'); + expect(created.userPromptTemplate).toBe('Rewrite {{note}} for leadership.'); + + const raw = await getCollection('note_prompts', '/userId').findById(created.id, 'user-1'); + expect(raw?.slug).toBe('private-template'); + expect(isEncryptedField(raw?.description)).toBe(true); + expect(isEncryptedField(raw?.systemPrompt)).toBe(true); + expect(isEncryptedField(raw?.userPromptTemplate)).toBe(true); + + const fetched = await getPromptTemplate(created.id, 'user-1'); + expect(fetched).toMatchObject({ + slug: 'private-template', + description: 'Internal reasoning instructions', + systemPrompt: 'Use confidential house style.', + userPromptTemplate: 'Rewrite {{note}} for leadership.', + }); + }); + + it('encrypts agent action details and review notes while preserving query metadata', async () => { + await createNoteAgentAction({ + id: 'action-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + noteId: 'note-1', + actorId: 'agent-1', + actorType: 'agent', + toolName: 'notes.notes.update', + actionType: 'update', + state: 'proposed', + reason: 'Agent proposed a sensitive rewrite', + beforeSummary: 'Original private note', + afterSummary: 'Rewritten private note', + idempotencyKey: 'idem-1', + correlationId: 'corr-1', + createdAt: '2026-05-05T00:00:00.000Z', + createdBy: 'user-1', + updatedAt: '2026-05-05T00:00:00.000Z', + updatedBy: 'user-1', + }); + + await updateNoteAgentAction('action-1', 'ws-1', { + state: 'approved', + reviewNote: 'Approved after private review', + updatedAt: '2026-05-05T01:00:00.000Z', + updatedBy: 'user-1', + }); + + const raw = await getCollection('note_agent_actions', '/workspaceId').findById('action-1', 'ws-1'); + expect(raw?.toolName).toBe('notes.notes.update'); + expect(raw?.idempotencyKey).toBe('idem-1'); + expect(isEncryptedField(raw?.reason)).toBe(true); + expect(isEncryptedField(raw?.beforeSummary)).toBe(true); + expect(isEncryptedField(raw?.afterSummary)).toBe(true); + expect(isEncryptedField(raw?.reviewNote)).toBe(true); + + const fetched = await getNoteAgentAction('action-1', 'ws-1'); + expect(fetched).toMatchObject({ + reason: 'Agent proposed a sensitive rewrite', + beforeSummary: 'Original private note', + afterSummary: 'Rewritten private note', + reviewNote: 'Approved after private review', + }); + }); +}); diff --git a/backend/src/modules/note-agent-actions/repository.ts b/backend/src/modules/note-agent-actions/repository.ts index 8ce65d0..a637fa4 100644 --- a/backend/src/modules/note-agent-actions/repository.ts +++ b/backend/src/modules/note-agent-actions/repository.ts @@ -1,11 +1,38 @@ import { getCollection } from '../../lib/datastore.js'; +import { decryptTextField, encryptTextField } from '../../lib/encrypted-fields.js'; import type { NoteAgentActionDoc, ListNoteAgentActionsQuery } from './types.js'; import type { FilterMap } from '@bytelyst/datastore'; +const ENCRYPT_CONTEXT = 'note_agent_actions'; + function collection() { return getCollection('note_agent_actions', '/workspaceId'); } +async function encryptFields(doc: NoteAgentActionDoc): Promise { + return { + ...doc, + reason: await encryptTextField(doc.reason, doc.userId, ENCRYPT_CONTEXT), + beforeSummary: await encryptTextField(doc.beforeSummary, doc.userId, ENCRYPT_CONTEXT), + afterSummary: await encryptTextField(doc.afterSummary, doc.userId, ENCRYPT_CONTEXT), + reviewNote: await encryptTextField(doc.reviewNote, doc.userId, ENCRYPT_CONTEXT), + }; +} + +async function decryptFields(doc: NoteAgentActionDoc): Promise { + return { + ...doc, + reason: await decryptTextField(doc.reason, doc.userId, ENCRYPT_CONTEXT), + beforeSummary: await decryptTextField(doc.beforeSummary, doc.userId, ENCRYPT_CONTEXT), + afterSummary: await decryptTextField(doc.afterSummary, doc.userId, ENCRYPT_CONTEXT), + reviewNote: await decryptTextField(doc.reviewNote, doc.userId, ENCRYPT_CONTEXT), + }; +} + +async function decryptBatch(docs: NoteAgentActionDoc[]): Promise { + return Promise.all(docs.map(decryptFields)); +} + export async function listNoteAgentActions( userId: string, productId: string, @@ -27,18 +54,20 @@ export async function listNoteAgentActions( limit: query.limit, }); - return { items, total }; + return { items: await decryptBatch(items), total }; } export async function getNoteAgentAction( id: string, workspaceId: string ): Promise { - return collection().findById(id, workspaceId); + const doc = await collection().findById(id, workspaceId); + return doc ? decryptFields(doc) : null; } export async function createNoteAgentAction(doc: NoteAgentActionDoc): Promise { - return collection().create(doc); + const created = await collection().create(await encryptFields(doc)); + return decryptFields(created); } export async function listPendingActions( @@ -62,9 +91,9 @@ export async function listPendingActions( collection().findMany({ filter: proposedFilter, sort: { updatedAt: -1 }, offset: 0, limit: limit + offset }), ]); - const merged = [...draftItems, ...proposedItems] + const merged = await decryptBatch([...draftItems, ...proposedItems] .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) - .slice(offset, offset + limit); + .slice(offset, offset + limit)); return { items: merged, total }; } @@ -80,7 +109,7 @@ export async function updateNoteAgentAction( } const merged: NoteAgentActionDoc = { - ...existing, + ...(await decryptFields(existing)), ...updates, id: existing.id, workspaceId: existing.workspaceId, @@ -93,5 +122,6 @@ export async function updateNoteAgentAction( actionType: existing.actionType, }; - return collection().upsert(merged); + const updated = await collection().upsert(await encryptFields(merged)); + return decryptFields(updated); } diff --git a/backend/src/modules/note-artifacts/repository.ts b/backend/src/modules/note-artifacts/repository.ts index 43ea787..1090167 100644 --- a/backend/src/modules/note-artifacts/repository.ts +++ b/backend/src/modules/note-artifacts/repository.ts @@ -1,11 +1,36 @@ import { getCollection } from '../../lib/datastore.js'; +import { decryptTextField, encryptTextField } from '../../lib/encrypted-fields.js'; import type { NoteArtifactDoc, ListNoteArtifactsQuery } from './types.js'; import type { FilterMap } from '@bytelyst/datastore'; +const ENCRYPT_CONTEXT = 'note_artifacts'; + function collection() { return getCollection('note_artifacts', '/workspaceId'); } +async function encryptFields(doc: NoteArtifactDoc): Promise { + return { + ...doc, + title: (await encryptTextField(doc.title, doc.userId, ENCRYPT_CONTEXT)) ?? doc.title, + description: await encryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT), + blobPath: await encryptTextField(doc.blobPath, doc.userId, ENCRYPT_CONTEXT), + }; +} + +async function decryptFields(doc: NoteArtifactDoc): Promise { + return { + ...doc, + title: (await decryptTextField(doc.title, doc.userId, ENCRYPT_CONTEXT)) ?? doc.title, + description: await decryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT), + blobPath: await decryptTextField(doc.blobPath, doc.userId, ENCRYPT_CONTEXT), + }; +} + +async function decryptBatch(docs: NoteArtifactDoc[]): Promise { + return Promise.all(docs.map(decryptFields)); +} + export async function listNoteArtifacts( userId: string, productId: string, @@ -29,15 +54,17 @@ export async function listNoteArtifacts( limit: query.limit, }); - return { items, total }; + return { items: await decryptBatch(items), total }; } export async function getNoteArtifact(id: string, workspaceId: string): Promise { - return collection().findById(id, workspaceId); + const doc = await collection().findById(id, workspaceId); + return doc ? decryptFields(doc) : null; } export async function createNoteArtifact(doc: NoteArtifactDoc): Promise { - return collection().create(doc); + const created = await collection().create(await encryptFields(doc)); + return decryptFields(created); } export async function deleteNoteArtifact(id: string, workspaceId: string): Promise { @@ -58,7 +85,7 @@ export async function updateNoteArtifact( } const merged: NoteArtifactDoc = { - ...existing, + ...(await decryptFields(existing)), ...updates, id: existing.id, workspaceId: existing.workspaceId, @@ -68,5 +95,6 @@ export async function updateNoteArtifact( artifactType: existing.artifactType, }; - return collection().upsert(merged); + const updated = await collection().upsert(await encryptFields(merged)); + return decryptFields(updated); } diff --git a/backend/src/modules/note-prompts/repository.ts b/backend/src/modules/note-prompts/repository.ts index dec657c..402f556 100644 --- a/backend/src/modules/note-prompts/repository.ts +++ b/backend/src/modules/note-prompts/repository.ts @@ -6,6 +6,7 @@ */ import { getCollection } from '../../lib/datastore.js'; +import { decryptTextField, encryptTextField } from '../../lib/encrypted-fields.js'; import { PRODUCT_ID } from '../../lib/product-config.js'; import type { FilterMap } from '@bytelyst/datastore'; import type { @@ -17,11 +18,34 @@ import type { const COLLECTION = 'note_prompts'; const PARTITION_KEY = '/userId'; +const ENCRYPT_CONTEXT = 'note_prompts'; function col() { return getCollection(COLLECTION, PARTITION_KEY); } +async function encryptFields(doc: PromptTemplateDoc): Promise { + return { + ...doc, + description: (await encryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT)) ?? '', + systemPrompt: (await encryptTextField(doc.systemPrompt, doc.userId, ENCRYPT_CONTEXT)) ?? doc.systemPrompt, + userPromptTemplate: (await encryptTextField(doc.userPromptTemplate, doc.userId, ENCRYPT_CONTEXT)) ?? doc.userPromptTemplate, + }; +} + +async function decryptFields(doc: PromptTemplateDoc): Promise { + return { + ...doc, + description: (await decryptTextField(doc.description, doc.userId, ENCRYPT_CONTEXT)) ?? '', + systemPrompt: (await decryptTextField(doc.systemPrompt, doc.userId, ENCRYPT_CONTEXT)) ?? doc.systemPrompt, + userPromptTemplate: (await decryptTextField(doc.userPromptTemplate, doc.userId, ENCRYPT_CONTEXT)) ?? doc.userPromptTemplate, + }; +} + +async function decryptBatch(docs: PromptTemplateDoc[]): Promise { + return Promise.all(docs.map(decryptFields)); +} + export async function createPromptTemplate( userId: string, input: CreatePromptTemplateInput, @@ -47,14 +71,16 @@ export async function createPromptTemplate( createdAt: now, updatedAt: now, }; - return col().create(doc); + const created = await col().create(await encryptFields(doc)); + return decryptFields(created); } export async function getPromptTemplate( id: string, userId: string, ): Promise { - return col().findById(id, userId); + const doc = await col().findById(id, userId); + return doc ? decryptFields(doc) : null; } export async function getPromptTemplateBySlug( @@ -66,14 +92,14 @@ export async function getPromptTemplateBySlug( filter: { slug, userId, productId: PRODUCT_ID } as FilterMap, limit: 1, }); - if (userResults.length > 0) return userResults[0]; + if (userResults.length > 0) return decryptFields(userResults[0]); // Check builtins (userId = '__builtin__') const builtinResults = await col().findMany({ filter: { slug, userId: '__builtin__', productId: PRODUCT_ID, isBuiltin: true } as FilterMap, limit: 1, }); - return builtinResults[0] ?? null; + return builtinResults[0] ? decryptFields(builtinResults[0]) : null; } export async function updatePromptTemplate( @@ -85,11 +111,12 @@ export async function updatePromptTemplate( if (!existing || existing.isBuiltin) return null; const updated: PromptTemplateDoc = { - ...existing, + ...(await decryptFields(existing)), ...input, updatedAt: new Date().toISOString(), }; - return col().upsert(updated); + const saved = await col().upsert(await encryptFields(updated)); + return decryptFields(saved); } export async function deletePromptTemplate( @@ -134,7 +161,7 @@ export async function listPromptTemplates( } // Merge: builtins first, then user - const all = [...builtinItems, ...userItems]; + const all = await decryptBatch([...builtinItems, ...userItems]); return { items: all.slice(query.offset, query.offset + query.limit), total: all.length, diff --git a/docs/FIELD_ENCRYPTION_COVERAGE.md b/docs/FIELD_ENCRYPTION_COVERAGE.md new file mode 100644 index 0000000..cd23964 --- /dev/null +++ b/docs/FIELD_ENCRYPTION_COVERAGE.md @@ -0,0 +1,26 @@ +# NoteLett Field Encryption Coverage + +Date: May 5, 2026 + +## Encrypted Fields + +NoteLett uses `@bytelyst/field-encrypt` through `backend/src/lib/field-encrypt.ts`. Repositories decrypt fields before returning documents and accept plaintext legacy fields during reads. + +| Container | Encrypted fields | Query-safe plaintext fields | +| --- | --- | --- | +| `notes` | `body` | `id`, `productId`, `workspaceId`, `userId`, `title`, `status`, `tags`, timestamps | +| `palace_memories` | `content` | wing/room ids, kinds, tags, embeddings, timestamps | +| `note_artifacts` | `title`, `description`, `blobPath` | `id`, `productId`, `workspaceId`, `userId`, `noteId`, `artifactType`, `contentType`, `sizeBytes`, timestamps | +| `note_prompts` | `description`, `systemPrompt`, `userPromptTemplate` | `id`, `productId`, `userId`, `slug`, `name`, prompt routing/config metadata, timestamps | +| `note_agent_actions` | `reason`, `beforeSummary`, `afterSummary`, `reviewNote` | `id`, `productId`, `workspaceId`, `userId`, `noteId`, actor/tool/action/state metadata, idempotency/correlation/workflow ids, review identity/timestamp | + +## Migration Notes + +This change is backward-compatible for existing plaintext documents: repository reads decrypt only values that match the shared `EncryptedField` envelope and pass plaintext through unchanged. Newly created and updated documents write encrypted values for the fields above. + +Recommended production migration before launch: + +1. Run a one-time backfill using `@bytelyst/field-encrypt` `migrateDocuments` or an equivalent repository-level script for the fields listed above. +2. Process one container at a time, partition-aware: `notes` and artifact/action containers by `workspaceId`, prompt templates by `userId`. +3. Verify by sampling raw Cosmos documents for `__encrypted: true` on sensitive fields and by reading the same records through the NoteLett API to confirm plaintext responses. +4. Keep query filters on the query-safe plaintext fields listed above; do not add server-side filters against encrypted text fields.