Skip to content

feat(14): user-to-user encrypted sharing#183

Merged
FSM1 merged 41 commits into
mainfrom
feat/phase-14-user-to-user-sharing
Feb 22, 2026
Merged

feat(14): user-to-user encrypted sharing#183
FSM1 merged 41 commits into
mainfrom
feat/phase-14-user-to-user-sharing

Conversation

@FSM1

@FSM1 FSM1 commented Feb 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • Phase 14: User-to-User Sharing — Complete implementation of encrypted folder/file sharing between CipherBox users
  • Client-side ECIES key re-wrapping ensures zero-knowledge architecture: the server never sees plaintext keys
  • Includes share creation, received/sent share browsing, share revocation with lazy key rotation, and post-upload key propagation

What's included

Crypto foundation:

  • reWrapKey primitive — unwrap with owner's private key, re-wrap with recipient's public key, zero plaintext
  • 11 security test cases covering correctness, ephemeral keys, error masking, and edge cases

API (NestJS):

  • Shares module with controller, service, entities (Share, ShareKey)
  • Endpoints: create share, list received/sent, revoke, hide, lookup user, share keys CRUD, lazy rotation
  • DTO validation with hex format, UUID, secp256k1 pubkey format, length limits
  • Partial unique index migration (WHERE revoked_at IS NULL) for soft-delete compatibility

Frontend (React):

  • ShareDialog modal for sharing folders with progress tracking
  • SharedFileBrowser for browsing received shares (read-only)
  • Share store (Zustand) and share service layer
  • Shared navigation hook with folder key management and cache TTL
  • Public key display in Settings for sharing
  • Context menu integration and sidebar nav

Security review fixes (applied in final commit):

  • H1: File keys now re-wrapped during folder sharing (was skipping files entirely)
  • H2: Full DTO input validation on all share endpoints
  • H3: Lookup endpoint returns boolean only (no user ID exposure)
  • H4: Partial unique index replaces absolute constraint (soft-delete safe)
  • M2-M4: Key zeroing documentation, navigation cleanup, cache TTL
  • L2-L4: Dead code removal, progress accuracy, revoked record cleanup

Test plan

  • All 241 crypto tests pass (pnpm --filter @cipherbox/crypto test)
  • Full monorepo build passes (pnpm build)
  • Security review completed (.planning/security/REVIEW-2026-02-21-phase14.md)
  • E2E: Create share via ShareDialog, verify recipient sees it in Shared page
  • E2E: Revoke share, verify recipient can no longer access
  • E2E: Share folder with files, verify file keys are re-wrapped
  • E2E: Verify lookup endpoint doesn't leak user IDs
  • Staging: Run migration, verify partial unique index

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Full user-to-user sharing: share folders & files by recipient public key; Share dialog, context-menu action, "Shared with me" read-only browser, settings public-key display, client-side stores/services/hooks, API endpoints, and automatic post-upload re-wrap + lazy rotation flows.
  • Bug Fixes

    • Fixed file-key re-wrapping so recipients can open shared files; revocation now supports lazy key rotation and proper cleanup.
  • Documentation

    • Phase 14 roadmap, verification, security review, context, learnings, and todos added/updated.
  • Tests

    • New unit and end-to-end suites covering re-wrap, controller/service behavior, and full sharing workflows.
  • Chores

    • DB migration for partial unique index; regenerated API spec/client and OpenAPI updates.

FSM1 and others added 25 commits February 21, 2026 07:04
Phase 14: User-to-User Sharing
- Implementation decisions documented
- Phase boundary established
- Design direction: terminal command style (Option A)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: fbd4105f9c83
Four screens added to Pencil design file:
- P14.1: Context menu with Share item (@ icon)
- P14.2: Share dialog modal (terminal command style)
- P14.3: Shared With Me view (~/shared, [RO] badges)
- P14.4: Settings public key section (--copy button)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: c644f3afc869
Phase 14: User-to-User Sharing
- ECIES re-wrapping protocol documented with sequence diagrams
- Share record storage architecture (shares + share_keys tables)
- Recipient discovery via API polling
- Lazy key rotation protocol on revocation
- File-level vs folder-level sharing analysis
- "Shared with me" browsing architecture
- Common pitfalls and risk assessment catalogued
- Recommended plan structure outlined

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 4eba54210b34
Research covers ECIES re-wrapping protocol, share record storage
architecture, lazy key rotation, and recipient browsing flow.
Plans structured in 4 waves: crypto+entities → backend API →
frontend store+dialog → browsing+revocation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 73f910f63661
- Create reWrapKey() that unwraps with owner key and re-wraps with recipient key
- Zero plaintext key from memory after re-wrapping
- Add KEY_REWRAP_FAILED error code to CryptoErrorCode type
- Export reWrapKey from @cipherbox/crypto package
- 4 test vectors: round-trip, multi-recipient, wrong key, invalid pubkey

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 9c367ec4d286
- Share entity with unique constraint on (sharer, recipient, ipnsName)
- ShareKey entity with CASCADE delete on share removal
- Soft-delete revokedAt for lazy key rotation pattern
- hiddenByRecipient for recipient to dismiss unwanted shares
- itemName plaintext field for display in "Shared with me"
- Both entities follow existing FolderIpns entity pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 21f9857aa519
Tasks completed: 2/2
- Create reWrapKey crypto utility with tests
- Create Share and ShareKey TypeORM entities

SUMMARY: .planning/phases/14-user-to-user-sharing/14-01-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 42ecfa22f6a4
- CreateShareDto with class-validator decorations for share creation
- AddShareKeysDto for adding re-wrapped keys to existing shares
- SharesService with 10 methods: createShare, getReceivedShares,
  getSentShares, getShareKeys, addShareKeys, revokeShare, hideShare,
  lookupUserByPublicKey, getPendingRotations, completeRotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 9948cc3c8fe5
…egenerate API client

- SharesController with 8 JWT-authenticated endpoints: create, received,
  sent, lookup, getKeys, addKeys, revoke, hide
- SharesModule registered in app.module with Share/ShareKey entities
- OpenAPI generator script updated with shares controller/service
- API client regenerated with typed share endpoints and model DTOs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 6ef1cf2733da
Tasks completed: 2/2
- Create DTOs and shares service
- Create shares controller, module, register in app.module, regenerate API client

SUMMARY: .planning/phases/14-user-to-user-sharing/14-02-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: b8af9ad1ecb7
- Zustand share store with received/sent shares, loading state, and CRUD actions
- Share service wrapping generated Orval API client for all 8 share endpoints
- Type-safe mapping between API responses and frontend domain types
- getSentSharesForItem with 30s cache for share dialog lookups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: ab01c45548e2
- ShareDialog with pubkey input, validation, and share creation
- Folder sharing traverses descendants and re-wraps keys with progress
- File sharing resolves metadata and re-wraps file key for recipient
- Recipient list with truncated pubkeys and inline revoke confirm
- Root folder sharing prevention
- Terminal aesthetic with share-dialog.css styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 046f4c1b3b1e
- Display user's secp256k1 public key in bordered monospace box
- Copy-to-clipboard button with 2s feedback (--copy / --copied)
- Terminal aesthetic: // comment headers, -- command style button
- Conditional render: only shows when vaultKeypair is available
- CSS with hover/focus-visible styles for accessibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 94603816a1a6
- Add onShare prop to ContextMenu with @ icon between Move and Details
- Add handleShare handler that closes menu and triggers callback
- Add shareItem state and handleShareClick in FileBrowser
- Render ShareDialog with item context (folderKey, ipnsName, parentFolderId)
- Share not shown in multi-select context menu (individual sharing only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 696e5ec9a42a
Tasks completed: 2/2
- Create share store and share service
- Add public key display section to Settings page

SUMMARY: .planning/phases/14-user-to-user-sharing/14-03-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 899fee325ca9
Tasks completed: 2/2
- Create ShareDialog modal component
- Add Share action to ContextMenu and wire in FileBrowser

SUMMARY: .planning/phases/14-user-to-user-sharing/14-04-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 6b85002884cf
…onent

- useSharedNavigation hook with folder browsing, file download, and hide actions
- SharedFileBrowser renders top-level shared list with SHARED BY column and [RO] badges
- ContextMenu gains readOnly and onHide props for read-only shared context
- share.service.ts: add reWrapForRecipients for post-upload/create key propagation
- useFolder.ts: wire fire-and-forget re-wrapping on file upload and subfolder create

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 69f086a8c326
…ntext menu

- SharedPage route at /shared with auth guard matching FilesPage pattern
- AppSidebar: add Shared nav item between Files and Settings
- NavItem: extend icon types with 'shared' (link emoji)
- Barrel export SharedFileBrowser from file-browser/index.ts
- API: add pending-rotations endpoint and completeRotation auth guard
- Regenerate API client for new endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 535642eb002d
- Restore rotation-related imports in share.service.ts (used by lazy rotation code)
- Remove premature checkPendingRotation/executeLazyRotation import from folder.service.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 59da6cd5ca36
…KeyDto

- folder.service.ts: add checkAndRotateIfNeeded with dynamic import to avoid circular deps
- API controller: use UpdateEncryptedKeyDto for type-safe body validation
- Add UpdateEncryptedKeyDto and regenerate API client
- Preparation for Plan 14-06 lazy rotation execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 6ecfe2627c48
Tasks completed: 2/2
- Task 1: Post-upload and post-create share key propagation
- Task 2: Lazy key rotation on folder modification after revoke

SUMMARY: .planning/phases/14-user-to-user-sharing/14-06-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: eb03d42b3142
Tasks completed: 2/2
- Create useSharedNavigation hook and SharedFileBrowser component
- Register shared route, add sidebar nav, and read-only ContextMenu

SUMMARY: .planning/phases/14-user-to-user-sharing/14-05-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 59c79b34eb12
Phase 14 verified (5/5 must-haves passed). SHARE-01 through SHARE-05
requirements marked Complete. SHARE-02/03 rescoped per research:
public key paste (no email invite) and instant share (no accept/decline).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 9389be54f2c7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 8841524be31f
H1: Re-wrap file keys during folder sharing (collectChildKeys was skipping files)
H2: Add DTO input validation (hex format, UUID, secp256k1 pubkey, length limits)
H3: Remove user ID exposure from lookup endpoint (return boolean only)
H4: Replace absolute unique constraint with partial index (WHERE revoked_at IS NULL)
M2: Document zeroing contract on reWrapForRecipients
M3: Zero folder keys on shared navigation (navigateToRoot, navigateUp)
M4: Add 60s TTL to share keys cache
L2: Remove dead fallback code in useSharedNavigation
L3: Fix child count in ShareDialog progress (count files + folders)
L4: Clean up revoked share records before re-sharing

Also adds 7 new security test cases for ECIES re-wrapping and creates
migration for partial unique index on shares table.

Deferred: M1 (key zeroing audit), M5 (public key format validation),
L1 (error handler masking), L4 (server-side child key limit) — tracked
in GSD todo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 6abe57e9ad35
@coderabbitai

coderabbitai Bot commented Feb 21, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Phase 14 adds user-to-user sharing: ECIES re-wrap primitive and tests; Share and ShareKey entities plus partial-unique-index migration; full Shares API and OpenAPI/client; frontend store/service, ShareDialog, Shared (read-only) browser, post-upload re-wrapping and lazy rotation; E2E tests and security/planning docs.

Changes

Cohort / File(s) Summary
Crypto
packages/crypto/src/ecies/rewrap.ts, packages/crypto/src/ecies/index.ts, packages/crypto/src/index.ts, packages/crypto/src/types.ts, packages/crypto/src/__tests__/rewrap.test.ts
Add reWrapKey ECIES primitive, export it, add KEY_REWRAP_FAILED error code, and add re-wrap tests.
DB & Migration
apps/api/src/shares/entities/share.entity.ts, apps/api/src/shares/entities/share-key.entity.ts, apps/api/src/shares/entities/index.ts, apps/api/src/migrations/1740300000000-SharesPartialUniqueIndex.ts
New Share and ShareKey TypeORM entities and barrel exports; migration replaces absolute unique constraint with partial unique index for active shares (revoked_at IS NULL).
Backend API (DTOs, Service, Controller, Module)
apps/api/src/shares/dto/*.ts, apps/api/src/shares/shares.service.ts, apps/api/src/shares/shares.controller.ts, apps/api/src/shares/shares.module.ts, apps/api/src/app.module.ts, apps/api/scripts/generate-openapi.ts
Add DTOs (CreateShareDto, AddShareKeysDto, UpdateEncryptedKeyDto, response DTOs), SharesService (create/list/keys/revoke/hide/rotation), SharesController endpoints (including rotation lifecycle), SharesModule; wire into AppModule and OpenAPI generator.
OpenAPI / API Client
packages/api-client/openapi.json, apps/web/src/api/*, apps/web/src/api/models/index.ts, apps/web/src/api/shares/shares.ts
OpenAPI expanded with /shares paths and schemas; regenerated Orval client and TS models including React Query hooks and typed API surface for shares.
Frontend State & Services
apps/web/src/stores/share.store.ts, apps/web/src/services/share.service.ts
Add useShareStore (Zustand) and share.service wrappers for share API (fetch/create/lookup/revoke/hide/keys), caching and helpers for coverage detection, re-wrap and rotation orchestration.
Share UI & File Browser Integration
apps/web/src/components/file-browser/ShareDialog.tsx, apps/web/src/components/file-browser/ContextMenu.tsx, apps/web/src/components/file-browser/FileBrowser.tsx, apps/web/src/components/file-browser/index.ts, apps/web/src/styles/share-dialog.css
New ShareDialog component (pubkey validation, recursive child-key collection, re-wrapping, progress/revoke UI); ContextMenu and FileBrowser wired to open dialog; styles added.
Shared Browsing (read-only)
apps/web/src/hooks/useSharedNavigation.ts, apps/web/src/components/file-browser/SharedFileBrowser.tsx, apps/web/src/routes/SharedPage.tsx, apps/web/src/routes/index.tsx, apps/web/src/styles/shared-browser.css
Add useSharedNavigation hook and SharedFileBrowser read-only UI, register /shared route, implement unwrapping/navigation and breadcrumbs.
Post-Upload Re-Wrapping & Lazy Rotation
apps/web/src/hooks/useFolder.ts, apps/web/src/services/folder.service.ts, apps/web/src/services/share.service.ts
Fire-and-forget post-upload/create re-wrapping for recipients; add checkAndRotateIfNeeded and lazy rotation flow to generate new folder keys, re-encrypt metadata, re-wrap keys for remaining recipients, and finalize rotation.
Preview & Reader Integrations
apps/web/src/hooks/useFilePreview.ts, apps/web/src/components/file-browser/*PreviewDialog.tsx, apps/web/src/components/file-browser/AudioPlayerDialog.tsx, apps/web/src/components/file-browser/TextEditorDialog.tsx
Thread shareId through preview and reader flows so shared-file previews/downloads use re-wrapped keys; update hooks/components to support shared-mode.
Settings & UI Polishes
apps/web/src/routes/SettingsPage.tsx, apps/web/src/App.css, apps/web/src/components/layout/AppSidebar.tsx, apps/web/src/components/layout/NavItem.tsx
Display user's 0x-prefixed public key with copy UI; add "Shared" nav item and icon; append pubkey CSS (duplicate block present).
E2E Tests & Page Objects
tests/e2e/tests/sharing-workflow.spec.ts, tests/e2e/page-objects/*, tests/e2e/utils/*, .github/workflows/e2e.yml
Add comprehensive E2E sharing workflow, page objects (ShareDialog, SharedFileBrowser), multi-account helpers, and broaden e2e test run.
Planning, Verification & Security Docs
.planning/*, .learnings/*, .planning/security/*, .planning/phases/14-user-to-user-sharing/*
Add/extend Phase 14 planning, research, verification, security review and todos (design, test vectors, findings and mitigations).

Sequence Diagram(s)

sequenceDiagram
    participant User as User (Sharer)
    participant UI as ShareDialog UI
    participant API as Shares API
    participant Svc as SharesService
    participant DB as Database

    User->>UI: paste recipient public key
    UI->>API: GET /shares/lookup?publicKey=X
    API->>Svc: lookupUserByPublicKey(X)
    Svc->>DB: SELECT user WHERE public_key = X
    DB-->>Svc: user row
    Svc-->>API: { exists: true }
    API-->>UI: recipient exists
    UI->>UI: unwrap owner key, re-wrap per-recipient (collect child keys)
    UI->>API: POST /shares (CreateShareDto + childKeys)
    API->>Svc: createShare(sharerId, dto)
    Svc->>DB: INSERT share, INSERT share_keys
    DB-->>Svc: persisted
    Svc-->>API: 201 { shareId }
    API-->>UI: Success
Loading
sequenceDiagram
    participant Recipient as User (Recipient)
    participant Browser as SharedFileBrowser
    participant Nav as useSharedNavigation
    participant API as API Client
    participant Crypto as Crypto Utils
    participant IPFS as IPFS/Metadata

    Recipient->>Browser: open /shared
    Browser->>Nav: init()
    Nav->>API: GET /shares/received
    API-->>Nav: [ReceivedShare(encryptedKey...)]
    Nav->>Crypto: unwrapKey(encryptedKey, vaultPrivateKey)
    Crypto-->>Nav: folderKey
    Nav->>IPFS: resolve IPNS + fetch metadata
    IPFS-->>Nav: folder contents
    Nav-->>Browser: render shared folder (read-only)
    Browser->>Nav: download file
    Nav->>API: GET /shares/{shareId}/keys
    API-->>Nav: [ShareKey(encryptedKey)]
    Nav->>Crypto: unwrapKey(reWrappedFileKey, vaultPrivateKey)
    Crypto-->>Nav: fileKey
    Nav->>IPFS: fetch + decrypt file content
    IPFS-->>Nav: decrypted file
    Nav-->>Browser: start download
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

render-preview

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(14): user-to-user encrypted sharing' clearly and concisely describes the main feature addition in this pull request.
Docstring Coverage ✅ Passed Docstring coverage is 81.48% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/phase-14-user-to-user-sharing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Key learnings: file keys silently skipped during sharing,
soft-delete vs unique constraints, lookup endpoint info leakage,
cache TTL patterns, key zeroing in navigation stacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 2ae68ec6f7e8
@FSM1 FSM1 self-assigned this Feb 21, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/file-browser/ContextMenu.tsx (1)

233-279: ⚠️ Potential issue | 🟡 Minor

Add readOnly guard to multi-select batch Move and Delete actions for consistency with single-select mode.

In multi-select mode, onBatchMove and onBatchDelete buttons render without checking readOnly, whereas single-select Rename, Move, Share, and Delete all gate on !readOnly. This creates an inconsistency: if a caller sets readOnly={true} with batch action handlers, destructive operations would still appear. While SharedFileBrowser currently hardcodes selectedCount={1} to avoid this, the component should be defensively consistent.

Apply !readOnly checks to batch Move and Delete (and the divider between them):

{/* Batch Move */}
-{onBatchMove && (
+{!readOnly && onBatchMove && (
  <button ...>
    Move to...
  </button>
)}

{/* Divider before destructive action */}
-<div className="context-menu-divider" role="separator" />
+{!readOnly && <div className="context-menu-divider" role="separator" />}

{/* Batch Delete */}
-{onBatchDelete && (
+{!readOnly && onBatchDelete && (
  <button ... className="context-menu-item context-menu-item--destructive" ...>
    Delete {selectedCount} items
  </button>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ContextMenu.tsx` around lines 233 - 279,
The multi-select UI renders batch Move and Delete (and the divider) without
respecting the readOnly prop; update the conditional rendering so Move and
Delete and the preceding divider only render when !readOnly (in addition to
existing onBatchMove/onBatchDelete checks). Specifically, in the isMultiSelect
block gate the Move button (currently rendered when onBatchMove) with
onBatchMove && !readOnly (or equivalent) and gate the destructive divider and
Delete button (currently rendered when onBatchDelete) with onBatchDelete &&
!readOnly so handleBatchMove/handleBatchDelete and the "Delete {selectedCount}
items" control are hidden in readOnly mode.
🟡 Minor comments (13)
apps/api/src/shares/dto/update-encrypted-key.dto.ts-8-12 (1)

8-12: ⚠️ Potential issue | 🟡 Minor

MinLength(2) is too permissive for ECIES ciphertext — tighten the floor

A secp256k1 ECIES-wrapped 32-byte key has a hard minimum size of roughly:

  • Uncompressed ephemeral pubkey: 65 B → 130 hex chars
  • AES-256-GCM ciphertext (32 B key) + auth tag (16 B): → 96 B → 192 hex chars
  • Total ≥ ~226 hex chars

MinLength(2) allows storing 1-byte hex strings that can never be valid wrapped key material, enabling accidental or deliberate DB pollution that will silently break key rotation for the recipient. As per coding guidelines, the ECIES secp256k1 primitive must be used for key wrapping; aligning the validation floor with the actual format makes the constraint self-documenting.

🛡️ Proposed fix
-  `@MinLength`(2)
+  `@MinLength`(220)
   `@MaxLength`(1024)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/update-encrypted-key.dto.ts` around lines 8 - 12, The
MinLength(2) on the encryptedKey DTO is too permissive for secp256k1
ECIES-wrapped 32-byte keys; update the decorator on encryptedKey (the property
with `@IsString`(), `@Matches`(...), `@MinLength`(...), `@MaxLength`(...)) to enforce a
hex-length floor matching the ECIES format (use MinLength(226) to reflect the
~226 hex-char minimum for uncompressed ephemeral pubkey + AES-256-GCM ciphertext
+ tag) so invalid/too-short hex values are rejected at validation time.
apps/web/src/App.css-1059-1062 (1)

1059-1062: ⚠️ Potential issue | 🟡 Minor

var(--color-bg) is undefined — use var(--color-black) instead

The custom property --color-bg is not defined anywhere in the project. Line 1061 is the only use of this property in the codebase, which will result in an unset color value. All other inverted-button hover states in App.css (.login-button, etc.) use var(--color-black) for text on a green background. This should match that pattern.

🐛 Proposed fix
 .settings-pubkey-copy:hover {
   background: var(--color-green-primary);
-  color: var(--color-bg);
+  color: var(--color-black);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/App.css` around lines 1059 - 1062, The hover rule for
.settings-pubkey-copy uses an undefined custom property var(--color-bg); update
the CSS selector .settings-pubkey-copy:hover to use the existing
var(--color-black) for the text color (matching other inverted-button hover
states like .login-button) so the hover text color is defined and consistent.
apps/web/src/routes/SharedPage.tsx-22-28 (1)

22-28: ⚠️ Potential issue | 🟡 Minor

Loading state missing role="status" for screen readers.

The loading div is visually presented but invisible to assistive technology. Add role="status" (and optionally aria-live="polite") so screen readers can announce the loading state.

♿ Proposed fix
-        <div className="loading">Loading...</div>
+        <div className="loading" role="status" aria-live="polite">Loading...</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/SharedPage.tsx` around lines 22 - 28, The loading
indicator returned in SharedPage (the isLoading branch that renders <AppShell>
with <div className="loading">Loading...</div>) lacks accessibility semantics;
update that div to include role="status" and aria-live="polite" (or
aria-live="assertive" if immediate announcement is required) so screen readers
will announce the loading state when SharedPage is checking authentication.
apps/web/src/routes/SettingsPage.tsx-50-57 (1)

50-57: ⚠️ Potential issue | 🟡 Minor

Missing error handler for navigator.clipboard.writeText.

If the clipboard API is unavailable or the write is denied (e.g., non-secure context, iframe sandbox), this produces an unhandled promise rejection. Add a .catch() or use async/await with try/catch.

🐛 Proposed fix
   const handleCopyPublicKey = useCallback(() => {
     if (!publicKeyHex) return;
-    navigator.clipboard.writeText(publicKeyHex).then(() => {
-      setCopied(true);
-      if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
-      copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
-    });
+    navigator.clipboard.writeText(publicKeyHex).then(() => {
+      setCopied(true);
+      if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
+      copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
+    }).catch(() => {
+      // Silently fail or show user feedback
+    });
   }, [publicKeyHex]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/SettingsPage.tsx` around lines 50 - 57, The handler
handleCopyPublicKey currently calls navigator.clipboard.writeText without
handling failures; update it to catch promise rejections (either add .catch(...)
to the writeText promise or convert the function to async and wrap await
navigator.clipboard.writeText(...) in try/catch) and in the catch block reset
any UI state as needed and surface or log the error; keep use of copyTimeoutRef
and setCopied the same on success, but ensure failures do not produce unhandled
promise rejections (e.g., call console.error or show a toast/notification on
error).
apps/web/src/components/file-browser/SharedFileBrowser.tsx-364-371 (1)

364-371: ⚠️ Potential issue | 🟡 Minor

Calling navigateUp() in a loop may produce incorrect intermediate states.

If the user clicks a breadcrumb two levels up, this calls navigateUp() twice. Each call reads from the nav stack and sets React state. Even in React's batched update mode, each invocation reads the state as it was before the handler started — the second navigateUp() won't see the result of the first. The result depends on whether navigateUp uses a ref-based stack (safe) or React state (unsafe).

Consider adding a navigateToBreadcrumb(index) function to useSharedNavigation that pops the stack to the desired level in a single operation.

Proposed approach

In useSharedNavigation, add a dedicated function:

const navigateToBreadcrumb = useCallback((targetIndex: number) => {
  // Pop the nav stack to targetIndex in one operation
  const popsNeeded = navStackRef.current.length - targetIndex;
  for (let i = 0; i < popsNeeded; i++) {
    navStackRef.current.pop();
  }
  // Restore state from the target level in a single setState call
  const target = navStackRef.current[navStackRef.current.length - 1];
  // ... set folderChildren, folderKey, breadcrumbs in one update
}, []);

Then in the breadcrumb click:

-                   const crumbIndex = breadcrumbs.indexOf(crumb);
-                   const popsNeeded = breadcrumbs.length - 1 - crumbIndex;
-                   for (let i = 0; i < popsNeeded; i++) {
-                     navigateUp();
-                   }
+                   navigateToBreadcrumb(breadcrumbs.indexOf(crumb));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx` around lines 364
- 371, The breadcrumb onClick loops calling navigateUp() which may read stale
React state; instead add a new method navigateToBreadcrumb(targetIndex) inside
useSharedNavigation that mutates the navigation stack in one operation (use
navStackRef.current to pop to targetIndex), then derive the target entry and
update folderChildren, folderKey and breadcrumbs in a single state
update/callback; replace the loop in the breadcrumb onClick with a single call
to navigateToBreadcrumb(crumbIndex) so the stack and UI move to the desired
level atomically.
apps/web/src/components/file-browser/ContextMenu.tsx-47-50 (1)

47-50: ⚠️ Potential issue | 🟡 Minor

readOnly JSDoc is inaccurate — Edit is not hidden in read-only mode.

The prop description says readOnly hides "Edit" actions, but lines 296–307 show the Edit button is gated only by isFile && onEdit, with no !readOnly check. If read-only mode is intentionally permissive for Edit (since editing is gated by the callback), update the comment to match actual behaviour.

📝 Suggested correction
-  /** Read-only mode: hides Rename, Move, Delete, Edit, Share actions */
+  /** Read-only mode: hides Rename, Move, Delete, and Share actions (Download, Edit, Preview remain available) */
   readOnly?: boolean;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ContextMenu.tsx` around lines 47 - 50,
The JSDoc for the readOnly prop is inaccurate: the Edit action is currently
rendered based on the render condition isFile && onEdit inside the ContextMenu
component and is not gated by readOnly; update the readOnly comment to state
that Rename/Move/Delete/Share are hidden but Edit remains available when an
onEdit callback is provided, or alternatively, if you want Edit truly hidden in
read-only mode, add a !readOnly check to the Edit render condition (isFile &&
onEdit && !readOnly) to enforce hiding—modify the readOnly JSDoc or the Edit
rendering accordingly.
apps/api/src/shares/dto/share-key.dto.ts-41-50 (1)

41-50: ⚠️ Potential issue | 🟡 Minor

Missing @ArrayMinSize(1) — empty keys array passes validation.

An empty array satisfies @IsArray() and the POST /shares/:shareId/keys call becomes a silent no-op (or triggers unexpected service behavior). Requiring at least one entry makes the intent explicit.

🛡️ Proposed fix
-import { IsArray, ValidateNested } from 'class-validator';
+import { IsArray, ArrayMinSize, ValidateNested } from 'class-validator';
 ...
   `@IsArray`()
+  `@ArrayMinSize`(1)
   `@ValidateNested`({ each: true })
   `@Type`(() => ShareKeyEntryDto)
   keys!: ShareKeyEntryDto[];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/share-key.dto.ts` around lines 41 - 50, Add a
minimum-size validation to the AddShareKeysDto.keys property so an empty array
fails validation: import and apply `@ArrayMinSize`(1) from class-validator to the
keys field (alongside the existing `@IsArray`(), `@ValidateNested`(), and `@Type`(()
=> ShareKeyEntryDto)) to require at least one ShareKeyEntryDto in the array and
prevent silent no-op POST /shares/:shareId/keys behavior.
apps/web/src/hooks/useSharedNavigation.ts-301-314 (1)

301-314: ⚠️ Potential issue | 🟡 Minor

Missing navigateToRoot in navigateUp dependency array.

navigateUp calls navigateToRoot() at line 312, but navigateToRoot is not listed in the dependency array at line 314. This is an exhaustive-deps violation. Both currently depend on folderKey, so it's unlikely to cause issues in practice, but it could break subtly if navigateToRoot's dependencies diverge in the future.

Suggested fix
-  }, [currentView, folderKey]);
+  }, [currentView, folderKey, navigateToRoot]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useSharedNavigation.ts` around lines 301 - 314, The
navigateUp useCallback is missing navigateToRoot in its dependency array; update
the useCallback declaration for navigateUp to include navigateToRoot (i.e.,
change the dependencies from [currentView, folderKey] to [currentView,
folderKey, navigateToRoot]) so React's exhaustive-deps rules are satisfied and
navigateUp will see the latest navigateToRoot implementation; if navigateToRoot
is unstable, wrap it with useCallback or stabilize it similarly to avoid
unnecessary re-creations of navigateUp.
apps/api/src/shares/shares.service.ts-264-277 (1)

264-277: ⚠️ Potential issue | 🟡 Minor

completeRotation doesn't verify the share is actually revoked.

A caller could hard-delete an active, non-revoked share by calling this endpoint. Consider adding a guard to ensure share.revokedAt is set before allowing the hard delete, to prevent accidental data loss.

Suggested guard
     if (share.sharerId !== sharerId) {
       throw new ForbiddenException('Only the sharer can complete rotation');
     }

+    if (!share.revokedAt) {
+      throw new ConflictException('Share must be revoked before completing rotation');
+    }
+
     // CASCADE will remove all associated ShareKey records
     await this.shareRepo.remove(share);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.service.ts` around lines 264 - 277, In
completeRotation, add a guard to ensure the share is revoked before
hard-deleting: after loading the share in completeRotation(shareId, sharerId)
check that share.revokedAt is set (non-null) and if not throw an appropriate
HTTP error (e.g., BadRequestException or Conflict) to block removal; only call
this.shareRepo.remove(share) when share.revokedAt exists. This prevents callers
from hard-deleting active/non-revoked shares.
apps/web/src/components/file-browser/ShareDialog.tsx-179-181 (1)

179-181: ⚠️ Potential issue | 🟡 Minor

Progress bar total will be inaccurate for nested folders.

countFolderChildren only counts immediate children, but collectChildKeys recursively traverses all descendants. For a folder with subfolders, progress.current will exceed progress.total, causing the progress bar to overflow past 100%.

Suggested fix: use an unbounded progress display or count recursively

The simplest fix is to change the progress display to not depend on a pre-computed total:

-          const totalSubfolders = countFolderChildren(metadata.children);
-          setProgress({ current: 0, total: totalSubfolders });
+          // Total is unknown for deeply nested folders; use current count only
+          setProgress({ current: 0, total: 0 });

           childKeys = await collectChildKeys(
             metadata.children,
             itemFolderKey,
             ownerPrivateKey,
             recipientPubKeyBytes,
-            (wrapped) => setProgress({ current: wrapped, total: totalSubfolders })
+            (wrapped) => setProgress((prev) => ({ current: wrapped, total: Math.max(prev?.total ?? 0, wrapped) }))
           );

Or alternatively, compute the total recursively before starting (though this doubles the traversal work).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ShareDialog.tsx` around lines 179 - 181,
countFolderChildren currently returns only immediate children count, which
mismatches the recursive traversal done by collectChildKeys and causes
progress.current to exceed progress.total; update countFolderChildren to compute
the recursive total of all descendant FolderChild items (i.e., walk subfolders
and sum their descendants) so progress.total reflects the full number of keys
collected, or alternatively switch the UI progress display to an
unbounded/indeterminate mode so it doesn't rely on progress.total; locate and
modify the countFolderChildren function (and any callers that set
progress.total) to perform a recursive count or toggle the progress component to
indeterminate.
apps/api/src/shares/shares.controller.ts-144-150 (1)

144-150: ⚠️ Potential issue | 🟡 Minor

publicKey query parameter lacks input validation.

The publicKey parameter is a raw string with no length or format constraints. While TypeORM parameterizes queries (no SQL injection risk), an unbounded string will hit the database on every request. Consider adding a ValidationPipe with a query DTO, or at minimum a @MaxLength or regex validation, consistent with the CreateShareDto.recipientPublicKey which validates ^(0x)?04[0-9a-fA-F]{128}$.

Example: inline Pipe-based validation
+ import { MaxLength } from 'class-validator';
  ...
- async lookupUser(`@Query`('publicKey') publicKey: string): Promise<{ exists: boolean }> {
+ async lookupUser(
+   `@Query`('publicKey', new ParseStringPipe({ maxLength: 132 })) publicKey: string
+ ): Promise<{ exists: boolean }> {

Or create a small LookupUserQueryDto with class-validator decorators and use @Query() dto: LookupUserQueryDto with a ValidationPipe.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.controller.ts` around lines 144 - 150, The
lookupUser controller method accepts an unvalidated publicKey string — update
the method (lookupUser) to validate input before calling
sharesService.lookupUserByPublicKey: either replace the raw `@Query`('publicKey')
publicKey: string with a validated DTO (e.g., LookupUserQueryDto used as
`@Query`() dto: LookupUserQueryDto) and enable ValidationPipe, or at minimum apply
class-validator decorators like `@Matches`(/^(0x)?04[0-9a-fA-F]{128}$/) and
`@MaxLength`(...) consistent with CreateShareDto.recipientPublicKey; ensure you
then pass the validated value to sharesService.lookupUserByPublicKey and keep
the existing NotFoundException behavior.
apps/web/src/services/share.service.ts-34-56 (1)

34-56: ⚠️ Potential issue | 🟡 Minor

JSDoc claims "Updates the share store" but functions don't touch the store.

Both fetchReceivedShares and fetchSentShares document "Updates the share store with the results" but only return the mapped arrays without calling any store setter. Callers (getSentSharesForItem, ensureFreshSentShares) handle the store update themselves. Remove the misleading JSDoc sentence to avoid confusion.

Also applies to: 62-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/share.service.ts` around lines 34 - 56, Update the
JSDoc for fetchReceivedShares and fetchSentShares to remove the incorrect
sentence claiming they "update the share store" — these functions only call
sharesControllerGetReceivedShares / sharesControllerGetSentShares and return
mapped ReceivedShare/SentShare arrays; revise the comment to state they fetch
and return mapped shares (or simply "Fetches received/sent shares and returns a
mapped array") so it accurately reflects the behavior of fetchReceivedShares and
fetchSentShares.
packages/api-client/openapi.json-1427-1460 (1)

1427-1460: ⚠️ Potential issue | 🟡 Minor

/shares/{shareId}/complete-rotation is missing a 403 response.

Other sharer-only mutation endpoints (/shares/{shareId}, /shares/{shareId}/encrypted-key, /shares/{shareId}/keys POST) define a 403 "Only the sharer can …" response. This endpoint omits it, which could cause inconsistent error handling on the client if an unauthorized user attempts to call it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api-client/openapi.json` around lines 1427 - 1460, The DELETE
operation at "/shares/{shareId}/complete-rotation" (operationId
SharesController_completeRotation) is missing a 403 response; add a 403 response
object matching the other sharer-only endpoints (e.g., description "Only the
sharer can complete key rotation" or similar) to the responses block so clients
receive a consistent "Only the sharer can …" error for unauthorized callers.
🧹 Nitpick comments (20)
.planning/todos/pending/2026-02-21-phase14-security-review-deferred.md (3)

15-15: M1 implementation note: itemName must be encrypted per-recipient, not once

The current wording — "Encrypt itemName with recipient's public key" — implies a single ciphertext. In multi-recipient shares the same folder/file is shared with several users, so the implementation will need one encrypted copy of itemName per share recipient (mirroring the ShareKey per-recipient pattern). A single ciphertext encrypted to one public key is unreadable by other recipients.

Worth clarifying this in the solution wording before pick-up to avoid an incorrect schema design at implementation time.

Also applies to: 22-22

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/todos/pending/2026-02-21-phase14-security-review-deferred.md at
line 15, The note about encrypting itemName is ambiguous: do not produce a
single ciphertext for itemName; instead, follow the existing per-recipient
pattern (same as ShareKey) and store one encrypted itemName per recipient in the
shares model/table so each share recipient has an itemName ciphertext encrypted
to their public key and can decrypt for display; update schema/logic that
writes/reads itemName to mirror ShareKey creation, storage and retrieval for
each recipient rather than a single encrypted value.

16-16: M5: Define behavior when all retries are exhausted

The solution mentions a "retry queue" but does not describe the failure escalation path. If retries are exhausted the recipient will hold a share record with no usable key indefinitely. Consider specifying an explicit terminal state (e.g., auto-revoke the share and notify the sharer) so implementors have a clear contract.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/todos/pending/2026-02-21-phase14-security-review-deferred.md at
line 16, The current reWrapForRecipients flow and its "retry queue" need a clear
terminal-state contract: update the reWrapForRecipients implementation and
accompanying retry-queue logic to, after a configurable maxRetry count is
exhausted for a recipient, (1) mark that recipient's share record with an
explicit terminal status field (e.g., "rewrap_failed"), (2) optionally
auto-revoke the share if a config flag is set, and (3) emit a non-blocking
notification/event to the sharer (and an auditable metric/log entry) so the UI
or operator can surface the failure; ensure the code paths that enqueue retries
and the retry worker check maxRetry and transition to the terminal state instead
of silently logging only, and document the new terminal status and notification
behavior for implementors.

18-18: L4: Consider documenting a maximum limit cap

The default limit=50 is sensible, but without a documented upper-bound (e.g., limit ≤ 100) the implementation has no guidance on clamping. An uncapped limit accepted from the client can become a DoS vector on the database query. Worth adding a maximum to the solution note.

Also applies to: 25-25

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/todos/pending/2026-02-21-phase14-security-review-deferred.md at
line 18, Add and enforce a documented maximum for the pagination `limit` to
prevent DoS: introduce a constant (e.g., MAX_SHARE_LIMIT = 100) and in the
request handlers getReceivedShares and getSentShares validate and clamp the
incoming `limit` query param (default to 50, if client sends > MAX_SHARE_LIMIT
set it to MAX_SHARE_LIMIT; if <=0 treat as default or return 400 per your API
convention). Update the handler logic that builds the DB query to use the
clamped `limit` and the `offset`, and add a short comment/docs note describing
the default and maximum cap so clients know the upper bound.
.planning/phases/14-user-to-user-sharing/14-CONTEXT.md (1)

16-16: Optional: fix heading-level skip flagged by markdownlint

### Invitation flow (h3) appears directly under the # Phase 14 (h1), skipping h2. This triggers MD001 heading-increment. Fixing it is purely cosmetic and non-blocking, but it would silence the lint warning.

💅 Proposed fix (optional)
-### Invitation flow
+## Invitation flow

Apply the same promotion to each ### heading under <decisions> and <specifics>.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/phases/14-user-to-user-sharing/14-CONTEXT.md at line 16, Change
the heading level for "Invitation flow" from ### to ## to avoid skipping h2
under "Phase 14" and similarly promote any other occurrences of ### headings
that live directly under the top-level "Phase 14" (specifically those inside the
<decisions> and <specifics> sections) to ## so the heading hierarchy is linear
(h1 -> h2 -> h3) and MD001 is silenced.
apps/web/src/services/folder.service.ts (1)

980-986: Silently skipping rotation when vault keypair is absent could mask problems.

When auth.vaultKeypair is null, the function returns the original key with rotated: false, and the pending rotation remains in the database. This means the rotation check will fire again on the next modification, which is the correct retry behavior.

However, if the vault keypair is persistently unavailable (e.g., a bug in auth state), this will silently degrade every folder modification with an extra network call to checkPendingRotation that always results in a skip. Consider tracking this (e.g., a counter or one-time warning) so it doesn't become a hidden performance drain in degraded states.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/folder.service.ts` around lines 980 - 986, The code
currently returns early when auth.vaultKeypair is missing (in the lazy-rotation
path that returns { folderKey: folderNode.folderKey, rotated: false }) which can
cause repeated silent skips; add lightweight tracking to avoid repeated no-op
calls: create a module-level map or counter (e.g., pendingRotationSkipCounts
keyed by folderNode.id or folderNode.folderKey) and increment it each time
auth.vaultKeypair is null inside the same check where it currently logs the
console.warn, emit the console.warn only the first time per folder (or when the
count exceeds a threshold) and optionally record/emit a metrics event; keep the
existing return value and leave checkPendingRotation behavior unchanged, but
ensure the log/metric uses folderNode.folderKey and the auth.vaultKeypair check
so you can locate and update the early-return block.
packages/crypto/src/index.ts (1)

14-43: Optionally extend the module JSDoc example to include reWrapKey.

reWrapKey is now a first-class public primitive, but the @example block only demonstrates wrapKey/unwrapKey. A brief snippet would improve discoverability for consumers of @cipherbox/crypto.

📝 Suggested JSDoc addition
  * // Wrap file key with user's public key
  * const wrappedKey = await wrapKey(fileKey, vaultKey.publicKey);
  *
  * // Unwrap and decrypt
  * const unwrappedKey = await unwrapKey(wrappedKey, vaultKey.privateKey);
  * const decrypted = await decryptAesGcm(ciphertext, unwrappedKey, iv);
+ *
+ * // Re-wrap a key from owner to recipient (user-to-user sharing)
+ * const recipientWrapped = await reWrapKey(wrappedKey, ownerPrivateKey, recipientPublicKey);
  * ```
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/crypto/src/index.ts` around lines 14 - 43, Update the module JSDoc
example to demonstrate the new reWrapKey primitive: after generating or
unwrapping the file key (functions generateFileKey, wrapKey, unwrapKey), show
how to call reWrapKey to re-encrypt/wrap an existing wrappedKey to a different
recipient public key (referencing reWrapKey and its inputs like wrappedKey and
newRecipientPublicKey), then show unwrapping the re-wrapped key with unwrapKey
and using it with decryptAesGcm; keep the snippet concise and placed near the
existing wrapKey/unwrapKey example so consumers can discover reWrapKey alongside
wrapKey/unwrapKey and encryptAesGcm/decryptAesGcm.
apps/web/src/api/models/childKeyDto.ts (1)

10-17: ChildKeyDto is structurally identical to ShareKeyEntryDto — consider consolidating at the API schema level.

Both DTOs have the same three fields with the same types and JSDoc comments. Their key-type enums (ChildKeyDtoKeyType and ShareKeyEntryDtoKeyType) are also identical ({ file, folder }). Since these are Orval-generated, the fix belongs in the backend OpenAPI schema: replace both backend DTOs with a single shared type (e.g., KeyEntryDto) referenced from both CreateShareDto.childKeys and AddShareKeysDto.keys. This would collapse the generated duplication on the frontend too.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/api/models/childKeyDto.ts` around lines 10 - 17, ChildKeyDto and
ShareKeyEntryDto are identical — consolidate them in the backend OpenAPI schema
by introducing a single shared type (e.g., KeyEntryDto) with the three fields
and a single enum for key type, then update CreateShareDto.childKeys and
AddShareKeysDto.keys to reference KeyEntryDto so Orval generates only one DTO
(replace ChildKeyDto, ShareKeyEntryDto, ChildKeyDtoKeyType, and
ShareKeyEntryDtoKeyType with the unified KeyEntryDto and its enum).
apps/web/src/routes/SharedPage.tsx (1)

4-4: Prefer barrel import for SharedFileBrowser.

SharedFileBrowser is already re-exported from the ../components/file-browser index barrel (confirmed at apps/web/src/components/file-browser/index.ts:18). Importing from the barrel keeps imports consistent with the rest of the codebase.

♻️ Proposed change
-import { SharedFileBrowser } from '../components/file-browser/SharedFileBrowser';
+import { SharedFileBrowser } from '../components/file-browser';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/SharedPage.tsx` at line 4, Replace the direct import of
SharedFileBrowser with the barrel export: in SharedPage.tsx update the import
statement that currently imports SharedFileBrowser from
'../components/file-browser/SharedFileBrowser' to import { SharedFileBrowser }
from the components/file-browser barrel (use the re-exported symbol
SharedFileBrowser) so imports match the project's barrel pattern and stay
consistent with other modules.
packages/crypto/src/__tests__/rewrap.test.ts (1)

66-83: Nit: duplicate reWrapKey calls in error assertion tests.

Tests 3, 4, and 5 each invoke reWrapKey twice — once to assert the error type, once for the message. Each invocation runs the full unwrap/wrap cycle. You can consolidate with a single call using toThrowError or chaining matchers, though this is purely a style preference.

♻️ Optional: single-call pattern
-    await expect(
-      reWrapKey(wrappedForAlice, wrongKeypair.privateKey, bob.publicKey)
-    ).rejects.toThrow(CryptoError);
-
-    await expect(
-      reWrapKey(wrappedForAlice, wrongKeypair.privateKey, bob.publicKey)
-    ).rejects.toThrow('Key re-wrapping failed');
+    const rejection = reWrapKey(wrappedForAlice, wrongKeypair.privateKey, bob.publicKey);
+    await expect(rejection).rejects.toThrow(CryptoError);
+    await expect(rejection).rejects.toThrow('Key re-wrapping failed');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/crypto/src/__tests__/rewrap.test.ts` around lines 66 - 83, The test
calls reWrapKey twice to assert type and message; consolidate into a single
assertion by invoking reWrapKey(wrappedForAlice, wrongKeypair.privateKey,
bob.publicKey) once and assert both the error type and message (e.g., use a
single rejects.toThrowError or capture the rejected promise/error and assert its
instanceof CryptoError and message contains "Key re-wrapping failed"); update
the test around reWrapKey/wrongKeypair/privateKey/bob.publicKey to remove the
duplicate call and perform both checks from the single invocation.
apps/web/src/styles/share-dialog.css (1)

211-236: Hardcoded #ef4444 should use the existing var(--color-error) design token.

Lines 216, 225, 229, 262, 268, 272 use hardcoded #ef4444 and its rgb(239 68 68 / …) variants, while .share-error (line 111) already references var(--color-error). For consistency and easier theming, derive these from the design token.

♻️ Example fix for .share-revoke-btn
 .share-revoke-btn {
   flex-shrink: 0;
   padding: 2px 8px;
   font-family: var(--font-family-mono);
   font-size: var(--font-size-xs);
-  color: `#ef4444`;
+  color: var(--color-error);
   background: transparent;
-  border: var(--border-thickness) solid rgb(239 68 68 / 30%);
+  border: var(--border-thickness) solid color-mix(in srgb, var(--color-error) 30%, transparent);
   cursor: pointer;
   transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
 }
 
 .share-revoke-btn:hover:not(:disabled) {
-  background-color: rgb(239 68 68 / 10%);
-  border-color: `#ef4444`;
+  background-color: color-mix(in srgb, var(--color-error) 10%, transparent);
+  border-color: var(--color-error);
 }
 
 .share-revoke-btn:focus-visible {
-  outline: 1px solid `#ef4444`;
+  outline: 1px solid var(--color-error);
   outline-offset: 1px;
 }

Apply the same pattern to .share-revoke-confirm-btn--yes (lines 261-274).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/styles/share-dialog.css` around lines 211 - 236, Replace
hardcoded `#ef4444` and its rgb(...) translucent variants in .share-revoke-btn and
.share-revoke-confirm-btn--yes with the design token var(--color-error): use
color: var(--color-error) and border-color: var(--color-error) (or outline:
var(--color-error)) and convert translucent uses to derive from the token (e.g.,
color-mix(to srgb, var(--color-error) 10%) or rgba from a --color-error-rgb
token if your CSS variables include it) so all instances (text color, border,
hover background, focus outline, disabled styles) consistently reference
var(--color-error) instead of the hardcoded hex/rgb values.
apps/web/src/components/file-browser/SharedFileBrowser.tsx (1)

24-75: File type detection helpers are duplicated.

These extension sets and helper functions (isImageFile, isPdfFile, isAudioFile, isVideoFile, isPreviewableFile) are likely duplicated from FileBrowser.tsx or a similar location. Consider extracting them into a shared utility (e.g., utils/file-types.ts) to reduce duplication across components.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx` around lines 24 -
75, The file-type detection (IMAGE_EXTENSIONS, PDF_EXTENSIONS, AUDIO_EXTENSIONS,
VIDEO_EXTENSIONS and the helpers isImageFile, isPdfFile, isAudioFile,
isVideoFile, isPreviewableFile) is duplicated; extract these constants and
functions into a single shared utility module and replace the local copies with
imports. Create a module that exports the extension sets and the five helper
functions with the exact names above, update SharedFileBrowser.tsx to import
them instead of defining them locally, and update any other components (e.g.,
FileBrowser) to import the same shared utilities so all callers use the
centralized implementation.
apps/api/src/shares/dto/create-share.dto.ts (1)

70-75: itemName accepts empty strings — consider adding @MinLength(1).

The itemName field has @MaxLength(255) but no minimum length check, so an empty string "" would pass validation. If a shared item with an empty name would cause display issues for the recipient (e.g., invisible row in the Shared With Me list), consider adding @MinLength(1).

Proposed fix
   `@IsString`()
+  `@MinLength`(1)
   `@MaxLength`(255)
   itemName!: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/create-share.dto.ts` around lines 70 - 75, The
itemName property currently allows empty strings; add a `@MinLength`(1) decorator
to the itemName field in create-share.dto (the property named itemName) to
enforce a non-empty value and update the imports to include MinLength from
class-validator; ensure the `@MinLength`(1) appears alongside `@IsString`() and
`@MaxLength`(255) so validation rejects "" inputs.
apps/api/src/shares/entities/share-key.entity.ts (1)

36-38: itemId should use type: 'uuid' rather than varchar(255).

The DTO validates itemId as a UUID (via @Matches UUID regex); the entity should match. PostgreSQL stores a uuid column as 16 bytes internally versus a 36-character string for varchar, making the composite unique constraint and the @Index() more efficient.

♻️ Proposed fix
   `@Index`()
-  `@Column`({ type: 'varchar', length: 255, name: 'item_id' })
+  `@Column`({ type: 'uuid', name: 'item_id' })
   itemId!: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/entities/share-key.entity.ts` around lines 36 - 38,
Change the itemId column definition in the ShareKey entity to use PostgreSQL
uuid type instead of varchar(255): update the `@Column` decorator on itemId (the
itemId property in the share-key.entity TypeORM entity) to use type: 'uuid' and
remove the length option so the database column is a native uuid, keeping the
column name 'item_id' and existing `@Index`() intact to match the DTO UUID
validation.
apps/api/src/shares/dto/share-key.dto.ts (2)

13-13: ShareKeyEntryDto should be exported.

Without export, the class is inaccessible to unit tests and other modules. NestJS Swagger resolves it correctly via the same-file @ApiProperty({ type: [ShareKeyEntryDto] }) reference (the generated shareKeyEntryDto.ts web model confirms this), but testability and reusability suffer.

♻️ Proposed fix
-class ShareKeyEntryDto {
+export class ShareKeyEntryDto {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/share-key.dto.ts` at line 13, The class
ShareKeyEntryDto is not exported, making it inaccessible to tests and other
modules; update the class declaration for ShareKeyEntryDto in share-key.dto.ts
to include the export keyword so it becomes export class ShareKeyEntryDto,
ensuring unit tests and other modules can import and reuse it (keep other DTOs
and ApiProperty references unchanged).

25-28: Prefer @IsUUID() over a hand-rolled UUID regex.

class-validator ships @IsUUID() which validates the same format and is more readable.

♻️ Proposed fix
-import {
-  IsString,
-  IsIn,
-  IsArray,
-  ValidateNested,
-  Matches,
-  MinLength,
-  MaxLength,
-} from 'class-validator';
+import {
+  IsString,
+  IsIn,
+  IsArray,
+  IsUUID,
+  ValidateNested,
+  Matches,
+  MinLength,
+  MaxLength,
+} from 'class-validator';
-  `@IsString`()
-  `@Matches`(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, {
-    message: 'itemId must be a valid UUID',
-  })
+  `@IsUUID`('4', { message: 'itemId must be a valid UUID' })
   itemId!: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/share-key.dto.ts` around lines 25 - 28, The itemId
property currently uses a hand-rolled regex via `@Matches`(...) (and `@IsString`())
to validate a UUID; replace that with class-validator's built-in `@IsUUID`()
decorator on the itemId property, remove the custom `@Matches` regex (and you can
drop `@IsString`() since `@IsUUID` implies string validation), and add an import for
IsUUID from 'class-validator' to the DTO file so validation is cleaner and more
readable.
apps/web/src/hooks/useSharedNavigation.ts (1)

99-108: Share keys cache is never cleared on navigation reset.

shareKeysCache (useRef<Map>) accumulates entries across share navigations. When the user calls navigateToRoot() (line 320), the folder state is reset and keys are zeroed, but the cache retains stale entries indefinitely. The 60-second TTL provides some protection, but if the user navigates between many shares in a session, the cache will grow unbounded. Consider clearing the cache in navigateToRoot.

Also applies to: 150-165

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useSharedNavigation.ts` around lines 99 - 108,
shareKeysCache (the useRef Map defined as shareKeysCache) is never cleared on
navigation reset and can grow unbounded; update the navigation reset logic by
calling shareKeysCache.current.clear() inside navigateToRoot and any other
functions that reset/share key state (the same reset paths referenced around the
150-165 region), so the cache is emptied whenever folder/share state is zeroed;
ensure you reference the shareKeysCache variable and invoke .clear() rather than
reassigning the ref.
apps/api/src/shares/shares.service.ts (1)

169-192: addShareKeys issues N individual queries per key entry.

Each iteration queries for an existing record and then saves. For a large folder share with many files/subfolders, this becomes an O(n) roundtrip pattern. Consider using TypeORM's upsert to batch this into a single query, or at minimum use queryRunner for a single transaction.

Example using TypeORM upsert
-    for (const entry of dto.keys) {
-      const existing = await this.shareKeyRepo.findOne({
-        where: { shareId, keyType: entry.keyType, itemId: entry.itemId },
-      });
-      if (existing) {
-        existing.encryptedKey = Buffer.from(entry.encryptedKey, 'hex');
-        await this.shareKeyRepo.save(existing);
-      } else {
-        const shareKey = this.shareKeyRepo.create({
-          shareId,
-          keyType: entry.keyType,
-          itemId: entry.itemId,
-          encryptedKey: Buffer.from(entry.encryptedKey, 'hex'),
-        });
-        await this.shareKeyRepo.save(shareKey);
-      }
-    }
+    const entities = dto.keys.map((entry) =>
+      this.shareKeyRepo.create({
+        shareId,
+        keyType: entry.keyType,
+        itemId: entry.itemId,
+        encryptedKey: Buffer.from(entry.encryptedKey, 'hex'),
+      })
+    );
+    await this.shareKeyRepo.upsert(entities, ['shareId', 'keyType', 'itemId']);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.service.ts` around lines 169 - 192, The
addShareKeys loop issues N separate find/save calls; replace it with a single
batched upsert using this.shareKeyRepo.upsert by preparing an array of records
with shareId, keyType, itemId and encryptedKey (Buffer.from(entry.encryptedKey,
'hex')) and calling upsert(records, { conflictPaths:
['shareId','keyType','itemId'] }) so DB does insert-or-update in one query;
alternatively wrap the loop in a single transaction using a QueryRunner
(startTransaction(), save all, commit/rollback) to avoid per-item
roundtrips—update the addShareKeys implementation to build the records array and
call shareKeyRepo.upsert (or use QueryRunner) instead of per-entry findOne/save.
apps/web/src/components/file-browser/ShareDialog.tsx (2)

231-246: Unsafe cast of API response to local SentShare[].

Line 235: (shares as unknown as SentShare[]) bypasses type safety entirely. If the API response shape changes, this will silently produce incorrect data. This is a consequence of the Orval-generated client returning void types. Consider adding runtime shape validation or at minimum a type assertion function.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ShareDialog.tsx` around lines 231 - 246,
The code unsafely casts the API result from sharesControllerGetSentShares() to
SentShare[]; replace the cast with runtime validation and safe handling: check
that the response is an array, implement a small type guard like
isSentShare(obj) that verifies required fields (e.g., ipnsName, recipient,
etc.), then filter using that guard and ipnsName before calling setRecipients;
if validation fails, setRecipients([]) and log a warning so malformed API
responses don't corrupt state. Ensure you reference
sharesControllerGetSentShares, SentShare, ipnsName, setRecipients, and
recipientsLoading in the updated flow.

35-43: Local SentShare type duplicates SentShare from share.store.ts.

This local type (with itemType: string) duplicates the SentShare type in apps/web/src/stores/share.store.ts (which uses itemType: 'folder' | 'file'). Consider importing and reusing the store type to keep them in sync.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ShareDialog.tsx` around lines 35 - 43,
Replace the local SentShare type in ShareDialog.tsx with the shared SentShare
type exported from the store (the SentShare type in the share store that defines
itemType as 'folder' | 'file'); remove the duplicate type declaration, import
the store's SentShare type where ShareDialog uses it, and update any usages in
the component to rely on the imported type so itemType and other fields remain
consistent across the codebase.
apps/web/src/services/share.service.ts (1)

379-382: checkPendingRotation fetches all pending rotations on every call.

This makes a full network round-trip to GET /shares/pending-rotations each time a folder modification begins. If the folder store already knows the set of pending rotation IPNS names (or if the caller already fetched them), this could be cached or batched similarly to sent shares. For now the overhead may be acceptable, but it will become a bottleneck once users accumulate many revoked shares.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/share.service.ts` around lines 379 - 382,
checkPendingRotation currently issues a network fetch by calling
fetchPendingRotations() on every invocation, causing unnecessary round-trips;
modify it to accept an optional pre-fetched set or cache of pending IPNS names
to avoid repeated GET /shares/pending-rotations calls: update
checkPendingRotation(folderIpnsName: string, pending?: Set<string> | string[])
to check the provided pending collection first (or populate/refresh a
short-lived in-memory cache inside the module) and only fall back to calling
fetchPendingRotations() when the pending list is not supplied or the cache is
stale; refer to the function names checkPendingRotation and
fetchPendingRotations to locate the change.

Comment thread apps/api/src/migrations/1740300000000-SharesPartialUniqueIndex.ts
Comment thread apps/api/src/shares/dto/index.ts
Comment thread apps/api/src/shares/dto/share-key.dto.ts
Comment thread apps/api/src/shares/entities/index.ts
Comment thread apps/api/src/shares/shares.controller.ts
Comment thread apps/web/src/services/share.service.ts Outdated
Comment thread apps/web/src/services/share.service.ts
Comment thread apps/web/src/services/share.service.ts
Comment thread apps/web/src/services/share.service.ts
Comment thread packages/api-client/openapi.json

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (8)
tests/e2e/utils/web3auth-helpers.ts (1)

290-316: Consider documenting (or eliminating) the in-place mutation of the caller's state parameter.

Lines 305–306 mutate authState directly:

authState.accessToken = accessToken;
if (email) authState.email = email;

When state is provided (new multi-account path), this silently modifies the caller's object — which is easy to miss when managing several TestAuthState references in a sharing test. The singleton case is unaffected since mutation is expected there.

♻️ Option A — document the mutation contract in the JSDoc
  /**
   * ...
+  * `@param` state - Optional explicit state (multi-account). When provided, its
+  *   `accessToken` (and optionally `email`) fields will be updated in-place
+  *   with a freshly refreshed token before re-injection.
   */
  export async function reinjectTestAuthAfterReload(
♻️ Option B — avoid mutation by returning the (possibly updated) state
  export async function reinjectTestAuthAfterReload(
    page: Page,
    state?: TestAuthState
- ): Promise<void> {
-   const authState = state ?? cachedTestAuthState;
+ ): Promise<TestAuthState | null> {
+   let authState = state ? { ...state } : cachedTestAuthState ? { ...cachedTestAuthState } : null;

    if (!process.env.TEST_LOGIN_SECRET || !authState) return;
    // ...
    if (refreshResponse.ok()) {
      const { accessToken, email } = await refreshResponse.json();
-     authState.accessToken = accessToken;
-     if (email) authState.email = email;
+     authState = { ...authState, accessToken, ...(email ? { email } : {}) };
+     if (!state) cachedTestAuthState = authState; // keep singleton in sync
    }

    await injectAuthState(page, authState);
    // ...
+   return authState;
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/utils/web3auth-helpers.ts` around lines 290 - 316, The function
reinjectTestAuthAfterReload mutates the caller's state object (authState) when
refreshing tokens; to fix, avoid in-place mutation: create a new object (e.g.,
newAuthState = { ...authState, accessToken, ...(email && { email }) }), call
injectAuthState(page, newAuthState), and return the updated TestAuthState from
reinjectTestAuthAfterReload; update any callers to use the returned state (and
preserve cachedTestAuthState behavior when state was undefined), or
alternatively add a clear JSDoc on reinjectTestAuthAfterReload and TestAuthState
noting that the provided state will be mutated if passed.
apps/api/src/shares/shares.controller.spec.ts (5)

84-107: createShare is missing a ConflictException propagation test.

The PR lists "Fixed TOCTOU race in createShare… converting duplicate-constraint violations into ConflictException" as a Critical Fix. The controller spec only covers the happy path; there's no test verifying that a ConflictException thrown by the service is correctly propagated to the caller.

✅ Suggested test case
it('should propagate ConflictException when share already exists', async () => {
  mockSharesService.createShare.mockRejectedValue(
    new ConflictException('Share already exists'),
  );
  const dto = {
    recipientPublicKey,
    itemType: 'folder' as const,
    ipnsName: 'k51qzi5uqu5dg12345',
    itemName: 'My Folder',
    encryptedKey: testEncryptedKey,
  };
  await expect(controller.createShare(mockReq, dto)).rejects.toThrow(ConflictException);
});

Also worth importing ConflictException from @nestjs/common at the top of the file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.controller.spec.ts` around lines 84 - 107, Add a
test that verifies controller.createShare propagates a ConflictException from
the service: mock mockSharesService.createShare to reject with new
ConflictException('Share already exists'), call controller.createShare(mockReq,
dto) with the same dto used in the happy-path test, and assert the promise
rejects with ConflictException (use await
expect(...).rejects.toThrow(ConflictException)); also import ConflictException
from '@nestjs/common' at the top of the spec file.

145-168: Missing test for service error propagation in lookupUser.

The PR specifically highlights: "Fixed lookupUser swallowing errors: now returns false only for 404 and re-throws other network/server errors." The existing tests cover true, false, and invalid format — but there is no test verifying that when lookupUserByPublicKey rejects with an unexpected error (e.g., InternalServerErrorException), the controller propagates it rather than converting it to NotFoundException.

✅ Suggested test case
it('should propagate unexpected service errors', async () => {
  const serviceError = new Error('DB connection failed');
  mockSharesService.lookupUserByPublicKey.mockRejectedValue(serviceError);

  const validKey = '0x04' + 'ab'.repeat(64);
  await expect(controller.lookupUser(validKey)).rejects.toThrow('DB connection failed');
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.controller.spec.ts` around lines 145 - 168, Add a
test that verifies unexpected service errors are re-thrown by
controller.lookupUser: mock mockSharesService.lookupUserByPublicKey to reject
with an error (e.g., new Error('DB connection failed')), call
controller.lookupUser with a valid public key ('0x04' + 'ab'.repeat(64)), and
assert the call rejects with the original error message so lookupUser does not
convert non-404 errors into NotFoundException.

163-167: Assert service is not called for invalid key formats.

The test correctly expects BadRequestException for malformed keys, but doesn't assert that lookupUserByPublicKey was never reached. Without this, a regression where the controller calls the service first (returning undefined) and then throws BadRequestException for a different reason would still pass.

♻️ Proposed addition
     await expect(controller.lookupUser('')).rejects.toThrow(BadRequestException);
+    expect(mockSharesService.lookupUserByPublicKey).not.toHaveBeenCalled();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.controller.spec.ts` around lines 163 - 167, Update
the test that checks invalid public key formats to also assert the service
method is never invoked: for each call to controller.lookupUser with invalid
inputs, add an expectation that the mocked SharesService.lookupUserByPublicKey
(or whatever mock is injected as "service.lookupUserByPublicKey") was not called
(e.g., expect(service.lookupUserByPublicKey).not.toHaveBeenCalled()). Ensure the
mock is reset/cleared before each assertion if needed so calls from previous
assertions don't interfere.

109-274: Happy-path and delegation coverage is solid.

getReceivedShares tests both a populated and empty result. The delegation tests for revokeShare, hideShare, updateShareEncryptedKey, and completeRotation all correctly assert that shareId and userId are threaded through. The getShareKeys test verifies the Buffer→hex conversion path.

One gap worth noting: getSentShares has no empty-array test (unlike getReceivedShares). Low impact since the controller path is identical, but consistent coverage would be cleaner.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.controller.spec.ts` around lines 109 - 274, Add a
mirror empty-array test for getSentShares: mock mockSharesService.getSentShares
to resolve to [] and call controller.getSentShares(mockReq), then assert the
result equals [] to match the existing getReceivedShares empty-case; locate the
test block around getSentShares and add the new spec using the same mockReq,
mockSharesService.getSentShares, and controller.getSentShares symbols.

159-160: Capture the rejected promise once instead of invoking the controller twice.

Each controller.lookupUser(validKey) on lines 159 and 160 is an independent call that triggers the service mock a second time. Capture the promise once and assert both properties against it.

♻️ Proposed fix
-      await expect(controller.lookupUser(validKey)).rejects.toThrow(NotFoundException);
-      await expect(controller.lookupUser(validKey)).rejects.toThrow('User not found');
+      const rejection = controller.lookupUser(validKey);
+      await expect(rejection).rejects.toThrow(NotFoundException);
+      await expect(rejection).rejects.toThrow('User not found');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.controller.spec.ts` around lines 159 - 160, The
test currently calls controller.lookupUser(validKey) twice causing the mock to
be invoked twice; instead capture the rejected promise once (e.g., const promise
= controller.lookupUser(validKey)) and then assert both expectations against
that single promise: await expect(promise).rejects.toThrow(NotFoundException)
and await expect(promise).rejects.toThrow('User not found'), referencing
controller.lookupUser and validKey so the service mock is only triggered once.
apps/web/src/components/file-browser/ShareDialog.tsx (1)

36-43: Local SentShare type duplicates SentShare from apps/web/src/stores/share.store.ts.

The local type uses the looser itemType: string while the store's type uses the narrower 'folder' | 'file'. Import and reuse the store type (casting s.itemType as 'folder' | 'file' when mapping from the API response) to keep a single source of truth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ShareDialog.tsx` around lines 36 - 43,
Replace the local duplicate SentShare type with the canonical SentShare from the
share.store by importing SentShare from apps/web/src/stores/share.store.ts and
reusing it in ShareDialog; when mapping API responses into SentShare (e.g., in
the function or mapping that produces objects with shareId, recipientPublicKey,
itemType, ipnsName, itemName, createdAt) cast the incoming s.itemType as
'folder' | 'file' (e.g., s.itemType as 'folder' | 'file') so the mapped objects
conform to the store's narrower itemType union.
apps/web/src/components/file-browser/SharedFileBrowser.tsx (1)

577-611: onClick and onDoubleClick both fire on a double-click — apply the 250 ms debounce.

For SharedListRow, both onClick and onDoubleClick are wired to onOpen. A user double-clicking a folder row triggers two navigateToShare calls (two IPNS fetches, two setFolderKey calls). The same issue exists in SharedFolderRow for folder items.

As per the coding guidelines, use a ~250 ms timer to delay the single-click action and cancel it on double-click to prevent these duplicate state changes.

As per coding guidelines, "When an element handles both onClick and onDoubleClick, use a ~250ms timer to delay single-click and cancel it on double-click to prevent conflicting state changes."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx` around lines 577
- 611, The SharedListRow (and similarly SharedFolderRow) currently calls onOpen
from both onClick and onDoubleClick causing duplicate actions; change the
handlers to use a ~250ms single-click delay: in the component create a ref-held
timeout id (e.g., clickTimeoutRef), have onClick start a setTimeout(~250) that
calls navigateToShare / setFolderKey (the existing onOpen logic), and have
onDoubleClick clear the timeout and immediately perform the double-click action
(call navigateToShare / setFolderKey once); also clear the timeout in a cleanup
effect to avoid leaks. Use the unique symbols SharedListRow, SharedFolderRow,
onClick/onDoubleClick, onOpen, navigateToShare and setFolderKey to locate where
to implement the timeout logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx`:
- Around line 174-178: handleDownload currently calls downloadSharedFile which
early-returns when currentShareId or folderKey are null (true for top-level list
view) and also fails key lookup because the synthetic FolderChild uses id:
sharedItem.share.shareId; add a path that downloads directly from the share
record: in the useSharedNavigation hook either expose a new
downloadSharedFileFromShareRecord(shareId, fileId) function (or reuse existing
navigateToShare → downloadSharedFileFromShare flow) and then update
handleDownload to branch: if contextShareId is set and currentView === 'list'
call the new download-from-share-record method with contextShareId and the
file's real id, otherwise fall back to downloadSharedFile; reference symbols:
handleDownload, downloadSharedFile, useSharedNavigation, navigateToShare,
downloadSharedFileFromShareRecord, contextShareId, currentView,
FolderChild/sharedItem.share.shareId.
- Around line 467-477: The empty-state block in SharedFileBrowser is rendering
even when an error exists; update the render condition for the empty-state (the
JSX guarded by {!isLoading && !hasChildren && (...)}) to also require no error
(e.g., add a check like && !error) so the empty-state only shows when not
loading, there are no children, and there is no error; locate the conditional
around the empty-state JSX in SharedFileBrowser (referencing isLoading,
hasChildren, and error) and modify it accordingly.
- Around line 363-370: Replace the synchronous loop that calls navigateUp()
repeatedly; instead compute popsNeeded, then pop that many entries directly from
navStackRef (or the component's nav stack) in a single pass, zeroing each popped
entry's folderKey Uint8Array with .fill(0) immediately after extracting it, and
finally perform a single state update to set the current folder/folderKey (via
the same setter used by navigateUp or by calling navigateToRoot/navigateUp once)
so no intermediate folderKey gets written into React state and left orphaned;
update the click handler that references breadcrumbs and navigateUp to use this
single-pass pop-and-zero approach.

In `@apps/web/src/components/file-browser/ShareDialog.tsx`:
- Around line 184-186: countFolderChildren currently returns only direct
children, causing progress to finish early vs. collectChildKeys which recurses;
update countFolderChildren to perform a recursive traversal of FolderChild[]
(mirroring collectChildKeys' recursion) and sum 1 per file/folder plus all
nested descendant counts so the progress total reflects the full set of keys to
be re-wrapped; locate and modify the countFolderChildren function and ensure it
handles the same FolderChild shape used by collectChildKeys.
- Around line 304-311: The catch is masking all failures from
sharesControllerLookupUser into "user not found"; update the error handling so
lookupUser errors are classified (e.g., inspect error.status or throw a typed
NotFoundError from the service) and only map 404/not-found to setError('user not
found'), while for other errors call setError('lookup failed, please try again')
and keep setIsSharing(false); change either the API client that implements
sharesControllerLookupUser to surface HTTP status or custom error types, or
update the catch in this component to branch on error.status or error.name
before setting the message.

In `@apps/web/src/hooks/useSharedNavigation.ts`:
- Around line 195-233: The folder branch leaves the decrypted itemKey in memory
if any of resolveIpnsRecord, fetchFromIpfs, or decryptFolderMetadata throws; to
fix, ensure itemKey is zeroed in all failure paths by wrapping the
folder-specific logic (the block after unwrapKey where you call
resolveIpnsRecord, fetchFromIpfs, JSON.parse, decryptFolderMetadata,
setFolderKey, setFolderChildren, setBreadcrumbs, etc.) in its own try/finally
and call itemKey.fill(0) in the finally; keep the existing file path behavior
that zeroes itemKey before calling downloadSharedFileFromShare and only
setFolderKey after successful decryption so the finally always clears secrets on
errors.
- Around line 318-331: The navigateUp callback omits navigateToRoot from its
useCallback dependency array, causing a react-hooks/exhaustive-deps violation;
update the useCallback dependencies for the navigateUp function to include
navigateToRoot (e.g., change from [currentView, folderKey] to [currentView,
folderKey, navigateToRoot]) so navigateUp always captures the latest
navigateToRoot reference.

---

Duplicate comments:
In `@tests/e2e/utils/web3auth-helpers.ts`:
- Around line 27-47: This is a duplicate review comment: no code changes
required—the TestAuthState interface already includes the requested `@security`
JSDoc on the interface and the privateKeyArr field; leave the TestAuthState
declaration (interface TestAuthState and its fields like privateKeyArr,
publicKeyArr, rootIpnsPrivateKeyArr) as-is and do not add or modify additional
annotations.

---

Nitpick comments:
In `@apps/api/src/shares/shares.controller.spec.ts`:
- Around line 84-107: Add a test that verifies controller.createShare propagates
a ConflictException from the service: mock mockSharesService.createShare to
reject with new ConflictException('Share already exists'), call
controller.createShare(mockReq, dto) with the same dto used in the happy-path
test, and assert the promise rejects with ConflictException (use await
expect(...).rejects.toThrow(ConflictException)); also import ConflictException
from '@nestjs/common' at the top of the spec file.
- Around line 145-168: Add a test that verifies unexpected service errors are
re-thrown by controller.lookupUser: mock mockSharesService.lookupUserByPublicKey
to reject with an error (e.g., new Error('DB connection failed')), call
controller.lookupUser with a valid public key ('0x04' + 'ab'.repeat(64)), and
assert the call rejects with the original error message so lookupUser does not
convert non-404 errors into NotFoundException.
- Around line 163-167: Update the test that checks invalid public key formats to
also assert the service method is never invoked: for each call to
controller.lookupUser with invalid inputs, add an expectation that the mocked
SharesService.lookupUserByPublicKey (or whatever mock is injected as
"service.lookupUserByPublicKey") was not called (e.g.,
expect(service.lookupUserByPublicKey).not.toHaveBeenCalled()). Ensure the mock
is reset/cleared before each assertion if needed so calls from previous
assertions don't interfere.
- Around line 109-274: Add a mirror empty-array test for getSentShares: mock
mockSharesService.getSentShares to resolve to [] and call
controller.getSentShares(mockReq), then assert the result equals [] to match the
existing getReceivedShares empty-case; locate the test block around
getSentShares and add the new spec using the same mockReq,
mockSharesService.getSentShares, and controller.getSentShares symbols.
- Around line 159-160: The test currently calls controller.lookupUser(validKey)
twice causing the mock to be invoked twice; instead capture the rejected promise
once (e.g., const promise = controller.lookupUser(validKey)) and then assert
both expectations against that single promise: await
expect(promise).rejects.toThrow(NotFoundException) and await
expect(promise).rejects.toThrow('User not found'), referencing
controller.lookupUser and validKey so the service mock is only triggered once.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx`:
- Around line 577-611: The SharedListRow (and similarly SharedFolderRow)
currently calls onOpen from both onClick and onDoubleClick causing duplicate
actions; change the handlers to use a ~250ms single-click delay: in the
component create a ref-held timeout id (e.g., clickTimeoutRef), have onClick
start a setTimeout(~250) that calls navigateToShare / setFolderKey (the existing
onOpen logic), and have onDoubleClick clear the timeout and immediately perform
the double-click action (call navigateToShare / setFolderKey once); also clear
the timeout in a cleanup effect to avoid leaks. Use the unique symbols
SharedListRow, SharedFolderRow, onClick/onDoubleClick, onOpen, navigateToShare
and setFolderKey to locate where to implement the timeout logic.

In `@apps/web/src/components/file-browser/ShareDialog.tsx`:
- Around line 36-43: Replace the local duplicate SentShare type with the
canonical SentShare from the share.store by importing SentShare from
apps/web/src/stores/share.store.ts and reusing it in ShareDialog; when mapping
API responses into SentShare (e.g., in the function or mapping that produces
objects with shareId, recipientPublicKey, itemType, ipnsName, itemName,
createdAt) cast the incoming s.itemType as 'folder' | 'file' (e.g., s.itemType
as 'folder' | 'file') so the mapped objects conform to the store's narrower
itemType union.

In `@tests/e2e/utils/web3auth-helpers.ts`:
- Around line 290-316: The function reinjectTestAuthAfterReload mutates the
caller's state object (authState) when refreshing tokens; to fix, avoid in-place
mutation: create a new object (e.g., newAuthState = { ...authState, accessToken,
...(email && { email }) }), call injectAuthState(page, newAuthState), and return
the updated TestAuthState from reinjectTestAuthAfterReload; update any callers
to use the returned state (and preserve cachedTestAuthState behavior when state
was undefined), or alternatively add a clear JSDoc on
reinjectTestAuthAfterReload and TestAuthState noting that the provided state
will be mutated if passed.

Comment thread apps/web/src/components/file-browser/SharedFileBrowser.tsx Outdated
Comment thread apps/web/src/components/file-browser/SharedFileBrowser.tsx Outdated
Comment thread apps/web/src/components/file-browser/SharedFileBrowser.tsx Outdated
Comment thread apps/web/src/components/file-browser/ShareDialog.tsx Outdated
Comment thread apps/web/src/components/file-browser/ShareDialog.tsx
Comment thread apps/web/src/hooks/useSharedNavigation.ts
Comment thread apps/web/src/hooks/useSharedNavigation.ts Outdated
FSM1 and others added 3 commits February 21, 2026 23:08
The SharedFileBrowser and file browser hooks fetch data on mount only
(useEffect with empty deps). When navigateToShared is called twice without
leaving the route (e.g., tests 3.2 then 4.2), the hash doesn't change,
the component doesn't remount, and stale data is shown. Fix by navigating
away first when already on the target route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 771434b7d415
1. Fix context menu download broken for top-level file shares
   (downloadSharedFile guards on null currentShareId/folderKey in list view)
2. Fix multi-level breadcrumb navigation leaking intermediate folder keys
   (add navigateToBreadcrumb hook with single-pass pop-and-zero)
3. Fix empty folder state rendering alongside error state
4. Fix misleading progress bar (switch to unbounded counter)
5. Distinguish 404 from network errors in user lookup
6. Fix itemKey not zeroed when folder navigation fails in navigateToShare
7. Fix navigateToRoot missing from navigateUp dependency array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 4985d640fadb
…est robustness

- Handle empty response bodies (201/204) in customInstance via text-first parsing
- Fix ShareDialog race condition where "no recipients yet" flashes before fetch starts
  by tracking recipientsFetched state
- Fix waitForRecipientsLoaded to wait for actual content instead of loading absence
- Fix breadcrumb exact matching (hasText: /^shared$/) to avoid partial matches
- Add IPNS resolution retry in test 4.2 and settle wait in test 7.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: af1dd25ebb3e

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (9)
apps/web/src/components/file-browser/ShareDialog.tsx (2)

325-336: Consider removing the long explanatory comment block.

Lines 325-336 contain stream-of-consciousness reasoning about which key is which. This reads more like implementation notes than documentation. A single concise comment would be clearer:

-        // For folders shared from a parent, folderKey IS the folder's own key
-        // (it's the folderKey passed as prop which is the parent's key --
-        //  but the item is a FolderEntry child that has folderKeyEncrypted)
-        // We need to use folderKeyEncrypted from the FolderEntry to get the actual folder key.
-        // Wait -- the folderKey prop is the PARENT folder's key. The item's own key
-        // is obtained by unwrapping folderKeyEncrypted from the FolderEntry.
-        // Actually, looking at the plan more carefully:
-        //   "For folders: folderKey is passed as prop"
-        // But folderKey is the PARENT's key. For sharing a subfolder, we need the subfolder's key.
-        // Let's check: if the item is a direct child folder, its folderKeyEncrypted
-        // is wrapped with the owner's public key (ECIES). We unwrap it to get the folder's own key.
-
-        // Unwrap the folder's own key from its encrypted form
+        // Unwrap the subfolder's own AES key from its ECIES-wrapped form
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ShareDialog.tsx` around lines 325 - 336,
Remove the long stream‑of‑consciousness comment block in ShareDialog.tsx (lines
discussing folderKey, folderKeyEncrypted and parent vs child keys) and replace
it with a single concise comment that states: for folders, the passed-in
folderKey is the parent folder's key and the item's own key must be obtained by
unwrapping FolderEntry.folderKeyEncrypted (ECIES) to get the actual folder key;
reference the FolderEntry type and the folderKey prop to make locating the logic
that unwraps folderKeyEncrypted clear.

108-157: Folder re-wrap failure at line 111 aborts processing of all remaining siblings.

If reWrapEncryptedKey throws for one subfolder (line 111), the error propagates unhandled out of the for loop, aborting the entire collectChildKeys — discarding already-collected keys. By contrast, the file branch (lines 90-107) catches errors per-child and continues.

For consistency and resilience, consider wrapping the folder re-wrap in a try/catch as well:

Proposed fix
     } else {
       const folder = child as FolderEntry;
       // Re-wrap the subfolder's folderKey for the recipient
+      try {
         const folderKeyRewrapped = await reWrapEncryptedKey(
           folder.folderKeyEncrypted,
           ownerPrivateKey,
           recipientPubKeyBytes
         );
         childKeys.push({
           keyType: 'folder' as ChildKeyDto['keyType'],
           itemId: folder.id,
           encryptedKey: folderKeyRewrapped,
         });
         wrapped++;
         onProgress(wrapped);

         // Recurse into subfolder: resolve its metadata and collect its children
-      try {
+        // ... existing try/catch for recursion ...
         const resolved = await resolveIpnsRecord(folder.ipnsName);
         // ...
-      } catch (err) {
-        console.error(`Failed to traverse subfolder ${folder.name}:`, err);
-        // Continue with other children
-      }
+      } catch (err) {
+        console.error(`Failed to re-wrap/traverse subfolder ${folder.name}:`, err);
+        // Continue with other children
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/ShareDialog.tsx` around lines 108 - 157,
The folder branch in collectChildKeys currently calls reWrapEncryptedKey
(reWrapEncryptedKey) without local error handling which lets a thrown error
abort the whole loop; wrap the reWrapEncryptedKey call (and the subsequent
childKeys push/wrapped/onProgress increment) in a try/catch so failures for a
single subfolder are logged and skipped rather than propagating, mirroring the
file branch behavior; ensure any sensitive folderKeyBytes created by unwrapKey
are still zeroed in a finally block and that traversal of subfolders
(resolveIpnsRecord, fetchFromIpfs, decryptFolderMetadata, and recursive
collectChildKeys) only runs for successfully rewrapped folders.
apps/web/src/components/file-browser/SharedFileBrowser.tsx (3)

369-373: Use the index parameter from .map() instead of breadcrumbs.indexOf(crumb).

The index is already available from the map callback. Using indexOf does a redundant O(n) scan and could return -1 if breadcrumbs is updated between render and click (stale closure edge case).

Proposed fix
-                  <button
-                    type="button"
-                    className="breadcrumb-item"
-                    onClick={() => navigateToBreadcrumb(breadcrumbs.indexOf(crumb))}
-                  >
+                  <button
+                    type="button"
+                    className="breadcrumb-item"
+                    onClick={() => navigateToBreadcrumb(index)}
+                  >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx` around lines 369
- 373, The click handler currently computes the breadcrumb position with
breadcrumbs.indexOf(crumb), causing an extra O(n) scan and potential -1 from
stale closures; update the map callback to use its provided index parameter and
pass that index to navigateToBreadcrumb (i.e., within the map over breadcrumbs
use (crumb, index) => onClick={() => navigateToBreadcrumb(index)}) so the button
uses the mapped index directly instead of calling breadcrumbs.indexOf(crumb).

24-75: File type detection helpers and truncatePubkey/sortItems are duplicated.

These utilities (isImageFile, isPdfFile, isAudioFile, isVideoFile, truncatePubkey, sortItems) appear to duplicate logic from the main FileBrowser and ShareDialog. Consider extracting them into a shared utility module to reduce duplication across components.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx` around lines 24 -
75, The helper functions isImageFile, isPdfFile, isAudioFile, isVideoFile,
isPreviewableFile and the utility functions truncatePubkey and sortItems are
duplicated across components; extract them into a single shared utility module
(e.g., fileBrowserUtils) and export each function, then replace the local
definitions in SharedFileBrowser, FileBrowser, and ShareDialog with imports from
that module; ensure the exported functions keep their exact behavior/signature
(same parameter types and return values), update all imports where those symbols
are used, and run the build/tests to confirm no behavioral changes.

324-326: No-op handlers onRename={() => {}} and onDelete={() => {}} in read-only mode.

Passing empty functions for actions that should never fire is a code smell. If the ContextMenu component supports readOnly mode (which it does — line 328/497), consider making onRename and onDelete optional in the ContextMenu props so you don't need to pass dummy handlers.

Also applies to: 494-495

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx` around lines 324
- 326, Remove the dummy no-op handlers by making onRename and onDelete optional
in the ContextMenu prop types and then only pass them from SharedFileBrowser
when not in read-only mode; update the ContextMenu component signature
(props/interface) to mark onRename?: (...) and onDelete?: (...), adjust any
internal calls to guard for undefined handlers, and in SharedFileBrowser.tsx
stop passing onRename={() => {}} and onDelete={() => {}} — only pass the real
handlers (e.g., the rename/delete functions) when readOnly is false (same change
for the other occurrence around lines 494-495), leaving onDownload as-is.
tests/e2e/tests/sharing-workflow.spec.ts (2)

117-120: Consider replacing hard-coded waitForTimeout with a UI assertion.

aliceNavigateBack uses a blanket 500ms wait. This can be flaky in CI. Consider waiting for a specific breadcrumb/element change instead.

   async function aliceNavigateBack(): Promise<void> {
     await alice.page.locator('[data-testid="parent-dir-row"]').dblclick();
-    await alice.page.waitForTimeout(500);
+    // Wait for the breadcrumb to update (adjust selector to match the expected state)
+    await alice.page.locator('.breadcrumb-item').first().waitFor({ state: 'visible', timeout: 5000 });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/sharing-workflow.spec.ts` around lines 117 - 120, The helper
aliceNavigateBack currently uses a fixed 500ms wait which is flaky; replace the
hard-coded waitForTimeout in aliceNavigateBack with a deterministic UI
assertion: after dblclicking the '[data-testid="parent-dir-row"]' locator, wait
for a specific element change (e.g., breadcrumb text update, a parent folder
breadcrumb becoming visible, or the previous directory row to appear/disappear)
using page.waitForSelector or locator.waitFor with the appropriate state so the
test proceeds only when the UI has actually updated.

278-298: Hard-coded waitForTimeout(3000) / waitForTimeout(2000) for IPNS resolution.

These waits are fragile. The retry logic at lines 282-288 is a good mitigation for the first one, but the second wait (line 298) has no fallback. Consider polling for a visible element instead.

That said, IPNS resolution latency is inherently unpredictable in E2E, so this may be an acceptable pragmatic choice. Just flag for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/sharing-workflow.spec.ts` around lines 278 - 298, Replace the
fragile hard-coded sleeps (bob.page.waitForTimeout(3000) and 2000) with
polling/wait-for-visible checks: after calling
bobSharedBrowser.openSharedItem(...) use
bobSharedBrowser.parentDirRow().waitFor({ timeout: 30000 }) or an explicit loop
that calls bobSharedBrowser.parentDirRow().isVisible() with short delays and a
timeout (mirroring the existing retry used around navigateToShared /
waitForLoaded / waitForSharedItem / navigateIntoFolder), and after
doubleClickFolderItem(subFolderName) wait for a known subfolder content
indicator (e.g. bobSharedBrowser.getFolderItemNames() to return non-empty or a
specific element’s isVisible()) with a timeout instead of waitForTimeout; update
or add helper methods on bobSharedBrowser if needed to encapsulate these waits.
apps/web/src/hooks/useSharedNavigation.ts (2)

391-448: downloadSharedFileFromShare doesn't need to be inside the hook body.

This function only uses its parameters and imported modules — it captures no hook state or refs. Extracting it as a module-level function would make the independence explicit and avoid unnecessary recreation on each render.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useSharedNavigation.ts` around lines 391 - 448, The
function downloadSharedFileFromShare should be moved out of the hook body to a
module-level export because it does not close over any hook state; extract it as
a top-level async function (keeping its signature
downloadSharedFileFromShare(share: ReceivedShare, privateKey: Uint8Array):
Promise<void>) and update the hook to call it. Ensure all referenced utilities
and stores (useDownloadStore.getState(), unwrapKey, hexToBytes,
resolveIpnsRecord, fetchFromIpfs, decryptFileMetadata, fetchShareKeys,
downloadFile, triggerBrowserDownload) remain imported at the top of the file and
that parentFolderKey.fill(0) and error handling logic are preserved; if the hook
previously relied on any hook-local variables, remove those dependencies or pass
them in as params and export the function if other modules need to call it.

454-508: Note: In-flight downloads will fail if the user navigates away mid-download.

downloadSharedFile captures folderKey by reference. If the user triggers a download and then navigates back (navigateToRoot zeroes the same Uint8Array), the in-progress decryptFileMetadata call at line 476 will receive zeroed bytes and fail. The error is caught at line 501, so this won't crash — but the user gets a generic "Download failed" message with no indication that navigation caused it.

This is acceptable from a security standpoint (zeroing keys promptly is the right call), but consider showing a more specific message if decryption fails with all-zero input, or disabling navigation while a download is active.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useSharedNavigation.ts` around lines 454 - 508,
downloadSharedFile can receive a zeroed folderKey if the user navigates away
(navigateToRoot) mid-download which causes decryptFileMetadata to fail and
produce a generic "Download failed" message; to fix, make the handler detect and
surface that case explicitly by (a) marking the download state when
downloadStore.startDownload is called (use/downloadStore flags like
isDownloading), (b) before calling decryptFileMetadata and again on catching its
error, check whether folderKey is all-zero (inspect the Uint8Array bytes) and if
so set a clear message like "Download cancelled: keys cleared by navigation" via
downloadStore.setError, and (c) optionally add a guard in navigateToRoot (or the
shared navigation logic) to prevent navigation while downloadStore.isDownloading
is true or prompt the user — update references to downloadSharedFile,
decryptFileMetadata, folderKey, downloadStore, and navigateToRoot accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/api/custom-instance.ts`:
- Around line 51-54: The check that guards JSON.parse should treat
whitespace-only bodies as empty; update the conditional around the variable text
(in custom-instance.ts) to also return undefined when text.trim() is empty
(e.g., if (!text || text.trim() === "")) so newline-only or space-only bodies
don't reach JSON.parse and throw a SyntaxError.

In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx`:
- Around line 510-556: Preview dialogs (ImagePreviewDialog, PdfPreviewDialog,
AudioPlayerDialog, VideoPlayerDialog) attempt to download shared files using
downloadFileFromIpns which expects fileMeta.fileKeyEncrypted to be unwrap-able
by the recipient but currently only has owner-wrapped keys; add a shareId prop
to each preview dialog and, inside their download flow (the handler that calls
downloadFileFromIpns), fetch the recipient-wrapped key from the share_keys table
(same approach used in useSharedNavigation.ts), substitute the re-wrapped key
into the FilePointer/fileMeta before calling downloadFileFromIpns, and ensure
the onClose/set state flows remain unchanged so recipient previewing uses the
correct re-wrapped file key.

---

Nitpick comments:
In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx`:
- Around line 369-373: The click handler currently computes the breadcrumb
position with breadcrumbs.indexOf(crumb), causing an extra O(n) scan and
potential -1 from stale closures; update the map callback to use its provided
index parameter and pass that index to navigateToBreadcrumb (i.e., within the
map over breadcrumbs use (crumb, index) => onClick={() =>
navigateToBreadcrumb(index)}) so the button uses the mapped index directly
instead of calling breadcrumbs.indexOf(crumb).
- Around line 24-75: The helper functions isImageFile, isPdfFile, isAudioFile,
isVideoFile, isPreviewableFile and the utility functions truncatePubkey and
sortItems are duplicated across components; extract them into a single shared
utility module (e.g., fileBrowserUtils) and export each function, then replace
the local definitions in SharedFileBrowser, FileBrowser, and ShareDialog with
imports from that module; ensure the exported functions keep their exact
behavior/signature (same parameter types and return values), update all imports
where those symbols are used, and run the build/tests to confirm no behavioral
changes.
- Around line 324-326: Remove the dummy no-op handlers by making onRename and
onDelete optional in the ContextMenu prop types and then only pass them from
SharedFileBrowser when not in read-only mode; update the ContextMenu component
signature (props/interface) to mark onRename?: (...) and onDelete?: (...),
adjust any internal calls to guard for undefined handlers, and in
SharedFileBrowser.tsx stop passing onRename={() => {}} and onDelete={() => {}} —
only pass the real handlers (e.g., the rename/delete functions) when readOnly is
false (same change for the other occurrence around lines 494-495), leaving
onDownload as-is.

In `@apps/web/src/components/file-browser/ShareDialog.tsx`:
- Around line 325-336: Remove the long stream‑of‑consciousness comment block in
ShareDialog.tsx (lines discussing folderKey, folderKeyEncrypted and parent vs
child keys) and replace it with a single concise comment that states: for
folders, the passed-in folderKey is the parent folder's key and the item's own
key must be obtained by unwrapping FolderEntry.folderKeyEncrypted (ECIES) to get
the actual folder key; reference the FolderEntry type and the folderKey prop to
make locating the logic that unwraps folderKeyEncrypted clear.
- Around line 108-157: The folder branch in collectChildKeys currently calls
reWrapEncryptedKey (reWrapEncryptedKey) without local error handling which lets
a thrown error abort the whole loop; wrap the reWrapEncryptedKey call (and the
subsequent childKeys push/wrapped/onProgress increment) in a try/catch so
failures for a single subfolder are logged and skipped rather than propagating,
mirroring the file branch behavior; ensure any sensitive folderKeyBytes created
by unwrapKey are still zeroed in a finally block and that traversal of
subfolders (resolveIpnsRecord, fetchFromIpfs, decryptFolderMetadata, and
recursive collectChildKeys) only runs for successfully rewrapped folders.

In `@apps/web/src/hooks/useSharedNavigation.ts`:
- Around line 391-448: The function downloadSharedFileFromShare should be moved
out of the hook body to a module-level export because it does not close over any
hook state; extract it as a top-level async function (keeping its signature
downloadSharedFileFromShare(share: ReceivedShare, privateKey: Uint8Array):
Promise<void>) and update the hook to call it. Ensure all referenced utilities
and stores (useDownloadStore.getState(), unwrapKey, hexToBytes,
resolveIpnsRecord, fetchFromIpfs, decryptFileMetadata, fetchShareKeys,
downloadFile, triggerBrowserDownload) remain imported at the top of the file and
that parentFolderKey.fill(0) and error handling logic are preserved; if the hook
previously relied on any hook-local variables, remove those dependencies or pass
them in as params and export the function if other modules need to call it.
- Around line 454-508: downloadSharedFile can receive a zeroed folderKey if the
user navigates away (navigateToRoot) mid-download which causes
decryptFileMetadata to fail and produce a generic "Download failed" message; to
fix, make the handler detect and surface that case explicitly by (a) marking the
download state when downloadStore.startDownload is called (use/downloadStore
flags like isDownloading), (b) before calling decryptFileMetadata and again on
catching its error, check whether folderKey is all-zero (inspect the Uint8Array
bytes) and if so set a clear message like "Download cancelled: keys cleared by
navigation" via downloadStore.setError, and (c) optionally add a guard in
navigateToRoot (or the shared navigation logic) to prevent navigation while
downloadStore.isDownloading is true or prompt the user — update references to
downloadSharedFile, decryptFileMetadata, folderKey, downloadStore, and
navigateToRoot accordingly.

In `@tests/e2e/tests/sharing-workflow.spec.ts`:
- Around line 117-120: The helper aliceNavigateBack currently uses a fixed 500ms
wait which is flaky; replace the hard-coded waitForTimeout in aliceNavigateBack
with a deterministic UI assertion: after dblclicking the
'[data-testid="parent-dir-row"]' locator, wait for a specific element change
(e.g., breadcrumb text update, a parent folder breadcrumb becoming visible, or
the previous directory row to appear/disappear) using page.waitForSelector or
locator.waitFor with the appropriate state so the test proceeds only when the UI
has actually updated.
- Around line 278-298: Replace the fragile hard-coded sleeps
(bob.page.waitForTimeout(3000) and 2000) with polling/wait-for-visible checks:
after calling bobSharedBrowser.openSharedItem(...) use
bobSharedBrowser.parentDirRow().waitFor({ timeout: 30000 }) or an explicit loop
that calls bobSharedBrowser.parentDirRow().isVisible() with short delays and a
timeout (mirroring the existing retry used around navigateToShared /
waitForLoaded / waitForSharedItem / navigateIntoFolder), and after
doubleClickFolderItem(subFolderName) wait for a known subfolder content
indicator (e.g. bobSharedBrowser.getFolderItemNames() to return non-empty or a
specific element’s isVisible()) with a timeout instead of waitForTimeout; update
or add helper methods on bobSharedBrowser if needed to encapsulate these waits.

Comment thread apps/web/src/api/custom-instance.ts Outdated
Comment thread apps/web/src/components/file-browser/SharedFileBrowser.tsx
Preview dialogs (image, PDF, audio, video) now receive shareId from
SharedFileBrowser and use re-wrapped keys from share_keys table instead
of owner-encrypted fileKeyEncrypted. Also guards against whitespace-only
response bodies in custom fetch instance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 6f96d356a6f4

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/components/file-browser/VideoPlayerDialog.tsx (1)

63-80: ⚠️ Potential issue | 🟠 Major

Same CTR streaming / re-wrapped-key blocking issue as AudioPlayerDialog.

useStreamingPreview runs without shareId, and blobPreview.open is gated on !streaming.isCtr. For shared video/mp4 or video/webm files (the most common formats), if the streaming probe returns isCtr = true, the blob path — which carries the re-wrapped key — is permanently suppressed. Recipients cannot preview these files.

🐛 Proposed fix — same pattern as AudioPlayerDialog
  const streaming = useStreamingPreview({
-   open: open && isStreamingCandidate,
+   open: open && isStreamingCandidate && !shareId,
    item,
    mimeType,
    folderKey,
  });

  const blobPreview = useFilePreview({
    open:
      open &&
-     (!isStreamingCandidate || !streaming.isSwReady || (!streaming.loading && !streaming.isCtr)),
+     (!!shareId || !isStreamingCandidate || !streaming.isSwReady || (!streaming.loading && !streaming.isCtr)),
    item,
    mimeType,
    folderKey,
    shareId,
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/VideoPlayerDialog.tsx` around lines 63 -
80, The streaming probe is run without shareId which can set
streaming.isCtr=true and permanently suppress the blob preview; fix by passing
shareId into useStreamingPreview (call site: useStreamingPreview({ open: open &&
isStreamingCandidate, item, mimeType, folderKey, shareId })) and update the
blobPreview open gate in useFilePreview to not block when a shareId is present
(change the open expression so it does not short-circuit blob preview solely
because streaming.isCtr when shareId exists), ensuring blobPreview and
useStreamingPreview both receive shareId and the blob path is allowed for shared
files.
apps/web/src/components/file-browser/AudioPlayerDialog.tsx (1)

68-85: ⚠️ Potential issue | 🟠 Major

CTR streaming path blocks the re-wrapped-key blob path for shared audio.

useStreamingPreview runs without shareId, so for a shared audio file that is identified as a CTR streaming candidate (isCtr = true), blobPreview.open evaluates to false — permanently locking out the re-wrapped key path that useFilePreview provides. Even when streaming subsequently errors, blobPreview never gets a chance to run because the open condition only checks !streaming.isCtr, not !streaming.error.

Recipients will see a streaming error with no recovery path for any shared audio file whose MIME type falls in STREAMING_AUDIO_MIMES.

🐛 Proposed fix — gate streaming off when previewing shared content
  const streaming = useStreamingPreview({
-   open: open && isStreamingCandidate,
+   open: open && isStreamingCandidate && !shareId,
    item,
    mimeType,
    folderKey,
  });

  const blobPreview = useFilePreview({
    open:
      open &&
-     (!isStreamingCandidate || !streaming.isSwReady || (!streaming.loading && !streaming.isCtr)),
+     (!!shareId || !isStreamingCandidate || !streaming.isSwReady || (!streaming.loading && !streaming.isCtr)),
    item,
    mimeType,
    folderKey,
    shareId,
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/AudioPlayerDialog.tsx` around lines 68 -
85, The CTR streaming path is blocking the fallback blob preview for shared
audio; change the useStreamingPreview invocation so streaming is disabled for
shared items (e.g., set open: open && isStreamingCandidate && !shareId) so that
useFilePreview can run for shared content and recover when streaming errors;
update the useStreamingPreview call and its props (and any related gating logic
that inspects streaming.isCtr or streaming.error) to ensure shared items prefer
the blob re-wrapped-key path while non-shared items still use streaming.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/file-browser/SharedFileBrowser.tsx`:
- Around line 419-430: The parent-dir row and other rows call the same handler
from both onClick and onDoubleClick (e.g., navigateUp, navigateToShare in
SharedListRow, navigateToSubfolder / onOpen in SharedFolderRow) which causes
triple-invocation on double-click; change these to use the project's ~250ms
click/double-click debounce pattern: on single click start a ~250ms timer that
invokes the single-click handler (navigateUp / navigateToShare /
navigateToSubfolder or onOpen), but if a double-click occurs before the timer
fires cancel the timer and invoke only the double-click behavior; apply this
pattern to the parent-dir row (replace direct onClick/onDoubleClick calls to
navigateUp) and likewise refactor SharedListRow and SharedFolderRow to use the
same debounced onOpen / double-click handling to prevent triple-firing.
- Around line 250-262: The list-view empty state in SharedFileBrowser is
rendering even when an error is present; update the JSX conditional that
currently reads {!isLoading && sharedItems.length === 0 && ( ... )} to also
check that there is no error (e.g., {!isLoading && !error && sharedItems.length
=== 0 && (...)}), ensuring the empty-state block only renders when not loading
and no error exists; adjust the condition around the empty-state markup that
includes sharedEmptyArt and data-testid="shared-empty-state".

---

Outside diff comments:
In `@apps/web/src/components/file-browser/AudioPlayerDialog.tsx`:
- Around line 68-85: The CTR streaming path is blocking the fallback blob
preview for shared audio; change the useStreamingPreview invocation so streaming
is disabled for shared items (e.g., set open: open && isStreamingCandidate &&
!shareId) so that useFilePreview can run for shared content and recover when
streaming errors; update the useStreamingPreview call and its props (and any
related gating logic that inspects streaming.isCtr or streaming.error) to ensure
shared items prefer the blob re-wrapped-key path while non-shared items still
use streaming.

In `@apps/web/src/components/file-browser/VideoPlayerDialog.tsx`:
- Around line 63-80: The streaming probe is run without shareId which can set
streaming.isCtr=true and permanently suppress the blob preview; fix by passing
shareId into useStreamingPreview (call site: useStreamingPreview({ open: open &&
isStreamingCandidate, item, mimeType, folderKey, shareId })) and update the
blobPreview open gate in useFilePreview to not block when a shareId is present
(change the open expression so it does not short-circuit blob preview solely
because streaming.isCtr when shareId exists), ensuring blobPreview and
useStreamingPreview both receive shareId and the blob path is allowed for shared
files.

Comment thread apps/web/src/components/file-browser/SharedFileBrowser.tsx Outdated
Comment thread apps/web/src/components/file-browser/SharedFileBrowser.tsx Outdated
- TextEditorDialog: readOnly + shareId props for shared text viewing
- SharedFileBrowser: text file detection, preview routing, editor dialog
- E2E Phase 11: 4 new tests for shared folder preview workflows
- Test utilities: createTestImageFile, context menu preview/share/hide

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: d860f5573b95

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/file-browser/TextEditorDialog.tsx (1)

91-131: ⚠️ Potential issue | 🟠 Major

plaintext bytes are never zeroed — decrypted file content leaks in memory

downloadFile / downloadFileFromIpns each return a Uint8Array whose fileKey is already zeroed internally (see download.service.ts finally block), but the returned plaintext buffer is left entirely to the caller to clear. Two paths here fail to do so:

  1. cancelled early-return (line 125) — the bytes are simply abandoned without zeroing.
  2. Happy path (line 127)TextDecoder.decode(plaintext) copies bytes into a JS string, but the source Uint8Array persists in memory unreferenced until GC.

Given the codebase's explicit key-hygiene pattern (fileKey zeroed in finally, private key never persisted), this is inconsistent and leaves decrypted file content in memory longer than necessary.

🔒 Proposed fix — zero `plaintext` on both paths
-        let plaintext: Uint8Array;
+        let plaintext: Uint8Array | undefined;
 
         if (shareId) {
           // Shared file path: use re-wrapped file key from share_keys
           const [{ metadata: fileMeta }, keys] = await Promise.all([
             resolveFileMetadata(item.fileMetaIpnsName, folderKey!),
             fetchShareKeys(shareId),
           ]);
 
           const fileKeyRecord = keys.find((k) => k.keyType === 'file' && k.itemId === item.id);
           if (!fileKeyRecord) {
             throw new Error('No re-wrapped file key available for this file');
           }
 
           plaintext = await downloadFile(
             {
               cid: fileMeta.cid,
               iv: fileMeta.fileIv,
               wrappedKey: fileKeyRecord.encryptedKey,
               originalName: item.name,
               encryptionMode: fileMeta.encryptionMode,
             },
             auth.vaultKeypair.privateKey
           );
         } else {
           // Owner path: use file key from metadata directly
           plaintext = await downloadFileFromIpns({
             fileMetaIpnsName: item.fileMetaIpnsName,
             folderKey: folderKey!,
             privateKey: auth.vaultKeypair.privateKey,
             fileName: item.name,
           });
         }
 
-        if (cancelled) return;
-
-        const text = new TextDecoder().decode(plaintext);
-        setContent(text);
-        setOriginalContent(text);
-        setLoading(false);
-
-        // Focus textarea after content loads (only in edit mode)
-        if (!readOnly) {
-          requestAnimationFrame(() => {
-            textareaRef.current?.focus();
-          });
-        }
+        try {
+          if (cancelled) return;
+
+          const text = new TextDecoder().decode(plaintext);
+          setContent(text);
+          setOriginalContent(text);
+          setLoading(false);
+
+          // Focus textarea after content loads (only in edit mode)
+          if (!readOnly) {
+            requestAnimationFrame(() => {
+              textareaRef.current?.focus();
+            });
+          }
+        } finally {
+          plaintext?.fill(0);
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/TextEditorDialog.tsx` around lines 91 -
131, The decrypted plaintext Uint8Array returned from downloadFile /
downloadFileFromIpns must be explicitly zeroed to avoid leaving sensitive bytes
in memory: after obtaining plaintext (variable plaintext) ensure you clear it on
the cancelled early-return path (before returning) and also after producing the
JS string (after TextDecoder.decode and after calling
setContent/setOriginalContent) — best done in a finally block or with
plaintext.fill(0) / a secure zero utility so zeroing always runs even on
exceptions; reference the variables/plaintext handling around downloadFile,
downloadFileFromIpns, the cancelled check, and the TextDecoder.decode usage to
locate where to add the zeroing.
🧹 Nitpick comments (6)
tests/e2e/tests/sharing-workflow.spec.ts (5)

373-383: Revocation negative test could be flaky if the shared view is cached.

getSharedItemNames() on line 380 is called immediately after waitForLoaded. If the backend cache or frontend store hasn't fully propagated the revocation, the folder could still appear briefly. Consider adding a waitForSharedItemGone call (which is available on SharedFileBrowserPage) instead of a point-in-time assertion.

♻️ Suggested improvement
-    const itemNames = await bobSharedBrowser.getSharedItemNames();
-    const hasFolderShare = itemNames.some((n) => n.includes(sharedFolderName));
-    expect(hasFolderShare).toBe(false);
+    // Wait for the revoked folder to disappear (handles cache propagation delay)
+    await bobSharedBrowser.waitForSharedItemGone(sharedFolderName, { timeout: 15000 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/sharing-workflow.spec.ts` around lines 373 - 383, The test
currently does a point-in-time check using bobSharedBrowser.getSharedItemNames()
after navigateToShared and waitForLoaded, which can be flaky due to caching;
replace that assertion with the built-in polling helper on the page object (use
bobSharedBrowser.waitForSharedItemGone(sharedFolderName) or equivalent on
SharedFileBrowserPage) so the test waits until the shared folder is absent
before asserting; keep navigateToShared and waitForLoaded but remove the
immediate getSharedItemNames()/expect block and call the waitForSharedItemGone
helper instead to reliably detect revocation.

540-550: Duplicate IPNS retry pattern — consider extracting a helper.

This retry pattern (open shared folder → wait → check parentDirRow → retry if not visible) is repeated from test 4.2 (lines 285–296). Extracting it into a shared helper would reduce duplication and make the retry strategy consistent.

♻️ Suggested helper
async function bobOpenSharedFolder(name: string): Promise<void> {
  await bobSharedBrowser.waitForSharedItem(name, { timeout: 15000 });
  await bobSharedBrowser.openSharedItem(name);
  try {
    await bobSharedBrowser.parentDirRow().waitFor({ state: 'visible', timeout: 15000 });
  } catch {
    await navigateToShared(bob);
    await bobSharedBrowser.waitForLoaded({ timeout: 30000 });
    await bobSharedBrowser.waitForSharedItem(name, { timeout: 15000 });
    await bobSharedBrowser.openSharedItem(name);
    await bobSharedBrowser.parentDirRow().waitFor({ state: 'visible', timeout: 30000 });
  }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/sharing-workflow.spec.ts` around lines 540 - 550, The test
duplicates an IPNS retry pattern (open shared folder → wait → verify
parentDirRow visibility → retry) across tests; extract this logic into a helper
(e.g., bobOpenSharedFolder) and replace the repeated block with a single call to
that helper; the helper should use bobSharedBrowser.waitForSharedItem,
bobSharedBrowser.openSharedItem, then await
bobSharedBrowser.parentDirRow().waitFor(...) and on timeout perform the retry
flow (navigateToShared, bobSharedBrowser.waitForLoaded, waitForSharedItem,
openSharedItem, then waitFor parentDirRow again) so both test 4.2 and this file
call the same robust routine.

120-123: Hardcoded waitForTimeout(500) is fragile.

Instead of a fixed 500ms sleep, wait for a meaningful condition (e.g., the breadcrumb to update or the expected folder list to re-render). This reduces flakiness on slower CI environments.

♻️ Suggested improvement
  async function aliceNavigateBack(): Promise<void> {
    await alice.page.locator('[data-testid="parent-dir-row"]').dblclick();
-   await alice.page.waitForTimeout(500);
+   // Wait for navigation to complete by checking for file list to stabilize
+   await aliceFileList.waitForLoaded({ timeout: 15000 });
  }

If FileListPage doesn't expose waitForLoaded, wait for the breadcrumb to change or a known element to appear.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/sharing-workflow.spec.ts` around lines 120 - 123, Replace the
fragile fixed sleep in aliceNavigateBack by waiting for a meaningful UI change:
after calling alice.page.locator('[data-testid="parent-dir-row"]').dblclick()
remove waitForTimeout(500) and instead wait for the breadcrumb or file list to
update (e.g., use FileListPage.waitForLoaded() if available, or waitForSelector
on the updated breadcrumb text/selector or for a known file/folder row to
appear/disappear). Ensure you reference the aliceNavigateBack function and the
parent-dir-row locator when implementing the replacement so the test only
proceeds once the navigation finish condition is observed.

285-296: IPNS retry logic uses arbitrary sleeps and inconsistent entry methods.

Line 287 uses waitForTimeout(3000) and the retry path (line 295) uses navigateIntoFolder (double-click) while the first attempt (line 286) uses openSharedItem (single-click). If the entry methods have different semantics, the retry may not reproduce the same state. Consider unifying the approach and replacing the fixed sleep with a waitFor on a meaningful condition (e.g., parentDirRow becoming visible).

♻️ Suggested improvement
-    await bobSharedBrowser.openSharedItem(sharedFolderName);
-    await bob.page.waitForTimeout(3000);
-
-    // If IPNS resolution failed on first try, retry once
-    const parentDirVisible = await bobSharedBrowser.parentDirRow().isVisible();
-    if (!parentDirVisible) {
-      await navigateToShared(bob);
-      await bobSharedBrowser.waitForLoaded({ timeout: 30000 });
-      await bobSharedBrowser.waitForSharedItem(sharedFolderName, { timeout: 15000 });
-      await bobSharedBrowser.navigateIntoFolder(sharedFolderName);
-    }
+    await bobSharedBrowser.openSharedItem(sharedFolderName);
+
+    // IPNS resolution can be slow on first access — wait with retry
+    try {
+      await bobSharedBrowser.parentDirRow().waitFor({ state: 'visible', timeout: 15000 });
+    } catch {
+      // Retry once: navigate back and re-enter
+      await navigateToShared(bob);
+      await bobSharedBrowser.waitForLoaded({ timeout: 30000 });
+      await bobSharedBrowser.waitForSharedItem(sharedFolderName, { timeout: 15000 });
+      await bobSharedBrowser.openSharedItem(sharedFolderName);
+      await bobSharedBrowser.parentDirRow().waitFor({ state: 'visible', timeout: 30000 });
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/sharing-workflow.spec.ts` around lines 285 - 296, Replace the
arbitrary sleep and inconsistent entry method: remove
bob.page.waitForTimeout(3000) and instead wait for the meaningful condition by
using bobSharedBrowser.parentDirRow().waitFor({ state: 'visible', timeout: 30000
}) (or an equivalent waitFor that checks visibility), and make the retry path
use the same entry method as the first attempt (use
bobSharedBrowser.openSharedItem(sharedFolderName) for the retry instead of
bobSharedBrowser.navigateIntoFolder). Keep the existing fallback calls
(navigateToShared, bobSharedBrowser.waitForLoaded,
bobSharedBrowser.waitForSharedItem) but follow them with the unified visibility
wait and the same openSharedItem invocation so both attempts reproduce the same
behavior.

304-306: Another hardcoded waitForTimeout(2000) for subfolder navigation.

Same concern as with other fixed sleeps — prefer waiting for a DOM condition such as the subfolder file appearing.

♻️ Suggested improvement
    // Navigate into subfolder
    await bobSharedBrowser.doubleClickFolderItem(subFolderName);
-   // Wait for subfolder content to load
-   await bob.page.waitForTimeout(2000);
+   // Wait for subfolder content to load — expect the file to appear
+   await bobSharedBrowser.getFolderItem(folderFile2Name).waitFor({
+     state: 'visible',
+     timeout: 15000,
+   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/sharing-workflow.spec.ts` around lines 304 - 306, Replace the
hardcoded sleep after doubleClickFolderItem: instead of calling
bob.page.waitForTimeout(2000), wait for a DOM condition that indicates the
subfolder content finished loading (e.g., use bob.page.waitForSelector or
waitForFunction to detect the expected subfolder file or item element). Update
the test around bobSharedBrowser.doubleClickFolderItem(subFolderName) to await a
selector that uniquely identifies the subfolder's file list entry (or a visible
container) before proceeding, so the test reliably continues when the UI has
actually rendered.
apps/web/src/components/file-browser/TextEditorDialog.tsx (1)

93-123: Document (or enforce) the shareIdreadOnly invariant

shareId and readOnly are independent props, but the PR's intent is that shared files are always read-only. If a caller passes shareId without readOnly, the file is loaded via the re-wrapped key path, yet the textarea is editable and the save button is shown. The save path (handleSave) re-encrypts with the user's own keypair and publishes to the owner's folder metadata — semantically correct for the owner but not for a recipient viewing a shared file.

♻️ Options

Option A — runtime invariant (defensive):

+  // shareId implies a shared (read-only) view; enforce the invariant early.
+  const effectiveReadOnly = readOnly ?? (shareId != null ? true : false);

Then replace all readOnly references with effectiveReadOnly.

Option B — type-level enforcement via discriminated union:

-type TextEditorDialogProps = {
-  readOnly?: boolean;
-  shareId?: string | null;
-  ...
-};
+type TextEditorDialogProps = {
+  ...
+} & (
+  | { readOnly?: false; shareId?: never }
+  | { readOnly: true; shareId?: string | null }
+);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/TextEditorDialog.tsx` around lines 93 -
123, Shared files can be editable if callers pass shareId without readOnly;
compute an effectiveReadOnly flag (e.g. const effectiveReadOnly = !!shareId ||
readOnly) inside TextEditorDialog and replace all direct uses of readOnly with
effectiveReadOnly (controls textarea disabled state, save button visibility, and
any UI that allows editing). Also update handleSave to early-return or throw
when effectiveReadOnly is true (and ensure the save button is disabled/hidden
when effectiveReadOnly), while leaving the existing download paths
(downloadFile, downloadFileFromIpns, fetchShareKeys) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/file-browser/TextEditorDialog.tsx`:
- Around line 209-213: The triggerBrowserDownload implementation in
download.service.ts currently constructs a Blob using content.buffer which can
include extra bytes when content is a subarray; update triggerBrowserDownload to
pass the typed array (Uint8Array) itself to new Blob (or convert ArrayBuffer to
a Uint8Array first) instead of using content.buffer, ensuring you handle both
ArrayBuffer and Uint8Array inputs and preserve mimeType and filename handling in
the existing function.

In `@tests/e2e/utils/test-files.ts`:
- Around line 95-167: The png Buffer in tests/e2e/utils/test-files.ts is
malformed: it contains an extra byte (0x33) which breaks chunk boundaries
(variable png), so fix by producing a valid 69-byte PNG — either remove the
spurious 0x33 from the array (so IDAT CRC bytes remain correct) or if the
intended IDAT was 13 bytes, change the IDAT length byte (currently 0x0c) to 0x0d
and recompute/update the following IDAT CRC bytes accordingly; ensure the final
png buffer is exactly the canonical 69 bytes and that the IDAT CRC matches the
IDAT data.

---

Outside diff comments:
In `@apps/web/src/components/file-browser/TextEditorDialog.tsx`:
- Around line 91-131: The decrypted plaintext Uint8Array returned from
downloadFile / downloadFileFromIpns must be explicitly zeroed to avoid leaving
sensitive bytes in memory: after obtaining plaintext (variable plaintext) ensure
you clear it on the cancelled early-return path (before returning) and also
after producing the JS string (after TextDecoder.decode and after calling
setContent/setOriginalContent) — best done in a finally block or with
plaintext.fill(0) / a secure zero utility so zeroing always runs even on
exceptions; reference the variables/plaintext handling around downloadFile,
downloadFileFromIpns, the cancelled check, and the TextDecoder.decode usage to
locate where to add the zeroing.

---

Nitpick comments:
In `@apps/web/src/components/file-browser/TextEditorDialog.tsx`:
- Around line 93-123: Shared files can be editable if callers pass shareId
without readOnly; compute an effectiveReadOnly flag (e.g. const
effectiveReadOnly = !!shareId || readOnly) inside TextEditorDialog and replace
all direct uses of readOnly with effectiveReadOnly (controls textarea disabled
state, save button visibility, and any UI that allows editing). Also update
handleSave to early-return or throw when effectiveReadOnly is true (and ensure
the save button is disabled/hidden when effectiveReadOnly), while leaving the
existing download paths (downloadFile, downloadFileFromIpns, fetchShareKeys)
unchanged.

In `@tests/e2e/tests/sharing-workflow.spec.ts`:
- Around line 373-383: The test currently does a point-in-time check using
bobSharedBrowser.getSharedItemNames() after navigateToShared and waitForLoaded,
which can be flaky due to caching; replace that assertion with the built-in
polling helper on the page object (use
bobSharedBrowser.waitForSharedItemGone(sharedFolderName) or equivalent on
SharedFileBrowserPage) so the test waits until the shared folder is absent
before asserting; keep navigateToShared and waitForLoaded but remove the
immediate getSharedItemNames()/expect block and call the waitForSharedItemGone
helper instead to reliably detect revocation.
- Around line 540-550: The test duplicates an IPNS retry pattern (open shared
folder → wait → verify parentDirRow visibility → retry) across tests; extract
this logic into a helper (e.g., bobOpenSharedFolder) and replace the repeated
block with a single call to that helper; the helper should use
bobSharedBrowser.waitForSharedItem, bobSharedBrowser.openSharedItem, then await
bobSharedBrowser.parentDirRow().waitFor(...) and on timeout perform the retry
flow (navigateToShared, bobSharedBrowser.waitForLoaded, waitForSharedItem,
openSharedItem, then waitFor parentDirRow again) so both test 4.2 and this file
call the same robust routine.
- Around line 120-123: Replace the fragile fixed sleep in aliceNavigateBack by
waiting for a meaningful UI change: after calling
alice.page.locator('[data-testid="parent-dir-row"]').dblclick() remove
waitForTimeout(500) and instead wait for the breadcrumb or file list to update
(e.g., use FileListPage.waitForLoaded() if available, or waitForSelector on the
updated breadcrumb text/selector or for a known file/folder row to
appear/disappear). Ensure you reference the aliceNavigateBack function and the
parent-dir-row locator when implementing the replacement so the test only
proceeds once the navigation finish condition is observed.
- Around line 285-296: Replace the arbitrary sleep and inconsistent entry
method: remove bob.page.waitForTimeout(3000) and instead wait for the meaningful
condition by using bobSharedBrowser.parentDirRow().waitFor({ state: 'visible',
timeout: 30000 }) (or an equivalent waitFor that checks visibility), and make
the retry path use the same entry method as the first attempt (use
bobSharedBrowser.openSharedItem(sharedFolderName) for the retry instead of
bobSharedBrowser.navigateIntoFolder). Keep the existing fallback calls
(navigateToShared, bobSharedBrowser.waitForLoaded,
bobSharedBrowser.waitForSharedItem) but follow them with the unified visibility
wait and the same openSharedItem invocation so both attempts reproduce the same
behavior.
- Around line 304-306: Replace the hardcoded sleep after doubleClickFolderItem:
instead of calling bob.page.waitForTimeout(2000), wait for a DOM condition that
indicates the subfolder content finished loading (e.g., use
bob.page.waitForSelector or waitForFunction to detect the expected subfolder
file or item element). Update the test around
bobSharedBrowser.doubleClickFolderItem(subFolderName) to await a selector that
uniquely identifies the subfolder's file list entry (or a visible container)
before proceeding, so the test reliably continues when the UI has actually
rendered.

Comment thread apps/web/src/components/file-browser/TextEditorDialog.tsx
Comment thread tests/e2e/utils/test-files.ts Outdated
FSM1 and others added 2 commits February 22, 2026 01:34
The fire-and-forget re-wrapping of file keys for share recipients
was silently failing due to two bugs:

1. share.store had a single `lastFetchedAt` timestamp shared between
   received and sent share caches. Fetching received shares (e.g. when
   navigating to /shared) would mark the sent shares cache as "fresh"
   even though sent shares were never fetched, causing
   ensureFreshSentShares() to return an empty array.

   Fix: split into `lastReceivedFetchedAt` and `lastSentFetchedAt`.

2. ShareDialog created shares via the API but never updated the global
   share store, so the sent shares cache was always stale after creating
   a new share.

   Fix: call addSentShare()/removeSentShare() on the store after
   create/revoke operations.

Added E2E test 7.3 that verifies Charlie can decrypt and preview a file
uploaded to an already-shared folder, proving re-wrapping works end-to-end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 7ba0c8ebd2ac
- Add `!error` guard to list-view empty state (prevents rendering
  empty state alongside error banner)
- Remove onClick from shared rows, use onDoubleClick only (consistent
  with normal FileBrowser; prevents triple-fire on double-click)
- Fix triggerBrowserDownload: cast Uint8Array as BlobPart instead of
  using .buffer (avoids potential data corruption from extra bytes)
- Replace hand-crafted PNG bytes with valid fixture file (69 bytes,
  correct CRC checksums); createTestImageFile now copies the fixture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 812995ab46e2
@FSM1 FSM1 merged commit 84a232d into main Feb 22, 2026
24 checks passed
@FSM1 FSM1 deleted the feat/phase-14-user-to-user-sharing branch March 3, 2026 22:21
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