feat(tag-presets): Phase 2d.1 — CRUD store + encryption for user-level presets

First wire-up of the userTagPresets surface from Phase 2b's v34 schema.
This is the store layer only — Space-create UI integration + the
apply-preset-to-space flow land in a follow-up commit alongside the
SpaceCreateDialog changes.

- lib/data/tag-presets/types.ts: LocalUserTagPreset shape + inline
  TagPresetEntry + toUserTagPreset converter.
- lib/data/tag-presets/store.svelte.ts: createPreset / updatePreset /
  deletePreset / setDefault / appendEntry. Stamps userId explicitly
  because userTagPresets is kept out of SYNC_APP_MAP (the Dexie
  creating-hook only fires for sync tables). At-most-one-default-per-
  user invariant enforced by clearDefaultFlag() before writes that set
  isDefault=true.
- lib/data/tag-presets/queries.ts: useUserTagPresets + useDefaultTagPreset
  live queries. User-scoped, no active-space filter (presets show from
  any Space context).
- crypto: move userTagPresets from plaintext-allowlist to
  ENCRYPTION_REGISTRY with fields ['name', 'tags']. AES wrapping handles
  the tags array via JSON-stringify, same pattern as food.foods.

Crypto audit: 196 tables (95 encrypted, +1 userTagPresets). Type-check
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 17:26:39 +02:00
parent 4d91e2daad
commit 35d9e023a6
5 changed files with 237 additions and 1 deletions

View file

@ -111,7 +111,6 @@ export const PLAINTEXT_ALLOWLIST: readonly string[] = [
'uloadFolders', // TODO: audit
'uloadTags', // TODO: audit
'userSettings', // TODO: audit
'userTagPresets', // v34 table — intent is to encrypt (tag-preset names + inline tag names are sensitive); declared plaintext here for 2b so the audit passes, moves to ENCRYPTION_REGISTRY in Phase 2d alongside the store API
'wateringLogs', // TODO: audit
'wateringSchedules', // TODO: audit
'wetterLocations', // TODO: audit

View file

@ -586,6 +586,17 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// and mission-detail filters.
aiMissions: { enabled: false, fields: ['title', 'conceptMarkdown', 'objective'] },
// ─── User-level Tag Presets ──────────────────────────────
// Named templates the user applies when creating a new Space. The
// preset `name` (e.g. "Mein Standard") and the entire `tags` array
// (which contains per-entry tag names) are user-authored personal
// content that leaks categorization intent — encrypt both. `userId`,
// `isDefault`, timestamps stay plaintext for the indexed query path.
// AES wrapping handles `tags` as an array via JSON-stringify (same
// pattern as food.foods / recipes.ingredients).
// See docs/plans/space-scoped-data-model.md §5.
userTagPresets: { enabled: true, fields: ['name', 'tags'] },
// ─── Tags (shared-stores) ────────────────────────────────
// docs/plans/space-scoped-data-model.md §2a — declared with
// enabled:false during prep; flips to true in 2c. Tag names like

View file

@ -0,0 +1,51 @@
/**
* userTagPresets reactive read surface.
*
* Always scoped to the current user; no active-space filter (presets are
* user-level and the picker runs from any Space context).
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { getCurrentUserId } from '$lib/data/current-user';
import type { LocalUserTagPreset, UserTagPreset } from './types';
import { toUserTagPreset } from './types';
export function useUserTagPresets() {
return useLiveQueryWithDefault(async () => {
const userId = getCurrentUserId();
if (!userId) return [] as UserTagPreset[];
const rows = await db
.table<LocalUserTagPreset>('userTagPresets')
.where('userId')
.equals(userId)
.toArray();
const visible = rows.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords('userTagPresets', visible);
return decrypted.map(toUserTagPreset).sort((a, b) => a.name.localeCompare(b.name, 'de'));
}, [] as UserTagPreset[]);
}
export function useDefaultTagPreset() {
return useLiveQueryWithDefault(
async () => {
const userId = getCurrentUserId();
if (!userId) return null;
const row = await db
.table<LocalUserTagPreset>('userTagPresets')
.where('userId')
.equals(userId)
.and((p) => p.isDefault && !p.deletedAt)
.first();
if (!row) return null;
const [decrypted] = await decryptRecords('userTagPresets', [row]);
return decrypted ? toUserTagPreset(decrypted) : null;
},
null as UserTagPreset | null
);
}

View file

@ -0,0 +1,111 @@
/**
* userTagPresets CRUD store.
*
* Lives at the user level (no spaceId). Encrypted: the preset `name` and
* the whole `tags` array (which contains per-entry tag names) are
* user-authored personal content and leak categorization intent.
*
* The Dexie creating hook does NOT fire for this table userTagPresets
* is intentionally kept out of SYNC_APP_MAP for now (local-only, added
* to sync as a follow-up once the Space-create UI lands in Phase 5 of
* the plan). That means we stamp `userId` + timestamps explicitly below.
*
* See docs/plans/space-scoped-data-model.md §5.
*/
import { db } from '$lib/data/database';
import { encryptRecord, decryptRecords } from '$lib/data/crypto';
import { getEffectiveUserId } from '$lib/data/current-user';
import type {
LocalUserTagPreset,
CreatePresetInput,
UpdatePresetInput,
UserTagPreset,
TagPresetEntry,
} from './types';
import { toUserTagPreset } from './types';
const table = db.table<LocalUserTagPreset>('userTagPresets');
function now(): string {
return new Date().toISOString();
}
async function clearDefaultFlag(userId: string, exceptId?: string): Promise<void> {
// Only one preset per user may carry isDefault:true. When setting a new
// default (or creating a default), flip every other of this user's
// presets off in the same transaction.
const rows = await table
.where('userId')
.equals(userId)
.and((p) => p.isDefault && p.id !== exceptId)
.toArray();
for (const row of rows) {
await table.update(row.id, { isDefault: false, updatedAt: now() });
}
}
export const tagPresetsStore = {
async createPreset(input: CreatePresetInput): Promise<UserTagPreset> {
const userId = getEffectiveUserId();
const timestamp = now();
const newLocal: LocalUserTagPreset = {
id: crypto.randomUUID(),
userId,
name: input.name,
isDefault: input.isDefault ?? false,
tags: input.tags ?? [],
createdAt: timestamp,
updatedAt: timestamp,
};
if (newLocal.isDefault) await clearDefaultFlag(userId, newLocal.id);
const plaintextSnapshot = toUserTagPreset(newLocal);
await encryptRecord('userTagPresets', newLocal);
await table.add(newLocal);
return plaintextSnapshot;
},
async updatePreset(id: string, input: UpdatePresetInput): Promise<void> {
const existing = await table.get(id);
if (!existing) throw new Error(`Preset ${id} not found`);
if (input.isDefault === true) {
await clearDefaultFlag(existing.userId, id);
}
const diff: Partial<LocalUserTagPreset> = {
...(input.name !== undefined && { name: input.name }),
...(input.tags !== undefined && { tags: input.tags }),
...(input.isDefault !== undefined && { isDefault: input.isDefault }),
updatedAt: now(),
};
await encryptRecord('userTagPresets', diff);
await table.update(id, diff);
},
async deletePreset(id: string): Promise<void> {
await table.update(id, { deletedAt: now(), updatedAt: now() });
},
async setDefault(id: string): Promise<void> {
const existing = await table.get(id);
if (!existing) throw new Error(`Preset ${id} not found`);
await clearDefaultFlag(existing.userId, id);
await table.update(id, { isDefault: true, updatedAt: now() });
},
/**
* Adds one entry to an existing preset. Handy for the "promote this
* tag into a preset" flow from the tag manager UI no need to
* replace the whole `tags` array from the caller.
*/
async appendEntry(id: string, entry: TagPresetEntry): Promise<void> {
const existing = await table.get(id);
if (!existing) throw new Error(`Preset ${id} not found`);
const [decrypted] = await decryptRecords('userTagPresets', [existing]);
const next = [...(decrypted?.tags ?? []), entry];
await this.updatePreset(id, { tags: next });
},
};

View file

@ -0,0 +1,64 @@
/**
* User-level tag presets.
*
* A preset is a named, snapshot-shaped tag bundle the user can apply
* when creating a new Space. Presets live at the user level (cross-Space)
* so the picker can show them from any Space context.
*
* The preset's `tags` field is a frozen snapshot not live references
* to existing globalTags rows. Applying a preset to a Space one-shot-
* copies each entry as a fresh `globalTags` row with a new UUID.
*
* See docs/plans/space-scoped-data-model.md §5.
*/
export interface TagPresetEntry {
name: string;
color: string;
icon?: string;
/**
* Optional group-by label on the preset. When the preset is applied to
* a Space, we create a `tagGroups` row for each distinct groupName and
* link the resulting globalTags to it so the user's familiar grouping
* shows up without them having to rebuild it per Space.
*/
groupName?: string;
}
export interface LocalUserTagPreset {
id: string;
userId: string;
name: string;
/** At most one preset per user may be the default. */
isDefault: boolean;
tags: TagPresetEntry[];
createdAt: string;
updatedAt: string;
deletedAt?: string;
}
export type UserTagPreset = Omit<LocalUserTagPreset, 'deletedAt'>;
export function toUserTagPreset(local: LocalUserTagPreset): UserTagPreset {
return {
id: local.id,
userId: local.userId,
name: local.name,
isDefault: local.isDefault,
tags: local.tags ?? [],
createdAt: local.createdAt,
updatedAt: local.updatedAt,
};
}
export interface CreatePresetInput {
name: string;
tags?: TagPresetEntry[];
isDefault?: boolean;
}
export interface UpdatePresetInput {
name?: string;
tags?: TagPresetEntry[];
isDefault?: boolean;
}