feat(backend): encrypt sensitive fields across all modules
- timers: description - routines: description, steps[].notes - shared-timers: description - webhooks: secret, description - Add @bytelyst/field-encrypt dep + chronomind-mek config - field-encrypt singleton (getEncryptor lazy pattern) - Encrypt on create/update/batchUpsert, decrypt on read/list/since - Backward-compatible via isEncryptedField guard - 182/182 tests passing, typecheck clean
This commit is contained in:
parent
7a1be12ef5
commit
253a9db0ea
29
backend/package-lock.json
generated
29
backend/package-lock.json
generated
@ -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",
|
||||
"fastify": "^5.2.1",
|
||||
"jose": "^6.0.8",
|
||||
"zod": "^3.24.2"
|
||||
@ -158,6 +159,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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@azure-rest/core-client": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@azure-rest/core-client/-/core-client-2.5.1.tgz",
|
||||
@ -425,6 +450,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/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
|
||||
@ -21,6 +21,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",
|
||||
"@azure/cosmos": "^4.2.0",
|
||||
|
||||
@ -9,6 +9,10 @@ const envSchema = baseBackendConfigSchema.extend({
|
||||
PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),
|
||||
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
|
||||
FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false),
|
||||
FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['memory', 'env', 'akv']).default('memory'),
|
||||
FIELD_ENCRYPT_KEY: z.string().optional(),
|
||||
FIELD_ENCRYPT_MEK_NAME: z.string().default('chronomind-mek'),
|
||||
AZURE_KEYVAULT_URL: z.string().optional(),
|
||||
});
|
||||
|
||||
export const config = envSchema.parse(process.env);
|
||||
|
||||
31
backend/src/lib/field-encrypt.ts
Normal file
31
backend/src/lib/field-encrypt.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Field-level encryption singleton for ChronoMind backend.
|
||||
*
|
||||
* Encrypts sensitive fields (e.g., timer/routine descriptions, webhook secrets)
|
||||
* at rest using AES-256-GCM envelope encryption via @bytelyst/field-encrypt.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export { isEncryptedField } from '@bytelyst/field-encrypt';
|
||||
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js';
|
||||
import type { RoutineDoc, RoutineQuery, BatchUpsertRoutinesResult } from './types.js';
|
||||
import type { FilterMap } from '@bytelyst/datastore';
|
||||
|
||||
@ -12,6 +13,60 @@ function collection() {
|
||||
return getCollection<RoutineDoc>('routines', '/userId');
|
||||
}
|
||||
|
||||
// ── Field encryption helpers ─────────────────────────────────
|
||||
|
||||
async function encryptRoutineFields(doc: RoutineDoc): Promise<RoutineDoc> {
|
||||
const enc = getEncryptor();
|
||||
const ctx = { userId: doc.userId, context: 'routines' };
|
||||
let result = doc;
|
||||
|
||||
if (result.description && typeof result.description === 'string' && result.description.length > 0) {
|
||||
const encrypted = await enc.encrypt(result.description, ctx);
|
||||
result = { ...result, description: encrypted as unknown as string };
|
||||
}
|
||||
|
||||
if (result.steps?.length) {
|
||||
const encSteps = await Promise.all(result.steps.map(async (step) => {
|
||||
if (step.notes && typeof step.notes === 'string' && step.notes.length > 0) {
|
||||
const encrypted = await enc.encrypt(step.notes, { ...ctx, context: 'routine-steps' });
|
||||
return { ...step, notes: encrypted as unknown as string };
|
||||
}
|
||||
return step;
|
||||
}));
|
||||
result = { ...result, steps: encSteps };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function decryptRoutineFields(doc: RoutineDoc): Promise<RoutineDoc> {
|
||||
const enc = getEncryptor();
|
||||
const ctx = { userId: doc.userId, context: 'routines' };
|
||||
let result = doc;
|
||||
|
||||
if (isEncryptedField(result.description)) {
|
||||
const decrypted = await enc.decrypt(result.description as any, ctx);
|
||||
result = { ...result, description: decrypted };
|
||||
}
|
||||
|
||||
if (result.steps?.length) {
|
||||
const decSteps = await Promise.all(result.steps.map(async (step) => {
|
||||
if (isEncryptedField(step.notes)) {
|
||||
const decrypted = await enc.decrypt(step.notes as any, { ...ctx, context: 'routine-steps' });
|
||||
return { ...step, notes: decrypted };
|
||||
}
|
||||
return step;
|
||||
}));
|
||||
result = { ...result, steps: decSteps };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function decryptRoutineBatch(docs: RoutineDoc[]): Promise<RoutineDoc[]> {
|
||||
return Promise.all(docs.map(decryptRoutineFields));
|
||||
}
|
||||
|
||||
export async function listRoutines(
|
||||
userId: string,
|
||||
productId: string,
|
||||
@ -34,15 +89,18 @@ export async function listRoutines(
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
return { items, total };
|
||||
return { items: await decryptRoutineBatch(items), total };
|
||||
}
|
||||
|
||||
export async function getRoutine(id: string, userId: string): Promise<RoutineDoc | null> {
|
||||
return collection().findById(id, userId);
|
||||
const doc = await collection().findById(id, userId);
|
||||
return doc ? decryptRoutineFields(doc) : null;
|
||||
}
|
||||
|
||||
export async function createRoutine(doc: RoutineDoc): Promise<RoutineDoc> {
|
||||
return collection().create(doc);
|
||||
const encrypted = await encryptRoutineFields(doc);
|
||||
const created = await collection().create(encrypted);
|
||||
return decryptRoutineFields(created);
|
||||
}
|
||||
|
||||
export async function updateRoutine(
|
||||
@ -66,8 +124,9 @@ export async function updateRoutine(
|
||||
syncVersion: expectedSyncVersion,
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
const doc = await collection().upsert(merged);
|
||||
return { doc, conflict: false };
|
||||
const encrypted = await encryptRoutineFields(merged);
|
||||
const doc = await collection().upsert(encrypted);
|
||||
return { doc: await decryptRoutineFields(doc), conflict: false };
|
||||
} catch {
|
||||
return { doc: null, conflict: false };
|
||||
}
|
||||
@ -90,11 +149,12 @@ export async function getRoutinesSince(
|
||||
sinceTimestamp: string,
|
||||
limit: number
|
||||
): Promise<RoutineDoc[]> {
|
||||
return collection().findMany({
|
||||
const docs = await collection().findMany({
|
||||
filter: { userId, productId, lastSyncedAt: { $gte: sinceTimestamp } },
|
||||
sort: { lastSyncedAt: 1 },
|
||||
limit,
|
||||
});
|
||||
return decryptRoutineBatch(docs);
|
||||
}
|
||||
|
||||
export async function batchUpsertRoutines(
|
||||
@ -120,7 +180,8 @@ export async function batchUpsertRoutines(
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
} as RoutineDoc;
|
||||
await collection().upsert(merged);
|
||||
const encrypted = await encryptRoutineFields(merged);
|
||||
await collection().upsert(encrypted);
|
||||
synced.push(routine.id);
|
||||
} else {
|
||||
conflicts.push({ id: routine.id, serverVersion: existing.syncVersion });
|
||||
@ -132,7 +193,8 @@ export async function batchUpsertRoutines(
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
} as RoutineDoc;
|
||||
await collection().create(doc);
|
||||
const encrypted = await encryptRoutineFields(doc);
|
||||
await collection().create(encrypted);
|
||||
synced.push(routine.id);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js';
|
||||
import type { SharedTimerDoc, SharedTimerQuery } from './types.js';
|
||||
import type { FilterMap } from '@bytelyst/datastore';
|
||||
|
||||
@ -12,6 +13,34 @@ function collection() {
|
||||
return getCollection<SharedTimerDoc>('shared_timers', '/householdId');
|
||||
}
|
||||
|
||||
// ── Field encryption helpers ─────────────────────────────────
|
||||
|
||||
async function encryptSharedTimerFields(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
|
||||
if (doc.description && typeof doc.description === 'string' && doc.description.length > 0) {
|
||||
const encrypted = await getEncryptor().encrypt(doc.description, {
|
||||
userId: doc.createdBy,
|
||||
context: 'shared-timers',
|
||||
});
|
||||
return { ...doc, description: encrypted as unknown as string };
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function decryptSharedTimerFields(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
|
||||
if (isEncryptedField(doc.description)) {
|
||||
const decrypted = await getEncryptor().decrypt(doc.description as any, {
|
||||
userId: doc.createdBy,
|
||||
context: 'shared-timers',
|
||||
});
|
||||
return { ...doc, description: decrypted };
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function decryptSharedTimerBatch(docs: SharedTimerDoc[]): Promise<SharedTimerDoc[]> {
|
||||
return Promise.all(docs.map(decryptSharedTimerFields));
|
||||
}
|
||||
|
||||
export async function listSharedTimers(
|
||||
householdId: string,
|
||||
productId: string,
|
||||
@ -33,22 +62,27 @@ export async function listSharedTimers(
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
return { items, total };
|
||||
return { items: await decryptSharedTimerBatch(items), total };
|
||||
}
|
||||
|
||||
export async function getSharedTimer(
|
||||
id: string,
|
||||
householdId: string
|
||||
): Promise<SharedTimerDoc | null> {
|
||||
return collection().findById(id, householdId);
|
||||
const doc = await collection().findById(id, householdId);
|
||||
return doc ? decryptSharedTimerFields(doc) : null;
|
||||
}
|
||||
|
||||
export async function createSharedTimer(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
|
||||
return collection().create(doc);
|
||||
const encrypted = await encryptSharedTimerFields(doc);
|
||||
const created = await collection().create(encrypted);
|
||||
return decryptSharedTimerFields(created);
|
||||
}
|
||||
|
||||
export async function replaceSharedTimer(doc: SharedTimerDoc): Promise<SharedTimerDoc> {
|
||||
return collection().upsert(doc);
|
||||
const encrypted = await encryptSharedTimerFields(doc);
|
||||
const saved = await collection().upsert(encrypted);
|
||||
return decryptSharedTimerFields(saved);
|
||||
}
|
||||
|
||||
export async function deleteSharedTimer(id: string, householdId: string): Promise<boolean> {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js';
|
||||
import type { TimerDoc, TimerQuery, BatchUpsertResult } from './types.js';
|
||||
import type { FilterMap } from '@bytelyst/datastore';
|
||||
|
||||
@ -12,6 +13,34 @@ function collection() {
|
||||
return getCollection<TimerDoc>('timers', '/userId');
|
||||
}
|
||||
|
||||
// ── Field encryption helpers ─────────────────────────────────
|
||||
|
||||
async function encryptTimerFields(doc: TimerDoc): Promise<TimerDoc> {
|
||||
if (doc.description && typeof doc.description === 'string' && doc.description.length > 0) {
|
||||
const encrypted = await getEncryptor().encrypt(doc.description, {
|
||||
userId: doc.userId,
|
||||
context: 'timers',
|
||||
});
|
||||
return { ...doc, description: encrypted as unknown as string };
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function decryptTimerFields(doc: TimerDoc): Promise<TimerDoc> {
|
||||
if (isEncryptedField(doc.description)) {
|
||||
const decrypted = await getEncryptor().decrypt(doc.description as any, {
|
||||
userId: doc.userId,
|
||||
context: 'timers',
|
||||
});
|
||||
return { ...doc, description: decrypted };
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function decryptTimerBatch(docs: TimerDoc[]): Promise<TimerDoc[]> {
|
||||
return Promise.all(docs.map(decryptTimerFields));
|
||||
}
|
||||
|
||||
export async function listTimers(
|
||||
userId: string,
|
||||
productId: string,
|
||||
@ -35,15 +64,18 @@ export async function listTimers(
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
return { items, total };
|
||||
return { items: await decryptTimerBatch(items), total };
|
||||
}
|
||||
|
||||
export async function getTimer(id: string, userId: string): Promise<TimerDoc | null> {
|
||||
return collection().findById(id, userId);
|
||||
const doc = await collection().findById(id, userId);
|
||||
return doc ? decryptTimerFields(doc) : null;
|
||||
}
|
||||
|
||||
export async function createTimer(doc: TimerDoc): Promise<TimerDoc> {
|
||||
return collection().create(doc);
|
||||
const encrypted = await encryptTimerFields(doc);
|
||||
const created = await collection().create(encrypted);
|
||||
return decryptTimerFields(created);
|
||||
}
|
||||
|
||||
export async function updateTimer(
|
||||
@ -68,8 +100,9 @@ export async function updateTimer(
|
||||
syncVersion: expectedSyncVersion,
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
const doc = await collection().upsert(merged);
|
||||
return { doc, conflict: false };
|
||||
const encrypted = await encryptTimerFields(merged);
|
||||
const doc = await collection().upsert(encrypted);
|
||||
return { doc: await decryptTimerFields(doc), conflict: false };
|
||||
} catch {
|
||||
return { doc: null, conflict: false };
|
||||
}
|
||||
@ -92,11 +125,12 @@ export async function getTimersSince(
|
||||
sinceTimestamp: string,
|
||||
limit: number
|
||||
): Promise<TimerDoc[]> {
|
||||
return collection().findMany({
|
||||
const docs = await collection().findMany({
|
||||
filter: { userId, productId, lastSyncedAt: { $gte: sinceTimestamp } },
|
||||
sort: { lastSyncedAt: 1 },
|
||||
limit,
|
||||
});
|
||||
return decryptTimerBatch(docs);
|
||||
}
|
||||
|
||||
export async function batchUpsert(
|
||||
@ -123,7 +157,8 @@ export async function batchUpsert(
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
};
|
||||
await collection().upsert(merged);
|
||||
const encrypted = await encryptTimerFields(merged);
|
||||
await collection().upsert(encrypted);
|
||||
synced.push(timer.id);
|
||||
} else {
|
||||
conflicts.push({ id: timer.id, serverVersion: existing.syncVersion });
|
||||
@ -136,7 +171,8 @@ export async function batchUpsert(
|
||||
productId,
|
||||
lastSyncedAt: now,
|
||||
} as TimerDoc;
|
||||
await collection().create(doc);
|
||||
const encrypted = await encryptTimerFields(doc);
|
||||
await collection().create(encrypted);
|
||||
synced.push(timer.id);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import { getEncryptor, isEncryptedField } from '../../lib/field-encrypt.js';
|
||||
import { NotFoundError, ConflictError } from '@bytelyst/errors';
|
||||
import type {
|
||||
WebhookSubscriptionDoc,
|
||||
@ -16,16 +17,59 @@ function eventsCollection() {
|
||||
return getCollection<WebhookEventDoc>('webhook_events', '/subscriptionId');
|
||||
}
|
||||
|
||||
// ── Field encryption helpers ─────────────────────────────────
|
||||
|
||||
async function encryptSubFields(doc: WebhookSubscriptionDoc): Promise<WebhookSubscriptionDoc> {
|
||||
const enc = getEncryptor();
|
||||
const ctx = { userId: doc.userId, context: 'webhooks' };
|
||||
let result = doc;
|
||||
|
||||
if (result.secret && typeof result.secret === 'string' && result.secret.length > 0) {
|
||||
const encrypted = await enc.encrypt(result.secret, ctx);
|
||||
result = { ...result, secret: encrypted as unknown as string };
|
||||
}
|
||||
|
||||
if (result.description && typeof result.description === 'string' && result.description.length > 0) {
|
||||
const encrypted = await enc.encrypt(result.description, { ...ctx, context: 'webhook-desc' });
|
||||
result = { ...result, description: encrypted as unknown as string };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function decryptSubFields(doc: WebhookSubscriptionDoc): Promise<WebhookSubscriptionDoc> {
|
||||
const enc = getEncryptor();
|
||||
const ctx = { userId: doc.userId, context: 'webhooks' };
|
||||
let result = doc;
|
||||
|
||||
if (isEncryptedField(result.secret)) {
|
||||
const decrypted = await enc.decrypt(result.secret as any, ctx);
|
||||
result = { ...result, secret: decrypted };
|
||||
}
|
||||
|
||||
if (isEncryptedField(result.description)) {
|
||||
const decrypted = await enc.decrypt(result.description as any, { ...ctx, context: 'webhook-desc' });
|
||||
result = { ...result, description: decrypted };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function decryptSubBatch(docs: WebhookSubscriptionDoc[]): Promise<WebhookSubscriptionDoc[]> {
|
||||
return Promise.all(docs.map(decryptSubFields));
|
||||
}
|
||||
|
||||
// ── Subscription CRUD ─────────────────────────────────────────
|
||||
|
||||
export async function listSubscriptions(
|
||||
userId: string,
|
||||
productId: string
|
||||
): Promise<WebhookSubscriptionDoc[]> {
|
||||
return subsCollection().findMany({
|
||||
const docs = await subsCollection().findMany({
|
||||
filter: { userId, productId },
|
||||
sort: { createdAt: -1 },
|
||||
});
|
||||
return decryptSubBatch(docs);
|
||||
}
|
||||
|
||||
export async function getSubscription(id: string, userId: string): Promise<WebhookSubscriptionDoc> {
|
||||
@ -33,7 +77,7 @@ export async function getSubscription(id: string, userId: string): Promise<Webho
|
||||
if (!doc) {
|
||||
throw new NotFoundError(`Webhook subscription '${id}' not found`);
|
||||
}
|
||||
return doc;
|
||||
return decryptSubFields(doc);
|
||||
}
|
||||
|
||||
export async function createSubscription(
|
||||
@ -59,7 +103,9 @@ export async function createSubscription(
|
||||
};
|
||||
|
||||
try {
|
||||
return await subsCollection().create(doc);
|
||||
const encrypted = await encryptSubFields(doc);
|
||||
const created = await subsCollection().create(encrypted);
|
||||
return decryptSubFields(created);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('already exists')) {
|
||||
throw new ConflictError(`Subscription '${id}' already exists`);
|
||||
@ -81,7 +127,9 @@ export async function updateSubscription(
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return subsCollection().upsert(updated);
|
||||
const encrypted = await encryptSubFields(updated);
|
||||
const saved = await subsCollection().upsert(encrypted);
|
||||
return decryptSubFields(saved);
|
||||
}
|
||||
|
||||
export async function deleteSubscription(id: string, userId: string): Promise<void> {
|
||||
@ -101,7 +149,8 @@ export async function findSubscriptionsForEvent(
|
||||
const subs = await subsCollection().findMany({
|
||||
filter: { userId, productId, active: true },
|
||||
});
|
||||
return subs.filter(s => s.events.includes(eventType));
|
||||
const decrypted = await decryptSubBatch(subs);
|
||||
return decrypted.filter(s => s.events.includes(eventType));
|
||||
}
|
||||
|
||||
// ── Increment Failure Count ───────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user