Skip to content

feat(pro): add micasa pro encrypted multi-device sync#791

Merged
cpcloud merged 90 commits intomainfrom
worktree-whimsical-spinning-rain
Mar 19, 2026
Merged

feat(pro): add micasa pro encrypted multi-device sync#791
cpcloud merged 90 commits intomainfrom
worktree-whimsical-spinning-rain

Conversation

@cpcloud
Copy link
Copy Markdown
Collaborator

@cpcloud cpcloud commented Mar 19, 2026

Summary

  • ULID primary keys: migrate all entities from auto-increment uint to ULID strings for sync compatibility
  • Oplog: change tracking via sync_oplog_entries table with GORM hooks for create/update/delete/restore
  • Encryption: NaCl secretbox (XSalsa20-Poly1305) envelope encryption with household key generation and NaCl box key exchange
  • Relay server (cmd/relay): full HTTP relay with PgStore, SHA-256 token auth, invite flow with FOR UPDATE locking, blob storage (upload/download/dedup/quota), Stripe webhook verification, graceful shutdown
  • Sync engine (internal/sync): pull/push with LWW conflict resolution, blob upload/download with encryption, blob_ref stripping in apply
  • CLI: pro init, pro join, pro status, pro storage, pro sync, pro invite, pro devices, pro conflicts commands
  • TUI background sync: indicator (synced/syncing/offline/conflict glyphs), debounce on mutations, deferred reload during form editing
  • Self-hosted relay: SELF_HOSTED=true mode with subscription bypass, configurable blob quota, Docker Compose stack (postgres + relay + optional Caddy TLS)
  • CASCADE oplog safety: HardDeleteMaintenance with proper oplog entries for child ServiceLogEntries and document detachment, TUI hard-delete extended to maintenance tab
  • ~18.7k lines across 118 files, ~1:1 production-to-test ratio

🤖 Generated with Claude Code

cpcloud and others added 30 commits March 19, 2026 14:52
…to ULID strings

Replace auto-increment uint primary keys with 26-character ULID text strings
across all entity models as the foundation for multi-device sync. This is
Phase 1 of the micasa Pro migration.

Key changes:
- Add internal/uid package wrapping oklog/ulid/v2 for ULID generation
- Convert all 12 entity model PKs and FKs from uint to string with
  BeforeCreate hooks that auto-assign ULIDs
- Fix all GORM queries: .First(model, stringID) -> .First(model, "id = ?", id)
  and .Delete(model, stringID) -> .Where("id = ?", id).Delete(model) to
  prevent SQL injection with string IDs
- Migrate FTS5 from content_rowid=id to content_rowid=rowid since FTS5
  requires integer rowids
- Update shadow DB cross-referencing to use ordinal string IDs for batch
  entity remapping
- Change zero-value sentinel from uint(0) to "" throughout app and data layers
- Update all ~60 store method signatures, handler interfaces, form data
  structs, and view types
- Update vendorHash in flake.nix for new oklog/ulid/v2 dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Full 26-char ULIDs are noisy in table views. Show only the last 7
characters (highest entropy / random component) while preserving the
full ID in rowMeta for all lookups and operations. Cap column width
at 7 to keep it compact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Every data mutation (insert, update, soft-delete, restore) now produces
an append-only oplog entry in sync_oplog_entries. AfterCreate hooks on
all 11 syncable models capture inserts; updates, deletes, and restores
use explicit oplog writes in Store methods since GORM's Model().Updates()
and Where().Delete() pass empty models to hooks. Document payloads
exclude BLOB data and include a blob_ref for content-addressed sync.
A context flag (syncApplyingKey) suppresses oplog writes when applying
remote operations to prevent infinite loops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
NaCl secretbox (XSalsa20-Poly1305) encryption with household symmetric
keys and Curve25519 device keypairs for future key exchange. Keys are
stored in $XDG_DATA_HOME/micasa/keys/ with 0600 permissions, separate
from the SQLite database so backups remain data-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Relay server stores and routes encrypted sync operations between
household devices without decrypting any data. Includes shared types
in internal/sync, a Store interface with in-memory implementation for
testing, HTTP handlers with bearer token auth, and comprehensive tests
covering two-device sync, pagination, and monotonic sequence assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sync client encrypts local ops and pushes to the relay, pulls remote
ops and decrypts them. Op applier uses last-write-wins per row with
timestamp comparison and device_id tiebreaker. Conflict losers are
preserved in the oplog with applied_at = NULL for audit and recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…exchange

Implements Phase 6 of the sync system:
- NaCl box (Curve25519 + XSalsa20-Poly1305) for asymmetric key exchange
- Invite code generation (8-char base32, 24h expiry, max 5 attempts)
- Full key exchange flow: invite -> join -> complete -> poll
- Device listing and revocation endpoints
- Rate limiting: max 3 active invites per household
- Invite codes consumed after successful key exchange

New relay endpoints:
- POST /households/{id}/invite
- POST /invite/{code}/join
- GET /households/{id}/pending-exchanges
- POST /key-exchange/{id}/complete
- GET /key-exchange/{id}
- GET /households/{id}/devices
- DELETE /households/{id}/devices/{device_id}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The invite code is now sent in the request body (invite_code field)
instead of the URL path. This matches the spec's endpoint layout where
all household operations are under /households/{id}/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Gate push/pull behind active Stripe subscription status, returning 402
when subscription is inactive. Add POST /webhooks/stripe endpoint with
manual HMAC-SHA256 signature verification (no stripe-go dependency).
Add GET /status endpoint for household sync status. Include MICASA_PRO_SPEC.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove sync_id_aliases (determined unnecessary in prior session)
- Remove device.token file storage (token stored in sync_device table)
- Remove phased timeline with week numbers and customer goals
- Replace build plan with done/remaining/post-v1 breakdown
- Mark key rotation as post-v1 (manual process documented instead)
- Keep conflicts, storage, and blob sync in v1 scope
- Remove unique constraint resolution's dependency on sync_id_aliases
- Delete MICASA_PRO_SPEC_POSSIBLY_OLD.md (superseded by current spec:
  used integer PKs, Postgres/S3/Fly.io stack, weekly timelines)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Key rotation is table stakes — without it a compromised device retains
cryptographic access to all future ops even after revocation. Restore
the full key rotation design (`micasa pro keys rotate`) to v1 scope.

Move device.token back to $XDG_DATA_HOME/micasa/keys/device.token
(0600 permissions) alongside other key material. Storing the bearer
token in the SQLite DB would mean `micasa backup backup.db` exports
credentials, contradicting the spec's own security model that backups
are data-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The GET /key-exchange/{id} endpoint is unauthenticated (the joiner has
no credentials yet). Previously it returned the device token and
encrypted household key on every call, meaning a leaked exchange ID
could be exploited indefinitely.

Now credentials are cleared from the store after the first successful
retrieval. A second GET returns ready=true but with empty token and
encrypted key fields. The exchange ID is effectively a one-time
capability token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename the credential storage directory from
$XDG_DATA_HOME/micasa/keys/ to $XDG_DATA_HOME/micasa/secrets/
to make the sensitivity of its contents unmistakable. The directory
holds household.key, device.key, device.pub, and device.token --
all material that must never be committed, cloud-backed-up, or
included in SQLite backups.

KeysDir() -> SecretsDir() with updated doc comment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix race condition on cached device ID with sync.Mutex
- Set syncApplying context in ApplyOps to prevent infinite sync loops
- Add table name allowlist to block remote ops targeting internal tables
- Use errors.Is for errConflictLoss to handle wrapped errors
- Zero key material after crypto operations with constant-time wipe
- Tighten ciphertext minimum size checks (nonce + auth tag overhead)
- Add Stringer redaction on HouseholdKey and DeviceKeyPair types
- Validate loaded device public key against private key on load
- Atomic file writes for device keypair persistence
- Add request body size limits (1 MB) on all relay JSON endpoints
- Validate public_key on household creation
- Stop leaking internal error messages to HTTP clients
- Reject Stripe webhook signatures with future timestamps
- Return 200 (not 404) for unknown subscriptions in webhook handler
- Increase invite code entropy from 40 to 64 bits
- Propagate rand.Read errors in invite code generation
- Use safeconv.Int for int64-to-int narrowing in RowCounts
- Add ORDER BY tiebreakers to all oplog queries
- Log JSON encoding errors instead of silently discarding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ages

- Make conflict resolution atomic by moving applied_at clearing into
  the same transaction as remote op application
- Fix off-by-one in invite attempt counting (> to >=)
- Add household ID validation in join handler
- Use errors.Is for http.ErrServerClosed check
- Distinguish ErrRecordNotFound from other DB errors in cachedDeviceID
- Fix DeleteIncident PreviousStatus assignment order
- Propagate re-read error in UpdateDocumentExtraction
- Reject fractional values in ParseStringID float64 case
- Make SaveHouseholdKey use atomicWriteFile
- Add HouseholdID to JoinResponse type
- Return 200 for unparseable webhook bodies per Stripe best practice
- Document intentional uint PK on ChatInput model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use os.CreateTemp with random suffix in atomicWriteFile to avoid
  predictable temp paths and concurrent write races
- Add deferred os.Remove cleanup for temp file on failure paths
- Return zero DeviceKeyPair on validation errors to prevent leaking
  loaded key material through error returns
- Simplify zeroize to use clear() instead of allocating a zero slice
- Convert allowedSyncTables mutable map to allowedSyncTable function
- Add /relay binary to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GenerateDeviceKeyPair and LoadDeviceKeyPair were returning the
partially-populated kp on errors after the private key had been
written into it. A caller inspecting the return value despite the
error could inadvertently use or log the private key material.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add householdID parameter to StartJoin interface and check it before
  incrementing usedAttempts, preventing attempt-burning attacks via
  mismatched household IDs in the URL
- Log DB errors in cachedDeviceID instead of silently returning empty
  string, making failures observable
- Reject Inf values in ParseStringID float64 case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nd sync device management

Add the non-CLI foundation for micasa pro commands:

- crypto: SaveDeviceToken/LoadDeviceToken with atomic writes and 0600 perms
- sync: NewManagementClient constructor for management-only calls
- sync: Client methods for all relay endpoints (CreateHousehold, Invite,
  Join, Status, ListDevices, RevokeDevice, key exchange)
- data: RelayURL field on SyncDevice, GetSyncDevice/UpdateSyncDevice/
  UpdateOplogDeviceIDs store methods, GormDB accessor for sync layer

CLI wiring deferred until #785 (Cobra migration) merges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wire all pro subcommands (init, status, sync, invite, join, devices,
devices revoke) into the Cobra command tree. Add workflow-oriented help
text with examples to the pro command group. Extend styledHelp to render
Cobra Example fields.

Address roborev findings: add household-mismatch test proving invite
attempts are not consumed, guard UpdateOplogDeviceIDs against empty IDs,
document singleton invariant on UpdateSyncDevice, clarify GormDB bridge
purpose, and document GetKeyExchangeResult unauthenticated-by-design
security model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Close store on all error paths in resolveProDeps and openAndMigrate
- Add defer store.Close() in all runPro* functions
- Replace busy-wait polling with signal-aware select loops (Ctrl+C safe)
- Extract resolveRelayURL helper (flag > env > default precedence)
- Add --relay-url flag to join command (was only on init)
- Fix TestRunProJoin calls for 3-arg signature, add TestResolveRelayURL
- Guard acceptExistingExtraction against empty DocID to fix
  TestExploreMode_AcceptWorksInExploreMode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e leaks

Replace six repeated store.Close() calls in resolveProDeps with a single
deferred closeOnErr guard pattern. Close setup stores in
TestRunProInitAlreadyInitialized and TestRunProJoinAlreadyInHousehold
before the SUT opens its own connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement encrypted blob upload/download through the relay so documents
sync their file data across devices. BLOBs are encrypted with the
household key (NaCl secretbox) and content-addressed by SHA-256 hash.

Relay endpoints (PUT/GET/HEAD /blobs/{household_id}/{hash}) enforce auth,
subscription gating, hash validation, and a 1 GB per-household quota.
Client treats HTTP 409 (dedup) as success. Push flow uploads blobs for
newly-pushed document ops; pull flow fetches blobs for documents that
arrived without data (ChecksumSHA256 set but Data nil).

Also fixes a bug where blob_ref in oplog payloads would cause GORM
errors since it has no corresponding DB column -- now stripped before
insert/update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add client-side SHA-256 integrity verification in DownloadBlob after
decryption to catch tampered or misaddressed blobs. Replace io.LimitReader
with http.MaxBytesReader in handlePutBlob for proper 413 handling. Return
error counts from uploadPendingBlobs/fetchPendingBlobs so runProSync can
report partial blob failures. Change BlobStorage from pointer to value
type since the relay always populates it. Document GORM soft-delete
scoping on PendingBlobDocuments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hes in integrity errors

Differentiate http.MaxBytesError (413) from other io.ReadAll failures
(400) in handlePutBlob so network errors aren't misreported as size
violations. Include expected and actual SHA-256 hashes in the blob
integrity check error message for easier debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hal errors

Reject applyInsert ops where the payload id does not match op.RowID to
prevent ID spoofing from malicious remote ops. Add runtime.KeepAlive to
crypto.zeroize so the compiler cannot elide clear(). Log unmarshal
errors in uploadPendingBlobs instead of silently continuing. Document
zero-knowledge hash design trade-off on handlePutBlob.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…essages

Extract ID validation from applyInsert into a standalone helper that
distinguishes missing/non-string ID from mismatched ID, improving
debuggability. Replace the nil-panic test with table-driven tests on
the extracted function covering match, mismatch, missing, and
non-string cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Exchange IDs are ephemeral coordination tokens, not database entities,
so they don't need ULID's time-sortable property. Replace uid.New()
with a 32-byte crypto/rand hex string (256-bit = 128-bit quantum
security via Grover's), eliminating the predictable ULID timestamp
prefix that narrows the search space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AuthenticateDevice previously iterated all stored bcrypt hashes per
request (~100ms each), creating a DoS vector that scales linearly with
device count. Replace with a SHA-256 keyed index for O(1) lookup.
Tokens are 256-bit crypto-random so a fast hash is safe -- bcrypt's
slow hashing only protects low-entropy passwords, not high-entropy
tokens. Relay test suite drops from ~0.6s to ~0.02s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ApplyOps now sorts DecryptedOps by Envelope.Seq before processing,
ensuring causal ordering regardless of relay return order. The current
MemStore returns ops in insertion order (which happens to match seq),
but a future Firestore-backed store could return ops unordered without
an explicit ORDER BY. Sorting client-side is cheap insurance against
that class of bugs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cpcloud and others added 9 commits March 19, 2026 14:53
Verifies subscription bypass, blob upload with unlimited quota,
blob download, and status reporting quota_bytes=0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Multi-stage Dockerfile builds a static relay binary on alpine.
Base compose runs postgres + relay with health checks.
Caddy override adds automatic TLS via DOMAIN env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The relay binary always calls WithBlobQuota via parseBlobQuota,
so the NewHandler default branch primarily serves tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ages

- Dockerfile: run as non-root user (uid 10001), add .dockerignore
- SELF_HOSTED: use strconv.ParseBool instead of exact "true" match
- WithBlobQuota: panic on negative values instead of silent unlimited
- runProStatus: deduplicate storage display via formatStorageUsage()
- Sync race: set syncSyncing at call site before dispatching doSync
- Surface blob errors and sync failures via setStatusError
- Engine: return ctx.Err() after blob phases; fix concurrency doc
- assert → require for precondition checks in integration test
- pg.Close() before os.Exit on migration failure
- Tests: truthy SELF_HOSTED variants, negative quota, custom quota,
  exitForm via sendKey, WithBlobQuota panic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove syncStartedMsg type and handler — status is now set at call
  sites before dispatching doSync, so the message is never produced
- Fix .dockerignore: *_test.go → **/*_test.go for recursive match

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ServiceLogEntry has OnDelete:CASCADE on MaintenanceItem, which means
a hard-delete of a maintenance item would silently remove child service
logs at the DB level with no oplog entries — breaking sync.

HardDeleteMaintenance follows the HardDeleteIncident pattern:
- Enumerates all child ServiceLogEntries (including soft-deleted)
- Detaches their documents (oplog update + DB persist)
- Writes oplog delete entries for each child
- Cleans up DeletionRecords for children and parent
- Writes oplog delete entry for the parent
- Hard-deletes the parent (CASCADE handles DB cleanup)

TUI: extend D (hard-delete) keybinding to work on maintenance tab
alongside incidents. Status bar prompt is entity-aware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents linked directly to the maintenance item (EntityKind=
"maintenance") were not being detached before hard-delete, leaving
them orphaned with a stale entity reference. Now detaches both
maintenance-item documents and service-log child documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace silently discarded errors (items, _ :=) with require.NoError
so failures produce clear assertion messages instead of index panics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- wrapcheck: wrap all external/interface errors with context
- noctx: use NewRequestWithContext for production HTTP clients;
  file-level nolint for httptest in test files
- errcheck/errchkjson: check json.Marshal and resp.Body.Close errors
- revive: rename unused BeforeCreate tx params to _
- staticcheck: replace nil contexts with context.Background()
- testifylint: assert.NotEmpty for IDs, require.Error for preconditions,
  assert.InDelta for floats, assert.Positive for > 0
- gosec: nolint for test credentials and weak random in test helpers
- intrange: use range-over-int loops
- ineffassign: remove dead rec assignment
- goconst: extract repeated test string to constant
- Bump google.golang.org/grpc 1.79.2 → 1.79.3 (GHSA-p77j-4mvh-x3m3)
- Fix flaky TestNewIsTimeSorted with 1ms sleep between ULID generations
- Update vendorHash in flake.nix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpcloud cpcloud force-pushed the worktree-whimsical-spinning-rain branch from ac37fb4 to 8a190bf Compare March 19, 2026 18:54
- Transaction() propagates deviceCell to prevent nil-pointer on
  txStore.DeviceID() calls
- UpdateDocument re-reads full row before writing oplog entry to avoid
  partial/corrupt payloads syncing to other devices
- findOrCreateVendor writes oplog update for contact-field changes on
  existing vendors so edits propagate via sync
- Validate public key length before copy in pro invite/join flows to
  prevent silent crypto failure from wrong-size keys
- pushAll intersects relay-confirmed IDs with actually-pushed IDs
  before marking ops as synced

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 19, 2026

Codecov Report

❌ Patch coverage is 66.09775% with 1311 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.37%. Comparing base (a12dea3) to head (181ee3a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
cmd/micasa/pro.go 30.00% 433 Missing and 22 partials ⚠️
internal/relay/pgstore.go 56.89% 172 Missing and 53 partials ⚠️
internal/data/store.go 59.00% 87 Missing and 36 partials ⚠️
cmd/relay/main.go 27.67% 81 Missing ⚠️
internal/relay/memstore.go 82.47% 50 Missing and 25 partials ⚠️
internal/extract/shadow.go 62.93% 32 Missing and 11 partials ⚠️
internal/data/oplog.go 76.40% 34 Missing and 8 partials ⚠️
internal/sync/blob.go 59.00% 29 Missing and 12 partials ⚠️
internal/crypto/keys.go 70.07% 26 Missing and 12 partials ⚠️
internal/data/models.go 71.20% 24 Missing and 12 partials ⚠️
... and 17 more
Additional details and impacted files
Files with missing lines Coverage Δ
internal/app/dashboard.go 87.31% <100.00%> (ø)
internal/app/styles.go 94.36% <100.00%> (+0.08%) ⬆️
internal/app/table.go 75.61% <100.00%> (ø)
internal/data/doccache.go 58.44% <100.00%> (ø)
internal/data/entity_rows.go 59.09% <ø> (ø)
internal/data/fts.go 82.03% <100.00%> (ø)
internal/data/meta_generated.go 100.00% <100.00%> (ø)
internal/data/query.go 79.26% <100.00%> (+0.16%) ⬆️
internal/data/seed_scaled.go 85.94% <100.00%> (-0.17%) ⬇️
internal/extract/llmextract.go 98.27% <100.00%> (ø)
... and 34 more

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

cpcloud and others added 12 commits March 19, 2026 15:15
- Move nolint:gosec directives to function call line (token.go,
  handler.go) so golangci-lint recognizes them
- Move nolint:testifylint to assert.Equal line (oplog_test.go)
- Skip file permission assertions on Windows (NTFS doesn't support
  Unix permissions)
- Bump buger/jsonparser 1.1.1 → 1.1.2 (GHSA-6g7g-w4f8-9c9x)
- Update vendorHash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- pushAll returns len(ids) (actually synced) not len(pushResp.Confirmed)
- Log warning when relay confirms unknown op IDs
- wasExisting excludes soft-deleted vendors to avoid double oplog
  (restore + update) on vendor restore path
- Use t.Skip instead of silent if-guard for Windows permission tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Relay handler (56.5% → 63.6%):
- 28 new tests covering error paths for handleStatus, handlePull,
  handlePutBlob, handleGetBlob, handleHeadBlob, handleCreateHousehold,
  handleCompleteKeyExchange, handleRevokeDevice, handleListDevices,
  handleGetPendingExchanges
- failingStore wrapper for injecting store errors

Sync engine (69.6% → 74.0%):
- 10 new tests for uploadPendingBlobs and fetchPendingBlobs
- Happy path, dedup skip, missing data, non-document ops, error
  counting, and combined upload+download in single cycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove unused h *Handler param from createTestHouseholdDirect and
  all 12 call sites
- Fix misleading doc comment on createTestHouseholdDirect
- Unify hex encoding in engine_test.go to fmt.Sprintf, drop
  encoding/hex import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
relay (56.5% → 66.1%):
- Join validation (name too long, missing invite, bad pubkey size)
- Stripe webhook (oversized body, bad JSON, unknown sub, store error)
- PutBlob oversized, requireSubscription error paths
- failingStore: UpdateSubscription/HouseholdBySubscription overrides

sync (69.6% → 76.2%):
- 16 apply.go tests: allowedSyncTable, ApplyOps edge cases, applyOne
  error paths, duplicate insert, restore non-existent, bad JSON
- uploadPendingBlobs/fetchPendingBlobs coverage already added

crypto (83.5% → 87.2%):
- atomicWriteFile error paths, Save*KeyPair bad directory
- readBoundedFile: oversized, empty, not found, exact max
- LoadDeviceKeyPair: wrong-size public/private keys

data (80.5% → 80.6%):
- findOrCreate empty/whitespace name
- softDeleteWith non-existent ID
- Restore flows for appliance/incident
- UpdateDocument oplog with partial update
- HardDeleteIncident with soft-deleted documents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a pgtest job with a postgres:17 service container that runs
PgStore integration tests (previously skipped due to missing DSN).
Coverage uploaded to Codecov with a "postgres" flag.

The pgstore_test.go file already exists with comprehensive tests
for all Store interface methods — they just needed a real Postgres
instance to run against.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- assert.Error → require.Error for error preconditions (testifylint)
- nolint:gosec on intentional chmod in permission test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Postgres disallows FOR UPDATE with aggregate functions (COUNT). Lock
the household row as the serialization point, then count invites
without FOR UPDATE.

Also fixes remaining lint: assert.Error → require.Error, gosec nolint
on intentional chmod in tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The -run TestPgStore filter meant only pgstore tests contributed to the
coverage file. Run all relay tests so both MemStore and PgStore paths
are in coverage-pg.txt, giving codecov a complete picture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cpcloud cpcloud merged commit 726b67e into main Mar 19, 2026
24 checks passed
@cpcloud cpcloud deleted the worktree-whimsical-spinning-rain branch March 19, 2026 22:31
cpcloud added a commit that referenced this pull request Mar 19, 2026
## Summary

- **ULID primary keys**: migrate all entities from auto-increment uint
to ULID strings for sync compatibility
- **Oplog**: change tracking via `sync_oplog_entries` table with GORM
hooks for create/update/delete/restore
- **Encryption**: NaCl secretbox (XSalsa20-Poly1305) envelope encryption
with household key generation and NaCl box key exchange
- **Relay server** (`cmd/relay`): full HTTP relay with PgStore, SHA-256
token auth, invite flow with FOR UPDATE locking, blob storage
(upload/download/dedup/quota), Stripe webhook verification, graceful
shutdown
- **Sync engine** (`internal/sync`): pull/push with LWW conflict
resolution, blob upload/download with encryption, `blob_ref` stripping
in apply
- **CLI**: `pro init`, `pro join`, `pro status`, `pro storage`, `pro
sync`, `pro invite`, `pro devices`, `pro conflicts` commands
- **TUI background sync**: indicator (synced/syncing/offline/conflict
glyphs), debounce on mutations, deferred reload during form editing
- **Self-hosted relay**: `SELF_HOSTED=true` mode with subscription
bypass, configurable blob quota, Docker Compose stack (postgres + relay
+ optional Caddy TLS)
- **CASCADE oplog safety**: `HardDeleteMaintenance` with proper oplog
entries for child ServiceLogEntries and document detachment, TUI
hard-delete extended to maintenance tab
- **~18.7k lines** across 118 files, ~1:1 production-to-test ratio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
cpcloud added a commit that referenced this pull request Mar 19, 2026
## Summary

- **ULID primary keys**: migrate all entities from auto-increment uint
to ULID strings for sync compatibility
- **Oplog**: change tracking via `sync_oplog_entries` table with GORM
hooks for create/update/delete/restore
- **Encryption**: NaCl secretbox (XSalsa20-Poly1305) envelope encryption
with household key generation and NaCl box key exchange
- **Relay server** (`cmd/relay`): full HTTP relay with PgStore, SHA-256
token auth, invite flow with FOR UPDATE locking, blob storage
(upload/download/dedup/quota), Stripe webhook verification, graceful
shutdown
- **Sync engine** (`internal/sync`): pull/push with LWW conflict
resolution, blob upload/download with encryption, `blob_ref` stripping
in apply
- **CLI**: `pro init`, `pro join`, `pro status`, `pro storage`, `pro
sync`, `pro invite`, `pro devices`, `pro conflicts` commands
- **TUI background sync**: indicator (synced/syncing/offline/conflict
glyphs), debounce on mutations, deferred reload during form editing
- **Self-hosted relay**: `SELF_HOSTED=true` mode with subscription
bypass, configurable blob quota, Docker Compose stack (postgres + relay
+ optional Caddy TLS)
- **CASCADE oplog safety**: `HardDeleteMaintenance` with proper oplog
entries for child ServiceLogEntries and document detachment, TUI
hard-delete extended to maintenance tab
- **~18.7k lines** across 118 files, ~1:1 production-to-test ratio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
cpcloud added a commit to cpcloud/micasa that referenced this pull request Mar 24, 2026
…v#791)

Thread context.Context through all sync Client methods (Push, Pull,
blob ops, household management) so callers can cancel in-flight HTTP
requests on shutdown. Fix PutBlob quota TOCTOU race with FOR UPDATE
lock on household row. Return explicit error on second key-exchange
credential retrieval instead of ambiguous empty response. Move invite
attempt counter increment after key-exchange creation so valid joins
don't consume brute-force slots. Make FTS update trigger soft-delete
aware (skip re-indexing deleted docs, handle restore correctly). Log
non-subscription webhook event types at info level. Guard BlobRef
oplog field with non-empty checksum check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant