feat(crypto): phase 7.1 — encrypt timeBlocks-coupled tasks + calendar events

Flips three coordinated registry entries to enabled:true at once:

  - tasks: title, description, subtasks, metadata
  - events (calendar): title, description, location
  - timeBlocks: title, description (NEW entry)

These three tables have to move together because the consumer modules
(todo, calendar) denormalize their title/description into a TimeBlock
for cheap calendar rendering. Encrypting only the source records would
still leak the same fields through the timeBlocks hub. Indexed columns
(startDate, endDate, kind, type, sourceModule/sourceId, parentBlockId,
recurrenceDate, isLive, isCompleted, dueDate, priority) all stay
plaintext — the calendar query layer needs them for range scans.

Service layer
-------------
- time-blocks/service.ts: createBlock + updateBlock now route through
  encryptRecord before the Dexie write. startFromScheduled decrypts the
  scheduled block first so the new logged block carries plaintext
  forward instead of an already-encrypted blob (encryptRecord is
  idempotent so this is also defence-in-depth). New decryptBlock helper
  for callers that need plaintext outside a liveQuery.
- todo/stores/tasks.svelte.ts: createTask snapshots the plaintext task
  before encryptRecord mutates it, returns the snapshot to the UI.
  updateTask decrypts the existing row before forwarding task.title as
  a fallback into updateBlock (would otherwise leak ciphertext to the
  linked TimeBlock). updateLabels + updateSubtasks decrypt-merge-encrypt
  so structured fields don't get spliced into a ciphertext blob.
- calendar/stores/events.svelte.ts: encryptRecord wrapped around all
  four event-write paths (create, update, updateSingleInstance,
  updateAllFuture).

Read paths
----------
Every liveQuery / one-shot read that surfaces title/description/
location through the UI now decrypts after the plaintext-metadata
filter:

  - time-blocks/queries.ts: useAllTimeBlocks, timeBlocksInRange$,
    timeBlocksBySource$, useLiveTimeBlock
  - todo/queries.ts: useAllTasks
  - calendar/queries.ts: useAllCalendarItems (decrypts both the blocks
    and the joined events)
  - cross-app-queries.ts: useOpenTasks, useTodayTasks, useUpcomingTasks,
    useUpcomingEvents
  - dashboard widgets: DayTimelineWidget, ActivityFeedWidget,
    TasksTodayWidget, UpcomingEventsWidget
  - search providers: todo + calendar (substring scoring needs
    plaintext)
  - quick-input adapters: todo + calendar (search-as-you-type)
  - calendar/components/ConflictWarning, CalendarHeader (iCal export
    embeds title in the file)
  - calendar/views/DetailView, todo/views/DetailView (inline editor)
  - api/services/qr-export (the QR snapshot would otherwise ship
    ciphertext)
  - triggers/suggestions (cross-matches habit titles against task /
    event titles)
  - todo/reminder-source (notification body uses task title)

Habits is implicitly covered: it only writes through createBlock /
updateBlock and only reads block.startDate from the timeBlock side, so
no per-store changes were needed for habits to participate.

Why
---
This closes the last big plaintext gap on the dashboard. tasks +
events + the timeBlocks hub were the highest-value targets after chat
+ contacts because they're the surfaces a casual observer of an
unlocked DB would scan first ("what's this person doing today?"). With
Phase 7.1, the answer to that query is opaque without the master key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 20:37:59 +02:00
parent 4bdf4238ce
commit c875b4e966
23 changed files with 362 additions and 176 deletions

View file

@ -1,12 +1,20 @@
/**
* QR Export API Service
* QR Export Service
*
* Collects data from contacts, calendar, and todo services for QR export.
* Builds a QR-encoded snapshot of the user's most relevant contacts,
* upcoming calendar events and todo tasks. Reads from the local
* IndexedDB (Dexie) directly there is no longer a per-app HTTP backend
* to call, all module data lives in the unified `mana` database via
* the local-first sync layer.
*/
import { contactsService, type Contact } from './contacts';
import { calendarService, type CalendarEvent } from './calendar';
import { todoService, type Task } from './todo';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { toTimeBlock } from '$lib/data/time-blocks/queries';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalContact } from '$lib/modules/contacts/types';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalTask } from '$lib/modules/todo/types';
import type { UserDataSummary } from './my-data';
import {
createManaQRExport,
@ -18,19 +26,15 @@ import {
type TodoPriority,
} from '@mana/qr-export';
/**
* Data collected for QR export
*/
/** Data collected for QR export. */
export interface QRExportData {
contacts: Contact[];
events: CalendarEvent[];
tasks: Task[];
contacts: LocalContact[];
events: Array<LocalEvent & { startTime: string; endTime: string | null; isAllDay: boolean }>;
tasks: LocalTask[];
userData: UserDataSummary | null;
}
/**
* Result of QR export generation
*/
/** Result of QR export generation. */
export interface QRExportResult {
encodeResult: EncodeResult;
stats: {
@ -40,54 +44,121 @@ export interface QRExportResult {
};
}
/**
* Map Contact to ContactInput for qr-export
*/
function mapContactToInput(contact: Contact): ContactInput {
const displayName = contactsService.getDisplayName(contact);
// ─── Local helpers (replace the deleted *Service modules) ─────
// Determine relation based on available data
// Default to 3 (Freund), but this could be enhanced with actual relation data
let relation: ContactRelation = 3;
/** Best-effort display name for a contact. Mirrors the legacy
* contactsService.getDisplayName so QR output stays consistent. */
function getContactDisplayName(c: LocalContact): string {
const anyC = c as unknown as Record<string, unknown>;
const displayName = anyC.displayName as string | undefined;
if (displayName) return displayName;
if (c.firstName && c.lastName) return `${c.firstName} ${c.lastName}`;
if (c.firstName) return c.firstName;
if (c.lastName) return c.lastName;
if (c.email) return c.email;
return 'Unknown';
}
/** Top N favorite (or recently updated) contacts. */
async function loadFavoriteContacts(limit: number): Promise<LocalContact[]> {
const all = await db.table<LocalContact>('contacts').toArray();
const live = all.filter((c) => !c.deletedAt && !c.isArchived);
live.sort((a, b) => {
// Favorites first, then by updatedAt descending.
if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1;
return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
});
return live.slice(0, limit);
}
/** Upcoming calendar events for the next `days` days. Pulls scheduling
* info from the linked timeBlocks (events table no longer holds dates
* directly after the v3 schema migration). */
async function loadUpcomingEvents(days: number): Promise<QRExportData['events']> {
const horizon = new Date(Date.now() + days * 86_400_000).toISOString();
const now = new Date().toISOString();
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const candidateBlocks = blocks
.filter(
(b) =>
!b.deletedAt &&
b.sourceModule === 'calendar' &&
b.type === 'event' &&
b.startDate >= now &&
b.startDate <= horizon
)
.sort((a, b) => a.startDate.localeCompare(b.startDate));
const upcomingBlocks = await decryptRecords('timeBlocks', candidateBlocks);
const allEvents = await db.table<LocalEvent>('events').toArray();
const visibleEvents = allEvents.filter((e) => !e.deletedAt);
const decryptedEvents = await decryptRecords('events', visibleEvents);
const eventsById = new Map<string, LocalEvent>();
for (const e of decryptedEvents) {
eventsById.set(e.id, e);
}
return upcomingBlocks
.map((block) => {
const tb = toTimeBlock(block);
const event = eventsById.get(block.sourceId);
if (!event) return null;
return {
...event,
startTime: tb.startDate,
endTime: tb.endDate,
isAllDay: tb.allDay,
};
})
.filter((e): e is NonNullable<typeof e> => e !== null);
}
/** Tasks with a dueDate inside the next `days` days, soft-deleted out. */
async function loadUpcomingTasks(days: number): Promise<LocalTask[]> {
const horizon = new Date(Date.now() + days * 86_400_000).toISOString();
const all = await db.table<LocalTask>('tasks').toArray();
const visible = all.filter(
(t) => !t.deletedAt && !t.isCompleted && t.dueDate && t.dueDate <= horizon
);
const decrypted = await decryptRecords('tasks', visible);
return decrypted.sort((a, b) => (a.dueDate ?? '').localeCompare(b.dueDate ?? ''));
}
// ─── Mappers (unchanged in spirit, retargeted at the local types) ───
function mapContactToInput(contact: LocalContact): ContactInput {
const displayName = getContactDisplayName(contact);
const relation: ContactRelation = 3; // default Freund
const anyC = contact as unknown as Record<string, unknown>;
return {
name: displayName,
phone: contact.mobile || contact.phone,
phone: (anyC.mobile as string | undefined) ?? contact.phone,
email: contact.email,
relation,
importance: contact.isFavorite ? 100 : 0,
};
}
/**
* Map CalendarEvent to EventInput for qr-export
*/
function mapEventToInput(event: CalendarEvent): EventInput {
const startDate = new Date(event.startTime);
const endDate = new Date(event.endTime);
function mapEventToInput(event: QRExportData['events'][number]): EventInput {
return {
title: event.title,
start: startDate,
end: endDate,
location: event.location,
title: event.title ?? '',
start: new Date(event.startTime),
end: new Date(event.endTime ?? event.startTime),
location: (event as { location?: string }).location,
allDay: event.isAllDay,
};
}
/**
* Map Task to TodoInput for qr-export
*/
function mapTaskToInput(task: Task): TodoInput {
// Map priority string to number
function mapTaskToInput(task: LocalTask): TodoInput {
const priorityMap: Record<string, TodoPriority> = {
urgent: 1,
high: 1,
medium: 2,
low: 3,
};
const priority = priorityMap[task.priority] || 2;
const priority = priorityMap[task.priority ?? 'medium'] ?? 2;
return {
title: task.title,
@ -97,32 +168,20 @@ function mapTaskToInput(task: Task): TodoInput {
};
}
/**
* QR Export service
*/
export const qrExportService = {
/**
* Collect all data needed for QR export
*/
async collectExportData(): Promise<QRExportData> {
// Fetch all data in parallel
const [contactsResult, eventsResult, tasksResult] = await Promise.all([
contactsService.getFavoriteContacts(10), // Get more, we'll filter
calendarService.getUpcomingEvents(30), // Next 30 days
todoService.getUpcomingTasks(30), // Next 30 days
]);
// ─── Public service ───────────────────────────────────────────
return {
contacts: contactsResult.data || [],
events: eventsResult.data || [],
tasks: tasksResult.data || [],
userData: null, // Will be set by caller if needed
};
export const qrExportService = {
/** Collect contacts/events/tasks needed for the QR export. */
async collectExportData(): Promise<QRExportData> {
const [contacts, events, tasks] = await Promise.all([
loadFavoriteContacts(10),
loadUpcomingEvents(30),
loadUpcomingTasks(30),
]);
return { contacts, events, tasks, userData: null };
},
/**
* Generate QR export from collected data
*/
/** Encode an already-collected dataset into a QR result. */
generateExport(
data: QRExportData,
options?: {
@ -135,31 +194,26 @@ export const qrExportService = {
const maxEvents = options?.maxEvents ?? 10;
const maxTodos = options?.maxTodos ?? 15;
// Map to input formats
const contactInputs = data.contacts.map(mapContactToInput);
const eventInputs = data.events.map(mapEventToInput);
const taskInputs = data.tasks.map(mapTaskToInput);
// Build export using the builder
const builder = createManaQRExport();
// Set user context if available
if (data.userData?.user) {
builder.user({
n: data.userData.user.name || data.userData.user.email.split('@')[0],
l: 'de', // Could be derived from user settings
z: 'Europe/Berlin', // Could be derived from user settings
l: 'de',
z: 'Europe/Berlin',
});
} else {
builder.userName('Mana User');
}
// Add data using smart selectors
builder.contactsFrom(contactInputs, maxContacts);
builder.eventsFrom(eventInputs, maxEvents);
builder.todosFrom(taskInputs, maxTodos);
// Encode
const encodeResult = builder.encode();
return {
@ -172,9 +226,7 @@ export const qrExportService = {
};
},
/**
* Generate QR export with all data fetched automatically
*/
/** One-shot helper used by the QR Export modal. */
async generateFullExport(
userData?: UserDataSummary | null,
options?: {

View file

@ -9,6 +9,7 @@
import { _ } from 'svelte-i18n';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
import { toTimeBlock } from '$lib/data/time-blocks/queries';
import type { TimeBlock } from '$lib/data/time-blocks/types';
@ -29,8 +30,9 @@
const recentQuery = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return locals
.filter((b) => !b.deletedAt)
const visible = locals.filter((b) => !b.deletedAt);
const decrypted = await decryptRecords('timeBlocks', visible);
return decrypted
.map(toTimeBlock)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, MAX_ITEMS);

View file

@ -9,6 +9,7 @@
import { _ } from 'svelte-i18n';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
import { toTimeBlock, getBlockDuration } from '$lib/data/time-blocks/queries';
import type { TimeBlock } from '$lib/data/time-blocks/types';
@ -26,10 +27,9 @@
.where('startDate')
.between(todayStart, todayEnd, true, true)
.toArray();
return locals
.filter((b) => !b.deletedAt)
.map(toTimeBlock)
.sort((a, b) => a.startDate.localeCompare(b.startDate));
const visible = locals.filter((b) => !b.deletedAt);
const decrypted = await decryptRecords('timeBlocks', visible);
return decrypted.map(toTimeBlock).sort((a, b) => a.startDate.localeCompare(b.startDate));
}, [] as TimeBlock[]);
let blocks = $derived(blocksQuery.value ?? []);

View file

@ -7,6 +7,7 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from './database';
import { decryptRecords } from './crypto';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
@ -27,7 +28,8 @@ import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/t
export function useOpenTasks() {
return useLiveQueryWithDefault(async () => {
const all = await db.table<LocalTask>('tasks').orderBy('order').toArray();
return all.filter((t) => !t.isCompleted && !t.deletedAt);
const visible = all.filter((t) => !t.isCompleted && !t.deletedAt);
return decryptRecords('tasks', visible);
}, [] as LocalTask[]);
}
@ -44,7 +46,8 @@ export function useTodayTasks() {
.where('dueDate')
.belowOrEqual(endOfToday.toISOString())
.toArray();
return candidates.filter((t) => !t.isCompleted && !t.deletedAt);
const visible = candidates.filter((t) => !t.isCompleted && !t.deletedAt);
return decryptRecords('tasks', visible);
}, [] as LocalTask[]);
}
@ -66,7 +69,8 @@ export function useUpcomingTasks(days = 7) {
.where('dueDate')
.between(startOfTomorrow.toISOString(), endOfWindow.toISOString(), true, true)
.toArray();
return candidates.filter((t) => !t.isCompleted && !t.deletedAt);
const visible = candidates.filter((t) => !t.isCompleted && !t.deletedAt);
return decryptRecords('tasks', visible);
}, [] as LocalTask[]);
}
@ -87,7 +91,8 @@ export function useUpcomingEvents(days = 7) {
.where('startDate')
.between(now.toISOString(), future.toISOString(), true, true)
.toArray();
return candidates.filter((b) => !b.deletedAt);
const visible = candidates.filter((b) => !b.deletedAt);
return decryptRecords('timeBlocks', visible);
}, [] as LocalTimeBlock[]);
}

View file

@ -95,10 +95,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
},
// ─── Tasks ───────────────────────────────────────────────
tasks: { enabled: false, fields: ['title', 'description', 'subtasks', 'metadata'] },
// Phase 7.1: tasks coordinated with timeBlocks below — title and
// description are duplicated to the TimeBlock for calendar display,
// so both sides have to be encrypted in lockstep.
tasks: { enabled: true, fields: ['title', 'description', 'subtasks', 'metadata'] },
// ─── Calendar ────────────────────────────────────────────
events: { enabled: false, fields: ['title', 'description', 'location'] },
// Same coordination as tasks: events.title/description/location are
// mirrored onto a TimeBlock; encrypting only the calendar copy
// would still leak via the timeBlocks table.
events: { enabled: true, fields: ['title', 'description', 'location'] },
// ─── Cycles ──────────────────────────────────────────────
// Health data — GDPR Art. 9 sensitive personal data category.
@ -177,6 +183,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// for now; broader coverage is a Phase 7 concern that needs a
// different storage layout.
invItems: { enabled: true, fields: ['description'] },
// ─── TimeBlocks (cross-module hub) ───────────────────────
// Phase 7.1: encrypted alongside tasks + calendar.events + habits
// because the consumer modules denormalize their title/description
// into the timeBlock for cheap calendar rendering. Encrypting only
// the source records would still leak the same fields here.
// Indexed columns (startDate, endDate, kind, type, sourceModule,
// sourceId, parentBlockId, recurrenceDate) all stay plaintext —
// the calendar query layer needs them for range scans.
timeBlocks: { enabled: true, fields: ['title', 'description'] },
};
/**

View file

@ -11,6 +11,7 @@
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
LocalTimeBlock,
TimeBlock,
@ -55,7 +56,9 @@ export function toTimeBlock(local: LocalTimeBlock): TimeBlock {
export function useAllTimeBlocks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
const visible = locals.filter((b) => !b.deletedAt);
const decrypted = await decryptRecords('timeBlocks', visible);
return decrypted.map(toTimeBlock);
}, [] as TimeBlock[]);
}
@ -70,7 +73,9 @@ export function timeBlocksInRange$(start: string, end: string) {
.where('startDate')
.between(start, end, true, true)
.toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
const visible = locals.filter((b) => !b.deletedAt);
const decrypted = await decryptRecords('timeBlocks', visible);
return decrypted.map(toTimeBlock);
});
}
@ -82,7 +87,9 @@ export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceI
.where('[sourceModule+sourceId]')
.equals([sourceModule, sourceId])
.toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
const visible = locals.filter((b) => !b.deletedAt);
const decrypted = await decryptRecords('timeBlocks', visible);
return decrypted.map(toTimeBlock);
});
}
@ -90,10 +97,14 @@ export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceI
export function useLiveTimeBlock() {
return useLiveQueryWithDefault(
async () => {
// Can't index boolean in Dexie reliably, so scan and filter
// Can't index boolean in Dexie reliably, so scan and filter.
// isLive is a plaintext column so we can find before decrypting,
// then only decrypt the single row we actually need.
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const active = locals.find((b) => b.isLive && !b.deletedAt);
return active ? toTimeBlock(active) : null;
if (!active) return null;
const [decrypted] = await decryptRecords('timeBlocks', [active]);
return toTimeBlock(decrypted);
},
null as TimeBlock | null
);

View file

@ -3,9 +3,21 @@
*
* Module stores create both their domain record and a timeBlock in the same
* Dexie transaction to keep them consistent.
*
* Phase 7.1 encryption: title + description are encrypted at rest. The
* consumer modules (todo, calendar, habits, times) flow their plaintext
* snapshots through this service, which wraps them via encryptRecord
* before the actual Dexie write so every caller gets encryption for
* free without touching their own code paths.
*
* `getBlock` returns the raw row (still encrypted). Read-paths must go
* through queries.ts which calls decryptRecord on the way out, OR call
* decryptBlock() explicitly if reading via getBlock for write-coupling
* (e.g. startFromScheduled needs the plaintext title to copy it forward).
*/
import { db } from '$lib/data/database';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { timeBlockTable } from './collections';
import type { LocalTimeBlock, CreateTimeBlockInput, UpdateTimeBlockInput } from './types';
@ -36,6 +48,9 @@ export async function createBlock(input: CreateTimeBlockInput): Promise<string>
updatedAt: now,
};
// Encrypt configured fields (title + description) before write.
// All other columns stay plaintext for indexed queries.
await encryptRecord('timeBlocks', block);
await timeBlockTable.add(block);
return id;
}
@ -43,10 +58,12 @@ export async function createBlock(input: CreateTimeBlockInput): Promise<string>
/** Update an existing timeBlock. */
export async function updateBlock(id: string, input: UpdateTimeBlockInput): Promise<void> {
const now = new Date().toISOString();
await timeBlockTable.update(id, {
const diff: Partial<LocalTimeBlock> = {
...input,
updatedAt: now,
});
};
await encryptRecord('timeBlocks', diff);
await timeBlockTable.update(id, diff);
}
/** Soft-delete a timeBlock. */
@ -84,6 +101,13 @@ export async function startFromScheduled(
const scheduled = await timeBlockTable.get(scheduledId);
if (!scheduled || scheduled.deletedAt) throw new Error('Scheduled block not found');
// scheduled.title is encrypted on disk — decrypt before forwarding
// to createBlock, otherwise the new logged block would carry the
// already-encrypted blob through encryptRecord again. encryptRecord
// is idempotent on already-encrypted strings, so this is defence-in-
// depth: future code that compares titles needs the plaintext anyway.
const decryptedScheduled = await decryptRecord('timeBlocks', { ...scheduled });
const now = new Date().toISOString();
const loggedId = await createBlock({
startDate: now,
@ -94,7 +118,7 @@ export async function startFromScheduled(
sourceModule: scheduled.sourceModule,
sourceId: scheduled.sourceId,
linkedBlockId: scheduledId,
title: overrides?.title ?? scheduled.title,
title: overrides?.title ?? decryptedScheduled.title,
color: overrides?.color ?? scheduled.color ?? null,
icon: overrides?.icon ?? scheduled.icon ?? null,
projectId: overrides?.projectId ?? scheduled.projectId ?? null,
@ -106,9 +130,27 @@ export async function startFromScheduled(
return loggedId;
}
/** Get a single timeBlock by ID. */
/**
* Get a single timeBlock by ID. Returns the raw row WITH ciphertext
* still in the encrypted columns caller is responsible for calling
* `decryptBlock` if they need the plaintext title/description.
*
* Read-paths via queries.ts already decrypt automatically; getBlock
* is the explicit escape hatch for code that needs the row outside
* a liveQuery (e.g. write-coupling helpers like startFromScheduled).
*/
export async function getBlock(id: string): Promise<LocalTimeBlock | undefined> {
const block = await timeBlockTable.get(id);
if (block?.deletedAt) return undefined;
return block;
}
/**
* Returns a decrypted copy of a single timeBlock convenience for the
* few callers that need plaintext title/description outside of the
* liveQuery layer. Mutates a fresh copy, never the original row, so the
* IndexedDB record stays encrypted.
*/
export async function decryptBlock(block: LocalTimeBlock): Promise<LocalTimeBlock> {
return decryptRecord('timeBlocks', { ...block });
}

View file

@ -14,6 +14,7 @@
Export,
} from '@mana/shared-icons';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { toTimeBlock } from '$lib/data/time-blocks/queries';
import { downloadICalendar } from '$lib/data/time-blocks/ical-export';
@ -41,8 +42,11 @@
async function handleExport() {
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const blocks = locals
.filter((b) => !b.deletedAt)
const visible = locals.filter((b) => !b.deletedAt);
// iCal export embeds the title/description in the file — must
// decrypt before writing or we'd ship ciphertext to the user.
const decrypted = await decryptRecords('timeBlocks', visible);
const blocks = decrypted
.map(toTimeBlock)
.filter((b) => calendarViewStore.visibleBlockTypes.has(b.type));
downloadICalendar(blocks);

View file

@ -4,6 +4,7 @@
-->
<script lang="ts">
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { toTimeBlock, findOverlaps } from '$lib/data/time-blocks/queries';
import type { TimeBlock } from '$lib/data/time-blocks/types';
@ -36,8 +37,10 @@
.where('startDate')
.between(dayStart, dayEnd, true, true)
.toArray()
.then((locals) => {
const blocks = locals.filter((b) => !b.deletedAt).map(toTimeBlock);
.then(async (locals) => {
const visible = locals.filter((b) => !b.deletedAt);
const decrypted = await decryptRecords('timeBlocks', visible);
const blocks = decrypted.map(toTimeBlock);
conflicts = findOverlaps(blocks, startDate, endDate, excludeBlockId);
});
});

View file

@ -12,6 +12,7 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalCalendar, LocalEvent, Calendar, CalendarEvent } from './types';
import { timeBlockToCalendarEvent } from './types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
@ -49,19 +50,23 @@ export function useAllCalendars() {
*/
export function useAllCalendarItems() {
return useLiveQueryWithDefault(async () => {
// Fetch all non-deleted timeBlocks
// Fetch all non-deleted timeBlocks (filter on plaintext deletedAt
// before paying the per-row decrypt cost)
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const activeBlocks = blocks.filter((b) => !b.deletedAt);
const visibleBlocks = blocks.filter((b) => !b.deletedAt);
const decryptedBlocks = await decryptRecords('timeBlocks', visibleBlocks);
// Fetch all non-deleted events for joining with calendar-type blocks
const events = await db.table<LocalEvent>('events').toArray();
const visibleEvents = events.filter((e) => !e.deletedAt);
const decryptedEvents = await decryptRecords('events', visibleEvents);
const eventsById = new Map<string, LocalEvent>();
for (const e of events) {
if (!e.deletedAt) eventsById.set(e.id, e);
for (const e of decryptedEvents) {
eventsById.set(e.id, e);
}
// Convert to CalendarEvent, joining event data for calendar blocks
return activeBlocks.map((block) => {
return decryptedBlocks.map((block) => {
const tb = toTimeBlock(block);
const eventData =
block.sourceModule === 'calendar' ? (eventsById.get(block.sourceId) ?? null) : null;
@ -70,32 +75,6 @@ export function useAllCalendarItems() {
}, [] as CalendarEvent[]);
}
/**
* Only native calendar events (for backward compatibility with calendar-specific views).
*/
export function useAllEvents() {
return useLiveQueryWithDefault(async () => {
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const calendarBlocks = blocks.filter(
(b) => !b.deletedAt && b.sourceModule === 'calendar' && b.type === 'event'
);
const events = await db.table<LocalEvent>('events').toArray();
const eventsById = new Map<string, LocalEvent>();
for (const e of events) {
if (!e.deletedAt) eventsById.set(e.id, e);
}
return calendarBlocks
.map((block) => {
const tb = toTimeBlock(block);
const eventData = eventsById.get(block.sourceId) ?? null;
return timeBlockToCalendarEvent(tb, eventData);
})
.filter((e) => e.calendarId !== '__external__');
}, [] as CalendarEvent[]);
}
// ─── Pure Calendar Helpers ─────────────────────────────────
/** Get visible calendars (where isVisible is true). */

View file

@ -5,6 +5,7 @@
import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@mana/shared-ui';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { parseEventInput, resolveEventIds, formatParsedEventPreview } from './utils/event-parser';
import { toCalendar } from './queries';
import type { LocalCalendar, LocalEvent } from './types';
@ -22,16 +23,17 @@ export function createAdapter(): InputBarAdapter {
async onSearch(query) {
const q = query.toLowerCase();
// Search timeBlocks of type 'event' for calendar events
// Search timeBlocks of type 'event' for calendar events. title is
// encrypted on disk, so we can only filter on plaintext metadata
// (sourceModule/type/deletedAt) before decrypting, then run the
// substring match on the decrypted titles.
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return blocks
.filter(
(b) =>
!b.deletedAt &&
b.sourceModule === 'calendar' &&
b.type === 'event' &&
b.title?.toLowerCase().includes(q)
)
const candidates = blocks.filter(
(b) => !b.deletedAt && b.sourceModule === 'calendar' && b.type === 'event'
);
const decrypted = await decryptRecords('timeBlocks', candidates);
return decrypted
.filter((b) => b.title?.toLowerCase().includes(q))
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())
.slice(0, 10)
.map((b) => ({

View file

@ -9,6 +9,7 @@
*/
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import {
@ -78,6 +79,11 @@ export const eventsStore = {
updatedAt: new Date().toISOString(),
};
// title/description/location are encrypted at rest. createBlock
// already handled the TimeBlock side; this wraps the LocalEvent
// row before the Dexie write. UI never sees this mutation —
// reads go through queries.ts which decrypts on the way out.
await encryptRecord('events', newLocal);
await db.table<LocalEvent>('events').add(newLocal);
CalendarEvents.eventCreated(!!input.recurrenceRule);
return { success: true, data: { id: eventId, timeBlockId } };
@ -134,6 +140,7 @@ export const eventsStore = {
if (input.color !== undefined) localData.color = input.color;
if (input.calendarId !== undefined) localData.calendarId = input.calendarId;
await encryptRecord('events', localData);
await db.table('events').update(id, localData);
CalendarEvents.eventUpdated();
return { success: true };
@ -182,6 +189,7 @@ export const eventsStore = {
if (input.location !== undefined) localData.location = input.location;
if (input.color !== undefined) localData.color = input.color;
await encryptRecord('events', localData);
await db.table('events').update(id, localData);
CalendarEvents.eventUpdated();
return { success: true };
@ -243,6 +251,7 @@ export const eventsStore = {
if (input.description !== undefined) localData.description = input.description;
if (input.location !== undefined) localData.location = input.location;
if (input.color !== undefined) localData.color = input.color;
await encryptRecord('events', localData);
await db.table('events').update(templateEvent.id, localData);
}

View file

@ -5,12 +5,13 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecord } from '$lib/data/crypto';
import { eventsStore } from '../stores/events.svelte';
import { Trash, MapPin, Clock, X } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalEvent } from '../types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast';
@ -57,7 +58,11 @@
const block = ev.timeBlockId
? await db.table<LocalTimeBlock>('timeBlocks').get(ev.timeBlockId)
: null;
return { event: ev, block: block ?? null };
// Both rows carry encrypted title/description (events also encrypts
// location). Decrypt clones so the inline editor binds to plaintext.
const decryptedEvent = await decryptRecord('events', { ...ev });
const decryptedBlock = block ? await decryptRecord('timeBlocks', { ...block }) : null;
return { event: decryptedEvent, block: decryptedBlock };
}).subscribe((val) => {
event = val?.event ?? null;
timeBlock = val?.block ?? null;

View file

@ -8,6 +8,7 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { BaseRecord } from '@mana/local-store';
interface Task extends BaseRecord {
@ -36,13 +37,14 @@
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<Task>('tasks').toArray();
return all
.filter((t) => {
if (t.isCompleted || t.deletedAt) return false;
if (!t.dueDate) return false;
return t.dueDate.slice(0, 10) <= todayStr;
})
.sort((a, b) => a.order - b.order);
const visible = all.filter((t) => {
if (t.isCompleted || t.deletedAt) return false;
if (!t.dueDate) return false;
return t.dueDate.slice(0, 10) <= todayStr;
});
// task.title is encrypted on disk; decrypt before rendering.
const decrypted = await decryptRecords('tasks', visible);
return decrypted.sort((a, b) => a.order - b.order);
}).subscribe({
next: (val) => {
tasks = val;

View file

@ -7,6 +7,7 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { BaseRecord } from '@mana/local-store';
interface CalendarEvent extends BaseRecord {
@ -36,13 +37,17 @@
const nowStr = now.toISOString();
const futureStr = future.toISOString();
const [allEvents, calendars] = await Promise.all([
const [rawEvents, calendars] = await Promise.all([
db.table<CalendarEvent>('events').toArray(),
db.table<Calendar>('calendars').toArray(),
]);
const calendarMap = new Map(calendars.map((c) => [c.id, c]));
// title/description/location are encrypted on disk; the widget
// renders title + location, so decrypt before further filtering.
const allEvents = await decryptRecords('events', rawEvents);
return allEvents
.filter((e) => {
if (e.deletedAt) return false;

View file

@ -4,6 +4,7 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
LocalTask,
LocalBoardView,
@ -42,7 +43,9 @@ export function toTask(local: LocalTask): Task {
export function useAllTasks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTask>('tasks').orderBy('order').toArray();
return locals.filter((t) => !t.deletedAt).map(toTask);
const visible = locals.filter((t) => !t.deletedAt);
const decrypted = await decryptRecords('tasks', visible);
return decrypted.map(toTask);
}, [] as Task[]);
}

View file

@ -6,6 +6,7 @@ import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@mana/shared-ui';
import { goto } from '$app/navigation';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from './utils/task-parser';
import type { LocalTask } from './types';
@ -19,13 +20,14 @@ export function createAdapter(): InputBarAdapter {
async onSearch(query) {
const q = query.toLowerCase();
const tasks = await db.table<LocalTask>('tasks').toArray();
// title + description are encrypted on disk; pre-filter on
// plaintext flags first, then decrypt the small candidate set.
const rawTasks = await db.table<LocalTask>('tasks').toArray();
const candidates = rawTasks.filter((t) => !t.deletedAt && !t.isCompleted);
const tasks = await decryptRecords('tasks', candidates);
return tasks
.filter(
(t) =>
!t.deletedAt &&
!t.isCompleted &&
(t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q))
(t) => t.title?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
)
.slice(0, 10)
.map((t) => ({

View file

@ -6,6 +6,7 @@
*/
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { ReminderSource, DueReminder } from '@mana/shared-stores';
import type { LocalTask, LocalReminder } from './types';
@ -17,7 +18,10 @@ export const todoReminderSource: ReminderSource = {
const pending = reminders.filter((r) => r.status === 'pending' && !r.deletedAt);
if (pending.length === 0) return [];
const tasks = await db.table<LocalTask>('tasks').toArray();
// task.title is encrypted on disk; the notification body uses the
// plaintext title, so decrypt before mapping.
const rawTasks = await db.table<LocalTask>('tasks').toArray();
const tasks = await decryptRecords('tasks', rawTasks);
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const now = Date.now();
const due: DueReminder[] = [];

View file

@ -9,6 +9,7 @@ import { taskTable } from '../collections';
import { toTask } from '../queries';
import type { LocalTask, TaskPriority, Subtask } from '../types';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { TodoEvents } from '@mana/shared-utils/analytics';
export const tasksStore = {
@ -72,14 +73,23 @@ export const tasksStore = {
newLocal.projectId = data.projectId;
}
// Snapshot plaintext for the return value before encryptRecord
// mutates `newLocal` in place. Callers (UI) need plaintext title
// + description; only the on-disk row is ciphertext.
const plaintextSnapshot = toTask({ ...newLocal });
await encryptRecord('tasks', newLocal);
await taskTable.add(newLocal);
TodoEvents.taskCreated(!!data.dueDate);
return toTask(newLocal);
return plaintextSnapshot;
},
async updateTask(id: string, data: Record<string, unknown>) {
const task = await taskTable.get(id);
if (!task) return;
const raw = await taskTable.get(id);
if (!raw) return;
// task.title/description are encrypted on disk — decrypt a clone so
// fallbacks like `data.title ?? task.title` forward plaintext to
// the linked TimeBlock instead of leaking ciphertext.
const task = await decryptRecord('tasks', { ...raw });
// Handle schedule changes via TimeBlock
const schedStartDate = data._scheduleStartDate as string | null | undefined;
@ -145,10 +155,12 @@ export const tasksStore = {
await updateBlock(task.scheduledBlockId, { recurrenceRule });
}
await taskTable.update(id, {
const diff: Record<string, unknown> = {
...data,
updatedAt: new Date().toISOString(),
});
};
await encryptRecord('tasks', diff);
await taskTable.update(id, diff);
TodoEvents.taskEdited();
},
@ -194,19 +206,27 @@ export const tasksStore = {
},
async updateSubtasks(id: string, subtasks: Subtask[]) {
await taskTable.update(id, {
const diff: Record<string, unknown> = {
subtasks,
updatedAt: new Date().toISOString(),
});
};
await encryptRecord('tasks', diff);
await taskTable.update(id, diff);
},
async updateLabels(id: string, labelIds: string[]) {
const existing = await taskTable.get(id);
const raw = await taskTable.get(id);
// metadata is in the encrypted-fields list — decrypt before
// merging labelIds in, otherwise we'd splice a plaintext key
// into a ciphertext blob.
const existing = raw ? await decryptRecord('tasks', { ...raw }) : null;
const existingMeta = (existing?.metadata as Record<string, unknown>) ?? {};
await taskTable.update(id, {
const diff: Record<string, unknown> = {
metadata: { ...existingMeta, labelIds },
updatedAt: new Date().toISOString(),
});
};
await encryptRecord('tasks', diff);
await taskTable.update(id, diff);
},
async reorderTasks(taskIds: string[]) {

View file

@ -5,14 +5,15 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecord } from '$lib/data/crypto';
import { tasksStore } from '../stores/tasks.svelte';
import { getBlock } from '$lib/data/time-blocks/service';
import { getBlock, decryptBlock } from '$lib/data/time-blocks/service';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { Check, Trash, X, CalendarBlank } from '@mana/shared-icons';
import SlotSuggestions from '$lib/modules/calendar/components/SlotSuggestions.svelte';
import type { ViewProps } from '$lib/app-registry';
import type { LocalTask, TaskPriority } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast';
@ -65,7 +66,11 @@
const t = await db.table<LocalTask>('tasks').get(taskId);
if (!t) return { task: null, block: null };
const block = t.scheduledBlockId ? await getBlock(t.scheduledBlockId) : null;
return { task: t, block: block ?? null };
// Decrypt clones so the inline editor binds to plaintext title /
// description / metadata. The on-disk rows stay encrypted.
const decryptedTask = await decryptRecord('tasks', { ...t });
const decryptedBlock = block ? await decryptBlock(block) : null;
return { task: decryptedTask, block: decryptedBlock };
}).subscribe((val) => {
task = val?.task ?? null;
if (val?.task && !focused) {

View file

@ -1,4 +1,5 @@
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { getManaApp } from '@mana/shared-branding';
import { scoreRecord, truncateSubtitle } from '../scoring';
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
@ -16,9 +17,12 @@ export const calendarSearchProvider: SearchProvider = {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
const events = await db.table('events').toArray();
// title/description/location are encrypted at rest. Filter on the
// plaintext deletedAt before paying for the decrypt batch.
const rawEvents = await db.table('events').toArray();
const visibleEvents = rawEvents.filter((e) => !e.deletedAt);
const events = await decryptRecords('events', visibleEvents);
for (const event of events) {
if (event.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'title', value: event.title, weight: 1.0 },

View file

@ -1,4 +1,5 @@
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { getManaApp } from '@mana/shared-branding';
import { scoreRecord, truncateSubtitle } from '../scoring';
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
@ -16,10 +17,13 @@ export const todoSearchProvider: SearchProvider = {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
// Search tasks
const tasks = await db.table('tasks').toArray();
// Search tasks. title + description are encrypted on disk; the
// scorer needs plaintext to do substring matching, so we decrypt
// the visible rows in one batch before iterating.
const rawTasks = await db.table('tasks').toArray();
const visibleTasks = rawTasks.filter((t) => !t.deletedAt);
const tasks = await decryptRecords('tasks', visibleTasks);
for (const task of tasks) {
if (task.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'title', value: task.title, weight: 1.0 },

View file

@ -4,6 +4,7 @@
*/
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { ConditionOp } from './conditions';
export interface AutomationSuggestion {
@ -77,8 +78,11 @@ export async function generateSuggestions(): Promise<AutomationSuggestion[]> {
}
// ─── Events ↔ Habits ────────────────────────────────────
// Title is encrypted on disk; we have to decrypt before string-matching
// against habit titles. Filter on plaintext metadata first to keep the
// decrypt batch small.
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const calendarBlocks = await db
const rawBlocks = await db
.table('timeBlocks')
.toArray()
.then((all: Record<string, unknown>[]) =>
@ -90,6 +94,7 @@ export async function generateSuggestions(): Promise<AutomationSuggestion[]> {
(b.startDate as string) >= thirtyDaysAgo
)
);
const calendarBlocks = await decryptRecords('timeBlocks', rawBlocks);
for (const habit of habits) {
const matchingEvents = calendarBlocks.filter((e: Record<string, unknown>) =>
@ -118,10 +123,12 @@ export async function generateSuggestions(): Promise<AutomationSuggestion[]> {
}
// ─── Tasks ↔ Habits ─────────────────────────────────────
const tasks = await db
// tasks.title is encrypted at rest — same decrypt-after-filter dance.
const rawTasks = await db
.table('tasks')
.toArray()
.then((all) => all.filter((t: Record<string, unknown>) => !t.deletedAt));
const tasks = await decryptRecords('tasks', rawTasks);
for (const habit of habits) {
const matchingTasks = tasks.filter((t: Record<string, unknown>) =>