Adds the client-side wire-up that lets browsers fetch their master key
from the mana-auth server vault and use it to encrypt/decrypt configured
record fields. Still a no-op at the user-visible level until Phase 4
flips registry entries to enabled:true on a per-table basis.
vault-client.ts
Browser HTTP client for the three Phase 2 endpoints. Built around a
factory that takes (authUrl, getToken) and returns { unlock, lock,
refetch, rotate, getState }. Reuses the active MemoryKeyProvider if
one is already installed, otherwise registers a fresh one.
unlock() flow:
1. Short-circuits if already unlocked.
2. GET /api/v1/me/encryption-vault/key with Bearer token.
3. On 404 + code:'VAULT_NOT_INITIALISED', auto-fires POST /init so
the user is bootstrapped on first login per device.
4. Imports the returned base64 bytes via importMasterKey() into a
non-extractable CryptoKey, pushes it into MemoryKeyProvider.
5. Zeroes the raw byte buffer once imported (best-effort heap hygiene).
Network layer: 3-attempt retry loop with full-jitter exponential
backoff (500ms→8s), retries only on 0/408/429/5xx. 4xx surfaces
immediately so auth/permission errors don't stall the UI for seconds.
Error categorisation: 401/403→auth, network→network, 5xx→server,
rest→unknown. Returned as VaultUnlockState so callers can render
intent ("please re-login" vs "we're trying again" vs "the server
is having a moment").
record-helpers.ts
encryptRecord(tableName, record):
- Looks up the registry, returns unchanged if the table is not
configured or registry entry is disabled.
- Builds a work list of fields that need encryption (skipping
null/undefined and already-encrypted blobs — the latter makes
the helper idempotent on a re-emit from liveQuery).
- Throws VaultLockedError on the first call that needs the key
but finds the vault locked. Module stores let it bubble; the
UI surfaces "you need to unlock" toast.
decryptRecord(tableName, record):
- Mirror of encryptRecord. Locked-vault behaviour is to LEAVE the
blobs in place (rather than throw) so views can still render
structural fields and show a "🔒" placeholder where content
used to be.
- Per-field decrypt failure (corrupt blob, wrong key) is caught,
logged, and the field stays encrypted. The rest of the record
decrypts normally — one bad blob doesn't kill the whole read.
decryptRecords: array variant that skips null/undefined entries.
Layout integration (+layout.svelte)
- createVaultClient is constructed once at module init, reused
across all auth-state changes.
- The existing $effect on authStore.user gets a new branch:
- userId set + hasAnyEncryption() → vaultClient.unlock()
- userId cleared → vaultClient.lock()
- hasAnyEncryption() guards the network round-trip: while every
table is enabled:false (Phase 3 default), no fetch happens at all.
Phase 4 enables tables one by one and the unlock kicks in
automatically.
Tests
- record-helpers.test.ts: 12 cases — encrypt skips non-listed fields,
null/undefined pass-through, idempotent on already-encrypted,
table-not-in-registry no-op, VaultLockedError on missing key,
decrypt roundtrip, locked-vault returns blobs unchanged, per-field
failure logged + others continue, JSON.stringify/parse roundtrip
survives the sync wire.
- vault-client.test.ts: 12 cases — happy path GET /key, idempotent
second unlock, 404 → auto /init, generic 404 does NOT trigger
/init, 401/403 → auth error, fetch throw → network error, no
token → auth error without network call, lock() clears key,
refetch() re-pulls, rotate() POSTs and installs.
Verified: 7 test files, 110/110 src/lib/data/ tests passing
(31 AES + 12 record-helpers + 12 vault-client + 20 sync + 6 activity
+ 19 recurrence + 10 misc helpers).
Phase 4 (next): pilot the notes module — flip its registry entry to
enabled:true, wrap the notes store add/update to call encryptRecord,
wrap the notes queries to call decryptRecord, add a settings page
showing lock state and a manual rotate button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds end-to-end browser voice capture for the Memoro module, mirroring the
existing dreams pattern: MediaRecorder → SvelteKit server proxy → mana-stt
on the Windows GPU box via Cloudflare tunnel.
Recording UI lives in /memoro page header (mic button + live timer + cancel +
sticky-permission retry). Server proxy at /api/v1/memoro/transcribe forwards
the blob with the server-held X-API-Key. memosStore.createFromVoice creates a
placeholder memo with processingStatus='processing' and fires transcribeBlob
in the background, which writes the transcript and flips status on completion
(or 'failed' with error in metadata).
Also corrects the mana-stt hostname across the repo: stt-api.mana.how (which
never existed in DNS) → gpu-stt.mana.how (the actual Cloudflare tunnel route
to the Windows GPU box). Adds an ENVIRONMENT_VARIABLES.md section explaining
how to obtain MANA_STT_API_KEY and where the tunnel terminates. Adds tunnel
health probes to the mac-mini health-check script so we catch tunnel-side
breakage in addition to LAN-side.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Consolidates all the "could still do" ideas from the initial design
sessions into a single roadmap document next to the module:
- Short-term quality-of-life polish (keyboard shortcuts, date picker,
orphan symptom IDs, plural forms)
- Mid-term features (BBT chart, history page, pattern recognition,
cycle notes panel, per-day detail page)
- Testing gaps (component tests, Playwright E2E, migration tests)
- Long-term production-readiness (notifications, memoro audio notes,
PDF export, privacy mode with app-lock, mobile port)
- Initial ManaScore estimate and ecosystem health indicators
- Explicit non-goals and a recommended next-steps ordering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the server side of the per-user encryption vault. Phase 1 shipped
the client foundation (no-op while every table is enabled:false). This
commit lets the client actually fetch a master key when Phase 3 flips
the registry switches.
Schema (Drizzle + raw SQL migration)
- auth.encryption_vaults: per-user wrapped MK + IV + format version +
kek_id stamp + created/rotated timestamps. PK = user_id, ON DELETE
CASCADE so account deletion wipes the vault.
- auth.encryption_vault_audit: append-only trail of init/fetch/rotate
actions with IP, user-agent, HTTP status, free-form context.
- sql/002_encryption_vaults.sql: idempotent CREATE TABLE + ENABLE +
FORCE row-level security with a `current_setting('app.current_user_id')`
policy on both tables. FORCE makes the policy apply to the table
owner too — no bypass via grants.
KEK loader (services/encryption-vault/kek.ts)
- Loads a 32-byte AES-256 KEK from the MANA_AUTH_KEK env var (base64).
- Production: missing or wrong-length input is fatal at boot.
- Development: 32-zero-byte fallback so contributors can run the
service without provisioning a secret. Logs a loud warning.
- wrapMasterKey / unwrapMasterKey use Web Crypto AES-GCM-256 over the
raw 32-byte MK with a fresh 12-byte IV per wrap. Returns base64
pair for storage.
- generateMasterKey + activeKekId helpers used by the service.
- Future migration to KMS / Vault: only loadKek() changes; the
kek_id stamp on each row tracks which KEK produced it.
EncryptionVaultService (services/encryption-vault/index.ts)
- init(userId): idempotent — returns existing MK or mints a new one.
- getMasterKey(userId): unwraps the stored MK; throws VaultNotFoundError
on no-row so the route can return 404 cleanly.
- rotate(userId): mints fresh MK, replaces wrap. Caller is on the
hook for re-encryption — destructive by design.
- withUserScope(userId, fn): wraps every read/write in a Drizzle
transaction with set_config('app.current_user_id', userId, true)
so the RLS policy admits only the matching row. Empty userId is
rejected up-front.
- writeAudit() appends a row to encryption_vault_audit on every
action including failures, so probing attempts leave a trail.
Routes (routes/encryption-vault.ts)
- POST /api/v1/me/encryption-vault/init — idempotent bootstrap
- GET /api/v1/me/encryption-vault/key — fetch the active MK
- POST /api/v1/me/encryption-vault/rotate — destructive rotation
- All return base64-encoded master key bytes plus formatVersion +
kekId. JWT-protected via the existing /api/v1/me/* middleware.
- readAuditContext() pulls X-Forwarded-For + User-Agent off the
request for the audit row.
Bootstrap (index.ts)
- loadKek() runs at top-level await before any route can fire so a
misconfigured KEK fails closed at boot, never at request time.
- encryptionVaultService is mounted under /api/v1/me/encryption-vault
so it inherits the existing JWT middleware and shows up next to the
GDPR self-service endpoints.
Tests (services/encryption-vault/kek.test.ts)
- 11 Bun-test cases covering: KEK load (happy path, wrong length,
idempotent, before-load guard), generateMasterKey randomness,
wrap/unwrap roundtrip, IV uniqueness across repeated wraps,
wrong-MK-length rejection, tampered-ciphertext rejection,
wrong-length IV rejection, wrong-KEK rejection.
- Service-level integration tests deferred — they need a real
Postgres for the RLS behaviour, set up via existing mana-sync
test pattern in CI.
Config + env
- .env.development gains MANA_AUTH_KEK= (empty → dev fallback)
with a comment explaining the production requirement.
- services/mana-auth/package.json gains "test": "bun test".
Verified: 11/11 KEK tests passing, 31/31 Phase 1 client tests still
passing, only pre-existing TS errors remain in mana-auth (auth.ts:281
forgetPassword + api-keys.ts:50 insert overload — both unrelated).
Phase 3: client wires the MemoryKeyProvider to GET /encryption-vault/key
on login, flips registry entries to enabled:true table by table, and
extends the Dexie hooks to call wrapValue/unwrapValue on configured
fields.
Phase 4: settings UI for lock state, key rotation, recovery code opt-in.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restore the events Playwright suite (lost in a rebase) and harden it
against Vite cold-start HMR flakes. Six tests cover the local-first
host flow (create, edit guests, RSVP totals, delete) and the public
RSVP page (snapshot render, submit, upsert, 404). The host flow runs
in guest mode and dismisses the welcome modal via a small helper.
playwright.config.ts boots mana-auth, the Vite dev server, and
mana-events as separate webServers with reuseExistingServer=true so
running tests against an already-up dev environment is a no-op. Bumps
the per-test timeout to 60s and the expect timeout to 10s, and tells
goto() to wait for networkidle so locator clicks don't race a Vite
recompile.
Replace the English-copy stubs in it.json, fr.json, and es.json with
actual Italian, French, and Spanish translations covering the full
cycles namespace — phase labels, flow/mood levels, section headers,
actions, placeholders, stats, relative dates, symptom manager, and
the calendar.
Key structure remains identical across all 5 locales so the parity
test still passes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hardcoded 'de-DE' toLocaleDateString calls across ListView,
CyclesWidget, and pure helpers with the active svelte-i18n locale.
Pure helpers in queries.ts now take their locale (and for relative
dates, their labels) as parameters so they stay pure and testable:
- formatLogDate(iso, labels, dateLocale)
- groupLogsByMonth(logs, dateLocale)
- New RelativeDateLabels type, exported from the module barrel
ListView builds relativeLabels from $_ and threads dateLocale through;
CyclesWidget does the same using a tiny $locale-derived helper.
New i18n keys cycles.relativeDate.{today,yesterday,daysAgo} across
all five locales (real de/en translations, stubs for it/fr/es).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lays the groundwork for selective field-level encryption-at-rest in the
data layer. Phase 1 ships ONLY the building blocks; nothing is actually
encrypted yet (every registry entry has enabled:false), so this commit
is a no-op for app behaviour and safe to merge.
New module: src/lib/data/crypto/
aes.ts — pure Web Crypto AES-GCM-256 wrap/unwrap
- wrapValue / unwrapValue with format-versioned envelope
`enc:1:<base64-iv>.<base64-ct>` — one-scan detection, survives
JSON.stringify on the sync wire, ~1.4× original byte length.
- JSON-stringifies the input so any value type works (string, number,
object, array). null/undefined pass through unchanged so optional
fields don't need a guard at every call site.
- Authenticated encryption: tampered ciphertext throws on decrypt.
- generateMasterKey / importMasterKey / exportMasterKey for the
Phase 2 server-side vault flow.
- toBufferSource() helper works around the TS 5.7 Uint8Array generic
parameterisation that broke the WebCrypto BufferSource overloads.
key-provider.ts — pluggable master-key source
- KeyProvider interface (getKey, isUnlocked, onChange).
- NullKeyProvider (default): always-locked, encryption call sites
silently skip. Safe for the rollout window where individual tables
are still flipping enabled:true.
- MemoryKeyProvider: holds a CryptoKey in process memory only,
notifies subscribers on lock/unlock transitions, sets a sentinel
in sessionStorage so the UI can detect the unlock state on hard
reload before the vault fetch completes.
- setKeyProvider / getKeyProvider / getActiveKey / isVaultUnlocked
are the boundary the rest of the data layer calls — no direct
references to the concrete provider.
registry.ts — strict per-table allowlist
- 30 tables registered, all enabled:false in Phase 1.
- Field selection rule: encrypt user-typed text, transcripts, PII,
free-form notes; leave IDs, timestamps, status flags, foreign
keys, sort keys plaintext so the query/index/sync layer keeps
working unchanged.
- getEncryptedFields(table) returns null for the common (disabled)
case so the Dexie hook hot-path stays allocation-free.
- hasAnyEncryption() lets the boot path skip the vault fetch
entirely while everything is still disabled.
index.ts — barrel export so consumers don't reach into sub-files.
aes.test.ts — 31 tests covering:
- isEncrypted detection (string prefix, non-strings, wrong version)
- wrap/unwrap roundtrip for string, empty string, unicode, object,
array, number, boolean, 10KB blob, null, undefined, plaintext
pass-through, null/undefined unwrap pass-through
- IV uniqueness across repeated wraps of the same plaintext
- Wrong-key rejection
- Tampered-ciphertext rejection (auth tag mismatch)
- Malformed-blob handling (missing iv/ct separator)
- importMasterKey / exportMasterKey raw byte roundtrip
- importMasterKey rejects non-32-byte input
- KeyProvider lifecycle: NullKeyProvider default, MemoryKeyProvider
set/get, listener fires only on transitions, dispose unsubscribes
- Registry: returns null for unregistered/disabled tables, every
entry has non-empty + duplicate-free fields list, hasAnyEncryption
returns false in Phase 1
All tests pass against Node 20 native Web Crypto. No fake-indexeddb
needed — the foundation is pure functions over crypto.subtle.
Verified: 31/31 new tests + 291/291 full mana/web suite passing.
Phase 2: mana-auth server-side vault (encryption_vaults table, KEK
loading, GET /me/encryption-key endpoint).
Phase 3: wire MemoryKeyProvider to the vault fetch on login, flip
registry entries to enabled:true table by table, extend Dexie hooks
to call wrapValue/unwrapValue on configured fields.
Phase 4: settings UI (lock state, key rotation, recovery code opt-in).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a CyclesWidget that appears on the unified dashboard. Shows the
current phase as a colored badge, the cycle day, a big-number
countdown to the next period, and the predicted next-period date.
Clickable — links to /cycles.
- New CyclesWidget.svelte under modules/core/widgets using liveQuery
against the cycles table
- Registered via WIDGET_REGISTRY + widgetComponents map
- WidgetType union + requiredBackend union both extended
- Existing dashboard.test.ts whitelist updated for the new backend
- i18n keys dashboard.widgets.cycles.{title,description,empty,open}
added across all 5 locales
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Loads de/en/it/fr/es cycles locale files and asserts their flattened
key paths are identical. Catches stub copies drifting silently when
new keys are added to de/en and forgotten in the others.
Also asserts every leaf value is a non-empty string so a missing
translation can't masquerade as null or an empty string.
Uses 'de' as the reference and renames vitest's 'it' to 'test' to
avoid shadowing the 'it.json' import.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a CycleCalendar component above the edit sections in the
workbench ListView. Shows a 7×6 month grid with each day colored by
its derived phase, small flow markers on days with bleeding, the
current day outlined, and the edit target highlighted with a ring.
- Prev/next month buttons and a clickable header to jump back to
the current month
- Monday-first week, weekday labels localized via locale store
- Clicking any day switches editingDate so the flow/mood/symptom
controls below update that day directly
- Collapsible via a +/− toggle in the section header
- i18n keys for calendar.title/prev/next; de + en translated,
it/fr/es mirrored from en
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add an ON DELETE CASCADE FK from rsvp_rate_buckets.token to
events_published.token. Without it, deleting a snapshot left orphaned
rate-limit rows behind, slowly leaking storage. Verified with a
direct SQL cascade test.
Add a modal to create, rename, recolor, and delete custom symptoms
without leaving the workbench view. Opens from a small "Verwalten"
button next to the Symptoms section header.
- New SymptomManager.svelte component wired to symptomsStore
- Inline edit mode with name + category select
- Delete with confirm (shows current name)
- i18n keys for manager strings + symptomCategory labels, de/en
translated, it/fr/es mirrored from en
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
24 tests covering the complex store interactions that pure-function
tests cannot reach:
- cyclesStore.createCycle auto-closes the previous open cycle and
computes length, but leaves future cycles untouched when backfilling
- cyclesStore.setPeriodEnd separates "end of bleeding" from endDate
- dayLogsStore.logDay upserts per date (no duplicates even across
multiple partial updates)
- Auto-start cycle fires on bleeding flow with no history or after a
closed cycle >= 10 days old, but NOT for spotting or mid-cycle bleeds
- Auto-end period sets periodEndDate after 2 dry days, does not
re-trigger on already-ended cycles
- Symptom reference counters adjust correctly when a log is created,
updated (adds/removes symptoms), and deleted
- autoAssignCycle retroactively attaches orphan logs to a newly
created cycle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous "click the lock icon" advice is wrong on macOS, where
"sticky deny" usually originates from the system-level Privacy &
Security setting, not a per-site browser setting. There is no lock
icon to click and the user has no obvious next step.
Now:
- The denied message detects macOS/iOS via navigator.platform and
walks through the actual fix path: System Settings → Privacy &
Security → Microphone → enable browser → fully quit and restart it
(Cmd+Q, not just close the tab — the permission only re-reads on
cold start). Also points to chrome://settings/content/microphone
as the second-most-likely culprit
- Non-mac path lists the same two causes in the right order
- Recorder.start now accepts { force: true } that bypasses the
Permissions API pre-check and actually calls getUserMedia, so the
raw browser error (NotAllowedError, SecurityError, etc) surfaces.
Useful when the Permissions API is wrong or stale
- ListView shows a "Trotzdem versuchen" button next to the error
text. Clicking it routes through the force path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The voice capture used to surface a generic "Mikrofon-Zugriff
verweigert" whenever getUserMedia rejected, even though the actual
cause was usually one of three distinct, fixable conditions:
- Insecure context (http://192.168.x.x:5173 instead of localhost or
https) — getUserMedia is silently unavailable, no prompt
- Sticky deny — user previously refused once, browser remembers and
rejects without ever asking again
- Hardware: no microphone, busy mic, security policy
Now the recorder:
1. Checks window.isSecureContext first and tells the user to switch
to https or localhost, naming the offending host
2. Queries the Permissions API for "microphone" before calling
getUserMedia. If state is "denied", shows step-by-step recovery
instructions (lock icon → mic → allow → reload) instead of pretending
the user actively denied just now
3. Maps NotAllowedError / NotFoundError / NotReadableError /
SecurityError to specific German messages, with the raw error as a
fallback so the rest is still debuggable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two adjustments after end-to-end testing the voice flow against the
self-hosted mana-stt on the GPU server:
- Browser MediaRecorder always sets a clean audio/* mime type, but
CLI clients (curl, scripts) often send application/octet-stream
for audio files. Empty mime types should also pass through. Tighten
rejection to clearly non-audio types only.
- await request.formData() throws on a missing/invalid body which
surfaces as a SvelteKit 500 with "Internal Error". Catch it and
return a 400 with a useful message instead.
Verified end-to-end with WhisperX large-v3-turbo: m4a (Anna voice)
transcribed in ~2.4s through the proxy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move all hardcoded German strings in the cycles ListView to per-module
translation files under lib/i18n/locales/cycles/. German and English
are fully translated; it/fr/es are stub copies of en.json for now.
Registers the cycles namespace in lib/i18n/index.ts alongside the other
modules. Phase labels, flow labels, mood labels, section headers,
buttons, placeholders, and the delete-confirmation message all flow
through $_('cycles.*').
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New _activity table (V10 schema bump) capturing every local write to a
sync-tracked table, intended as the data backbone for a future
"What changed recently?" UI and per-record history view.
Schema is deliberately tiny — no field diffs, no payloads — so the
disk footprint stays bounded:
++id, createdAt, appId, collection, recordId, op, userId
plus compound indexes [appId+createdAt] and [collection+recordId] for
the per-app feed and per-record history paths.
Population
database.ts trackActivity() helper is called from the same Dexie
creating/updating hooks that already drive _pendingChanges. Lives
next to trackPendingChange to share the db reference and avoid an
import cycle with activity.ts. Server-applied changes are skipped
(the apply lock guards both writers) so the feed reflects local
user intent rather than sync echo. Soft deletes (deletedAt set on
an update) are recorded as op:'delete'.
Read API (activity.ts)
- getRecentActivity({ appId?, collection?, recordId?, limit? })
walks the appropriate compound index in reverse and short-
circuits on the limit, so cost is O(limit) regardless of total
log size. Always scoped to the active user via getEffectiveUserId.
- pruneActivityLog() drops entries >90d old + caps the table at
ACTIVITY_MAX_ENTRIES (10k) by FIFO.
Scheduling
data-layer-listeners.ts now runs pruneActivityLog alongside the
existing tombstone cleanup (boot + 24h interval), with a separate
Sentry tag so failures of one job don't mask the other.
Tests
6 new tests in activity.test.ts cover insert / update / delete
hook propagation, appId filter, multi-user isolation, the limit
option, and TTL pruning. All pass against fake-indexeddb.
Drive-by
vite.config.ts gains a `test.exclude` for `e2e/**` so the new
Playwright specs the events module shipped don't crash vitest with
`test.afterAll() not expected here`. Two pre-existing failures
unrelated to this audit are now also out of the way.
Verified: 22/22 test files, 220/220 tests passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two improvements to the SSE event loop in connectSSE:
1. Read/apply pipelining
The previous loop did read → parse → await applyServerChanges →
read. A slow apply blocked the network reader, so each event
incurred the latency of the previous event's IndexedDB write
before the next chunk could even start streaming in.
Now apply work is enqueued onto a sequential promise chain
(applyChain) and the read loop returns to draining the network
immediately. LWW correctness still requires in-order application,
so the chain serialises applies — the win is just decoupling I/O
from disk work, not parallelism. The chain is awaited once at the
end so the SSE state never resumes from a cursor that hasn't been
written.
2. Allocation-light parser
indexOf/slice replaces split('\n\n') and split('\n'). The previous
parser allocated a fresh array of strings on every chunk; the new
one walks the rolling buffer in place and only materialises the
one event block currently being inspected. Same complexity, less
GC pressure on busy streams.
Drive-by: tightens the JSON.parse error handling to skip malformed
events explicitly instead of swallowing them inside an outer try.
Verified: 20/20 sync.test.ts still passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Click any row in the recent-entries list to switch the editing target
to that day. The flow/mood/symptom/temperature/notes controls then
update that past entry instead of today, with a pink banner showing
which day is being edited and offering 'back to today' and 'delete'
buttons. Confirmation dialog prevents accidental deletes.
Implementation: editingDate signal drives all logDay() calls and a
derived editingLog from useAllDayLogs() avoids creating per-date
queries. The dayLogsStore.deleteLog() soft-deletes via deletedAt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Schema bump V9 adds an updatedAt secondary index to the six tables
that the cross-app dashboard widgets use for "recent N" lookups:
conversations, images, presiDecks, documents, songs, mukkePlaylists.
Dexie builds the index lazily on first open — no migration code,
no data touched.
Recent-query refactor:
useRecentConversations
useRecentImages
useRecentDecks
useRecentDocuments
All four switched from `toArray() + JS sort + slice` to
`orderBy('updatedAt').reverse().filter().limit()`. Dexie walks the
BTree backwards and short-circuits as soon as `limit` matches
accumulate, so the cost is O(limit + filtered) instead of O(table).
For a dashboard with thousands of stored conversations or images,
the dashboard widget previously read every record on every render
(liveQuery re-runs on any write). Now it stops after 5–6 hits.
Verified: 20/20 sync.test.ts still passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user logs a bleeding flow (light/medium/heavy) and the previous
cycle ended at least 10 days ago (or no cycle exists), automatically
create a new cycle. When the user logs 'none' for at least 2 consecutive
days after the last bleeding day in an open cycle, automatically set
periodEndDate to that last bleeding day.
Heuristics live in utils/auto-detect.ts as pure functions and are wired
into dayLogsStore.logDay. Conservative thresholds avoid false positives
for mid-cycle spotting and partial bleeding patterns. 18 unit tests
cover the edge cases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. database.ts — defer pending-change writes out of the user transaction.
`taskTable.add()` opens an implicit Dexie transaction scoped only to
`tasks`. The creating-hook then tried to write to `_pendingChanges`,
which is not in scope, throwing `NotFoundError: object store not in
scope` and breaking every create across todo/calendar/contacts/etc.
`queueMicrotask` is not enough — Dexie binds the active transaction
to the current zone via Promise scheduling and treats microtasks as
"still inside". `setTimeout(0)` breaks out cleanly so the deferred
add() spawns its own implicit transaction.
2. workbench/AppPage.svelte — guard ListView reload by appId.
The list-loader $effect read `app` (a $derived of getApp(appId)) and
on every reactive churn cleared `ListComponent = null`, making the
whole carousel flash a spinner. After a task create, liveQuery churn
propagated up enough to retrigger this effect, which looked exactly
like a full page reload to the user. Now we only reload when appId
itself changes, with a stale-load guard for out-of-order awaits.
3. zitare/ListView.svelte — pull `quotesStore.initialize()` out of $effect.
The effect called initialize() (which writes `currentQuote` $state)
and then read `currentQuote` back, creating a classic write-then-read
loop that hit `effect_update_depth_exceeded`. Initialize now runs in
onMount; the effect is read-only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
base-client.test.ts
Source had been localised to German (Sitzung abgelaufen,
Keine Berechtigung, Server-Fehler (500)) but the test still
asserted on the old English strings. Updates the assertions
to the German substrings so a future copy tweak doesn't
break them again.
dashboard.test.ts
Widget registry has grown from 16 to 22 entries and the
required-backend list now includes nutriphi and planta. The
hard count assertion is replaced with a >=16 floor so adding
widgets no longer requires updating the test on every PR.
content/help/index.test.ts
getManaHelpContent() routes through svelte-i18n's t() helper.
In the test env the i18n store was uninitialised, so the
helper returned bare key strings and the .split(',') on a
missing tags entry threw. Adds a beforeAll that registers
the help dictionary for both de and en and awaits waitLocale
so the helper resolves real values.
Verified: 196/196 tests across 20 files now passing
(was 154/163 before this commit).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a one-tap voice recorder at the top of the Dreams module. Speak
your dream right after waking, the audio is sent through a server-side
proxy to mana-stt, and the transcript appears in the entry as soon as
it lands.
- New /api/v1/dreams/transcribe SvelteKit server route proxies the
upload to mana-stt with the server-held MANA_STT_API_KEY (never
exposed to the browser); validates mime, size, missing config
- Adds MANA_STT_URL + MANA_STT_API_KEY to the mana-web env config in
generate-env.mjs (private, not PUBLIC_ prefixed)
- New DreamRecorder class wraps MediaRecorder with reactive
$state — status, elapsed timer, error; supports cancel
- dreamsStore.createFromVoice creates a placeholder dream with
processingStatus='transcribing' and kicks off the upload
- dreamsStore.transcribeBlob uploads, writes the result back into
the dream, falls back to processingStatus='failed' on errors
- Adds processingStatus + processingError + audioDurationMs to
LocalDream; backwards-compatible defaults in toDream
- Mic button in ListView with idle / requesting / recording
(with elapsed timer + pulsing red) / stopping states
- Cancel button discards the in-flight recording
- Transcribing badge ●●● + failed ! badge on dream rows
- Inline editor shows live transcription status; while it's running
and the user hasn't typed anything, the transcript folds into the
edit buffer as soon as it arrives
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PublicRsvpList: collapse onMount/onDestroy/$effect into a single
$effect with proper cleanup; eliminates the redundant interval init
paths and the dead else-branch
- DetailView: re-push the snapshot to mana-events when a published
event is opened, so any earlier fire-and-forget that lost a write
silently self-heals
- New _eventsTombstones queue (db version 8): when unpublish/delete
fails to remove the server snapshot, queue (eventId, token) for
retry; ListView drains the queue on mount with capped attempts
- Public /rsvp/[token]: detect Accept-Language in +page.server.ts,
pass lang to the page, and use a small inline DE/EN dict in
strings.ts — no svelte-i18n on the public route
New unified-app module under apps/mana/apps/web/src/lib/modules/cycles.
Adds three Dexie tables (cycles, cycleDayLogs, cycleSymptoms) in db v7,
SYNC_APP_MAP entry, app-registry registration, branding (icon + entry +
APP_URLS), and a /cycles route.
Includes phase derivation (menstruation/follicular/ovulation/luteal),
heuristic next-period and fertile-window prediction (rolling mean over
last 6 cycles), 10 default symptoms, and 33 unit tests covering the
pure utilities.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New data-layer-listeners.ts wires the fire-and-forget CustomEvents the
sync engine and quota helpers emit into the rest of the app:
- mana:storage-quota-exceeded
→ toast.info / .warning / .error depending on whether the recovery
cleanup succeeded, and a Sentry capture for the failure cases.
- mana:sync-telemetry
→ push:error / pull:error are routed to captureException with the
error category as a tag. Auth and network errors are downgraded to
console.warn so they don't drown Sentry in expected token blips.
→ apply:malformed-drop becomes a captureMessage warning.
→ success events log to console.debug only when import.meta.env.DEV.
- Tombstone cleanup loop
→ cleanupTombstones() runs once on idle after boot, then every 24h.
Errors caught locally and reported via captureException with a
'tombstone-cleanup' tag. Soft-deleted rows older than 30 days are
hard-purged so the IndexedDB doesn't grow unbounded.
Wired into the root layout's onMount: installDataLayerListeners()
returns a dispose function that removes both window listeners and
clears the cleanup interval.
Closes the audit's "no telemetry" + "no quota handling" + "tombstone
cleanup helper exists but unused" trio in one shot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SymbolsView:
- Sort tabs (Häufigkeit / A-Z / Zuletzt) above the cloud
- More dramatic font scaling using sqrt easing for visual hierarchy
- "?" badge on symbols without a personal meaning, dimmed until hover
- "Zuletzt" sort shows the most recent dreamDate per symbol
- A-Z and Zuletzt switch the cloud to a vertical list layout
- Hides symbols whose count dropped to zero (e.g. after merge)
SymbolDetailView:
- Auto-save with 500ms debounce + transient "Gespeichert" hint
- Co-occurring chips are clickable and navigate to that symbol's detail
- Dream refs are clickable buttons; ListView passes onOpenDream so a
click jumps back to the timeline and opens the dream for editing
- Manual merge UI: "Zusammenführen…" button reveals a select with all
other symbols, confirmation dialog before merging
- Re-initializes edit buffer when navigating between symbols (lastInitId
guard instead of one-shot initialized flag)
Helpers:
- getLastUsedBySymbol returns a Map of symbol → most recent dreamDate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New Hono+Bun service at services/mana-events on port 3065 with two
schemas in mana_platform: events_published (snapshots) and public_rsvps
(unauthenticated responses), plus a per-token hourly rate-limit bucket.
- Host endpoints (JWT) for publish/update/unpublish/list-rsvps
- Public endpoints for snapshot fetch + RSVP upsert with rate limiting
- New /rsvp/[token] page outside the auth gate, SSR-loads the snapshot
- Client store wires publishEvent/unpublishEvent to the server, syncs
snapshot updates after edits, and deletes the snapshot on event delete
- DetailView polls GET /events/:id/rsvps every 30s while open and lets
hosts import a public response into their local guest list
- generate-env, setup-databases.sh, .env.development, hooks.server.ts,
package.json wired for local dev
Adds a Symbols view to the Dreams module — the long-term differentiator
that lets users build a personal symbol vocabulary instead of relying
on generic dream-dictionary entries.
- New view-mode tabs (Träume / Symbole) at the top of the Dreams view
- SymbolsView: wordcloud-style list of all symbols sized by frequency,
with name search and inline color dots
- SymbolDetailView: editable name + personal meaning + color picker,
mood distribution bars, co-occurring symbols, and chronological
list of all dreams that reference the symbol
- dreamsStore.updateSymbol: rename propagates to all referencing dreams,
collisions auto-merge with the existing symbol
- dreamsStore.deleteSymbol: removes the symbol from all dreams
- dreamsStore.mergeSymbols: rewrites references and sums counts
- New query helpers: getDreamsWithSymbol, getMoodDistribution,
getCooccurringSymbols
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Updates DATA_LAYER_AUDIT.md to reflect the actual state after the
seven-commit audit pass. All critical (🔴) and high-priority (🟡)
items from the original audit are now closed; remaining 🟢 items are
listed in a Backlog section with clear next steps.
Status table at the top maps each sprint to its commit hash so the
audit doc is now self-referential — anyone reading it can jump
directly to the change that fixed each item.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sprint 4.1 — per-table sync apply lock
Replaces the global _applyingServerChanges boolean with a Set of
currently-applying table names (beginApplyingTables / isApplyingTable).
applyServerChanges now scopes the lock to exactly the tables it touches,
so a user typing into chat while todo is syncing no longer has their
write silently dropped from _pendingChanges. The legacy single-flag API
is kept as a thin shim for backward compatibility.
Sprint 4.2 — IndexedDB quota handling
- quota-detect.ts (no Dexie deps, importable from database.ts):
isQuotaError() across browsers + Dexie wrapped errors,
notifyQuotaExceeded() dispatches a CustomEvent the UI can subscribe to.
- quota.ts (re-exports detect helpers + adds db-aware bits):
cleanupTombstones() hard-deletes old soft-deleted rows to reclaim space,
withQuotaRecovery() wraps a write op with one cleanup-and-retry pass.
- applyServerChanges wraps each per-table transaction in a quota
recovery loop. A full DB no longer crashes the pull.
- The Dexie creating/updating hooks now write _pendingChanges via
trackPendingChange(), which catches QuotaError on the fire-and-forget
promise and surfaces the event instead of silently losing the entry.
Sprint 4.3 — sync telemetry events
New sync-telemetry.ts emits a window CustomEvent for every push/pull
lifecycle transition: push:start/ok/error, pull:start/ok/error,
apply:malformed-drop, apply:done. Errors carry a coarse category
(network/auth/http-5xx/http-4xx/parse/unknown) and durations are
measured in ms. No record contents are emitted — safe to forward to
Sentry / a debug HUD without leaking PII.
Sprint 4.4 — indexed queries on hot dashboard paths
Three cross-app dashboard widgets that previously full-scanned every
task / time block on every render now use indexed range queries:
- useTodayTasks → .where('dueDate').belowOrEqual(endOfToday)
- useUpcomingTasks → .where('dueDate').between(start, end)
- useUpcomingEvents → .where('startDate').between(now, future)
useFavoriteContacts hits the indexed isFavorite column directly (with
a number-or-boolean compound key for legacy / fresh records).
Verified: 20/20 tests in sync.test.ts still passing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New 'events' module for planning gatherings with guest lists and RSVPs,
distinct from the personal calendar. Events surface in the calendar via
TimeBlock with sourceModule='events'. Guests, RSVPs and a publish stub
work fully local-first; the public RSVP server lands in Phase 1b.
- Filter tabs (All / Lucid / Nightmare / Recurring) above the dream list
- Symbol chips in the insights ribbon are clickable to filter the list
- Symbol chips on each dream row are clickable too, with active state
- Editor exposes dreamDate, bedtime and wakeTime via native pickers
- Sleep quality star rating in the editor (1–5, toggleable)
- Recurring-dream toggle alongside the lucid toggle
- Recurring badge on dream rows
- Dream row converted from <button> to div role=button so nested chip
buttons are valid HTML
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new Dreams module to the unified Mana app for capturing dream
journal entries with mood, lucid status, recurring symbols, and
timeline insights. Founder-tier gated for now.
- Dexie schema v5 with dreams, dreamSymbols, dreamTags
- Mutation store with auto symbol counting on create/update/delete
- ListView with quick capture, inline editor, mood picker, lucid
toggle, monthly grouping, insights ribbon, context menu
- Workbench registration with note → dream drop transform
- New 'dream' DragType, dreams app icon, mana-apps catalog entry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Package uses moduleResolution: NodeNext which requires explicit .js extensions on relative ESM imports. Without these, prepare/build failed and broke pnpm install for the whole monorepo.
The implicit-any errors on (table) callbacks were cascading from the broken imports — they resolve once the modules import correctly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The lockfile had grown five (!) different vitest versions over time:
1.6.1, 2.1.9, 3.2.4, 4.1.2 and 4.1.3 — pulled in by various
packages that pinned outdated majors. The mismatch produced the
classic "createDOMElementFilter not found" startup crash because
hoisted @vitest/utils@3.x was loaded by the nested @vitest/runner@4.x.
Bumped every package.json that pinned an old vitest:
- apps/manavoxel/apps/web (^4.1.0 → ^4.1.2)
- apps/matrix/apps/web (^4.1.0 → ^4.1.2)
- apps/memoro/apps/server (^3.0.0 → ^4.1.2)
- apps/nutriphi/packages/shared (^2.1.8 → ^4.1.2)
- packages/qr-export (^3.0.5 → ^4.1.2)
- packages/shared-llm (^2.0.0 → ^4.1.2)
- packages/shared-storage (^4.1.0 → ^4.1.2)
- packages/spiral-db (^1.6.1 → ^4.1.2)
- packages/test-config (^3.0.0 → ^4.1.2)
- packages/wallpaper-generator (^3.0.5 → ^4.1.2)
After a clean pnpm-lock.yaml regenerate, every @vitest/* sub-package
resolves to a single version (4.1.3, picked by semver) — no more
duplicates between hoisted and nested node_modules.
Verified by running:
pnpm --filter @mana/web vitest run src/lib/data/sync.test.ts
→ 20/20 tests passing in 217ms
pnpm --filter @mana/web vitest run src/lib/data/time-blocks/recurrence.test.ts
→ 19/19 tests passing in 198ms
Pre-existing test failures in base-client.test.ts (German error
strings vs english assertions), dashboard.test.ts (widget count
drift), and content/help/index.test.ts (svelte-i18n locale not
initialised in test env) are unrelated and tracked separately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Type-aware linting via @typescript-eslint/projectService loads the entire TS program into memory; with 27+ modules in the unified app the default 4GB heap OOMs. Bump explicitly in the lint script.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- shared-branding/mana-apps: drop duplicate `mana` and obsolete `inventar` URL entries
- web/app.d.ts: move __BUILD_HASH__/__BUILD_TIME__ ambient declarations into declare global so they survive module-scoping
- web: remove dead supabase template (routes/api/example, lib/server/middleware) — locals.session no longer exists post auth migration
- habits/queries: drop stale Record<string,string> cast on LocalHabit (legacy emoji field)
- shared-stores/toggle-field: cast to Dexie UpdateSpec instead of Partial<T> for newer dexie types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The ManaCore→Mana rename converted both `manaCoreSvg` and the existing
`manaSvg` to the same identifier, leaving two `const manaSvg = ...`
declarations and two `mana:` keys in APP_ICONS. This broke any consumer
of the package with a duplicate-symbol error at SSR build time.
Removed the legacy ManaCore icon (4-circle quartet) and kept the
current Mana brand icon (single droplet). Removed the duplicate
APP_ICONS entry as well.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New SyncChange / FieldChange / SyncOp types replace `any[]` in
applyServerChanges. The wire format is now self-documenting and
TypeScript catches malformed callsites at compile time.
- isValidSyncChange() validates incoming server payloads at the boundary:
malformed entries are dropped with a single warn log, valid ones are
applied. A bad row from the server can no longer corrupt IndexedDB.
Hand-rolled type guards keep us free of a runtime-validation dep.
- applyServerChanges() and readFieldTimestamps() are now top-level
exports (extracted out of createUnifiedSync's closure) so they can be
imported directly by tests. Behaviour is unchanged — the closure
variant inside the sync manager just resolves the module-level
symbol now.
- New sync.test.ts covers:
* pure isValidSyncChange and readFieldTimestamps cases
* field-level LWW: server-newer wins, split outcome when local-newer
on one field and server-newer on another
* insert with __fieldTimestamps stamping
* soft-delete LWW guard
* malformed-entry drop with valid entries surviving
* sync-loop guard: server-applied writes don't generate _pendingChanges
- fake-indexeddb added as devDependency for the integration tests.
Note: the monorepo's vitest install is currently tangled across mixed
@vitest/* package versions in the lockfile, so `pnpm test` fails before
reaching this file. The tests are written to pass on any vitest 4.x once
that's untangled — needs its own dedicated cleanup pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- calendar/types: replace duplicate recurrenceRule with recurrenceDate on CalendarEvent; map it in timeBlockToCalendarEvent
- recurrence: drop stale Record casts now that LocalTimeBlock types isRecurrenceException and recurrenceDate
- todo: route recurrenceRule through TimeBlock in createTask/updateTask, load it from block in useTaskForm; accept labelIds via metadata; remove stale projectId casts
- calendar/events: include linkedBlockId/parentBlockId/recurrenceDate in createDraftEvent
- habits: drop unused db / LocalTimeBlock imports
- eslint-config: disable consistent-type-imports (parser conflict with .svelte.ts files)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous startup.sh checked colima status via `colima status | grep running`
and, if that failed, ran `colima stop --force` unconditionally before starting.
This is destructive: a transient status mis-detection can kill a healthy running
VM, and the subsequent start often hangs because of leftover locks/processes.
Triggered today during the ManaCore→Mana rename: reloading the docker-startup
LaunchAgent ran the script, which falsely concluded colima was down, killed the
running VM, and left 12 zombie limactl processes plus a stale disk lock symlink.
The whole production stack (incl. Forgejo) was offline until manual cleanup.
Changes:
- Use `docker info` as the readiness check instead of `colima status` —
it directly tests the thing we care about (docker socket reachable)
- Only do cleanup work when we actually need to start; never SIGKILL a
running VM as a "precaution"
- When we do need to start: reap any zombie limactl/colima processes from
prior failed runs, and clear the stale disk-in-use lock if no process
actually holds it
- Verify successful start with `docker info`, not `colima status`
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Defense-in-depth on top of the existing application-level WHERE clauses:
- Migrate() now ENABLE + FORCE row level security on sync_changes and
installs a policy that gates rows on current_setting('app.current_user_id').
FORCE makes the policy apply to the table owner too, so the application
role used by mana-sync cannot bypass it regardless of grants.
- New withUser(ctx, userID, fn) helper opens a transaction and calls
set_config('app.current_user_id', userID, true) before running fn.
Empty userIDs are rejected up-front so an unauthenticated request can
never reach the database with an empty RLS scope (which would match
every row).
- RecordChange / GetChangesSince / GetAllChangesSince all run inside
withUser. WITH CHECK on the policy double-validates the user_id column
on insert against the active session, so a future code path that
forgets the WHERE clause cannot leak data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Single source of truth for the active user via data/current-user.ts;
layout pushes authStore.user.id into it on every auth state change.
- Dexie creating-hook auto-stamps userId from getEffectiveUserId(); the
updating-hook strips userId from modifications so records are
effectively user-immutable after creation.
- BaseRecord gains an optional userId so module types inherit it without
per-module declarations. All hardcoded 'guest'/'local' fallbacks in
module type-converters and session timer stores are deleted; the dead
userId field is removed from the public view types where it was
unused (Task, Conversation, Template, Deck, Plant, Contact, etc.).
- New guest-migration.ts: on first authenticated session, walks every
sync-tracked table, deletes guest-owned records and re-adds them so
the creating-hook re-stamps with the real user id and produces fresh
insert pending-changes with the full payload. Stale guest pending-
changes are cleared up-front.
- Drive-by: root onMount now returns its cleanup synchronously; the
previous async form silently dropped the cleanup callback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>