Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
df02568
docs(09): capture phase context
FSM1 Feb 7, 2026
ace9359
docs(09): research phase domain
FSM1 Feb 7, 2026
beaddfe
docs(09): create phase plan
FSM1 Feb 7, 2026
82c2892
fix(09): revise phase plans based on checker feedback
FSM1 Feb 7, 2026
6113fd8
fix(09): revise plans based on checker feedback (iteration 3)
FSM1 Feb 7, 2026
531a432
docs: log learnings from Phase 9 planning session
FSM1 Feb 7, 2026
f2d53fe
feat(09-03): add desktop client support to auth endpoints
FSM1 Feb 7, 2026
e9e8a2b
test(09-03): add desktop client auth flow tests
FSM1 Feb 7, 2026
83a8fe9
feat(09-01): scaffold Tauri v2 desktop app in monorepo
FSM1 Feb 7, 2026
bd0b263
docs(09-03): complete desktop auth endpoints plan
FSM1 Feb 7, 2026
7c4a8f6
docs(09-01): complete Tauri v2 scaffold plan
FSM1 Feb 7, 2026
8d5201b
feat(09-02): implement Rust AES-256-GCM, ECIES, Ed25519, and folder m…
FSM1 Feb 7, 2026
d6490c3
feat(09-02): implement IPNS records and cross-language test vectors
FSM1 Feb 7, 2026
f06321d
docs(09-02): complete Rust crypto module plan
FSM1 Feb 7, 2026
0277bc5
feat(09-04): implement API client, Keychain auth, and app state
FSM1 Feb 7, 2026
77bafb7
feat(09-04): implement Tauri commands for auth flow with webview-base…
FSM1 Feb 8, 2026
b423836
docs(09-04): complete auth and keychain integration plan
FSM1 Feb 8, 2026
4b2faec
feat(09-05): implement IPFS/IPNS API calls, inode table, and caching …
FSM1 Feb 8, 2026
75927d6
feat(09-05): implement FUSE read operations and mount/unmount wiring
FSM1 Feb 8, 2026
12bc8a2
docs(09-05): complete FUSE filesystem read operations plan
FSM1 Feb 8, 2026
b022fdb
feat(09-06): implement temp-file write model and IPNS publish helpers
FSM1 Feb 8, 2026
76d91cf
feat(09-06): implement FUSE file mutation operations
FSM1 Feb 8, 2026
82b5a51
feat(09-06): implement FUSE directory mutation operations
FSM1 Feb 8, 2026
7d998ff
docs(09-06): complete FUSE write operations plan
FSM1 Feb 8, 2026
547b13a
feat(09-07): implement system tray, background sync daemon, and offli…
FSM1 Feb 8, 2026
7e0baad
fix(09): add Vite dev server config for Tauri desktop app
FSM1 Feb 8, 2026
095b3e1
fix(09): create webview window on tray Login click
FSM1 Feb 8, 2026
37d20b5
fix(09): add Buffer/process polyfills for Web3Auth in Tauri webview
FSM1 Feb 8, 2026
b9b0b6c
fix(09): update Web3Auth init to v10 API (.init() not .initModal())
FSM1 Feb 8, 2026
50e4628
fix(09): allow OAuth popup windows in Tauri webview
FSM1 Feb 8, 2026
92fe45d
fix(09): use Create response for OAuth popups to preserve window.opener
FSM1 Feb 8, 2026
842eee4
fix(09): use v10 getIdentityToken() instead of authenticateUser()
FSM1 Feb 8, 2026
3766e10
fix(09): clear stale Web3Auth cache on init, add debug logging
FSM1 Feb 8, 2026
1d3dd61
fix(09): include publicKey and loginType in desktop login request
FSM1 Feb 8, 2026
696a9a8
fix(09): send compressed public key in login request
FSM1 Feb 8, 2026
232abea
fix(09): vault init for new users, window lifecycle, rename to CipherBox
FSM1 Feb 8, 2026
1158d25
fix(09): UAT fixes β€” FUSE-T NFS stability, login/logout lifecycle, cl…
FSM1 Feb 8, 2026
ac8583a
docs: add Phase 9 UAT learnings for FUSE-T, Tauri, macOS, and testing
FSM1 Feb 8, 2026
1faf8b8
chore: update CodeRabbit config to review .learnings and .planning
FSM1 Feb 8, 2026
adba910
fix(09): revert dynamic import in ipns.service, fix lint in test vectors
FSM1 Feb 8, 2026
361c1fa
fix(09): replace ESM-only ipns package with inline protobuf decoder
FSM1 Feb 8, 2026
92224fe
chore: add todo for vault sync loading state UX
FSM1 Feb 8, 2026
d81383e
docs: add security review report for Phase 9 Desktop Client
FSM1 Feb 8, 2026
c8f27f0
fix(09): address all High and Medium security review findings
FSM1 Feb 8, 2026
bbb50d9
ci: skip desktop app build in e2e workflow
FSM1 Feb 8, 2026
bccb804
fix(09): update token service test for tokenPrefix query
FSM1 Feb 8, 2026
ad5f54e
test(09): add unit tests for inline IPNS record parser
FSM1 Feb 8, 2026
e19e227
ci: skip desktop app build in CI workflow
FSM1 Feb 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ reviews:
Focus on test coverage, edge cases, and test quality.
Ensure tests are meaningful and not just for coverage metrics.

- path: '**/*.md'
instructions: |
Markdown files are documentation and learnings. Use them for context
when reviewing related code, but do NOT flag markdown formatting,
linting, or style issues (these are handled by markdownlint).

- path: '.planning/**'
instructions: |
Planning documents provide context for the current development phase.
Use for understanding intent, but do not review for style or formatting.

- path: '.learnings/**'
instructions: |
Agent learning documents capture development insights. Use for context
when reviewing related code, but do not review for style or formatting.

# Paths to ignore in reviews
path_filters:
- '!**/node_modules/**'
Expand All @@ -68,13 +84,10 @@ reviews:
- '!**/.next/**'
- '!**/coverage/**'
- '!**/pnpm-lock.yaml'
- '!**/*.lock'
- '!**/generated/**'
- '!.claude/agents/**'
- '!.claude/commands/**'
- '!.claude/get-shit-done/**'
- '!.learnings/**'
- '!.planning/**'

chat:
# Enable chat interactions in PR comments
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,5 +180,5 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build all packages
run: pnpm build
- name: Build packages (excluding desktop)
run: pnpm --filter @cipherbox/crypto --filter @cipherbox/api --filter @cipherbox/web build
4 changes: 2 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ jobs:
npm install
npm run build

- name: Build packages
run: pnpm build
- name: Build packages (excluding desktop)
run: pnpm --filter @cipherbox/crypto --filter @cipherbox/api --filter @cipherbox/web build
env:
VITE_WEB3AUTH_CLIENT_ID: ${{ secrets.VITE_WEB3AUTH_CLIENT_ID }}
VITE_API_URL: http://localhost:3000
Expand Down
28 changes: 28 additions & 0 deletions .learnings/2026-02-07-rust-typescript-data-model-fidelity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Rust-TypeScript Data Model Fidelity in Cross-Language Ports

**Date:** 2026-02-07

## Original Prompt

> /gsd:plan-phase (Phase 9: Desktop Client β€” Tauri app with FUSE mount, porting existing TypeScript crypto/data models to Rust)

## What I Learned

- **Every field matters when porting TypeScript types to Rust structs.** The plan checker caught that `FolderEntry` was missing `ipnsPrivateKeyEncrypted` and `VaultResponse` was missing `encryptedRootIpnsPrivateKey` + `rootIpnsPublicKey`. These aren't cosmetic omissions β€” without them, IPNS record signing (required for every write operation) would silently fail at runtime.
- **Encrypted key fields are the most dangerous to miss.** They don't show up in happy-path read operations (you can browse files fine), but every mutation path breaks. Easy to miss during planning because reads work without them.
- **Serde `rename_all = "camelCase"` is essential** when Rust structs deserialize from TypeScript-generated JSON. The TypeScript types use camelCase (`ipnsPrivateKeyEncrypted`), Rust convention is snake_case (`ipns_private_key_encrypted`). Without the Serde attribute, deserialization silently drops fields.
- **Desktop auth can't use HTTP-only cookies.** Tauri apps run on `tauri://localhost` which doesn't participate in the browser cookie jar. The API needs a body-based refresh token path for desktop clients.
- **System browser redirect is wrong for key extraction.** Passing private keys as URL parameters (deep link callback) is a security risk β€” URLs appear in browser history, process lists, and logs. Revised to run Web3Auth inside the Tauri webview with IPC key transfer instead.

## What Would Have Helped

- A checklist of ALL fields from the TypeScript types before writing Rust equivalents (the planner initially just grabbed the "obvious" fields)
- Knowing upfront that fuser's macOS support is "untested" β€” this is the highest-risk integration point and should be proven with a PoC before any detailed planning
- Understanding that FUSE-T has specific limitations (no file locking, readdir must return all entries in one pass, timestamps can't be set independently) β€” these constrain the FUSE implementation design

## Key Files

- `packages/crypto/src/folder/types.ts` β€” TypeScript FolderEntry/FileEntry types (source of truth for Rust ports)
- `apps/api/src/vault/vault.service.ts` β€” VaultResponse shape (what the API actually returns)
- `packages/crypto/src/ipns/` β€” IPNS record creation (must be replicated exactly in Rust)
- `.planning/phases/09-desktop-client/09-RESEARCH.md` β€” FUSE-T limitations and Tauri patterns
59 changes: 59 additions & 0 deletions .learnings/2026-02-08-desktop-testing-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Desktop Client Testing Strategy

**Date:** 2026-02-08

## Original Prompt

> Phase 9 UAT revealed that every test required manual human interaction because Web3Auth login cannot be automated. This makes CI/CD and agent-driven testing impossible.

## What I Learned

### The Web3Auth Testing Wall

- Web3Auth login requires a real browser interaction (Google OAuth, email OTP, etc.). There is no headless/programmatic bypass in the SDK.
- Every UAT test required a human to: click Login in tray, complete Web3Auth flow, wait for FUSE mount, then test the actual feature.
- This made iterative debugging painfully slow β€” each fix required a full rebuild + manual login cycle.
- Agent-assisted development (Claude) could diagnose issues from logs and write fixes, but could never verify them independently.

### Proposed Solution: Auth Bypass for Development

A `--dev-key <hex-private-key>` CLI argument that bypasses Web3Auth entirely:

1. Accept a secp256k1 private key via CLI arg or environment variable (`CIPHERBOX_DEV_KEY`)
2. Derive the public key from it
3. Call the API's `/auth/login` endpoint directly (the API already accepts `{ publicKey, loginType: "desktop" }`)
4. Store the resulting JWT and proceed to FUSE mount

This enables:

- **Automated testing:** Playwright/script can launch app with `--dev-key`, test FUSE operations, quit
- **Agent-driven UAT:** Claude can launch the app, verify FUSE behavior via `ls`/`cat`/`echo`, and iterate without human intervention
- **CI integration:** Spin up API + app with test key, run filesystem operation tests
- **Faster debugging:** Skip the 15-second Web3Auth flow on every iteration

### Implementation Notes

- Gate behind `#[cfg(debug_assertions)]` or a `dev` feature flag β€” never ship in release builds
- The test account's private key can live in `tests/e2e/.env` alongside existing test credentials
- Key derivation: `secp256k1::SecretKey::from_slice(&hex::decode(key))` -> compressed public key -> `/auth/login`
- After auth, the flow joins the normal path: fetch vault metadata, mount FUSE, start sync

### What Else Would Help

- **FUSE unit tests:** Test the `CipherBoxFS` struct directly without mounting. Mock the API client, call `lookup()`, `readdir()`, `read()` etc. as method calls. This tests all the NFS-sensitive logic (inode stability, cache behavior, platform file filtering) without needing a real mount.
- **Integration test script:** A shell script that exercises FUSE operations after mount: `ls`, `cat`, `echo >`, `mkdir`, `mv`, `rm`, and verifies results. Combined with `--dev-key`, this gives end-to-end coverage.
- **Snapshot testing for inode table:** Serialize the inode table state after `populate_folder()`, compare against known-good snapshots. Catches inode stability regressions.

### Testing Priorities for Linux/Windows Ports

1. **Start with the auth bypass** β€” get FUSE mounting testable without UI interaction
2. **Port the FUSE unit tests first** β€” the inode table and cache logic is shared
3. **Platform-specific tests:** Linux FUSE has different behavior (multithreaded, no NFS translation). Windows WinFSP has its own quirks. Each needs platform-specific test coverage.
4. **The channel-based prefetch pattern** should have its own test β€” verify that content arrives via channel and cache is populated correctly

## Key Files

- `apps/desktop/src-tauri/src/main.rs` β€” CLI argument parsing (add `--dev-key` here)
- `apps/desktop/src-tauri/src/commands.rs` β€” `handle_auth_complete` (the flow to join after bypass auth)
- `apps/desktop/src-tauri/src/api/auth.rs` β€” API auth calls
- `tests/e2e/.env` β€” Test credentials
87 changes: 87 additions & 0 deletions .learnings/2026-02-08-fuse-t-nfs-macos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# FUSE-T NFS on macOS β€” Hard-Won Lessons

**Date:** 2026-02-08

## Original Prompt

> Phase 9: Build a Tauri desktop client with FUSE mount for transparent file access to CipherBox vault.

We chose FUSE-T (userspace NFS) over macFUSE (kernel extension) because Apple deprecated kexts. This introduced a whole class of NFS-specific issues that don't exist with kernel FUSE.

## What I Learned

### The Single-Thread Rule

- **ALL FUSE-T NFS callbacks run on a single thread.** Any blocking call β€” even 500ms β€” stalls the entire filesystem and causes macOS NFS client to report "server connection interrupted."
- `read()` is the most dangerous callback. IPFS fetches take 1-3s. A naive `block_on(fetch)` inside `read()` will kill the mount within seconds.
- **Solution:** Channel-based prefetch architecture. `open()` fires a background IPFS fetch via `content_tx`. `read()` drains `content_rx` into cache non-blocking. On cache miss, return `EIO` β€” NFS retries automatically.
- This pattern applies to ANY async I/O in FUSE callbacks: never block, always defer to background tasks and drain results opportunistically.

### Inode Stability is Sacred

- NFS clients cache inode numbers aggressively. If `populate_folder()` allocates new ino numbers for children that already exist (same name, same content), NFS returns "stale file handle" errors and Finder disconnects.
- **Solution:** `populate_folder()` must check `find_child(parent_ino, &name)` and reuse the existing ino. Only allocate new inos for genuinely new children.
- Also preserve the `children` list and `children_loaded` state of existing folder inodes β€” don't reset them on refresh.

### READDIR Cache is Permanent (Practically)

- macOS NFS client caches READDIR results and does NOT re-fetch even when the directory's mtime changes via GETATTR. There is no server-side mechanism to invalidate this cache.
- `acdirmin`/`acdirmax` mount options could help, but FUSE-T doesn't expose them from the server side.
- **Consequence:** The FIRST READDIR response for any directory must be correct. There are no second chances.
- **Solution:** Pre-populate all immediate subfolders during mount (before the FUSE event loop starts), so the first READDIR returns real children, not empty results.
- Files created via CLI (`echo > ~/CipherBox/folder/file.txt`) will appear in `ls` but NOT in Finder until a new Finder window is opened. This is a known limitation of NFS on macOS β€” no FSEvents on NFS mounts.

### READDIR Deduplication

- NFS calls `readdir` twice per directory listing: once at offset=0 and once at offset=N (continuation). Both calls trigger the same background refresh logic.
- **Solution:** Only fire background refresh on `offset == 0`. The offset=N call will use whatever data is already cached.

### Directory mtime Matters

- NFS uses directory mtime to decide if READDIR cache is valid (even though it doesn't always re-fetch).
- `populate_folder()` must detect when children actually changed and bump parent `mtime`+`ctime` to `SystemTime::now()`.
- Similarly, mutation callbacks (create, mkdir, unlink, rmdir, rename) must bump parent mtime.
- Use `DIR_TTL=0` for directories (always re-validate via GETATTR) and `FILE_TTL=60s` for files.

### Lookup Consistency

- NFS client does LOOKUP for every entry returned by READDIR, including "." and "..". Returning ENOENT for ".." causes an immediate NFS disconnect.
- **Solution:** Handle "." and ".." explicitly in `lookup()`, returning the current and parent inode respectively.

### FUSE-T Rename Truncation

- FUSE-T truncates the filename in rename callbacks by exactly 8 bytes. A file named `document.txt` might arrive as `docu` in the rename callback's `newname` parameter.
- **Solution:** Suffix-match fallback β€” if exact match fails, find the child whose name ends with the (truncated) new name.

### Platform Special Files

- macOS generates `.DS_Store`, `.Spotlight-V100`, `.Trashes`, `.fseventsd`, `._*` resource forks, `.localized`, `Icon\r` on every directory access.
- These MUST be filtered: ENOENT in lookup, filtered from readdir, EACCES on create/mkdir, excluded from rename.
- Centralize in an `is_platform_special()` helper β€” the list is long and you'll need it everywhere.

### Mutation Cooldown

- Background metadata refreshes (IPNS resolve) can overwrite local mutations before they propagate through IPNS (which is eventually consistent, ~30s).
- **Solution:** Track `mutated_folders` with timestamps. Skip background refreshes for 30 seconds after any local mutation to that folder.

## What Would Have Helped

- A document explaining FUSE-T's NFS translation layer behavior β€” none exists. We learned everything through trial and error.
- Understanding upfront that FUSE-T != kernel FUSE. Every assumption about FUSE behavior needs re-verification under NFS semantics.
- A test harness that could exercise FUSE callbacks without requiring a full mount (unit-test the filesystem struct directly).
- Knowing that macOS NFS READDIR caching is essentially permanent would have led us to eager pre-population from the start.

## Key Files

- `apps/desktop/src-tauri/src/fuse/mod.rs` β€” CipherBoxFS struct, mount/unmount, drain helpers, pre-population
- `apps/desktop/src-tauri/src/fuse/operations.rs` β€” All FUSE callbacks (lookup, getattr, readdir, read, write, create, mkdir, rename, unlink, rmdir)
- `apps/desktop/src-tauri/src/fuse/inode.rs` β€” Inode table, populate_folder with ino reuse
- `apps/desktop/src-tauri/src/fuse/cache.rs` β€” Metadata and content caches with TTL
- `apps/desktop/src-tauri/.cargo/config.toml` β€” FUSE-T pkg-config override
- `apps/desktop/src-tauri/pkg-config/fuse.pc` β€” Custom fuse.pc pointing to FUSE-T headers

## Implications for Linux/Windows

- **Linux:** Can use kernel FUSE (libfuse) directly. Most NFS-specific issues disappear. Inode stability still matters. No READDIR caching issue. No rename truncation. No single-thread constraint (FUSE supports multithreaded mode). The channel-based prefetch architecture is still beneficial for performance.
- **Windows:** WinFSP or Dokan. Different callback model entirely. The async/non-blocking architecture translates well. Platform special files will be different (desktop.ini, Thumbs.db, etc.). Inode concept replaced by file IDs β€” same stability requirement.
- **Shared code:** The `InodeTable`, `MetadataCache`, `ContentCache`, and the channel-based prefetch pattern are platform-agnostic. The FUSE callback implementations will need per-platform variants, but the data structures and async patterns can be reused.
39 changes: 39 additions & 0 deletions .learnings/2026-02-08-macos-system-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# macOS System Integration Gotchas

**Date:** 2026-02-08

## Original Prompt

> Phase 9 UAT: Desktop client with FUSE mount, tray icon, keychain persistence.

## What I Learned

### Keychain (keyring crate)

- `keyring::set_password()` fails with "already exists" error if the item already exists in macOS Keychain. The crate doesn't upsert.
- **Workaround:** Always `delete_credential()` before `set_password()`. But this still fails intermittently β€” possibly a timing issue with Keychain's internal locking or access group conflicts.
- The intermittent nature suggests a race condition in Keychain Services itself, or possibly Keychain Access app holding a read lock.

### Force Unmount

- `umount(path)` fails with "Resource busy" when Finder has open handles on the mount point (common β€” Finder reads `.DS_Store` and metadata eagerly).
- `diskutil unmount path` also fails in this case.
- `diskutil unmount force path` works reliably. This is the equivalent of `umount -f` but goes through DiskArbitration framework.
- **Always** use force unmount as the fallback, not just `diskutil unmount`.

### Stale Mount Point

- After a crash or ungraceful exit, `~/CipherBox` may contain `.DS_Store`, `.metadata_never_index`, and potentially cached Finder metadata.
- FUSE-T mount on a non-empty directory works but can behave unexpectedly.
- **Solution:** On startup, if the mount directory exists, remove all its contents before mounting.

### Spotlight Indexing

- Without mitigation, Spotlight will try to index the FUSE mount, generating constant read traffic.
- Creating `.metadata_never_index` in the mount root prevents this.
- Must be created AFTER cleaning stale files but BEFORE mounting FUSE.

## Key Files

- `apps/desktop/src-tauri/src/fuse/mod.rs` β€” Mount point cleanup, Spotlight suppression, force unmount
- `apps/desktop/src-tauri/src/api/auth.rs` β€” Keychain token storage with delete-before-set
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Replace ESM-only `ipns` package with inline protobuf decoder

**Date:** 2026-02-08

## Original Prompt

> Implement the following plan: Replace `ipns` package in API with inline protobuf decoder. The `ipns` npm package (v10.1.3) is ESM-only, which doesn't play nicely with NestJS's CommonJS compilation. This forced a dynamic `await import('ipns')` hack that broke Jest mocking, which led to a cascade of test/behavior changes that ultimately broke the desktop FUSE client (502 errors hanging the NFS thread).

## What I Learned

- **ESM-only packages in CJS NestJS are poison**: The `ipns` package forced a dynamic `import()` hack, which broke Jest mocking (can't mock dynamic imports easily), which forced a `moduleNameMapper` workaround, which created a fragile test mock that masked real behavior differences.
- **Only extract what you need from protobuf**: The API used ONE function (`unmarshalIPNSRecord`) and read TWO fields (`value` string, `sequence` bigint). That's fields 1 and 5 in the protobuf wire format. ~65 lines of inline varint/length-delimited parsing replaced an entire dependency tree.
- **Protobuf wire format is simple for read-only**: Varint tags encode `(field_number << 3) | wire_type`. Wire type 0 = varint, 2 = length-delimited. Skip everything else. No schema compilation needed.
- **`resolveRecord` re-throw behavior was the real FUSE killer**: The old code would re-throw `BAD_GATEWAY` when DB cache was empty. The FUSE NFS client would block on this 502, stalling the single NFS thread and disconnecting Finder. Returning `null` (β†’ 404) is gracefully handled.
- **Test expectations must match behavioral changes**: When changing from "throw on failure" to "return null on failure", 7 tests needed updating. The plan only anticipated 2 (the ones already modified in the working tree). Always count ALL tests that assert `rejects.toThrow` for the changed code path.

## What Would Have Helped

- The plan's test change count (2 tests) was based on the already-modified working tree, not the full set of tests affected by the behavioral change. Should have grepped for `rejects.toThrow` in the resolve tests before starting.
- Understanding that `parseIpnsRecordBytes` throws `HttpException(BAD_GATEWAY)` which IS caught by `resolveRecord`'s BAD_GATEWAY handler β€” so parse errors also fall through to DB cache now.

## Key Files

- `apps/api/src/ipns/ipns-record-parser.ts` β€” inline protobuf decoder (new)
- `apps/api/src/ipns/ipns.service.ts` β€” uses inline parser, no more dynamic import
- `apps/api/src/ipns/ipns.service.spec.ts` β€” 44 tests, 7 updated for null-return behavior
- `apps/api/package.json` β€” `ipns` removed from dependencies
- `apps/api/jest.config.js` β€” `ipns` moduleNameMapper removed
Loading