Skip to content

feat: phase 15 link sharing#190

Merged
FSM1 merged 36 commits into
mainfrom
feat/phase-15-link-sharing
Feb 24, 2026
Merged

feat: phase 15 link sharing#190
FSM1 merged 36 commits into
mainfrom
feat/phase-15-link-sharing

Conversation

@FSM1

@FSM1 FSM1 commented Feb 23, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

New Features

  • Invite Link Sharing: Create and share encrypted invite links for folders and files without requiring recipient accounts beforehand.
  • Invite Landing Page: Recipients access shared content via a dedicated landing page with simplified authentication and auto-claim upon login.
  • Invite Management: View, copy, and revoke active invite links with automatic expiration after 7 days.

User Interface

  • Tabbed Share Dialog: Share dialog now includes separate tabs for Direct Share and Invite Link workflows.

@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown

Walkthrough

Phase 15: Link Sharing implementation introduces a complete invite-link system with ephemeral key encryption. Adds a ShareInvite entity with database migration, REST API endpoints for public/authenticated invite operations, frontend UI components for creating and claiming invites, ephemeral key-based cryptography, and comprehensive end-to-end testing infrastructure.

Changes

Cohort / File(s) Summary
Planning & Project State
.planning/REQUIREMENTS.md, .planning/ROADMAP.md, .planning/STATE.md, .planning/phases/15-link-sharing/*
Updated milestones to mark SHARE-06 and SHARE-07 complete; restructured Phase 15 roadmap by splitting into Phase 15 (Link Sharing) and Phase 15.1 (Client-Side Search); updated project state to Phase 15 completion with extensive planning documents including research, verification, and security review artifacts.
Backend Entity & Migration
apps/api/src/shares/entities/share-invite.entity.ts, apps/api/src/migrations/1740400000000-AddShareInvites.ts
Introduced ShareInvite entity with token, sharer/claimant references, encrypted keys (JSONB), status, expiry, and audit fields; added idempotent database migration with cascading FK constraints and indices on sharer_id and expires_at.
Backend DTOs
apps/api/src/shares/dto/create-invite.dto.ts, apps/api/src/shares/dto/claim-invite.dto.ts, apps/api/src/shares/dto/invite-response.dto.ts
Added request/response DTOs with nested child key structures, class-validator decorators for hex-encoded keys, enum constraints on itemType/keyType, and UUID validation for itemId fields.
Backend Controllers
apps/api/src/shares/invites.controller.ts, apps/api/src/shares/share-invites.controller.ts
Created two controllers: InvitesController at /invites (public status check, authenticated data fetch/claim) and ShareInvitesController at /shares/invites (authenticated create/list/revoke) with per-endpoint guard configuration and Swagger documentation.
Backend Service
apps/api/src/shares/shares.service.ts, apps/api/src/shares/shares.service.spec.ts
Extended SharesService with six invite methods: createInvite, getInviteStatus, getInviteForClaim, claimInvite (with atomic transactional claim logic), getInvitesForItem, revokeInvite; includes auto-expiration handling and comprehensive test coverage with mock DataSource for transactions.
Type Consolidation
apps/api/src/common/types.ts, apps/api/src/device-approval/device-approval.controller.ts, apps/api/src/ipfs/ipfs.controller.ts, apps/api/src/ipns/ipns.controller.ts, apps/api/src/shares/shares.controller.ts, apps/api/src/vault/vault.controller.ts
Centralized RequestWithUser interface to common/types; removed local declarations across five controllers; added new pipe ParseTokenPipe for base64url token validation.
Module & OpenAPI Wiring
apps/api/src/app.module.ts, apps/api/src/shares/shares.module.ts, apps/api/scripts/generate-openapi.ts
Registered ShareInvite entity in app and shares modules; added InvitesController and ShareInvitesController to module declarations; updated OpenAPI generator with new controllers, mock repositories, and API tags.
Frontend Invite Service
apps/web/src/services/invite.service.ts, apps/web/src/lib/crypto/key-wrapping.ts
Implemented ephemeral keypair generation, invite URL construction with private key in fragment, createInviteLink with child key collection, claimInvite with re-wrapping, and utility functions for status/list/revoke; added key-wrapping utilities (collectChildKeys, reWrapEncryptedKey) extracted from UI components.
Frontend Components
apps/web/src/components/file-browser/InviteLinkTab.tsx, apps/web/src/routes/InvitePage.tsx, apps/web/src/components/file-browser/ShareDialog.tsx
Created tabbed ShareDialog (Direct Share / Invite Link), new InviteLinkTab component for invite management, and standalone InvitePage with token parsing, status checking, auto-claim flow on authentication, MFA integration, and error state handling.
Generated API Clients
apps/web/src/api/invites/invites.ts, apps/web/src/api/share-invites/share-invites.ts
Generated React Query hooks and mutation utilities via Orval for all invite endpoints: query hooks for public status/data and authenticated claims, with overloaded variants and comprehensive type coverage.
Generated API Models
apps/web/src/api/models/claimInviteDto.ts, apps/web/src/api/models/createInviteDto.ts, apps/web/src/api/models/inviteResponseDto.ts, apps/web/src/api/models/...
Added 15+ generated TypeScript interfaces and type aliases for invite DTOs, status enums, and child key structures; updated barrel export in models/index.ts.
Test Infrastructure
tests/e2e/page-objects/dialogs/invite-link-tab.page.ts, tests/e2e/page-objects/pages/invite.page.ts, tests/e2e/tests/invite-link-workflow.spec.ts
Created page objects for invite link interactions (clipboard capture, revoke flows, list management) and invite landing page (navigation, state detection, claim flow assertions); added comprehensive 21-test e2e suite validating file/folder invites, claim outcomes, revocation, and error states.
Styling & Design
apps/web/src/styles/share-dialog.css, apps/web/src/styles/invite-page.css, designs/DESIGN.md, designs/cipher-box-design.pen
Added tab bar styling with active/hover states, invite list layout with metadata/actions, terminal-themed landing page card with auth/claiming/error UI, status badges, error borders; updated design system with Phase 15 components and interaction patterns.
Security & Verification
packages/api-client/openapi.json, .planning/security/REVIEW-phase15-link-sharing.md, .planning/security/REVIEW-2026-02-24-phase15-implementation.md, .planning/phases/15-link-sharing/15-VERIFICATION.md
Updated OpenAPI spec with new endpoints and schemas; added security review documenting 8 findings (3 HIGH, 3 MEDIUM, 1 LOW, 1 INFO) with mitigation steps; added post-implementation review confirming fixes and remaining test gaps.
Configuration
apps/api/jest.config.js, apps/web/index.html
Added per-file code coverage thresholds for invite controllers (80% lines, 65–74% branches); added referrer policy meta tag to web app index.

Sequence Diagram(s)

sequenceDiagram
    participant User as User (Sharer)
    participant Web as Web App
    participant API as API Server
    participant DB as Database

    User->>Web: Click "Create Invite Link" in ShareDialog
    Web->>Web: Generate ephemeral secp256k1 keypair
    Note over Web: ephemeralPrivateKey (to be placed in URL fragment)
    Web->>Web: Unwrap item key with vault privateKey
    Web->>Web: Wrap item key with ephemeral publicKey
    Web->>Web: Collect & wrap child keys (for folders)
    Web->>API: POST /shares/invites {itemType, ipnsName, itemName, encryptedKey, encryptedChildKeys}
    API->>API: Generate random token (base64url)
    API->>API: Set expiry = now + 7 days
    API->>DB: INSERT into share_invites (token, sharer_id, encryptedKey, ..., status='active')
    DB-->>API: ShareInvite created
    API-->>Web: {id, token, ...}
    Web->>Web: Build URL: /invite/{token}#{ephemeralPrivKeyHex}
    Web->>Web: Zero ephemeral material
    User->>User: Copy & share invite URL
Loading
sequenceDiagram
    participant Recipient as Recipient (Unauthenticated)
    participant Web as Web App
    participant API as API Server
    participant Auth as Auth System
    participant DB as Database

    Recipient->>Web: Navigate to /invite/{token}#{ephemeralPrivKeyHex}
    Web->>Web: Parse token & ephemeralPrivKey from URL
    Web->>Web: Strip ephemeralPrivKey from address bar
    Web->>API: GET /invites/{token} (public, throttled)
    API->>DB: SELECT * from share_invites WHERE token = ? AND status='active'
    DB-->>API: ShareInvite record (or null if expired)
    API-->>Web: {status: 'active'} or error
    Web->>Web: Render login options if valid
    Recipient->>Web: Click "Login with Google" (or other auth)
    Web->>Auth: Initiate OAuth flow
    Auth-->>Web: JWT token + user context
    Web->>Web: Auto-trigger claim flow (isOpen && authenticated && valid)
    Web->>API: GET /invites/{token}/data (authenticated)
    API->>DB: SELECT * from share_invites WHERE token = ? AND status='active'
    DB-->>API: Full invite data (encryptedKey, encryptedChildKeys)
    API-->>Web: {status, encryptedKey, encryptedChildKeys, itemType, ipnsName, itemName}
    Web->>Web: Unwrap encryptedKey with ephemeralPrivKey
    Web->>Web: Re-wrap with recipient's vault publicKey
    Web->>Web: Re-wrap all childKeys with recipient key
    Web->>API: POST /invites/{token}/claim {encryptedKey, childKeys}
    API->>DB: BEGIN TRANSACTION
    API->>DB: UPDATE share_invites SET status='claimed', claimed_by=?, claim_count++ WHERE token=? AND status='active'
    API->>DB: INSERT into shares (item_ipns_name, ...) returning share_id
    API->>DB: INSERT into share_keys (share_id, key_type, encrypted_key) for each childKey
    DB-->>API: success or 409 Conflict (already claimed)
    API->>DB: COMMIT TRANSACTION
    API-->>Web: {shareId}
    Web->>Web: Zero ephemeral key material
    Web->>Web: Redirect to /shared
    Recipient->>Web: View auto-claimed shared content
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • feat(14): user-to-user encrypted sharing #183: Modifies the same shares feature surface (api app module, shares module/service/entities, OpenAPI/client generation, frontend share utilities); implements Phase 14 user-to-user Share/ShareKey with reWrapKey, directly preceding and complementing Phase 15's invite infrastructure.
  • feat: SIWE wallet login + unified identity (Phase 12.3) #126: Updates OpenAPI generation wiring (apps/api/scripts/generate-openapi.ts) to register new controllers and entity mocks; Phase 15 adds InvitesController/ShareInvitesController while this PR handles different controller registration, sharing the same code-level surface.
  • docs: milestone 2 & 3 planning #101: Modifies project planning and milestone documentation (REQUIREMENTS.md, ROADMAP.md, STATE.md); both PRs interact with the planning system to track phase completion and roadmap evolution.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: phase 15 link sharing' accurately reflects the primary change—the implementation of Phase 15 Link Sharing feature with invite-link infrastructure, controllers, services, and UI components.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/phase-15-link-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.

FSM1 and others added 23 commits February 23, 2026 20:41
Phase 15: Link Sharing
- Split Phase 15 into Link Sharing (15) and Client-Side Search (15.1)
- Implementation decisions documented (invite model, ephemeral key bridge)
- Phase boundary established (account-required invite links, not unauthenticated)
- Design mockups added to .pen file (invite landing page, share dialog with tabs)
- Roadmap and state updated to reflect phase split

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 0edb1bc2f4bf
Phase 15: Link Sharing
- Standard stack identified (existing crypto primitives, no new deps)
- Critical discovery: HashRouter fragment collision requires URL format change
- Architecture patterns documented (entity, migration, claim flow, tab UI)
- Pitfalls catalogued (6 items including migration discipline, auth guard)
- Phase 14 codebase audit complete with file references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: b68a1ee43cda
Phase 15: Link Sharing
- 3 plans in 3 waves
- 0 parallel, 3 sequential
- Ready for execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 4740cf2db694
Address 5 checker issues: add authenticated GET /invites/:token/data
endpoint for claim flow, split into two NestJS controllers for two
path prefixes, make collectChildKeys extraction mandatory, split
InvitePage task into landing page + auth integration, fix duplicate
</output> tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: ea8cf70f5362
Pre-implementation review of ephemeral key bridge design.
0 critical, 3 high (all hardening), 3 medium, 1 low findings.
No execution blockers - design is cryptographically sound.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: e711c610a51d
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 547f588c7fdb
Add missing 15-04-PLAN.md to roadmap plan list, update research flag
from NEEDS to COMPLETE, and advance current position to Phase 15 planned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 9b308ecfa201
- ShareInvite entity with token, encryptedKey, encryptedChildKeys (JSONB), status, expiresAt
- Migration 1740400000000 creates share_invites table with IF NOT EXISTS
- CreateInviteDto, ClaimInviteDto with class-validator decorators
- InviteResponseDto, InviteStatusResponseDto, InviteDataResponseDto for all endpoints
- entities/index.ts exports ShareInvite

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

- InvitesController at /invites: public status check, authenticated data fetch, claim
- ShareInvitesController at /shares/invites: authenticated create, list, revoke
- SharesService: 6 invite methods with auto-expire on read and atomic single-claim
- ShareInvite registered in shares.module, app.module, and generate-openapi.ts
- Regenerated API client with invites and share-invites endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: d87e9f5afafc
Tasks completed: 2/2
- ShareInvite entity, migration, and DTOs
- Two controller classes, service methods, and module registration

SUMMARY: .planning/phases/15-link-sharing/15-01-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: c4d1a5be010e
- Extract collectChildKeys and reWrapEncryptedKey to apps/web/src/lib/crypto/key-wrapping.ts
- ShareDialog imports from shared utility instead of inline definitions
- Enables code sharing between direct share (Phase 14) and invite link (Phase 15)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 17ecc5521db7
- generateEphemeralKeypair using secp256k1.keygen() for invite creation
- createInviteLink wraps item+child keys with ephemeral pubkey, builds URL
- claimInvite fetches from authenticated /invites/:token/data, unwraps+re-wraps
- buildInviteUrl produces HashRouter-safe #/invite/:token?key=<hex> format
- checkInviteStatus, fetchInvitesForItem, revokeInvite for invite management
- All ephemeral and plaintext key material zeroed in finally blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: b1144c95ee6e
Tasks completed: 2/2
- Extract shared key-wrapping utilities from ShareDialog
- Frontend invite service with ephemeral key bridge crypto

SUMMARY: .planning/phases/15-link-sharing/15-02-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 69064e94ab85
- Add tab bar (DIRECT SHARE | INVITE LINK) with ARIA tablist/tab roles
- Create InviteLinkTab with link creation, clipboard copy, and revoke
- Widen modal to 600px via share-dialog-backdrop CSS
- Add focus-visible styles for tab buttons and create button
- Active invites shown with status badge and inline revoke confirm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 8dc6a2eb42b0
- Create InvitePage with state machine (loading/valid/claiming/claimed/error)
- Parse ephemeral key from hash fragment query param via useSearchParams
- Check invite status on mount, show branded card or error state
- Add /invite/:token route outside AppShell in routes/index.tsx
- Create invite-page.css with terminal aesthetic, green/red card variants
- Include MatrixBackground and StagingBanner matching Login page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 9581541e6f3a
…ication

- Add inline auth (Google, Email, Wallet) matching Login.tsx patterns
- Auto-claim via useEffect watching isAuthenticated state transition
- Handle MFA/REQUIRED_SHARE with DeviceWaitingScreen and RecoveryInput
- Navigate to /shared after successful claim with 500ms delay
- Zero ephemeral key ref in both success and finally paths
- Fix pre-existing shares.service.spec.ts missing ShareInvite mock
- Full monorepo builds, all 529 API tests pass, web lint clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 466b17a6972d
Tasks completed: 3/3
- ShareDialog tabbed interface + InviteLinkTab
- InvitePage landing page with status check and route config
- InvitePage auth integration, claim flow, and build verification

SUMMARY: .planning/phases/15-link-sharing/15-03-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 2be087c9e6b4
- InviteLinkTabPage: tab switching, create link, clipboard intercept, invite list, revoke
- InvitePageObject: navigation, state detection, auth area, error states, wait helpers
- Updated barrel exports in dialogs/index.ts and page-objects/index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 78bb9ac5187e
- 21 serial tests covering complete invite link lifecycle
- ShareDialog tab UI: switching, default state, empty state
- File invite: create link, capture URL, claim via authenticated context
- Folder invite: create, claim, navigate folder tree
- Link management: revoke, revoked landing page error
- Error states: invalid URL, non-existent token, already-claimed, self-claim
- URL parsing helper for HashRouter invite URLs
- Multi-account setup with Alice (sharer), Dave, Eve (recipients)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tasks completed: 2/2
- Page objects for InviteLinkTab and InvitePage
- Invite link E2E test suite (21 serial tests)

Phase 15 (Link Sharing) complete: 4/4 plans executed.

SUMMARY: .planning/phases/15-link-sharing/15-04-SUMMARY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 068d605becf5
InviteResponseDto was missing the `id` field, causing revoke to send a
base64url token to ParseUUIDPipe which rejects non-UUID strings with 400.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 9ebcbebb2860
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: befc2640a74b
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 1a7590ec42cd
@FSM1 FSM1 force-pushed the feat/phase-15-link-sharing branch from 5342783 to 00a69cd Compare February 23, 2026 21:32
FSM1 and others added 5 commits February 23, 2026 22:41
InvitesController (8 tests), ShareInvitesController (7 tests), and
24 invite service method tests. Fixes branch/function coverage
thresholds (80%/85%).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: bd4c031eafd5
secp256k1.keygen() returns a 33-byte compressed public key, but
wrapKey() requires a 65-byte uncompressed key (0x04 prefix). This
caused "Key wrapping failed" when creating invite links for files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: f4b9aa21b2f0
InviteLinkTab initialized invitesLoading=false, causing a brief render
of the empty state before useEffect fired to fetch invites. This race
condition made E2E waitForLoaded() resolve on the empty state before
the API response arrived, causing getInviteCount() to return 0.

Initialize invitesLoading=true so the loading state shows until the
first fetch completes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 0af6ee07a3f7
InvitePageObject.goto() derived the base URL from page.url(), but for
fresh browser contexts the URL is "about:blank". The split('#')[0]
fallback didn't trigger because "about:blank" is truthy. This caused
navigation to about:blank#/invite/... instead of localhost:5173.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 7b4953f1eb41
The list endpoint only returns active invites (status='active'). The
Phase 3 invite was claimed by Dave, so it correctly doesn't appear.
Remove the toBeGreaterThanOrEqual(1) assertion; the rest of the test
already handles creating a fresh invite to revoke.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: e3f4049b6635
@FSM1 FSM1 marked this pull request as ready for review February 23, 2026 22:28

@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: 20

🧹 Nitpick comments (15)
apps/web/src/styles/share-dialog.css (1)

365-390: Duplicated button styles — consider a shared utility class.

.invite-link-create-btn and .share-submit-btn are nearly identical (same padding, font, color, background, border, cursor, transition, hover, :focus-visible, and disabled rules). The only functional difference is the flex-shrink: 0 on .share-submit-btn. Extracting a shared .btn-ghost-green utility class would eliminate the duplication and make future theming changes a single-point edit.

♻️ Proposed shared utility class
+/* Shared ghost green button base */
+.btn-ghost-green {
+  padding: var(--spacing-xs) var(--spacing-sm);
+  font-family: var(--font-family-mono);
+  font-size: var(--font-size-sm);
+  font-weight: var(--font-weight-semibold);
+  color: var(--color-green-primary);
+  background: transparent;
+  border: var(--border-thickness) solid var(--color-green-dim);
+  cursor: pointer;
+  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+}
+
+.btn-ghost-green:hover:not(:disabled) {
+  background-color: var(--color-green-darker);
+  border-color: var(--color-green-primary);
+}
+
+.btn-ghost-green:focus-visible {
+  outline: 1px solid var(--color-green-primary);
+  outline-offset: 1px;
+}
+
+.btn-ghost-green:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}

-.share-submit-btn {
-  flex-shrink: 0;
-  padding: var(--spacing-xs) var(--spacing-sm);
-  font-family: var(--font-family-mono);
-  font-size: var(--font-size-sm);
-  font-weight: var(--font-weight-semibold);
-  color: var(--color-green-primary);
-  background: transparent;
-  border: var(--border-thickness) solid var(--color-green-dim);
-  cursor: pointer;
-  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
-}
-
-.share-submit-btn:hover:not(:disabled) { ... }
-.share-submit-btn:focus-visible { ... }
-.share-submit-btn:disabled { ... }
+.share-submit-btn {
+  flex-shrink: 0;
+}

-.invite-link-create-btn {
-  padding: var(--spacing-xs) var(--spacing-sm);
-  ...all duplicated rules...
-}
-
-.invite-link-create-btn:hover:not(:disabled) { ... }
-.invite-link-create-btn:focus-visible { ... }
-.invite-link-create-btn:disabled { ... }

Then add btn-ghost-green alongside share-submit-btn / invite-link-create-btn in the JSX.

🤖 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 365 - 390, Extract the
duplicated styles from .invite-link-create-btn and .share-submit-btn into a new
utility class .btn-ghost-green containing the shared padding, font-family,
font-size, font-weight, color, background, border, cursor, transition, hover,
:focus-visible, and :disabled rules; keep only the unique rule (flex-shrink: 0)
on .share-submit-btn and remove duplicated rules from .invite-link-create-btn,
then update the JSX to add the .btn-ghost-green class alongside
.share-submit-btn and .invite-link-create-btn so both buttons inherit the shared
styling.
apps/web/src/components/file-browser/InviteLinkTab.tsx (2)

139-141: role="tabpanel" is missing aria-labelledby to associate it with its controlling tab.

For ARIA compliance, a tabpanel should reference the id of the tab element that controls it via aria-labelledby. The parent ShareDialog presumably renders a tab with an id — pass that as a prop or use a convention.

Proposed fix
-    <div className="invite-link-tab" role="tabpanel">
+    <div className="invite-link-tab" role="tabpanel" aria-labelledby="share-tab-invite-link">

Ensure the corresponding tab element in ShareDialog.tsx has id="share-tab-invite-link".

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

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx` around lines 139 -
141, The InviteLinkTab component renders a div with role="tabpanel" but lacks
aria-labelledby; update InviteLinkTab to accept a prop (e.g., tabId or
labelledBy) and set aria-labelledby={tabId} on the div, and ensure the
corresponding tab in ShareDialog uses id="share-tab-invite-link" (or pass that
id into InviteLinkTab) so the tabpanel is properly associated with its
controlling tab.

42-68: Stale success/error messages persist across tab reopens.

When isOpen transitions to true, the effect refetches invites but doesn't clear error and success state from a previous session. A user could see a stale "link copied" message or old error from a prior interaction.

Proposed fix
   useEffect(() => {
     if (!isOpen) return;
 
     let cancelled = false;
     setInvitesLoading(true);
+    setError(null);
+    setSuccess(null);
+    setConfirmRevokeId(null);
 
     fetchInvitesForItem(ipnsName)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx` around lines 42 - 68,
When the tab opens, clear any previous success/error state so stale messages
don't persist: inside the useEffect that runs when isOpen becomes true (the
block that calls fetchInvitesForItem and uses setInvitesLoading), add calls to
reset the component's error and success state (e.g. call
setError(null/undefined) and setSuccess(null/undefined) or the actual setters
used in this component) before starting the fetch; keep the existing cancelled
checks and loading handling intact.
apps/web/src/styles/invite-page.css (2)

116-135: .invite-card__loading, .invite-card__claiming, and .invite-card__success are nearly identical.

All three share the same font-family, font-size, and text-align — they differ only in color. A shared base class (or grouping the common properties) would reduce duplication.

Proposed consolidation
+.invite-card__loading,
+.invite-card__claiming,
+.invite-card__success {
+  font-family: var(--font-family-mono);
+  font-size: var(--font-size-sm);
+  text-align: center;
+}
+
 .invite-card__loading {
-  font-family: var(--font-family-mono);
-  font-size: var(--font-size-sm);
   color: var(--color-text-secondary);
-  text-align: center;
 }
 
 .invite-card__claiming {
-  font-family: var(--font-family-mono);
-  font-size: var(--font-size-sm);
   color: var(--color-green-primary);
-  text-align: center;
 }
 
 .invite-card__success {
-  font-family: var(--font-family-mono);
-  font-size: var(--font-size-sm);
   color: var(--color-green-primary);
-  text-align: center;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/styles/invite-page.css` around lines 116 - 135, These three
classes (.invite-card__loading, .invite-card__claiming, .invite-card__success)
duplicate font-family, font-size, and text-align; consolidate common properties
by creating a shared base selector (e.g., .invite-card__status or grouping the
three selectors together) that contains font-family: var(--font-family-mono),
font-size: var(--font-size-sm), and text-align: center, then keep only the
differing color declarations on .invite-card__loading, .invite-card__claiming,
and .invite-card__success to remove duplication.

39-41: Hardcoded #ef4444 error color used in 3 places — consider using a CSS variable.

Lines 40, 144, and 189 all use the hardcoded #ef4444 color while the rest of the file consistently uses CSS variables. Extracting this to a design token (e.g., --color-error) would keep the invite page consistent with the design system and simplify future theme changes.

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

In `@apps/web/src/styles/invite-page.css` around lines 39 - 41, Replace the three
hardcoded uses of `#ef4444` with a CSS variable (e.g., --color-error) and update
the .invite-card--error rule to use var(--color-error); add or reference the
design token in the stylesheet (or :root) so other rules on lines 144 and 189
can use var(--color-error) as well, ensuring consistency with the file's
existing CSS variable usage and theming conventions.
apps/api/src/shares/share-invites.controller.spec.ts (1)

22-22: Consider typing mockReq to avoid repeated as any casts.

The lint warnings on lines 28, 77, 100, 118, 135, 143, 154 are all about any. You can reduce noise by typing mockReq more precisely and casting sharer to the User entity type.

🔧 Suggested minimal type for mockReq
- const mockReq: { user: { id: string } } = { user: { id: userId } };
+ const mockReq = { user: { id: userId } } as RequestWithUser;

This requires importing RequestWithUser (or defining a compatible local type). Similarly for line 28:

- sharer: {} as any,
+ sharer: { id: userId } as User,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/share-invites.controller.spec.ts` at line 22, The tests
are using an untyped mockReq which forces multiple "as any" casts; replace the
inline type for mockReq with the proper RequestWithUser (or define a local
compatible interface) and import it where needed, then replace ad-hoc casts by
casting the test user/sharer to the actual User entity type (e.g., cast sharer
to User) so occurrences around mockReq and variables named sharer no longer
require "as any" casts; update any test declarations that reference mockReq,
RequestWithUser, or sharer to use these types consistently.
tests/e2e/page-objects/dialogs/invite-link-tab.page.ts (1)

208-216: Clipboard interceptor doesn't restore the original writeText — may leak across tests.

If multiple tests run sequentially in the same page context, the patched writeText accumulates wrappers. Consider restoring the original in a cleanup method, or at least making the interceptor idempotent.

🔧 Idempotent interceptor
   async interceptClipboard(): Promise<void> {
     await this.page.evaluate(() => {
-      (window as unknown as Record<string, string>).__clipboardContent = '';
-      const original = navigator.clipboard.writeText.bind(navigator.clipboard);
-      navigator.clipboard.writeText = async (text: string) => {
-        (window as unknown as Record<string, string>).__clipboardContent = text;
-        return original(text);
-      };
+      const w = window as unknown as Record<string, unknown>;
+      if (!w.__clipboardIntercepted) {
+        const original = navigator.clipboard.writeText.bind(navigator.clipboard);
+        navigator.clipboard.writeText = async (text: string) => {
+          (w as Record<string, string>).__clipboardContent = text;
+          return original(text);
+        };
+        w.__clipboardIntercepted = true;
+      }
+      (w as Record<string, string>).__clipboardContent = '';
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/page-objects/dialogs/invite-link-tab.page.ts` around lines 208 -
216, The interceptClipboard method currently replaces
navigator.clipboard.writeText without restoring it or guarding against repeated
patches; make it idempotent and/or provide a restore hook: when
interceptClipboard runs, detect and preserve the original writeText (store it on
window under a unique symbol like __originalClipboardWriteText) and only wrap if
not already patched, and add a complementary restoreClipboard method that
restores navigator.clipboard.writeText from __originalClipboardWriteText and
clears __clipboardContent; reference interceptClipboard, __clipboardContent,
__originalClipboardWriteText, and navigator.clipboard.writeText to locate and
implement the fix.
apps/web/src/components/file-browser/ShareDialog.tsx (1)

350-370: ARIA tabs pattern: consider adding id, aria-controls, and aria-labelledby for screen reader linkage.

The role="tablist", role="tab", and aria-selected attributes are correct. For full WAI-ARIA Tabs Pattern compliance, each tab should have an id and aria-controls pointing to the panel id, and each panel should have aria-labelledby pointing back to its tab.

♿ Proposed enhancement
          <button
            type="button"
            role="tab"
+           id="share-tab-direct"
            aria-selected={activeTab === 'direct'}
+           aria-controls="share-panel-direct"
            className={`share-tab${activeTab === 'direct' ? ' share-tab--active' : ''}`}
            onClick={() => setActiveTab('direct')}
          >
            {'DIRECT SHARE'}
          </button>
          <button
            type="button"
            role="tab"
+           id="share-tab-invite"
            aria-selected={activeTab === 'invite'}
+           aria-controls="share-panel-invite"
            className={`share-tab${activeTab === 'invite' ? ' share-tab--active' : ''}`}
            onClick={() => setActiveTab('invite')}
          >
            {'INVITE LINK'}
          </button>

  // And on the panels:
- <div role="tabpanel">
+ <div role="tabpanel" id="share-panel-direct" aria-labelledby="share-tab-direct">

As per coding guidelines: apps/web/**: "Focus on: Accessibility (a11y) concerns."

🤖 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 350 - 370,
The tab buttons in ShareDialog (elements with className "share-tab" and state
activeTab / setter setActiveTab inside the "share-tab-bar") need accessible
linkage: give each tab button a stable id and an aria-controls that references
the corresponding panel id (e.g., "share-tab-direct" -> "share-panel-direct" and
"share-tab-invite" -> "share-panel-invite"), and update each panel element to
include the matching id and aria-labelledby pointing back to its tab id; ensure
aria-selected is kept and the onClick handlers (setActiveTab) still toggle tabs.
apps/api/src/shares/dto/invite-response.dto.ts (1)

60-68: Extract InviteChildKeyDto to resolve loose OpenAPI schema for encryptedChildKeys.

The inline type for encryptedChildKeys causes NestJS Swagger to generate an incorrect OpenAPI schema: "type": "object" instead of "type": "array" with proper item definitions. This results in loose typing in the generated API client.

InviteChildKeyDto already exists in create-invite.dto.ts with proper @ApiProperty decorators and generates the correct array schema there. Extract it to a shared location (or move from create-invite.dto.ts) and reuse it in InviteDataResponseDto to ensure consistent, properly-typed OpenAPI documentation.

Suggested refactor
+class InviteChildKeyDto {
+  `@ApiProperty`({
+    description: 'Type of key: file or folder',
+    enum: ['file', 'folder'],
+  })
+  keyType!: 'file' | 'folder';
+
+  `@ApiProperty`({
+    description: 'UUID of the file or subfolder',
+  })
+  itemId!: string;
+
+  `@ApiProperty`({
+    description: 'Hex-encoded ECIES ciphertext of the key wrapped with ephemeral public key',
+  })
+  encryptedKey!: string;
+}
+
 export class InviteDataResponseDto {
   `@ApiProperty`({
     description: 'Array of child keys wrapped with ephemeral public key, or null',
     nullable: true,
+    type: [InviteChildKeyDto],
   })
-  encryptedChildKeys!: Array<{
-    keyType: 'file' | 'folder';
-    itemId: string;
-    encryptedKey: string;
-  }> | null;
+  encryptedChildKeys!: InviteChildKeyDto[] | null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/invite-response.dto.ts` around lines 60 - 68, Replace
the inline object type for encryptedChildKeys in InviteDataResponseDto with the
shared InviteChildKeyDto: move or centralize InviteChildKeyDto (currently in
create-invite.dto.ts) into a shared DTO file or export it from its module, then
import it into invite-response.dto.ts and change encryptedChildKeys to use
InviteChildKeyDto[] | null and update the `@ApiProperty` decorator to reference
the DTO (e.g. type: () => InviteChildKeyDto, isArray: true, nullable: true) so
Swagger generates an array schema with proper item definitions.
apps/api/src/shares/shares.service.ts (1)

367-379: Auto-expire hard-deletes invites on GET — verify this is intended for the status endpoint.

getInviteStatus is called from the public (unauthenticated) endpoint. A malicious actor could force-expire an invite by polling with the token after the TTL passes. Since expiry is TTL-based and the invite is already past its useful life, this is likely benign, but worth noting that any unauthenticated caller with the token can trigger the delete.

Additionally, if getInviteStatus and getInviteForClaim race on the same expired invite, one could delete the entity while the other is mid-operation. The likelihood is low given the narrow window, but consider soft-expiring (setting status = 'expired') instead of hard-deleting to avoid potential EntityNotFound errors from concurrent access.

🤖 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 367 - 379,
getInviteStatus currently hard-deletes expired invites (inviteRepo.remove) when
called from the public endpoint, which allows anyone with the token to trigger
deletion and can race with getInviteForClaim; change this to perform a safe
"soft-expire" update: in getInviteStatus, when invite.status === 'active' &&
invite.expiresAt < new Date(), set invite.status = 'expired' and persist via
inviteRepo.save() or inviteRepo.update({ id: invite.id }, { status: 'expired' })
instead of remove, and ensure callers rely on invite.status to determine
validity; optionally wrap the update in a transaction or use optimistic locking
if your entity supports versioning to avoid races with getInviteForClaim.
tests/e2e/tests/invite-link-workflow.spec.ts (1)

38-47: parseInviteUrl will throw on malformed URLs.

If the clipboard returns an unexpected value (no #, no ?key=), hashPart is undefined and the subsequent .split('?') throws an unhandled error, producing a confusing test failure. Adding a guard or using the URL constructor would make failures more diagnosable.

🔧 Suggested defensive version
 function parseInviteUrl(url: string): { token: string; ephemeralKey: string } {
-  // URL format: http://localhost:5173/#/invite/TOKEN?key=KEY
-  const hashPart = url.split('#')[1]; // /invite/TOKEN?key=KEY
-  const [path, query] = hashPart.split('?');
-  const token = path.split('/invite/')[1];
-  const params = new URLSearchParams(query);
-  const ephemeralKey = params.get('key')!;
+  const hashIndex = url.indexOf('#');
+  if (hashIndex === -1) throw new Error(`Invalid invite URL (no hash): ${url}`);
+  const hashPart = url.slice(hashIndex + 1);
+  const [path, query] = hashPart.split('?');
+  const token = path.split('/invite/')[1];
+  if (!token) throw new Error(`Invalid invite URL (no token): ${url}`);
+  const params = new URLSearchParams(query);
+  const ephemeralKey = params.get('key');
+  if (!ephemeralKey) throw new Error(`Invalid invite URL (no key): ${url}`);
   return { token, ephemeralKey };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 38 - 47, The
parseInviteUrl function currently assumes the input contains a hash and query
and will throw on malformed URLs; update parseInviteUrl to defensively validate
the URL parts (or use the built-in URL constructor) before splitting: check that
url.includes('#') and that the hash part contains '/invite/' and a '?key=' (or
parse via new URL(urlWithHash) after ensuring a base origin) and throw a clear,
descriptive error if missing; reference the function name parseInviteUrl and the
local variables hashPart, path, token, ephemeralKey when adding the guards so
the test failure becomes explicit instead of an unhandled exception.
apps/api/src/shares/invites.controller.spec.ts (2)

21-27: Consider typing mockReq and mockInvite.sharer to satisfy lint.

The linter flags any at lines 27, 136, and 149. For sharer, a minimal typed stub would silence the warning. For mockReq, it's already typed but the controller parameter expects the full Request interface, so the as any cast at call sites is pragmatic for tests.

🔧 Suggested minimal fix for the sharer field
-    sharer: {} as any,
+    sharer: { id: '660e8400-e29b-41d4-a716-446655440001' } as unknown as ShareInvite['sharer'],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/invites.controller.spec.ts` around lines 21 - 27, The
test linter errors come from using `any` for mock objects; replace the
`mockInvite.sharer` stub with a minimal typed object (e.g., a
Partial<User>-shape with only the properties the controller accesses such as id
and email) instead of `{} as any`, and change `mockReq` to a narrower
Request-compatible type (e.g., `Partial<Request> & { user: { id: string } }` or
import Express' Request and use `Partial<Request>`) while keeping the existing
`as any` casts at controller call sites if needed; update references in
invites.controller.spec.ts to use these types for `mockReq` and
`mockInvite.sharer` (symbols: mockReq, mockInvite, sharer) to satisfy the
linter.

107-114: Double invocation of getInviteData in the NotFoundException test.

Lines 110–113 call controller.getInviteData(testToken) twice — once to assert the exception type and once for the message. Each call invokes the mock again. Consider chaining the assertion or using rejects.toThrow with a single call to avoid redundant execution:

🔧 Suggested fix
-      await expect(controller.getInviteData(testToken)).rejects.toThrow(NotFoundException);
-      await expect(controller.getInviteData(testToken)).rejects.toThrow(
-        'Invite not found or expired'
-      );
+      await expect(controller.getInviteData(testToken)).rejects.toThrow(
+        new NotFoundException('Invite not found or expired')
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/invites.controller.spec.ts` around lines 107 - 114, The
test calls controller.getInviteData(testToken) twice causing duplicate mock
invocations; replace the two expects with a single assertion that checks both
type and message in one call — e.g. call await
expect(controller.getInviteData(testToken)).rejects.toThrowError(new
NotFoundException('Invite not found or expired')) so the test uses one
invocation of getInviteData and still verifies the NotFoundException and its
message (referencing controller.getInviteData and
mockSharesService.getInviteForClaim).
apps/api/src/shares/invites.controller.ts (1)

18-22: Extract RequestWithUser interface to a shared location to reduce duplication.

This interface is defined in 8 separate controller files across the codebase (vault, shares, share-invites, invites, ipfs, ipns, device-approval, and auth controllers). Extract it to a common location such as src/common/interfaces/request-with-user.interface.ts to maintain a single source of truth and prevent drift if the interface needs future updates.

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

In `@apps/api/src/shares/invites.controller.ts` around lines 18 - 22, Extract the
local RequestWithUser interface into a single shared module (exporting an
interface with user: { id: string }) and update invites.controller.ts to import
that shared interface instead of declaring it locally; then remove the
duplicated interface declaration from this file and replace equivalent
declarations in the other controllers (vault, shares, share-invites, invites,
ipfs, ipns, device-approval, auth) to import the shared RequestWithUser to keep
one source of truth.
apps/web/src/services/invite.service.ts (1)

299-308: checkInviteStatus swallows all errors as 'expired'.

Network failures, server 500s, and auth errors will all appear as "expired" to the user. This is acceptable for a status pre-check (user sees a recoverable error card), but consider logging the error at warn level (without sensitive data) for debugging.

Proposed change
   } catch {
+    // Network/server errors treated as expired for UX, but log for debugging
+    // (no sensitive data in status check)
     return 'expired';
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/invite.service.ts` around lines 299 - 308, The function
checkInviteStatus currently swallows all exceptions and returns 'expired';
update its catch to capture the error (e.g. catch (err)) and emit a
warning-level log that includes the error message/stack but not the token or
other sensitive data, then continue returning 'expired'; use the existing
application logger (or import a shared logger) for the warning so you log via
the same facility as other services; keep the return behavior unchanged, only
add the logging around invitesControllerGetInviteStatus failure.
🤖 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/api/src/migrations/1740400000000-AddShareInvites.ts`:
- Around line 16-37: Add a foreign key on share_invites.claimed_by to users(id)
to prevent orphaned UUIDs: alter the CREATE TABLE block for "share_invites" to
include a CONSTRAINT (e.g., "FK_share_invites_claimed_by") FOREIGN KEY
("claimed_by") REFERENCES "users" ("id") with an appropriate ON DELETE action
(recommend ON DELETE SET NULL so a deleted user doesn't drop the invite) and ON
UPDATE NO ACTION; additionally decide whether multi-claim behavior is
intended—either remove "max_claims" if never used or add a code comment
documenting that "max_claims" supports multiple claimers while "claimed_by" is
only the last/single claimer and adjust schema (e.g., separate claim table) if
true.

In `@apps/api/src/shares/entities/share-invite.entity.ts`:
- Around line 17-18: The token column in the ShareInvite entity is missing the
TypeORM unique flag; update the `@Column` decorator for the token property (token
in the ShareInvite entity/class) to include unique: true so the entity matches
the existing DB constraint (UQ_share_invites_token) and implicit index created
by the migration.

In `@apps/api/src/shares/invites.controller.ts`:
- Around line 111-131: The claimInvite controller method returns an object {
shareId } but the `@ApiResponse`({ status: 201 }) has no response schema; add a
small DTO (e.g., ClaimInviteResponseDto with an `@ApiProperty`() shareId: string)
and update the decorator to `@ApiResponse`({ status: 201, type:
ClaimInviteResponseDto }) so the OpenAPI spec includes the response shape;
ensure the DTO is exported/imported and ApiProperty is imported from
`@nestjs/swagger` and referenced in the claimInvite method's decorator.

In `@apps/api/src/shares/share-invites.controller.ts`:
- Around line 22-26: Duplicate RequestWithUser interfaces across multiple
controllers should be consolidated into one shared type module (e.g.,
request.types) that exports interface RequestWithUser extends Request { user: {
id: string } }; create that module, update all controllers that currently
declare RequestWithUser (vault, auth, ipfs, device-approval, shares/*, ipns) to
import the shared RequestWithUser instead of redeclaring it, and standardize on
the same Request type by replacing any use of ExpressRequest with Request from
'express' so all imports reference the same Express Request type; finally remove
the local interface declarations from each controller.
- Around line 104-106: Create a ListInvitesQueryDto (e.g., class
ListInvitesQueryDto with `@IsString`, `@IsNotEmpty`, `@MaxLength`(255) and
`@ApiProperty` on ipnsName) and update the listInvites controller method signature
to accept `@Query`() query: ListInvitesQueryDto and pass query.ipnsName into the
service call instead of the raw `@Query`('ipnsName') ipnsName: string; also move
the RequestWithUser type into a shared types module (e.g., request-with-user.ts)
and import that shared type where controllers currently declare RequestWithUser
to remove duplication across controllers.

In `@apps/web/src/api/models/inviteDataResponseDto.ts`:
- Around line 17-21: The `@ApiProperty`() on the encryptedChildKeys field is
missing array metadata; update the decorator on encryptedChildKeys in
InviteResponseDto (the DTO class where encryptedChildKeys is declared) to
include type: 'array' and an items schema (either items: { $ref: ... } pointing
to the DTO/class that represents a single encrypted child key or items: { type:
'object', properties: { keyType: { type: 'string' }, itemId: { type: 'string' },
encryptedKey: { type: 'string' } } } ) and keep nullable: true and the existing
description so Orval generates Array<...> | null instead of { [key:string]:
unknown } | null; reference the encryptedChildKeys property and the DTO class
name used for individual child-key objects when adding the items metadata.

In `@apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts`:
- Around line 9-13: The response DTO's encryptedChildKeys is missing an explicit
ApiProperty type causing the generated client type to be { [key: string]:
unknown } | null; update the InviteDataResponseDto class to annotate
encryptedChildKeys with `@ApiProperty`({ type: [InviteChildKeyDto], nullable: true
}) (or equivalent) so it becomes Array<InviteChildKeyDto> | null, then re-run
the client generator (pnpm api:generate) to regenerate the typed frontend model.

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx`:
- Around line 70-105: The handleCreate function currently wraps both
createInviteLink and fetchInvitesForItem in a single try/catch so a failure
during fetchInvitesForItem overwrites the successful creation message; split the
logic so createInviteLink (and clipboard write + setSuccess) are awaited in
their own try block and only then call fetchInvitesForItem inside a separate
try/catch; on fetch failure log the error and setError (or ignore) but do not
clear or overwrite the previously setSuccess; refer to handleCreate,
createInviteLink, fetchInvitesForItem, setSuccess, setError, and setInvites when
making this change.
- Around line 83-93: The current code swallows navigator.clipboard.writeText
errors but always calls setSuccess("link copied to clipboard..."), which is
misleading and can lose the ephemeral URL; update InviteLinkTab so the clipboard
write result controls the message: attempt navigator.clipboard.writeText(url)
and on success call setSuccess with the truncated displayUrl, but on failure
setSuccess to a different message that includes the truncated displayUrl (or a
clear "copy failed — here is the link" text) and ensure the raw url is stored in
component state (e.g., inviteUrl/url state) or rendered so the user can manually
copy it; reference navigator.clipboard.writeText, displayUrl, setSuccess and the
url variable when making the change.

In `@apps/web/src/lib/crypto/key-wrapping.ts`:
- Around line 71-119: The folder branch calls
reWrapEncryptedKey(folder.folderKeyEncrypted, ownerPrivateKey,
targetPubKeyBytes) without a try/catch, so if it throws it aborts traversal;
wrap that call (and the subsequent childKeys.push/wrapped/onProgress) in a
try/catch mirroring the file branch: on error log the failure (include folder.id
or folder.name and the error), skip adding that folder's key (do not increment
wrapped or call onProgress), and continue processing remaining children; keep
the existing recursion
(resolveIpnsRecord/unwrapKey/decryptFolderMetadata/collectChildKeys) unchanged
and ensure folderKeyBytes is still zeroed in its finally block.

In `@apps/web/src/routes/index.tsx`:
- Around line 6-13: InvitePage reads the ephemeral key into ephemeralKeyRef from
searchParams.get('key') but never removes it from the URL; update the mount
logic in InvitePage (where ephemeralKeyRef is set) to immediately call the
router navigate to the same path without the key query param (use the existing
navigate from react-router and pass replace: true) so the key is stripped from
the address bar and not written as a new history entry; ensure you construct the
new location from the current pathname and any other safe params but omit 'key'.

In `@apps/web/src/routes/InvitePage.tsx`:
- Around line 76-91: The effect calling checkInviteStatus(token) must handle
rejected promises: append a .catch(...) to the checkInviteStatus(token) call to
handle network/errors, check the existing cancelled flag inside the catch to
avoid state updates after unmount, and then call setErrorReason(...) (or set a
generic error reason like 'network') and setPageState('error'); also log the
error (console.error or process logger) so rejections are not unhandled. Ensure
you reference the existing cancelled variable, checkInviteStatus, setPageState,
and setErrorReason so the handler updates the component state correctly.
- Line 84: The code is casting an arbitrary status to ErrorReason
(setErrorReason(status as ErrorReason)), which can produce undefined error text;
change the assignment to validate the returned status against your allowed
ErrorReason values (or ERROR_MESSAGES keys) and if it is not recognized, fall
back to 'invalid' before calling setErrorReason (e.g., compute const reason =
isValidErrorReason(status) ? status : 'invalid' and then
setErrorReason(reason)). Ensure you reference the symbols setErrorReason,
checkInviteStatus, ErrorReason, and ERROR_MESSAGES when implementing the
guard/fallback.
- Around line 44-58: useSearchParams is reading query params but the ephemeral
key is in the URL hash fragment, so change how ephemeralKeyRef is initialized:
read window.location.hash (or parse key=value from the hash like "#key=...") and
set ephemeralKeyRef.current to that value instead of using
useSearchParams.get('key'); update any code that depends on ephemeralKeyRef
accordingly and remove the unused useSearchParams import; ensure you do not log
or persist the key anywhere (no console.log or storage).
- Around line 104-128: The setTimeout started inside the claimInvite promise
chain can fire after the component unmounts; to fix it, capture the timer id
(e.g., const timerRef = useRef<number | null>(null)) when calling setTimeout in
the claimInvite.then block and replace direct setTimeout with timerRef.current =
window.setTimeout(...), then clear it in a cleanup (useEffect return) or on
unmount via if (timerRef.current) { clearTimeout(timerRef.current);
timerRef.current = null; } to prevent navigate('/shared', { replace: true })
from running on an unmounted component; keep existing ephemeralKeyRef.current =
null behavior in finally.

In `@apps/web/src/services/invite.service.ts`:
- Around line 279-284: The client uses an unsafe cast around
invitesControllerClaimInvite because the backend claim endpoint lacks an
explicit ApiResponse type, causing Orval to generate void; fix the backend
controller method that handles the claim (the claim/claimInvite handler) by
adding an `@ApiResponse`({ status: 201, description: 'Invite claimed
successfully', type: <DTOClass> }) where <DTOClass> is a DTO that defines {
shareId: string } (or create a new DTO like ClaimInviteResponseDto with a
shareId field) so Orval can regenerate correct types and you can remove the as
unknown as { shareId: string } cast in invite.service.ts.

In `@designs/cipher-box-design.pen`:
- Around line 40956-40964: The text node with id "gzqpC" (name: policyNote)
hardcodes “expires in 7 days, single-claim” which may diverge from backend
TTL/claim policy; verify the backend contract for invite TTL and single-claim
semantics and update this node's content (and the corresponding expired-state
copy elsewhere that currently reads “valid for 7 days”) to either reflect the
actual configurable values or use a generic phrase (e.g., “expires after the
configured period” / “single-use” only when guaranteed). Ensure both the
policyNote ("gzqpC") and the matching expired-state copy are kept in sync or
replaced with a variable-driven/generic string.
- Around line 39304-39312: The subtext element (id "TBokI", name "subtext") uses
fill "#006644" at fontSize 11 and JetBrains Mono which likely fails WCAG AA;
update this element to use the project’s accessible muted-text token (or a
lighter hex color that meets 4.5:1 contrast against black) or increase the text
severity (e.g., bump fontSize and/or fontWeight) so the combination of fill,
fontSize 11/JetBrains Mono meets AA; apply the same change to any other elements
using "#006644" for small text.

In `@packages/api-client/openapi.json`:
- Around line 1612-1614: The `@ApiResponse` decorator on the claim endpoint lacks
a response schema so the OpenAPI spec omits the { shareId: string } body; update
the `@ApiResponse` on the controller method that returns the claim response
(invites.controller.ts, the claim handler) to include a schema describing an
object with a string property "shareId" (and optional example), so the generated
spec shows the response body structure.
- Around line 2871-2875: The OpenAPI schema incorrectly emits encryptedChildKeys
as "object" because the InviteDataResponseDto.encryptedChildKeys `@ApiProperty`
lacks an explicit type; update the decorator to specify the array element type
(e.g., type: [InviteChildKeyDto] or use a class reference matching
CreateInviteDto's pattern) so the generated schema becomes type: "array" with
the correct item shape, then regenerate the spec with pnpm api:generate.

---

Nitpick comments:
In `@apps/api/src/shares/dto/invite-response.dto.ts`:
- Around line 60-68: Replace the inline object type for encryptedChildKeys in
InviteDataResponseDto with the shared InviteChildKeyDto: move or centralize
InviteChildKeyDto (currently in create-invite.dto.ts) into a shared DTO file or
export it from its module, then import it into invite-response.dto.ts and change
encryptedChildKeys to use InviteChildKeyDto[] | null and update the `@ApiProperty`
decorator to reference the DTO (e.g. type: () => InviteChildKeyDto, isArray:
true, nullable: true) so Swagger generates an array schema with proper item
definitions.

In `@apps/api/src/shares/invites.controller.spec.ts`:
- Around line 21-27: The test linter errors come from using `any` for mock
objects; replace the `mockInvite.sharer` stub with a minimal typed object (e.g.,
a Partial<User>-shape with only the properties the controller accesses such as
id and email) instead of `{} as any`, and change `mockReq` to a narrower
Request-compatible type (e.g., `Partial<Request> & { user: { id: string } }` or
import Express' Request and use `Partial<Request>`) while keeping the existing
`as any` casts at controller call sites if needed; update references in
invites.controller.spec.ts to use these types for `mockReq` and
`mockInvite.sharer` (symbols: mockReq, mockInvite, sharer) to satisfy the
linter.
- Around line 107-114: The test calls controller.getInviteData(testToken) twice
causing duplicate mock invocations; replace the two expects with a single
assertion that checks both type and message in one call — e.g. call await
expect(controller.getInviteData(testToken)).rejects.toThrowError(new
NotFoundException('Invite not found or expired')) so the test uses one
invocation of getInviteData and still verifies the NotFoundException and its
message (referencing controller.getInviteData and
mockSharesService.getInviteForClaim).

In `@apps/api/src/shares/invites.controller.ts`:
- Around line 18-22: Extract the local RequestWithUser interface into a single
shared module (exporting an interface with user: { id: string }) and update
invites.controller.ts to import that shared interface instead of declaring it
locally; then remove the duplicated interface declaration from this file and
replace equivalent declarations in the other controllers (vault, shares,
share-invites, invites, ipfs, ipns, device-approval, auth) to import the shared
RequestWithUser to keep one source of truth.

In `@apps/api/src/shares/share-invites.controller.spec.ts`:
- Line 22: The tests are using an untyped mockReq which forces multiple "as any"
casts; replace the inline type for mockReq with the proper RequestWithUser (or
define a local compatible interface) and import it where needed, then replace
ad-hoc casts by casting the test user/sharer to the actual User entity type
(e.g., cast sharer to User) so occurrences around mockReq and variables named
sharer no longer require "as any" casts; update any test declarations that
reference mockReq, RequestWithUser, or sharer to use these types consistently.

In `@apps/api/src/shares/shares.service.ts`:
- Around line 367-379: getInviteStatus currently hard-deletes expired invites
(inviteRepo.remove) when called from the public endpoint, which allows anyone
with the token to trigger deletion and can race with getInviteForClaim; change
this to perform a safe "soft-expire" update: in getInviteStatus, when
invite.status === 'active' && invite.expiresAt < new Date(), set invite.status =
'expired' and persist via inviteRepo.save() or inviteRepo.update({ id: invite.id
}, { status: 'expired' }) instead of remove, and ensure callers rely on
invite.status to determine validity; optionally wrap the update in a transaction
or use optimistic locking if your entity supports versioning to avoid races with
getInviteForClaim.

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx`:
- Around line 139-141: The InviteLinkTab component renders a div with
role="tabpanel" but lacks aria-labelledby; update InviteLinkTab to accept a prop
(e.g., tabId or labelledBy) and set aria-labelledby={tabId} on the div, and
ensure the corresponding tab in ShareDialog uses id="share-tab-invite-link" (or
pass that id into InviteLinkTab) so the tabpanel is properly associated with its
controlling tab.
- Around line 42-68: When the tab opens, clear any previous success/error state
so stale messages don't persist: inside the useEffect that runs when isOpen
becomes true (the block that calls fetchInvitesForItem and uses
setInvitesLoading), add calls to reset the component's error and success state
(e.g. call setError(null/undefined) and setSuccess(null/undefined) or the actual
setters used in this component) before starting the fetch; keep the existing
cancelled checks and loading handling intact.

In `@apps/web/src/components/file-browser/ShareDialog.tsx`:
- Around line 350-370: The tab buttons in ShareDialog (elements with className
"share-tab" and state activeTab / setter setActiveTab inside the
"share-tab-bar") need accessible linkage: give each tab button a stable id and
an aria-controls that references the corresponding panel id (e.g.,
"share-tab-direct" -> "share-panel-direct" and "share-tab-invite" ->
"share-panel-invite"), and update each panel element to include the matching id
and aria-labelledby pointing back to its tab id; ensure aria-selected is kept
and the onClick handlers (setActiveTab) still toggle tabs.

In `@apps/web/src/services/invite.service.ts`:
- Around line 299-308: The function checkInviteStatus currently swallows all
exceptions and returns 'expired'; update its catch to capture the error (e.g.
catch (err)) and emit a warning-level log that includes the error message/stack
but not the token or other sensitive data, then continue returning 'expired';
use the existing application logger (or import a shared logger) for the warning
so you log via the same facility as other services; keep the return behavior
unchanged, only add the logging around invitesControllerGetInviteStatus failure.

In `@apps/web/src/styles/invite-page.css`:
- Around line 116-135: These three classes (.invite-card__loading,
.invite-card__claiming, .invite-card__success) duplicate font-family, font-size,
and text-align; consolidate common properties by creating a shared base selector
(e.g., .invite-card__status or grouping the three selectors together) that
contains font-family: var(--font-family-mono), font-size: var(--font-size-sm),
and text-align: center, then keep only the differing color declarations on
.invite-card__loading, .invite-card__claiming, and .invite-card__success to
remove duplication.
- Around line 39-41: Replace the three hardcoded uses of `#ef4444` with a CSS
variable (e.g., --color-error) and update the .invite-card--error rule to use
var(--color-error); add or reference the design token in the stylesheet (or
:root) so other rules on lines 144 and 189 can use var(--color-error) as well,
ensuring consistency with the file's existing CSS variable usage and theming
conventions.

In `@apps/web/src/styles/share-dialog.css`:
- Around line 365-390: Extract the duplicated styles from
.invite-link-create-btn and .share-submit-btn into a new utility class
.btn-ghost-green containing the shared padding, font-family, font-size,
font-weight, color, background, border, cursor, transition, hover,
:focus-visible, and :disabled rules; keep only the unique rule (flex-shrink: 0)
on .share-submit-btn and remove duplicated rules from .invite-link-create-btn,
then update the JSX to add the .btn-ghost-green class alongside
.share-submit-btn and .invite-link-create-btn so both buttons inherit the shared
styling.

In `@tests/e2e/page-objects/dialogs/invite-link-tab.page.ts`:
- Around line 208-216: The interceptClipboard method currently replaces
navigator.clipboard.writeText without restoring it or guarding against repeated
patches; make it idempotent and/or provide a restore hook: when
interceptClipboard runs, detect and preserve the original writeText (store it on
window under a unique symbol like __originalClipboardWriteText) and only wrap if
not already patched, and add a complementary restoreClipboard method that
restores navigator.clipboard.writeText from __originalClipboardWriteText and
clears __clipboardContent; reference interceptClipboard, __clipboardContent,
__originalClipboardWriteText, and navigator.clipboard.writeText to locate and
implement the fix.

In `@tests/e2e/tests/invite-link-workflow.spec.ts`:
- Around line 38-47: The parseInviteUrl function currently assumes the input
contains a hash and query and will throw on malformed URLs; update
parseInviteUrl to defensively validate the URL parts (or use the built-in URL
constructor) before splitting: check that url.includes('#') and that the hash
part contains '/invite/' and a '?key=' (or parse via new URL(urlWithHash) after
ensuring a base origin) and throw a clear, descriptive error if missing;
reference the function name parseInviteUrl and the local variables hashPart,
path, token, ephemeralKey when adding the guards so the test failure becomes
explicit instead of an unhandled exception.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1942b80 and 6b51066.

📒 Files selected for processing (66)
  • .planning/REQUIREMENTS.md
  • .planning/ROADMAP.md
  • .planning/STATE.md
  • .planning/phases/15-link-sharing/15-01-PLAN.md
  • .planning/phases/15-link-sharing/15-01-SUMMARY.md
  • .planning/phases/15-link-sharing/15-02-PLAN.md
  • .planning/phases/15-link-sharing/15-02-SUMMARY.md
  • .planning/phases/15-link-sharing/15-03-PLAN.md
  • .planning/phases/15-link-sharing/15-03-SUMMARY.md
  • .planning/phases/15-link-sharing/15-04-PLAN.md
  • .planning/phases/15-link-sharing/15-04-SUMMARY.md
  • .planning/phases/15-link-sharing/15-CONTEXT.md
  • .planning/phases/15-link-sharing/15-RESEARCH.md
  • .planning/phases/15-link-sharing/15-VERIFICATION.md
  • .planning/security/REVIEW-phase15-link-sharing.md
  • apps/api/scripts/generate-openapi.ts
  • apps/api/src/app.module.ts
  • apps/api/src/migrations/1740400000000-AddShareInvites.ts
  • apps/api/src/shares/dto/claim-invite.dto.ts
  • apps/api/src/shares/dto/create-invite.dto.ts
  • apps/api/src/shares/dto/invite-response.dto.ts
  • apps/api/src/shares/entities/index.ts
  • apps/api/src/shares/entities/share-invite.entity.ts
  • apps/api/src/shares/invites.controller.spec.ts
  • apps/api/src/shares/invites.controller.ts
  • apps/api/src/shares/share-invites.controller.spec.ts
  • apps/api/src/shares/share-invites.controller.ts
  • apps/api/src/shares/shares.module.ts
  • apps/api/src/shares/shares.service.spec.ts
  • apps/api/src/shares/shares.service.ts
  • apps/web/src/api/invites/invites.ts
  • apps/web/src/api/models/claimChildKeyDto.ts
  • apps/web/src/api/models/claimChildKeyDtoKeyType.ts
  • apps/web/src/api/models/claimInviteDto.ts
  • apps/web/src/api/models/createInviteDto.ts
  • apps/web/src/api/models/createInviteDtoItemType.ts
  • apps/web/src/api/models/index.ts
  • apps/web/src/api/models/inviteChildKeyDto.ts
  • apps/web/src/api/models/inviteChildKeyDtoKeyType.ts
  • apps/web/src/api/models/inviteDataResponseDto.ts
  • apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts
  • apps/web/src/api/models/inviteDataResponseDtoItemType.ts
  • apps/web/src/api/models/inviteDataResponseDtoStatus.ts
  • apps/web/src/api/models/inviteResponseDto.ts
  • apps/web/src/api/models/inviteResponseDtoItemType.ts
  • apps/web/src/api/models/inviteResponseDtoStatus.ts
  • apps/web/src/api/models/inviteStatusResponseDto.ts
  • apps/web/src/api/models/inviteStatusResponseDtoStatus.ts
  • apps/web/src/api/models/shareInvitesControllerListInvitesParams.ts
  • apps/web/src/api/share-invites/share-invites.ts
  • apps/web/src/components/file-browser/InviteLinkTab.tsx
  • apps/web/src/components/file-browser/ShareDialog.tsx
  • apps/web/src/lib/crypto/key-wrapping.ts
  • apps/web/src/routes/InvitePage.tsx
  • apps/web/src/routes/index.tsx
  • apps/web/src/services/invite.service.ts
  • apps/web/src/styles/invite-page.css
  • apps/web/src/styles/share-dialog.css
  • designs/DESIGN.md
  • designs/cipher-box-design.pen
  • packages/api-client/openapi.json
  • tests/e2e/page-objects/dialogs/index.ts
  • tests/e2e/page-objects/dialogs/invite-link-tab.page.ts
  • tests/e2e/page-objects/index.ts
  • tests/e2e/page-objects/pages/invite.page.ts
  • tests/e2e/tests/invite-link-workflow.spec.ts

Comment thread apps/api/src/migrations/1740400000000-AddShareInvites.ts
Comment thread apps/api/src/shares/entities/share-invite.entity.ts Outdated
Comment thread apps/api/src/shares/invites.controller.ts
Comment thread apps/api/src/shares/share-invites.controller.ts Outdated
Comment thread apps/api/src/shares/share-invites.controller.ts
Comment thread apps/web/src/services/invite.service.ts Outdated
Comment thread designs/cipher-box-design.pen
Comment thread designs/cipher-box-design.pen
Comment thread packages/api-client/openapi.json Outdated
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: 13

♻️ Duplicate comments (18)
packages/api-client/openapi.json (2)

1612-1615: ⚠️ Potential issue | 🟠 Major

Confirm the claim response schema is emitted.

The 201 response for /invites/{token}/claim has no schema (Line 1612-1614). If the handler returns a body (e.g., { shareId: string }), the generated client will drop it. Add an explicit response schema (or align the handler to return no body) and regenerate the spec.

🔧 Suggested `@ApiResponse` schema (controller)
- `@ApiResponse`({ status: 201, description: 'Invite claimed successfully' })
+ `@ApiResponse`({
+   status: 201,
+   description: 'Invite claimed successfully',
+   schema: {
+     type: 'object',
+     properties: {
+       shareId: { type: 'string', description: 'UUID of the created share' }
+     },
+     required: ['shareId']
+   }
+ })
#!/bin/bash
# Inspect the claim invite handler return value and swagger decorator.
rg -n "claimInvite" apps/api/src/shares -g '*.controller.ts' -C 4
rg -n "@ApiResponse" apps/api/src/shares/invites.controller.ts -C 4
🤖 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 1612 - 1615, The 201 response
for the claim endpoint is missing a response schema, so update the controller's
claim handler (e.g., the method named claimInvite in invites.controller.ts) to
include an explicit `@ApiResponse` decorator that defines the returned schema (for
example { type: 'object', properties: { shareId: { type: 'string' } } } or the
proper DTO class), or change the handler to return no body; after adding the
proper `@ApiResponse/`@ApiOkResponse (or matching DTO) regenerate the OpenAPI spec
so the 201 response includes the schema.

2871-2875: ⚠️ Potential issue | 🟠 Major

Fix encryptedChildKeys schema to be an array.

InviteDataResponseDto.encryptedChildKeys is emitted as "type": "object" (Line 2871-2874). If the DTO is an array (with nullable), the spec should be "type": "array" with InviteChildKeyDto items. Update the @ApiProperty to specify the array element type and regenerate the spec.

🔧 Suggested DTO annotation
- `@ApiProperty`({
-   description: 'Array of child keys wrapped with ephemeral public key, or null',
-   nullable: true,
- })
+ `@ApiProperty`({
+   type: [InviteChildKeyDto],
+   description: 'Array of child keys wrapped with ephemeral public key, or null',
+   nullable: true,
+ })
  encryptedChildKeys: InviteChildKeyDto[] | null;
#!/bin/bash
# Locate the DTO and the encryptedChildKeys property decoration.
rg -n "InviteDataResponseDto" apps/api/src/shares/dto -g '*.dto.ts' -C 4
rg -n "encryptedChildKeys" apps/api/src/shares/dto -g '*.dto.ts' -C 4
🤖 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 2871 - 2875, The OpenAPI
schema shows InviteDataResponseDto.encryptedChildKeys as an object but it should
be an array of InviteChildKeyDto; update the DTO decorator for the
encryptedChildKeys property (InviteDataResponseDto.encryptedChildKeys) to
specify the element type and array shape (use the InviteChildKeyDto type and
mark as an array and nullable in the `@ApiProperty` options), then regenerate the
OpenAPI spec so the generated JSON uses "type": "array" with the correct items
schema.
designs/cipher-box-design.pen (2)

39515-39523: ⚠️ Potential issue | 🟡 Minor

Align invite-expiry copy with backend TTL/claim semantics.

The policy note (Line 40956–Line 40964) and expired-state copy (Line 39515–Line 39523) hardcode “7 days” and “single-claim.” If TTL or claim policy is configurable, this will mislead users. Please verify the backend contract and update both strings (or make them generic) and keep them in sync.

✏️ Suggested generic copy (if TTL is configurable)
-                  "content": "Invite links are valid for 7 days.\nAsk the sender for a new link.",
+                  "content": "Invite links expire after the configured period.\nAsk the sender for a new link.",

-                  "content": "// expires in 7 days, single-claim",
+                  "content": "// expires after the configured period",

Also applies to: 40956-40964

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

In `@designs/cipher-box-design.pen` around lines 39515 - 39523, The invite expiry
copy hardcodes “7 days” and “single-claim” which may diverge from backend
TTL/claim semantics; confirm the backend contract (TTL and claim policy) and
either make the UI strings generic or read the actual values from the same
source used by the backend, then update the text nodes (e.g., the text element
with id "errSubtext" and the policy-note string used in the expired-state/policy
block) so they no longer hardcode “7 days”/“single-claim” but instead reflect
the configured TTL/claim policy or a generic phrase.

39304-39312: ⚠️ Potential issue | 🟠 Major

Increase contrast for muted text at small sizes (#006644).

#006644 at 10–11px on black likely misses WCAG AA for normal text. This shows up in the subtext (Line 39304–Line 39312) and other small labels in this hunk (e.g., Line 39670, Line 39823, Line 39867, Line 40004, Line 40658, Line 40733). Please use the accessible muted-text token or increase size/weight across all occurrences.

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

In `@designs/cipher-box-design.pen` around lines 39304 - 39312, The subtext
element with id "TBokI" (name "subtext", fontFamily "JetBrains Mono", fontSize
11, fill "#006644") uses too-low contrast for small text; update this and all
other small-label occurrences currently using "#006644" (e.g., the other muted
labels called out in the review) to use the accessible muted-text token (instead
of the hardcoded hex) or switch to a higher-contrast color/weight/size (e.g.,
darker hex token, increase fontSize or set fontWeight to "medium") so the text
meets WCAG AA for 10–11px text; apply the same change consistently across each
matching element that uses "#006644".
apps/web/src/lib/crypto/key-wrapping.ts (1)

74-85: ⚠️ Potential issue | 🟠 Major

Folder key re-wrapping still not wrapped in try-catch.

The reWrapEncryptedKey call (and the subsequent childKeys.push / wrapped++ / onProgress) for the folder branch has no error handling. If reWrapEncryptedKey throws for any subfolder, traversal aborts entirely — inconsistent with the file branch (Lines 53–70) which continues on error.

🛡️ Proposed fix (mirrors file branch pattern)
     } else {
       const folder = child as FolderEntry;
-      // Re-wrap the subfolder's folderKey for the target
-      const folderKeyRewrapped = await reWrapEncryptedKey(
-        folder.folderKeyEncrypted,
-        ownerPrivateKey,
-        targetPubKeyBytes
-      );
-      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 {
+      try {
+        // Re-wrap the subfolder's folderKey for the target
+        const folderKeyRewrapped = await reWrapEncryptedKey(
+          folder.folderKeyEncrypted,
+          ownerPrivateKey,
+          targetPubKeyBytes
+        );
+        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
         const resolved = await resolveIpnsRecord(folder.ipnsName);
         // ... rest of recursion unchanged ...
       } catch (err) {
-        console.error(`Failed to traverse subfolder ${folder.name}:`, 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/lib/crypto/key-wrapping.ts` around lines 74 - 85, Wrap the
folder branch call to reWrapEncryptedKey and the subsequent
childKeys.push/wrapped++/onProgress in a try-catch similar to the file branch:
try to call reWrapEncryptedKey(folder.folderKeyEncrypted, ownerPrivateKey,
targetPubKeyBytes), push the child key on success, increment wrapped and call
onProgress; in the catch block log or warn about the error (including folder.id
and the error) and continue without throwing so traversal doesn't abort. Ensure
you reference reWrapEncryptedKey, folder.folderKeyEncrypted, childKeys.push,
wrapped and onProgress when making the change.
apps/api/src/shares/entities/share-invite.entity.ts (1)

17-18: ⚠️ Potential issue | 🟡 Minor

Entity should declare unique: true on token column to match the database constraint.

The migration includes a UNIQUE constraint on token, but the entity decorator doesn't reflect it. This mismatch means TypeORM's schema metadata won't be aware of the uniqueness, which matters for synchronize mode and for developer clarity.

Proposed fix
- `@Column`({ type: 'varchar', length: 44, name: 'token' })
+ `@Column`({ type: 'varchar', length: 44, name: 'token', unique: true })
  token!: 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-invite.entity.ts` around lines 17 - 18,
The token column in the ShareInvite entity is missing the unique flag that the
migration enforces; update the `@Column` decorator for token (in the ShareInvite
entity/class) to include unique: true so the entity metadata matches the DB
UNIQUE constraint and TypeORM knows the column is unique.
apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts (1)

9-13: ⚠️ Potential issue | 🟠 Major

Unresolved: encryptedChildKeys still generates as { [key: string]: unknown } | null.

This issue was flagged in a previous review and remains unaddressed. The backend InviteDataResponseDto is still missing type: [InviteChildKeyDto] in its @ApiProperty decorator, so orval cannot infer the array element type and falls back to a generic object map. Until fixed, any frontend code iterating over child keys during invite claim/re-wrap will have no type safety.

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

In `@apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts` around
lines 9 - 13, The generated frontend type
InviteDataResponseDtoEncryptedChildKeys is too generic because the backend DTO
is missing an explicit array element type; fix the backend InviteDataResponseDto
by adding an `@ApiProperty` decorator on the encryptedChildKeys property with
type: [InviteChildKeyDto] (reference InviteDataResponseDto and
InviteChildKeyDto), then re-run the OpenAPI/orval generation so the client type
is emitted as InviteChildKeyDto[] | null instead of { [key: string]: unknown } |
null.
apps/api/src/migrations/1740400000000-AddShareInvites.ts (1)

29-35: ⚠️ Potential issue | 🟡 Minor

Add a foreign key for claimed_by to prevent orphaned references.

claimed_by currently lacks a FK to users(id), so deleted users can leave orphaned UUIDs. Consider ON DELETE SET NULL to preserve the invite record.

🔧 Proposed migration tweak
         CONSTRAINT "FK_share_invites_sharer" FOREIGN KEY ("sharer_id")
-          REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
+          REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
+        CONSTRAINT "FK_share_invites_claimed_by" FOREIGN KEY ("claimed_by")
+          REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE NO ACTION
       )
#!/bin/bash
# Locate claimed_by and constraints in the migration
rg -n 'claimed_by|FK_share_invites' apps/api/src/migrations/1740400000000-AddShareInvites.ts -C3
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/migrations/1740400000000-AddShareInvites.ts` around lines 29 -
35, The migration leaves "claimed_by" as a plain uuid without a foreign key,
allowing orphaned UUIDs; update the AddShareInvites migration to make
"claimed_by" nullable (if not already) and add a FK constraint (e.g., CONSTRAINT
"FK_share_invites_claimed_by") that references users(id) with ON DELETE SET NULL
and ON UPDATE NO ACTION so deleted users set claimed_by to null instead of
leaving orphans; edit the migration where "claimed_by" and other CONSTRAINTs are
declared to add this new FK next to "FK_share_invites_sharer".
apps/web/src/api/models/inviteDataResponseDto.ts (1)

17-21: Tighten encryptedChildKeys typing by fixing the OpenAPI schema and regenerating.

The generated type is { [key: string]: unknown } | null, which weakens type safety in claim flows. Update the backend InviteResponseDto @ApiProperty() for encryptedChildKeys to describe an array of { keyType, itemId, encryptedKey }, then regenerate the client so this becomes Array<...> | null.

#!/bin/bash
# Verify backend schema and generated model for encryptedChildKeys
rg -n 'encryptedChildKeys' apps/api/src/shares/dto -g '*.ts' -C2
echo "=== Generated client model ==="
rg -n 'encryptedChildKeys' apps/web/src/api/models/inviteDataResponseDto*.ts -C2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/api/models/inviteDataResponseDto.ts` around lines 17 - 21, The
generated client types show encryptedChildKeys as `{ [key: string]: unknown } |
null`; update the backend DTO by changing the InviteResponseDto `@ApiProperty`()
for encryptedChildKeys to explicitly describe an array of objects with
properties keyType, itemId, and encryptedKey (e.g., type: 'array' with items
schema having those three fields and their types, and nullable: true), then
re-run the OpenAPI client generation to regenerate InviteDataResponseDto so
encryptedChildKeys becomes Array<{ keyType, itemId, encryptedKey }> | null;
target the InviteResponseDto class and the encryptedChildKeys property when
making the change and regenerating.
apps/api/src/shares/invites.controller.ts (2)

121-121: claimInvite @ApiResponse(201) has no response schema — generated client returns unknown.

Without a type on the decorator, the OpenAPI spec omits the shareId field and the frontend client loses type-safety on the claim result.

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

In `@apps/api/src/shares/invites.controller.ts` at line 121, The `@ApiResponse`({
status: 201, description: 'Invite claimed successfully' }) on the claimInvite
endpoint doesn't specify a response type, so the OpenAPI spec omits the shareId
and the generated client returns unknown; update the claimInvite controller to
provide an explicit response DTO (e.g., ClaimInviteResponseDto or
InviteClaimResponseDto) in the `@ApiResponse/type` (or provide a schema with the
shareId field) so the OpenAPI output includes shareId; ensure the DTO contains
shareId and use that DTO type in the `@ApiResponse` decorator on the claimInvite
method.

18-22: RequestWithUser interface duplicated again.

Same local re-declaration as in share-invites.controller.ts. The prior review recommended extracting it to a shared types file. This new file should import from that shared location once created.

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

In `@apps/api/src/shares/invites.controller.ts` around lines 18 - 22, The
RequestWithUser interface is duplicated here; remove the local declaration and
import the shared type instead: create or use the previously suggested shared
types file (e.g., a common types module) that exports RequestWithUser, then
replace the local interface in invites.controller.ts with an import of
RequestWithUser and update any usages in functions/methods (e.g., request
handlers) to use the imported type; ensure the import name matches the exported
symbol and run type-checks to confirm no remaining duplicates.
apps/web/src/components/file-browser/InviteLinkTab.tsx (2)

83-93: Clipboard failure still shows misleading "link copied to clipboard" message.

The inner catch on line 86 silently swallows the error, then line 93 unconditionally shows the "copied" message. Since the ephemeral URL is not persisted, a user in an insecure context loses the link with no indication.

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

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx` around lines 83 - 93,
The current logic in InviteLinkTab calls navigator.clipboard.writeText(url)
inside a try/catch but always calls setSuccess("link copied to clipboard: ...")
regardless of whether the write succeeded; change it to set success only when
navigator.clipboard.writeText(url) resolves, and in the catch branch call
setSuccess (or setError) with a clear message that the link was created but
clipboard copy failed and include the displayUrl so the user can copy it
manually; update references to navigator.clipboard.writeText, url, displayUrl
and setSuccess (or setError) in InviteLinkTab accordingly so the UI reflects
clipboard failure instead of misleadingly reporting a copy.

95-104: fetchInvitesForItem failure inside the outer try still overwrites the creation success message.

If fetchInvitesForItem on line 96 throws, the catch on line 98 shows "failed to create invite link" even though the invite was created and the URL was copied. The list refresh should be isolated in its own try/catch.

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

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx` around lines 95 -
104, The outer try's call to fetchInvitesForItem can throw and trigger the same
catch that reports invite-creation failure; wrap the refresh in its own
try/catch so creation success isn't overwritten. After the invite creation and
URL copy, call fetchInvitesForItem(ipnsName) inside a nested try block,
setInvites(updatedInvites) on success, and in that inner catch only log the
refresh error (e.g., console.error) and avoid calling setError for creation;
keep the outer catch reserved for actual creation failures and leave
setIsCreating(false) in the finally block.
apps/api/src/shares/share-invites.controller.ts (2)

22-26: RequestWithUser interface is duplicated — extract to shared module.

Same local interface in both invites.controller.ts and this file. The prior review identified this across 7+ controllers.

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

In `@apps/api/src/shares/share-invites.controller.ts` around lines 22 - 26, The
RequestWithUser interface is duplicated across controllers; extract it into a
shared typings module (e.g., create and export a UserRequest or RequestWithUser
type from a central types file) and replace the local interface in
share-invites.controller.ts (and other controllers like invites.controller.ts)
with an import of that shared type; update imports to reference the new exported
symbol RequestWithUser (or chosen name) and remove the local interface
declarations to avoid duplication.

104-106: ipnsName query parameter still lacks DTO-level validation.

A bare @Query('ipnsName') ipnsName: string bypasses the global ValidationPipe. Empty or oversized strings reach the service without rejection.

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

In `@apps/api/src/shares/share-invites.controller.ts` around lines 104 - 106, The
listInvites handler accepts a raw `@Query`('ipnsName') string which bypasses the
ValidationPipe; create a query DTO (e.g., ListInvitesQueryDto) with decorators
like `@IsString`(), `@IsNotEmpty`() and `@MaxLength`(...) for ipnsName, update the
controller method signature to accept `@Query`() query: ListInvitesQueryDto and
read ipnsName from query.ipnsName, and ensure the DTO type is exported/used so
global validation will reject empty/oversized values before reaching
listInvites.
apps/web/src/routes/InvitePage.tsx (2)

104-112: ⚠️ Potential issue | 🟡 Minor

Clear the redirect timer on unmount.
The setTimeout can fire after unmount and navigate unexpectedly. Track and clear it in a cleanup effect.

🧹 Suggested fix
+  const redirectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  useEffect(() => {
+    return () => {
+      if (redirectTimerRef.current) clearTimeout(redirectTimerRef.current);
+    };
+  }, []);
...
-        setTimeout(() => {
+        redirectTimerRef.current = setTimeout(() => {
           navigate('/shared', { replace: true });
         }, 500);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/InvitePage.tsx` around lines 104 - 112, The setTimeout
started after claimInvite can fire after the component unmounts and cause an
unexpected navigate; store the timeout id (e.g., in a ref like redirectTimerRef)
when calling setTimeout in the claimInvite.then block and clear it on unmount
(and when starting a new timer) in a useEffect cleanup so navigate('/shared', {
replace: true }) won't run after unmount; update references to
ephemeralKeyRef.current = null and the new redirectTimerRef to ensure the timer
is cleared appropriately.

68-87: ⚠️ Potential issue | 🟠 Major

Handle invite status lookup failures to avoid infinite loading.
A rejected checkInviteStatus leaves pageState stuck at "loading" and produces an unhandled rejection. Add a catch that respects the cancelled flag and sets an error state.

🔧 Suggested fix
-    checkInviteStatus(token).then((status) => {
-      if (cancelled) return;
-
-      if (status === 'active') {
-        setPageState('valid');
-      } else {
-        setErrorReason(status as ErrorReason);
-        setPageState('error');
-      }
-    });
+    checkInviteStatus(token)
+      .then((status) => {
+        if (cancelled) return;
+        if (status === 'active') {
+          setPageState('valid');
+        } else {
+          setErrorReason(status as ErrorReason);
+          setPageState('error');
+        }
+      })
+      .catch(() => {
+        if (cancelled) return;
+        setErrorReason('invalid');
+        setPageState('error');
+      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/InvitePage.tsx` around lines 68 - 87, The useEffect
calling checkInviteStatus(token) lacks a .catch, causing unhandled rejections
and the pageState to remain "loading"; add a .catch handler on the promise
returned by checkInviteStatus that first returns early if the local cancelled
flag is true, then sets setErrorReason to an appropriate ErrorReason (e.g.,
'invalid' or 'unknown') and calls setPageState('error'), and optionally logs the
error; ensure this catch lives alongside the existing .then so rejected lookups
are handled and the cancelled gate is respected.
apps/web/src/services/invite.service.ts (1)

279-283: ⚠️ Potential issue | 🟠 Major

Remove the unsafe cast on the claim response by fixing the OpenAPI typing.
invitesControllerClaimInvite is typed as void, so this cast can hide backend/client mismatches. Add a response DTO to the claim endpoint and regenerate the client to get a real { shareId } type.

#!/bin/bash
# Verify claim invite response typing in backend controllers/OpenAPI.
rg -n "claimInvite" apps/api/src -C 3
rg -n "@ApiResponse" apps/api/src -C 3 | rg -i "claim"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/invite.service.ts` around lines 279 - 283,
invitesControllerClaimInvite is currently typed as void, so remove the unsafe
cast by adding a proper response DTO for the claim endpoint in the backend
OpenAPI (e.g., a ClaimInviteResponse with shareId), annotate the controller
method with the new `@ApiResponse` referencing that DTO, regenerate the TypeScript
client so invitesControllerClaimInvite returns the correct { shareId: string }
type, and then update the call site (where result is assigned) to use the typed
response instead of casting.
🧹 Nitpick comments (9)
apps/api/src/shares/dto/claim-invite.dto.ts (1)

23-28: Add @IsUUID() to itemId for format validation.

The field is documented as "UUID of the file or subfolder" but only @MinLength(1) is applied — any non-empty string passes validation. Adding @IsUUID() provides defense-in-depth input validation that matches the documented semantics and rejects structurally invalid item IDs before they reach the service layer.

♻️ Proposed fix
-import {
-  IsString,
-  IsIn,
-  IsArray,
-  ValidateNested,
-  IsOptional,
-  Matches,
-  MaxLength,
-  MinLength,
-} from 'class-validator';
+import {
+  IsString,
+  IsIn,
+  IsArray,
+  IsUUID,
+  ValidateNested,
+  IsOptional,
+  Matches,
+  MaxLength,
+  MinLength,
+} from 'class-validator';
   `@IsString`()
-  `@MinLength`(1)
+  `@IsUUID`()
   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/claim-invite.dto.ts` around lines 23 - 28, The itemId
property in ClaimInviteDto is documented as a UUID but currently only has
`@IsString`() and `@MinLength`(1); add the `@IsUUID`() class-validator decorator to
the itemId field to enforce UUID format, and import IsUUID from
'class-validator' at the top of the file (keep existing `@IsString/`@MinLength if
desired or remove `@MinLength` if redundant). Target the itemId field in the
ClaimInviteDto (itemId!: string) when applying this change.
apps/api/src/shares/dto/invite-response.dto.ts (1)

60-68: Extract encryptedChildKeys element shape to a named DTO class.

The NestJS Swagger CLI plugin does not reliably introspect inline anonymous object types inside generics. Without an explicit type: () => [ChildKeyResponseDto] hint, the generated OpenAPI schema will render each array element as a plain object, producing an imprecise client contract. Extracting to a named class (or reusing a shared one) is the safe path.

♻️ Proposed refactor
+export class ChildKeyResponseDto {
+  `@ApiProperty`({ enum: ['file', 'folder'] })
+  keyType!: 'file' | 'folder';
+
+  `@ApiProperty`()
+  itemId!: string;
+
+  `@ApiProperty`({ description: 'Hex-encoded ECIES ciphertext' })
+  encryptedKey!: string;
+}

 export class InviteDataResponseDto {
   // ...

   `@ApiProperty`({
     description: 'Array of child keys wrapped with ephemeral public key, or null',
     nullable: true,
+    type: () => [ChildKeyResponseDto],
   })
-  encryptedChildKeys!: Array<{
-    keyType: 'file' | 'folder';
-    itemId: string;
-    encryptedKey: string;
-  }> | null;
+  encryptedChildKeys!: ChildKeyResponseDto[] | null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/invite-response.dto.ts` around lines 60 - 68, Extract
the inline anonymous element shape into a named DTO (e.g., ChildKeyResponseDto)
and update the encryptedChildKeys property to use that DTO type; create a class
ChildKeyResponseDto with properties keyType: 'file' | 'folder', itemId: string,
encryptedKey: string, then change encryptedChildKeys in InviteResponseDto to
type ChildKeyResponseDto[] | null and annotate it with `@ApiProperty`({ type: ()
=> [ChildKeyResponseDto], description: 'Array of child keys wrapped with
ephemeral public key, or null', nullable: true }) so Swagger correctly generates
the array element schema.
apps/api/src/shares/dto/create-invite.dto.ts (1)

14-14: InviteChildKeyDto is not exported, forcing duplication.

The identical { keyType, itemId, encryptedKey } shape is duplicated as an inline anonymous type in InviteDataResponseDto.encryptedChildKeys. Exporting this class lets the response DTO reference it directly and aligns with the named InviteChildKeyDto that Orval already generates on the frontend.

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

In `@apps/api/src/shares/dto/create-invite.dto.ts` at line 14, The
InviteChildKeyDto class is declared but not exported, causing duplication of the
{ keyType, itemId, encryptedKey } shape; export InviteChildKeyDto and update
InviteDataResponseDto to reference InviteChildKeyDto for its encryptedChildKeys
property instead of the inline anonymous type so the backend matches the named
DTO Orval generates on the frontend (ensure the class is exported and any
existing inline type is removed or replaced).
apps/api/src/shares/entities/share-invite.entity.ts (1)

58-65: claimedBy only stores one user ID, but maxClaims allows more than 1.

If maxClaims is ever set to > 1, only the last claimer's ID would be recorded in claimedBy, losing traceability for earlier claims. If multi-claim invites are intended for future use, consider a join table or an array column. If single-claim is the only supported scenario, consider dropping maxClaims/claimCount and using a simple boolean, or adding a comment documenting that maxClaims > 1 is unsupported.

This isn't blocking since the default is 1 and the current service code enforces single-claim, but it's worth clarifying intent.

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

In `@apps/api/src/shares/entities/share-invite.entity.ts` around lines 58 - 65,
The ShareInvite entity currently has maxClaims and claimCount but claimedBy
stores only one UUID, which will lose earlier claimer IDs if maxClaims > 1;
update the model to make intent explicit: either (A) support multi-claim by
replacing claimedBy with a relation/join table (e.g., ShareInvite.claims
many-to-one/many-to-many or a claims array column) and adjust claim handling to
push new claim entries and increment claimCount, or (B) enforce single-claim by
removing/maxClaims/claimCount or replacing them with a boolean (e.g., claimed)
and add a clear comment on claimedBy being single-use; update the ShareInvite
entity and corresponding service code that references claimedBy, maxClaims, and
claimCount to match the chosen approach (use symbols claimedBy, maxClaims,
claimCount, and the ShareInvite entity to locate changes).
tests/e2e/page-objects/dialogs/invite-link-tab.page.ts (1)

208-217: interceptClipboard accumulates closure chains when called repeatedly.

Each call captures the already-patched writeText as original, growing the call chain. Consider adding a restoreClipboard() counterpart or resetting the override per call:

♻️ Suggested fix
  async interceptClipboard(): Promise<void> {
    await this.page.evaluate(() => {
+     // Store the true original once; skip re-patching if already intercepted.
+     const win = window as unknown as Record<string, unknown>;
+     if (!win.__clipboardOriginal) {
+       win.__clipboardOriginal = navigator.clipboard.writeText.bind(navigator.clipboard);
+     }
+     const original = win.__clipboardOriginal as (text: string) => Promise<void>;
-     (window as unknown as Record<string, string>).__clipboardContent = '';
-     const original = navigator.clipboard.writeText.bind(navigator.clipboard);
+     win.__clipboardContent = '';
      navigator.clipboard.writeText = async (text: string) => {
-       (window as unknown as Record<string, string>).__clipboardContent = text;
+       (win as Record<string, string>).__clipboardContent = text;
        return original(text);
      };
    });
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/page-objects/dialogs/invite-link-tab.page.ts` around lines 208 -
217, The interceptClipboard function wraps navigator.clipboard.writeText each
time it's called, causing closure chains to accumulate; modify it to store the
original writeText once (e.g., save to window.__originalClipboardWriteText)
before overriding or add a restoreClipboard() that restores
navigator.clipboard.writeText from that saved original; ensure
interceptClipboard checks for an existing saved original and does not rewrap it
so repeated calls don’t grow the chain (referencing interceptClipboard and the
new restoreClipboard or window.__originalClipboardWriteText symbol).
apps/api/src/shares/share-invites.controller.spec.ts (1)

22-22: Replace as any casts with as unknown as Request to fix lint warnings.

mockReq is already narrowly typed on Line 22 but then widened back to any at every call site. The same pattern applies to sharer at Line 28. The static analysis rightly flags all seven occurrences.

♻️ Suggested fix
+import type { Request } from 'express';
+import type { User } from '../users/entities/user.entity'; // adjust import path
 ...
-  const mockReq: { user: { id: string } } = { user: { id: userId } };
+  const mockReq = { user: { id: userId } } as unknown as Request;
 ...
-    sharer: {} as any,
+    sharer: {} as unknown as User,
 ...
-      const result = await controller.createInvite(mockReq as any, dto);
+      const result = await controller.createInvite(mockReq, dto);
 // apply same de-casting to all other call sites (listInvites, revokeInvite)

Also applies to: 77-77, 100-100, 118-118, 135-135, 143-143, 154-154

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

In `@apps/api/src/shares/share-invites.controller.spec.ts` at line 22, Replace the
loose "as any" casts for the test request and user objects with a safe cast to
Express's Request by using "as unknown as Request": change the declaration of
mockReq ({ user: { id: userId } }) and the sharer variable to be cast via "as
unknown as Request" (and update each call site that currently does "as any") so
the seven occurrences referenced (roughly around mockReq and sharer uses) use
"as unknown as Request" instead of "as any"; ensure you import Request from
'express' if not already present and apply the same replacement at the other
listed occurrences (lines ~77, 100, 118, 135, 143, 154).
apps/api/src/shares/share-invites.controller.ts (1)

56-80: Inline return types diverge from the declared @ApiResponse DTO — use InviteResponseDto as the method return type.

Both createInvite and listInvites annotate their @ApiResponse with type: InviteResponseDto / type: [InviteResponseDto], but the TypeScript method signatures use anonymous inline types. Any future field added to InviteResponseDto won't surface a compile-time error if the method still returns the old shape.

♻️ Proposed refactor
-  async createInvite(
-    `@Request`() req: RequestWithUser,
-    `@Body`() dto: CreateInviteDto
-  ): Promise<{
-    id: string; token: string; itemType: string; ipnsName: string;
-    itemName: string; status: string; expiresAt: Date; createdAt: Date;
-  }> {
+  async createInvite(
+    `@Request`() req: RequestWithUser,
+    `@Body`() dto: CreateInviteDto
+  ): Promise<InviteResponseDto> {

Apply the same change to listInvites.

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

In `@apps/api/src/shares/share-invites.controller.ts` around lines 56 - 80, The
controller methods currently return anonymous inline types that diverge from the
`@ApiResponse` DTOs; change the createInvite signature to return InviteResponseDto
and listInvites to return InviteResponseDto[] (import InviteResponseDto if not
already imported), and ensure the returned objects from createInvite and
listInvites exactly match the fields on InviteResponseDto (add any missing
properties or map fields accordingly) so TypeScript will enforce future DTO
changes.
tests/e2e/tests/invite-link-workflow.spec.ts (2)

126-129: Prefer state-based waits over fixed timeouts.
waitForTimeout can be flaky across environments; consider waiting on breadcrumbs, folder rows, or the shared list to stabilize navigation.

Also applies to: 438-457

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

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 126 - 129, Replace
the fixed 500ms timeout in aliceNavigateBack with a state-based wait: after
calling alice.page.locator('[data-testid="parent-dir-row"]').dblclick() wait for
a deterministic UI signal such as the breadcrumb locator, the target folder row,
or the shared list locator to appear or become stable (e.g., await
alice.page.waitForSelector(...) or await locator.waitFor({ state: 'visible' }))
so the test does not rely on timing; apply the same replacement to the other
waitForTimeout usages referenced around the block covering lines 438-457 to wait
on the appropriate breadcrumb/folder/shared-list locators instead of using fixed
timeouts.

38-46: Guard parseInviteUrl for malformed URLs.
Add explicit validation so failures are clearer if the URL format changes.

🔎 Suggested fix
 function parseInviteUrl(url: string): { token: string; ephemeralKey: string } {
   // URL format: http://localhost:5173/#/invite/TOKEN?key=KEY
-  const hashPart = url.split('#')[1]; // /invite/TOKEN?key=KEY
+  const hashPart = url.split('#')[1]; // /invite/TOKEN?key=KEY
+  if (!hashPart) throw new Error('Invite URL missing hash fragment');
   const [path, query] = hashPart.split('?');
+  if (!query) throw new Error('Invite URL missing query string');
   const token = path.split('/invite/')[1];
   const params = new URLSearchParams(query);
-  const ephemeralKey = params.get('key')!;
+  const ephemeralKey = params.get('key');
+  if (!ephemeralKey) throw new Error('Invite URL missing key');
   return { token, ephemeralKey };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 38 - 46, The
parseInviteUrl function currently assumes a well-formed hash and query; add
explicit validation inside parseInviteUrl to guard against malformed URLs by
checking that the URL contains a '#' and a non-empty hashPart, that hashPart
splits into a path and query, that the path contains '/invite/' and yields a
non-empty token, and that URLSearchParams(query).get('key') returns a non-empty
ephemeralKey; if any check fails, throw a clear Error with a descriptive message
(e.g., "Invalid invite URL: missing hash", "Invalid invite URL: missing token",
or "Invalid invite URL: missing key") so callers can see exactly what part of
the URL was malformed.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1942b80 and 6b51066.

📒 Files selected for processing (66)
  • .planning/REQUIREMENTS.md
  • .planning/ROADMAP.md
  • .planning/STATE.md
  • .planning/phases/15-link-sharing/15-01-PLAN.md
  • .planning/phases/15-link-sharing/15-01-SUMMARY.md
  • .planning/phases/15-link-sharing/15-02-PLAN.md
  • .planning/phases/15-link-sharing/15-02-SUMMARY.md
  • .planning/phases/15-link-sharing/15-03-PLAN.md
  • .planning/phases/15-link-sharing/15-03-SUMMARY.md
  • .planning/phases/15-link-sharing/15-04-PLAN.md
  • .planning/phases/15-link-sharing/15-04-SUMMARY.md
  • .planning/phases/15-link-sharing/15-CONTEXT.md
  • .planning/phases/15-link-sharing/15-RESEARCH.md
  • .planning/phases/15-link-sharing/15-VERIFICATION.md
  • .planning/security/REVIEW-phase15-link-sharing.md
  • apps/api/scripts/generate-openapi.ts
  • apps/api/src/app.module.ts
  • apps/api/src/migrations/1740400000000-AddShareInvites.ts
  • apps/api/src/shares/dto/claim-invite.dto.ts
  • apps/api/src/shares/dto/create-invite.dto.ts
  • apps/api/src/shares/dto/invite-response.dto.ts
  • apps/api/src/shares/entities/index.ts
  • apps/api/src/shares/entities/share-invite.entity.ts
  • apps/api/src/shares/invites.controller.spec.ts
  • apps/api/src/shares/invites.controller.ts
  • apps/api/src/shares/share-invites.controller.spec.ts
  • apps/api/src/shares/share-invites.controller.ts
  • apps/api/src/shares/shares.module.ts
  • apps/api/src/shares/shares.service.spec.ts
  • apps/api/src/shares/shares.service.ts
  • apps/web/src/api/invites/invites.ts
  • apps/web/src/api/models/claimChildKeyDto.ts
  • apps/web/src/api/models/claimChildKeyDtoKeyType.ts
  • apps/web/src/api/models/claimInviteDto.ts
  • apps/web/src/api/models/createInviteDto.ts
  • apps/web/src/api/models/createInviteDtoItemType.ts
  • apps/web/src/api/models/index.ts
  • apps/web/src/api/models/inviteChildKeyDto.ts
  • apps/web/src/api/models/inviteChildKeyDtoKeyType.ts
  • apps/web/src/api/models/inviteDataResponseDto.ts
  • apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts
  • apps/web/src/api/models/inviteDataResponseDtoItemType.ts
  • apps/web/src/api/models/inviteDataResponseDtoStatus.ts
  • apps/web/src/api/models/inviteResponseDto.ts
  • apps/web/src/api/models/inviteResponseDtoItemType.ts
  • apps/web/src/api/models/inviteResponseDtoStatus.ts
  • apps/web/src/api/models/inviteStatusResponseDto.ts
  • apps/web/src/api/models/inviteStatusResponseDtoStatus.ts
  • apps/web/src/api/models/shareInvitesControllerListInvitesParams.ts
  • apps/web/src/api/share-invites/share-invites.ts
  • apps/web/src/components/file-browser/InviteLinkTab.tsx
  • apps/web/src/components/file-browser/ShareDialog.tsx
  • apps/web/src/lib/crypto/key-wrapping.ts
  • apps/web/src/routes/InvitePage.tsx
  • apps/web/src/routes/index.tsx
  • apps/web/src/services/invite.service.ts
  • apps/web/src/styles/invite-page.css
  • apps/web/src/styles/share-dialog.css
  • designs/DESIGN.md
  • designs/cipher-box-design.pen
  • packages/api-client/openapi.json
  • tests/e2e/page-objects/dialogs/index.ts
  • tests/e2e/page-objects/dialogs/invite-link-tab.page.ts
  • tests/e2e/page-objects/index.ts
  • tests/e2e/page-objects/pages/invite.page.ts
  • tests/e2e/tests/invite-link-workflow.spec.ts

Comment thread .planning/phases/15-link-sharing/15-VERIFICATION.md
Comment thread .planning/security/REVIEW-phase15-link-sharing.md
Comment thread apps/api/src/shares/dto/create-invite.dto.ts
Comment thread apps/api/src/shares/dto/create-invite.dto.ts
Comment thread apps/api/src/shares/dto/invite-response.dto.ts Outdated
Comment thread apps/web/src/components/file-browser/InviteLinkTab.tsx Outdated
Comment thread apps/web/src/components/file-browser/ShareDialog.tsx Outdated
Comment thread apps/web/src/services/invite.service.ts
Comment thread apps/web/src/styles/share-dialog.css
Comment thread tests/e2e/page-objects/dialogs/invite-link-tab.page.ts
FSM1 and others added 2 commits February 23, 2026 23:56
- Add FK constraint for claimed_by -> users(id) in share_invites migration
- Add unique: true to token column on ShareInvite entity
- Create ClaimInviteResponseDto and type the claim @apiresponse(201)
- Fix encryptedChildKeys @ApiProperty to use type: [InviteChildKeyDto]
- Export InviteChildKeyDto from create-invite.dto for reuse in response DTO
- Extract RequestWithUser to apps/api/src/common/types.ts (was duplicated 7x)
- Separate clipboard success tracking in InviteLinkTab (no false "copied" msg)
- Isolate invite list refresh error from creation success in InviteLinkTab
- Wrap folder key re-wrapping in try-catch in collectChildKeys (key-wrapping.ts)
- Strip ephemeral key from URL on mount in InvitePage
- Add .catch() to checkInviteStatus promise in InvitePage
- Validate ErrorReason before cast (fall back to 'invalid')
- Clean up redirect setTimeout on unmount via ref in InvitePage
- Remove unsafe cast in invite.service.ts (now properly typed by Orval)
- Regenerate API client with corrected OpenAPI spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 72568cd8adeb
Security:
- Collapse public invite status endpoint to prevent token-existence oracle
  (returns 404 for all non-active states instead of leaking claimed/revoked)
- Wrap claimInvite in DB transaction to prevent "claimed-but-no-share" state
- Add @IsUUID() validation on itemId in both create and claim DTOs
- Increase @minlength to 258 on encryptedKey (minimum ECIES secp256k1 ciphertext)

Accessibility:
- Wire ARIA attributes on ShareDialog tab widget (id, aria-controls,
  aria-labelledby, roving tabIndex, ArrowLeft/ArrowRight keyboard nav)

UX:
- Show error to user when invite list fetch fails (was console-only)
- Use var(--font-size-xs) instead of hardcoded 10px on invite-status-badge
- Add .share-error selector to e2e waitForLoaded for fast failure on API errors

Tests:
- Update invites.controller.spec to match collapsed status endpoint behavior
- Add test for revoked status returning NotFoundException
- Add mock DataSource for transactional claimInvite in shares.service.spec
- Add encryptedChildKeys exposure check in share-invites.controller.spec

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

@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: 6

Caution

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

⚠️ Outside diff range comments (1)
.planning/REQUIREMENTS.md (1)

427-427: ⚠️ Potential issue | 🟡 Minor

Stale Last updated timestamp.

The footer still reads 2026-02-11 after Milestone 3 roadmap creation; it should be bumped to reflect this Phase 15 status update.

📝 Proposed fix
-Last updated: 2026-02-11 after Milestone 3 roadmap creation (M3 phase mappings added)
+Last updated: 2026-02-23 after Phase 15 completion (SHARE-06/07 marked Complete)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/REQUIREMENTS.md at line 427, Update the stale footer string "Last
updated: 2026-02-11 after Milestone 3 roadmap creation (M3 phase mappings
added)" in .planning/REQUIREMENTS.md to the current Phase 15 update date and
wording (e.g., "Last updated: 2026-02-23 after Phase 15 status update") so the
document reflects the new status; locate the exact footer line containing that
original phrase and replace the date and parenthetical note accordingly.
♻️ Duplicate comments (17)
apps/api/src/shares/share-invites.controller.spec.ts (1)

140-147: ⚠️ Potential issue | 🟡 Minor

Add encryptedChildKeys to the “no internal fields” list assertion.

This mirrors the createInvite test and ensures list responses never expose child key data.

🔧 Suggested fix
 expect('encryptedKey' in result[0]).toBe(false);
 expect('sharerId' in result[0]).toBe(false);
+expect('encryptedChildKeys' in result[0]).toBe(false);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/share-invites.controller.spec.ts` around lines 140 - 147,
The test in share-invites.controller.spec.ts should also assert that list
responses do not include child key data: after calling
mockSharesService.getInvitesForItem and awaiting controller.listInvites (the
existing test using mockReq and item id), add an assertion that
'encryptedChildKeys' is not present on result[0] (similar to the existing checks
for 'encryptedKey' and 'sharerId'); update the test that uses
mockSharesService.getInvitesForItem and the result variable to include
expect('encryptedChildKeys' in result[0]).toBe(false).
apps/web/src/styles/share-dialog.css (1)

430-435: Use a design-token font size for the status badge.

This still hard-codes 10px and diverges from the tokenized sizing used elsewhere in the file.

♻️ Suggested tweak
 .invite-status-badge {
   flex-shrink: 0;
   padding: 1px 6px;
   font-family: var(--font-family-mono);
-  font-size: 10px;
+  font-size: var(--font-size-xs);
   font-weight: var(--font-weight-semibold);
   letter-spacing: 0.05em;
   color: var(--color-green-primary);
   border: var(--border-thickness) solid var(--color-green-dim);
 }
🤖 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 430 - 435, The
.invite-status-badge rule currently hard-codes font-size: 10px; replace that
literal with the project's design token (e.g., font-size: var(--font-size-xs) or
the appropriate token used elsewhere in this file) so the badge follows
tokenized sizing; update the font-size declaration in the .invite-status-badge
CSS rule to use the matching --font-size-* token used by other selectors in this
stylesheet.
apps/api/src/shares/dto/create-invite.dto.ts (2)

23-28: ⚠️ Potential issue | 🟡 Minor

Validate itemId as a UUID.

@IsString() + @MinLength(1) accepts non-UUIDs, pushing malformed IDs into DB queries.

✅ Suggested fix
 import {
   IsString,
   IsIn,
   IsArray,
   ValidateNested,
   IsOptional,
   Matches,
   MaxLength,
   MinLength,
+  IsUUID,
 } from 'class-validator';

   `@IsString`()
+  `@IsUUID`()
   `@MinLength`(1)
   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/create-invite.dto.ts` around lines 23 - 28, The
itemId property on the CreateInviteDto is currently validated only as a
non-empty string and must be validated as a UUID to avoid malformed IDs; update
the CreateInviteDto's itemId validators to use `@IsUUID`() (and remove or replace
`@IsString`()/@MinLength(1) as appropriate) and add the necessary import for
IsUUID from class-validator so incoming itemId values are enforced as valid
UUIDs.

30-71: ⚠️ Potential issue | 🟡 Minor

Increase encryptedKey MinLength to a realistic ECIES payload size.

@MinLength(2) allows garbage ciphertexts; downstream unwrap will fail. Please constrain to the minimum ECIES secp256k1 AES‑256‑GCM payload length.

🔐 Suggested fix (both encryptedKey fields)
-  `@MinLength`(2)
+  `@MinLength`(258) // minimum for ECIES secp256k1 + AES-256-GCM wrapping a 32-byte key
   `@MaxLength`(2048)
   encryptedKey!: string;
What is the minimum ciphertext length (in hex characters) produced by eciesjs secp256k1 AES-256-GCM when encrypting a 32-byte payload with an uncompressed public key?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/create-invite.dto.ts` around lines 30 - 71, The
encryptedKey MinLength is too low (currently 2) allowing invalid ciphertexts;
update the `@MinLength` on both encryptedKey properties (the one above and the
encryptedKey in class CreateInviteDto) to the realistic minimum for ECIES
secp256k1 AES‑256‑GCM with an uncompressed public key: 250 hex characters (125
bytes * 2), i.e., set `@MinLength`(250) for those encryptedKey fields so
validators reject undersized/garbage payloads.
apps/web/src/components/file-browser/ShareDialog.tsx (1)

351-490: ⚠️ Potential issue | 🟡 Minor

Complete the ARIA tab pattern + keyboard handling for the tab bar (Line 351+).

role="tab" without ids, aria-controls, roving tabIndex, and arrow-key handling leaves the tabset inaccessible; both tabpanels also need aria-labelledby.

♿ Suggested wiring
-<div className="share-tab-bar" role="tablist">
+<div
+  className="share-tab-bar"
+  role="tablist"
+  aria-label="Share method"
+  onKeyDown={(e) => {
+    if (e.key === 'ArrowRight') setActiveTab('invite');
+    if (e.key === 'ArrowLeft') setActiveTab('direct');
+  }}
+>
   <button
     type="button"
+    id="share-tab-direct"
     role="tab"
     aria-selected={activeTab === 'direct'}
+    aria-controls="share-panel-direct"
+    tabIndex={activeTab === 'direct' ? 0 : -1}
     className={`share-tab${activeTab === 'direct' ? ' share-tab--active' : ''}`}
     onClick={() => setActiveTab('direct')}
   >
     {'DIRECT SHARE'}
   </button>
   <button
     type="button"
+    id="share-tab-invite"
     role="tab"
     aria-selected={activeTab === 'invite'}
+    aria-controls="share-panel-invite"
+    tabIndex={activeTab === 'invite' ? 0 : -1}
     className={`share-tab${activeTab === 'invite' ? ' share-tab--active' : ''}`}
     onClick={() => setActiveTab('invite')}
   >
     {'INVITE LINK'}
   </button>
 </div>

 {activeTab === 'direct' && (
-  <div role="tabpanel">
+  <div id="share-panel-direct" role="tabpanel" aria-labelledby="share-tab-direct">

Also ensure InviteLinkTab’s root panel uses id="share-panel-invite" and aria-labelledby="share-tab-invite" (or pass props through) so both panels are correctly associated.

As per coding guidelines, “When adding role and tabIndex={0} to an element, must also add keyboard interaction contract for that role.”

🤖 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 351 - 490,
The tab bar in ShareDialog uses role="tab" but lacks ids, aria-controls, roving
tabIndex and arrow-key handling, and the panels lack aria-labelledby—make the
tab buttons fully accessible by assigning unique ids (e.g., "share-tab-direct"
and "share-tab-invite"), add matching aria-controls pointing to panel ids
("share-panel-direct"/"share-panel-invite"), implement roving focus/tabIndex
logic and left/right/up/down arrow key handling in a new handler (e.g.,
handleTabKeyDown) used on the tab buttons to call setActiveTab and move focus,
and ensure each tabpanel (including InviteLinkTab) has id and aria-labelledby
referencing its tab id (or pass those props into InviteLinkTab so its root uses
id="share-panel-invite" and aria-labelledby="share-tab-invite"); update any
aria-selected usage to reflect the activeTab variable.
apps/web/src/components/file-browser/InviteLinkTab.tsx (1)

48-58: ⚠️ Potential issue | 🟡 Minor

Surface invite-fetch errors instead of showing a misleading empty state.

If the fetch fails, users see “no active invite links.” Set an error so the existing alert renders.

🛠️ Suggested fix
   .catch((err) => {
     if (!cancelled) {
       console.error('Failed to fetch invites:', err);
+      setError('failed to load invite links');
     }
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx` around lines 48 - 58,
When fetchInvitesForItem(ipnsName) fails, the catch currently only logs the
error which leads the UI to show "no active invite links"; update the catch to
set an error state so the existing error alert renders: inside the .catch((err)
=> { if (!cancelled) { ... } }) call set the component's invite-fetch error
state (use the existing error setter if present, otherwise add one like
setInvitesError) with the error or a message, and ensure you still keep
console.error; keep the cancelled check and do not modify the successful .then
branch that calls setInvites.
designs/cipher-box-design.pen (4)

39304-39312: Unresolved past-review issue: TBokI subtext contrast.

#006644 on #000000 yields ≈ 3.36:1, which fails the 4.5:1 AA minimum for normal text at 11px. This element was flagged in the previous review and remains unaddressed.

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

In `@designs/cipher-box-design.pen` around lines 39304 - 39312, The subtext
element with id "TBokI" currently uses fill "#006644" on a "#000000" background
and fails WCAG AA contrast for 11px text (≈3.36:1); update the element (id
"TBokI", name "subtext") to use a darker color value that achieves at least
4.5:1 contrast against black (or alternatively increase the font size/weight to
meet 4.5:1), e.g., replace the fill with a darker hex that meets the ratio and
keep fontFamily "JetBrains Mono" and fontSize 11 unless you choose the
size/weight change. Ensure the changed color/typography is applied to the
"subtext" element so it passes automated contrast checks.

39514-39524: Unresolved past-review issues in expired-state subtext: contrast + hardcoded TTL copy.

hSiQp (errSubtext) carries two problems already raised in the previous review:

  1. #006644 at 11 px on black — same contrast failure (~3.36:1) as TBokI.
  2. "Invite links are valid for 7 days." — the previous review explicitly called out this expired-state copy as a hardcoded TTL that may diverge from the backend contract.

Both remain unresolved.

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

In `@designs/cipher-box-design.pen` around lines 39514 - 39524, The errSubtext
element (id: hSiQp / name: errSubtext) has two outstanding issues: the color
`#006644` at fontSize 11 fails contrast on a dark background, and the copy
contains a hardcoded TTL string ("Invite links are valid for 7 days.") that can
drift from backend values; fix by choosing a higher-contrast color or increasing
size/weight so WCAG AA contrast is met for 11px text (or switch to a vetted
token from the design palette) and replace the literal TTL sentence with a
dynamic placeholder/variable (e.g., use a backend-driven {ttlText} or
localization key) so the UI displays the canonical TTL from the backend rather
than a hardcoded value.

40956-40964: Unresolved past-review issue: hardcoded "// expires in 7 days, single-claim" in gzqpC.

This node was specifically flagged in the previous review cycle. The policy copy remains hardcoded and has not been updated to either reflect the confirmed backend TTL/claim contract or replaced with a generic/variable-driven string.

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

In `@designs/cipher-box-design.pen` around lines 40956 - 40964, The node gzqpC
(name: policyNote) contains a hardcoded policy string "// expires in 7 days,
single-claim"; replace this hardcoded copy with a variable-driven or templated
value sourced from the canonical backend TTL/claim contract (e.g., use a
policyNoteText variable or placeholder that is populated at build/runtime from
the backend config or localization file) so the UI reflects the confirmed
TTL/claim terms; update any code that renders gzqpC to read that variable
instead of the literal string and ensure the variable name matches the backend
field used for TTL/claim text.

39673-39678: Unresolved past-review issue: #006644 on black recurs pervasively across the new Share Dialog frame.

The previous review asked to fix all occurrences of #006644 at small sizes. The new "P15 - Share Dialog (Invite Tab)" frame introduces at least a dozen additional instances at fontSize 9–11 (e.g. userEmail i074c, breadcrumb EAQ4X/EbdH7, column headers VeXI1/c3Y0s/pV4PG, file row metadata cells, sidebar settingsText/quotaText, footer links, statusText, tabDirectText, desc, linksLabel). None of these would pass the 4.5:1 AA threshold at their current sizes.

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

In `@designs/cipher-box-design.pen` around lines 39673 - 39678, Replace all
occurrences of the color "#006644" in the "P15 - Share Dialog (Invite Tab)"
frame with an accessible color that meets WCAG AA 4.5:1 contrast for the small
text sizes used (fontSize 9–11), or alternatively increase the text weight/size
where appropriate; specifically update instances referenced as userEmail /
i074c, breadcrumb EAQ4X and EbdH7, column header layers VeXI1, c3Y0s, pV4PG,
file row metadata cells, sidebar labels settingsText and quotaText, footer
links, statusText, tabDirectText, desc, and linksLabel so that each element's
computed contrast ratio against its background meets 4.5:1. Ensure the
replacement color is applied to the "fill" property for those text layers (e.g.,
where "fill": "#006644" appears) and verify visually at the target font sizes.
.planning/phases/15-link-sharing/15-VERIFICATION.md (1)

6-20: Verify whether this gap is still valid — InviteResponseDto now includes id field.

The DTO in the current code (invite-response.dto.ts, line 8–9) includes the id field. However, the frontend fetchInvitesForItem mapping in invite.service.ts (line 321) may still be mapping inv.token as id. If the frontend mapping was also updated, this gap section should be marked as resolved.

#!/bin/bash
# Check if invite.service.ts still maps token as id
rg -n 'id:\s*inv\.token' --type=ts
# Also check for the correct mapping using inv.id
rg -n 'id:\s*inv\.id' --type=ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/phases/15-link-sharing/15-VERIFICATION.md around lines 6 - 20, The
frontend still maps invite.token to the visible id causing revoke to send a
base64 token to the DELETE endpoint which expects a UUID; update
fetchInvitesForItem in apps/web/src/services/invite.service.ts to use the real
UUID field (inv.id) returned by InviteResponseDto (or adjust the backend list
response to include id and regenerate the API client), and ensure revokeInvite
(share-invites.controller's DELETE handler) receives that UUID value rather than
the token so ParseUUIDPipe succeeds.
apps/api/src/shares/dto/invite-response.dto.ts (1)

26-30: Add explicit format: 'date-time' to @ApiProperty on Date fields.

Without { type: 'string', format: 'date-time' }, the OpenAPI spec relies on the Swagger CLI plugin to infer the correct type. Making it explicit avoids ambiguity.

Proposed fix
-  `@ApiProperty`({ description: 'When the invite expires' })
+  `@ApiProperty`({ description: 'When the invite expires', type: 'string', format: 'date-time' })
   expiresAt!: Date;

-  `@ApiProperty`({ description: 'When the invite was created' })
+  `@ApiProperty`({ description: 'When the invite was created', type: 'string', format: 'date-time' })
   createdAt!: Date;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/dto/invite-response.dto.ts` around lines 26 - 30, Update
the `@ApiProperty` decorators for the Date fields in InviteResponseDto: change the
decorators on the expiresAt and createdAt properties (expiresAt, createdAt) to
explicitly specify the OpenAPI type and format by adding { type: 'string',
format: 'date-time' } so the generated spec marks these Date fields as date-time
strings rather than relying on inference.
tests/e2e/page-objects/dialogs/invite-link-tab.page.ts (1)

151-157: waitForLoaded still doesn't handle the API error state — will time out instead of failing fast.

If the API call fails and the component renders .share-error, neither .invite-link-item nor .share-recipients-empty matches, causing a 10s timeout before the test fails with an unhelpful error.

Proposed fix
  async waitForLoaded(): Promise<void> {
-   await this.page
-     .locator('.invite-link-item, .invite-link-tab .share-recipients-empty')
-     .first()
-     .waitFor({ state: 'visible', timeout: 10000 });
+   await this.page
+     .locator(
+       '.invite-link-item, .invite-link-tab .share-recipients-empty, .invite-link-tab .share-error'
+     )
+     .first()
+     .waitFor({ state: 'visible', timeout: 10000 });
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/page-objects/dialogs/invite-link-tab.page.ts` around lines 151 -
157, The waitForLoaded method currently waits only for '.invite-link-item' or
'.share-recipients-empty' and will timeout if the API error state renders
'.share-error'; update waitForLoaded to wait for any of the three selectors
('.invite-link-item', '.invite-link-tab .share-recipients-empty',
'.share-error') and immediately fail-fast when '.share-error' becomes visible by
throwing a clear error (reference the waitForLoaded method and the
'.share-error' selector so the test fails with a helpful message instead of
timing out).
apps/api/src/shares/shares.service.ts (1)

403-504: ⚠️ Potential issue | 🟠 Major

Make invite claim + share creation transactional.

The invite is marked claimed before Share/ShareKey writes. If those writes fail, the invite stays claimed and the recipient can’t retry. Wrap the UPDATE and share creation in a single transaction (or reorder with a conditional update after successful writes).

🤖 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 403 - 504, The current
claimInvite flow marks the ShareInvite as claimed via
inviteRepo.createQueryBuilder().update(...) before creating Share/ShareKey, so
failures leave the invite consumed; wrap the UPDATE and the subsequent
Share/ShareKey creation (use of inviteRepo, shareRepo, shareKeyRepo and
operations create/save/remove) in a single database transaction (or perform the
UPDATE as the final step inside the same transaction) so the invite status and
the created Share/ShareKey are committed atomically and rolled back together on
error; use your ORM transaction manager/QueryRunner to beginTransaction(), run
the SELECT/UPDATE, create/save Share and ShareKey records, then commit or
rollback on failure.
apps/web/src/services/invite.service.ts (1)

12-67: ⚠️ Potential issue | 🟠 Major

Replace @noble/secp256k1 with an approved ECIES/ECDSA library.

Frontend crypto guidelines restrict ECIES/ECDSA operations to ethers.js or libsodium.js. Please switch keypair generation to one of those libraries.

🔧 Example using ethers.js
-import * as secp256k1 from '@noble/secp256k1';
+import { Wallet, getBytes } from 'ethers';
...
 function generateEphemeralKeypair(): {
   privateKey: Uint8Array;
   publicKey: Uint8Array;
   privateKeyHex: string;
 } {
-  const keypair = secp256k1.keygen();
-  return {
-    privateKey: keypair.secretKey,
-    publicKey: secp256k1.getPublicKey(keypair.secretKey, false), // uncompressed 65-byte key for ECIES
-    privateKeyHex: bytesToHex(keypair.secretKey),
-  };
+  const wallet = Wallet.createRandom();
+  const privateKey = getBytes(wallet.privateKey); // 32 bytes
+  const publicKey = getBytes(wallet.publicKey);   // uncompressed 65 bytes
+  return {
+    privateKey,
+    publicKey,
+    privateKeyHex: wallet.privateKey.replace(/^0x/, ''),
+  };
 }
#!/bin/bash
# Verify which crypto libs are available and where `@noble/secp256k1` is used.
rg -n "@noble/secp256k1|ethers|libsodium" apps/web/src/services/invite.service.ts apps/web/package.json

As per coding guidelines: Use Web Crypto API or libsodium.js for implementing AES-256-GCM, HKDF-SHA256 key derivation - for ECIES secp256k1 and ECDSA use ethers.js or libsodium.js; never implement custom cryptographic functions.

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

In `@apps/web/src/services/invite.service.ts` around lines 12 - 67, The
generateEphemeralKeypair function currently imports and uses `@noble/secp256k1`;
replace that with an approved library (ethers.js or libsodium.js) per frontend
crypto guidelines. Update the import (remove `@noble/secp256k1`) and re-implement
generateEphemeralKeypair to produce the same outputs (privateKey: Uint8Array,
publicKey: Uint8Array as uncompressed 65-byte EC public key, privateKeyHex
string) using ethers.js (e.g., Wallet.createRandom() for a private key and
utils.computePublicKey to derive the uncompressed public key) or libsodium.js
equivalent; keep bytesToHex for privateKeyHex and ensure any downstream callers
of generateEphemeralKeypair and its returned shapes remain compatible. Also
remove/replace any other uses of secp256k1 in this file (search for secp256k1)
so the module imports only approved crypto libs.
packages/api-client/openapi.json (2)

1587-1641: Claim endpoint response schema now properly defined.

The ClaimInviteResponseDto schema (lines 2963–2972) correctly describes the { shareId: string } response body, resolving the previously flagged issue about the missing response schema.

🤖 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 1587 - 1641, The OpenAPI spec
for the POST /invites/{token}/claim (operationId InvitesController_claimInvite)
must return the defined ClaimInviteResponseDto for the 201 response; update the
operation's responses so the 201 content schema references
ClaimInviteResponseDto (which describes { shareId: string }) and ensure the
summary/tags remain accurate; verify the requestBody still references
ClaimInviteDto and that security includes bearer as shown.

2885-2926: InviteDataResponseDto.encryptedChildKeys correctly typed as array.

The field is now "type": "array" with "items": { "$ref": "...InviteChildKeyDto" } (lines 2900–2903), resolving the previously flagged issue where it was incorrectly generated as "type": "object".

🤖 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 2885 - 2926,
InviteDataResponseDto previously had encryptedChildKeys typed as "object" but
the diff fixes it to "type": "array" with "items": { "$ref":
"#/components/schemas/InviteChildKeyDto" }; confirm and keep this change by
ensuring InviteDataResponseDto.encryptedChildKeys is an array with the correct
items schema (InviteChildKeyDto) and nullable: true remains if null is allowed,
and verify the property remains listed in the required array.
🧹 Nitpick comments (9)
apps/web/src/styles/invite-page.css (2)

123-135: .invite-card__claiming and .invite-card__success are identical rule sets.

These two classes share the exact same declarations. If the intent is for them to diverge later, this is fine as-is; otherwise they could be combined into a single rule with a comma-separated selector to reduce duplication.

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

In `@apps/web/src/styles/invite-page.css` around lines 123 - 135, The CSS contains
duplicated rule sets for .invite-card__claiming and .invite-card__success;
combine them into a single rule using a comma-separated selector
(.invite-card__claiming, .invite-card__success) to remove duplication (or keep
separate only if you plan differing styles later), ensuring the shared
declarations (font-family, font-size, color, text-align) are defined once.

39-41: Hardcoded error color #ef4444 repeated in three places — consider a CSS variable.

The color #ef4444 is used for .invite-card--error, .invite-card__error, and .invite-card__login-error. Extracting it into a CSS custom property (e.g., --color-error) would improve maintainability and keep it consistent with the rest of the design token pattern used throughout this file.

Also applies to: 141-147, 186-191

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

In `@apps/web/src/styles/invite-page.css` around lines 39 - 41, Extract the
hardcoded error color into a CSS custom property and use it in the three
selectors; define something like --color-error: `#ef4444` (e.g., in :root or the
.invite-card root) and replace the literal color in .invite-card--error,
.invite-card__error, and .invite-card__login-error with var(--color-error) so
all three use the same token and are easy to update.
apps/api/src/shares/invites.controller.spec.ts (2)

131-140: Static analysis: as any casts on mockReq.

Lines 136 and 149 cast mockReq as any to satisfy the controller parameter type. Since mockReq is already typed as { user: { id: string } }, you could type it as RequestWithUser (importing from ../common/types) to eliminate the as any casts and satisfy the linter.

Proposed improvement
+import { RequestWithUser } from '../common/types';
 ...
-const mockReq: { user: { id: string } } = { user: { id: userId } };
+const mockReq = { user: { id: userId } } as RequestWithUser;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/invites.controller.spec.ts` around lines 131 - 140, The
test uses an unsafe cast "mockReq as any" to call controller.claimInvite;
replace this by typing mockReq as RequestWithUser (import RequestWithUser from
../common/types) so the request matches the controller signature without any
casts, update the mockReq declaration to use RequestWithUser and keep using the
same mock shape ({ user: { id: userId } }) so controller.claimInvite and the
expectations (mockSharesService.claimInvite) compile cleanly without as any.

27-27: Static analysis: as any for mock entity relation.

The sharer: {} as any is flagged by the linter. In test files this is a common pattern for mocking entity relations, but you could define a minimal type or use a type assertion to a partial entity instead.

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

In `@apps/api/src/shares/invites.controller.spec.ts` at line 27, The test uses a
broad cast "sharer: {} as any" which the linter flags; replace it with a
minimally typed mock for the relation instead: declare a typed partial mock for
the "sharer" (e.g. const sharer: Partial<YourEntityType> = { id: 'mock-id',
email: 'mock@example.com' } or similar minimal properties used by the test) and
use that in place of "{} as any" (reference the "sharer" variable in
invites.controller.spec.ts and cast only if necessary to the exact entity type
rather than using any).
.planning/phases/15-link-sharing/15-RESEARCH.md (1)

60-61: Add a language to the fenced code block (MD040).

markdownlint flags the project-structure block missing a language identifier.

✍️ Suggested fix
-```
+```text
 apps/api/src/shares/
   entities/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/phases/15-link-sharing/15-RESEARCH.md around lines 60 - 61, The
fenced code block that lists the project structure (the block containing
"apps/api/src/shares/" and "entities/") is missing a language identifier
(MD040); open the code block's opening triple backticks and add a language token
(e.g., "text") so the block starts with ```text, keeping the inner content
unchanged to satisfy markdownlint.
tests/e2e/tests/invite-link-workflow.spec.ts (3)

38-47: parseInviteUrl — non-null assertion on ephemeralKey will throw opaque error on malformed URLs.

If the URL doesn't contain a ?key= param, params.get('key') returns null and the ! assertion silently produces null at runtime (TypeScript assertions are erased). The test would then pass a null ephemeral key, causing a confusing downstream failure.

Since this is test code, this is low-risk — a malformed URL means the test is already broken. But a guard would improve debuggability.

Optional improvement
  const params = new URLSearchParams(query);
- const ephemeralKey = params.get('key')!;
+ const ephemeralKey = params.get('key');
+ if (!ephemeralKey) throw new Error(`No 'key' param in invite URL: ${url}`);
  return { token, ephemeralKey };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 38 - 47, The
parseInviteUrl function uses a non-null assertion on params.get('key') which can
yield a null ephemeralKey for malformed URLs; update parseInviteUrl to validate
inputs (ensure hashPart exists, token parsed from path is present, and
params.get('key') !== null) and throw a clear, descriptive Error (including the
original url) when any piece is missing so tests fail fast and with a helpful
message; reference the parseInviteUrl function and its local vars hashPart,
token, params, and ephemeralKey when making the checks.

126-129: Hard-coded waitForTimeout(500) in aliceNavigateBack is a minor flakiness risk.

Consider waiting for a specific navigation state (e.g., breadcrumb change or URL change) instead of a fixed delay. However, this pattern appears to be used in existing sharing workflow tests, so it's consistent.

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

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 126 - 129, The
helper aliceNavigateBack currently uses a hard-coded await
alice.page.waitForTimeout(500) which can cause flakiness; update
aliceNavigateBack to wait for a deterministic UI/navigation signal instead (for
example: wait for breadcrumb text to change, wait for the parent directory row
to appear/disappear, or use alice.page.waitForURL to detect the URL change) by
replacing the fixed timeout with an explicit wait (e.g., waitForSelector or
waitForFunction targeting the breadcrumb or row state) after the dblclick on
'[data-testid="parent-dir-row"]' so the test proceeds only when navigation is
complete.

431-463: Folder traversal test uses multiple hard-coded waits (3s + 2s) plus a retry pattern.

Lines 438 and 456 use waitForTimeout with 3s and 2s respectively. This is pragmatic given IPNS resolution latency but could contribute to test flakiness or unnecessarily slow runs. The retry pattern on lines 440–447 is a good resilience measure.

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

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 431 - 463, Replace
the hard-coded waits and one-shot retry with deterministic waits and a bounded
retry loop: instead of eve.page.waitForTimeout(3000) and waitForTimeout(2000),
wait for specific UI conditions such as parentDirRow().isVisible(),
waitForSharedItem(sharedFolderName), or getFolderItemNames() to include the
expected items; update the post-open logic in navigateToShared/openSharedItem
flow to poll for parentDirRow visibility (or folder item presence) with a short
interval and overall timeout, and turn the single retry block (which calls
navigateToShared, waitForLoaded, waitForSharedItem, navigateIntoFolder) into a
retry-until-success loop that throws after a max timeout to avoid flakiness; use
the existing helpers (parentDirRow, waitForLoaded, waitForSharedItem,
getFolderItemNames, doubleClickFolderItem, navigateIntoFolder, navigateToRoot)
to implement these waits.
apps/web/src/lib/crypto/key-wrapping.ts (1)

101-110: Progress callback in recursive case reports approximate values.

The sub-progress callback onProgress(wrapped + subWrapped) (line 107) uses the wrapped value captured at the start of this folder's processing. For deeply nested trees, the total reported during recursion may briefly under-count keys wrapped in sibling branches that were processed before this subfolder. This is cosmetic and fine for a progress indicator, but worth noting.

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

In `@apps/web/src/lib/crypto/key-wrapping.ts` around lines 101 - 110, The progress
callback is using a stale pre-captured total (wrapped) when reporting subfolder
progress via onProgress(wrapped + subWrapped); update the callback so it
computes the total at callback execution instead of using the initial captured
value — e.g., read the current outer total inside the subWrapped callback or
have collectChildKeys pass absolute counts back (use symbols: collectChildKeys,
onProgress, wrapped, subWrapped, metadata.children) so the reported progress is
always wrapped (current) + subWrapped rather than a potentially stale wrapped
value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.planning/phases/15-link-sharing/15-CONTEXT.md:
- Line 20: The Heading "### Sharing model" is flagged for a level jump (MD001);
fix it by adjusting the heading level or surrounding structure so heading levels
only change by one—either change "### Sharing model" to the correct level (e.g.,
"## Sharing model" or "#### Sharing model" to match adjacent headings) or insert
the missing intermediate heading above it; locate the literal heading "###
Sharing model" in the file and update it accordingly.

In `@apps/api/src/shares/dto/claim-invite.dto.ts`:
- Around line 23-28: The itemId property in the DTO is currently only validated
as a non-empty string; add UUID validation by decorating itemId with `@IsUUID`()
(on the same property as `@IsString`()/@MinLength(1)) so malformed DB IDs fail
fast, and import IsUUID from class-validator alongside the other validators
(target the itemId field in the claim-invite DTO).
- Around line 30-48: The encryptedKey fields currently use `@MinLength`(2) which
is too small for an ECIES secp256k1 ciphertext; update the validation on the
encryptedKey property in both ClaimChildKeyDto (the first class with
encryptedKey) and ClaimInviteDto to use `@MinLength`(258) (258 hex chars = 129
bytes) and keep the existing `@MaxLength`(1024) and `@Matches` regex unchanged so
downstream ECIES unwrap will receive a realistically sized payload.

In `@apps/api/src/shares/share-invites.controller.spec.ts`:
- Around line 22-24: The test uses an ad-hoc type for mockReq and several "as
any" casts; import RequestWithUser from ../common/types, change the mockReq
declaration to use RequestWithUser (e.g., const mockReq: RequestWithUser = {
user: { id: userId } }), update any places where mockReq is passed to controller
methods to remove "as any" casts, and run tests to ensure type compatibility
with controller methods (adjust mockReq shape to include any additional
RequestWithUser fields used by methods).

In `@apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts`:
- Around line 9-13: Remove the orphaned generated type
InviteDataResponseDtoEncryptedChildKeys by deleting the file
apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts; verify there
are no remaining imports of InviteDataResponseDtoEncryptedChildKeys (search
across the repo), remove any references from barrels or index exports, and
confirm InviteDataResponseDto now uses the inlined encryptedChildKeys:
InviteChildKeyDto[] | null type; run the TypeScript build/tests to ensure no
missing type references remain.

In `@designs/cipher-box-design.pen`:
- Around line 39323-39332: The text color `#4a5a4e` used for small informational
elements (e.g., secNote id 9NV7i, signupNote id QR1Lu, expiry1 id K7mUm and
similar 9–10px labels) fails WCAG AA contrast against black (~3.12:1); update
these text fills to a lighter muted token that meets ≥4.5:1 on `#000000` (or
alternatively increase those specific fontSize values to ≥24px to qualify as
large text) — locate and replace the fill value for secNote, signupNote,
footerText, errFooterText, expiry1 (and other instances of `#4a5a4e`) with an
accessible hex (or update fontSize) and verify contrast ratio reaches at least
4.5:1.

---

Outside diff comments:
In @.planning/REQUIREMENTS.md:
- Line 427: Update the stale footer string "Last updated: 2026-02-11 after
Milestone 3 roadmap creation (M3 phase mappings added)" in
.planning/REQUIREMENTS.md to the current Phase 15 update date and wording (e.g.,
"Last updated: 2026-02-23 after Phase 15 status update") so the document
reflects the new status; locate the exact footer line containing that original
phrase and replace the date and parenthetical note accordingly.

---

Duplicate comments:
In @.planning/phases/15-link-sharing/15-VERIFICATION.md:
- Around line 6-20: The frontend still maps invite.token to the visible id
causing revoke to send a base64 token to the DELETE endpoint which expects a
UUID; update fetchInvitesForItem in apps/web/src/services/invite.service.ts to
use the real UUID field (inv.id) returned by InviteResponseDto (or adjust the
backend list response to include id and regenerate the API client), and ensure
revokeInvite (share-invites.controller's DELETE handler) receives that UUID
value rather than the token so ParseUUIDPipe succeeds.

In `@apps/api/src/shares/dto/create-invite.dto.ts`:
- Around line 23-28: The itemId property on the CreateInviteDto is currently
validated only as a non-empty string and must be validated as a UUID to avoid
malformed IDs; update the CreateInviteDto's itemId validators to use `@IsUUID`()
(and remove or replace `@IsString`()/@MinLength(1) as appropriate) and add the
necessary import for IsUUID from class-validator so incoming itemId values are
enforced as valid UUIDs.
- Around line 30-71: The encryptedKey MinLength is too low (currently 2)
allowing invalid ciphertexts; update the `@MinLength` on both encryptedKey
properties (the one above and the encryptedKey in class CreateInviteDto) to the
realistic minimum for ECIES secp256k1 AES‑256‑GCM with an uncompressed public
key: 250 hex characters (125 bytes * 2), i.e., set `@MinLength`(250) for those
encryptedKey fields so validators reject undersized/garbage payloads.

In `@apps/api/src/shares/dto/invite-response.dto.ts`:
- Around line 26-30: Update the `@ApiProperty` decorators for the Date fields in
InviteResponseDto: change the decorators on the expiresAt and createdAt
properties (expiresAt, createdAt) to explicitly specify the OpenAPI type and
format by adding { type: 'string', format: 'date-time' } so the generated spec
marks these Date fields as date-time strings rather than relying on inference.

In `@apps/api/src/shares/share-invites.controller.spec.ts`:
- Around line 140-147: The test in share-invites.controller.spec.ts should also
assert that list responses do not include child key data: after calling
mockSharesService.getInvitesForItem and awaiting controller.listInvites (the
existing test using mockReq and item id), add an assertion that
'encryptedChildKeys' is not present on result[0] (similar to the existing checks
for 'encryptedKey' and 'sharerId'); update the test that uses
mockSharesService.getInvitesForItem and the result variable to include
expect('encryptedChildKeys' in result[0]).toBe(false).

In `@apps/api/src/shares/shares.service.ts`:
- Around line 403-504: The current claimInvite flow marks the ShareInvite as
claimed via inviteRepo.createQueryBuilder().update(...) before creating
Share/ShareKey, so failures leave the invite consumed; wrap the UPDATE and the
subsequent Share/ShareKey creation (use of inviteRepo, shareRepo, shareKeyRepo
and operations create/save/remove) in a single database transaction (or perform
the UPDATE as the final step inside the same transaction) so the invite status
and the created Share/ShareKey are committed atomically and rolled back together
on error; use your ORM transaction manager/QueryRunner to beginTransaction(),
run the SELECT/UPDATE, create/save Share and ShareKey records, then commit or
rollback on failure.

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx`:
- Around line 48-58: When fetchInvitesForItem(ipnsName) fails, the catch
currently only logs the error which leads the UI to show "no active invite
links"; update the catch to set an error state so the existing error alert
renders: inside the .catch((err) => { if (!cancelled) { ... } }) call set the
component's invite-fetch error state (use the existing error setter if present,
otherwise add one like setInvitesError) with the error or a message, and ensure
you still keep console.error; keep the cancelled check and do not modify the
successful .then branch that calls setInvites.

In `@apps/web/src/components/file-browser/ShareDialog.tsx`:
- Around line 351-490: The tab bar in ShareDialog uses role="tab" but lacks ids,
aria-controls, roving tabIndex and arrow-key handling, and the panels lack
aria-labelledby—make the tab buttons fully accessible by assigning unique ids
(e.g., "share-tab-direct" and "share-tab-invite"), add matching aria-controls
pointing to panel ids ("share-panel-direct"/"share-panel-invite"), implement
roving focus/tabIndex logic and left/right/up/down arrow key handling in a new
handler (e.g., handleTabKeyDown) used on the tab buttons to call setActiveTab
and move focus, and ensure each tabpanel (including InviteLinkTab) has id and
aria-labelledby referencing its tab id (or pass those props into InviteLinkTab
so its root uses id="share-panel-invite" and
aria-labelledby="share-tab-invite"); update any aria-selected usage to reflect
the activeTab variable.

In `@apps/web/src/services/invite.service.ts`:
- Around line 12-67: The generateEphemeralKeypair function currently imports and
uses `@noble/secp256k1`; replace that with an approved library (ethers.js or
libsodium.js) per frontend crypto guidelines. Update the import (remove
`@noble/secp256k1`) and re-implement generateEphemeralKeypair to produce the same
outputs (privateKey: Uint8Array, publicKey: Uint8Array as uncompressed 65-byte
EC public key, privateKeyHex string) using ethers.js (e.g.,
Wallet.createRandom() for a private key and utils.computePublicKey to derive the
uncompressed public key) or libsodium.js equivalent; keep bytesToHex for
privateKeyHex and ensure any downstream callers of generateEphemeralKeypair and
its returned shapes remain compatible. Also remove/replace any other uses of
secp256k1 in this file (search for secp256k1) so the module imports only
approved crypto libs.

In `@apps/web/src/styles/share-dialog.css`:
- Around line 430-435: The .invite-status-badge rule currently hard-codes
font-size: 10px; replace that literal with the project's design token (e.g.,
font-size: var(--font-size-xs) or the appropriate token used elsewhere in this
file) so the badge follows tokenized sizing; update the font-size declaration in
the .invite-status-badge CSS rule to use the matching --font-size-* token used
by other selectors in this stylesheet.

In `@designs/cipher-box-design.pen`:
- Around line 39304-39312: The subtext element with id "TBokI" currently uses
fill "#006644" on a "#000000" background and fails WCAG AA contrast for 11px
text (≈3.36:1); update the element (id "TBokI", name "subtext") to use a darker
color value that achieves at least 4.5:1 contrast against black (or
alternatively increase the font size/weight to meet 4.5:1), e.g., replace the
fill with a darker hex that meets the ratio and keep fontFamily "JetBrains Mono"
and fontSize 11 unless you choose the size/weight change. Ensure the changed
color/typography is applied to the "subtext" element so it passes automated
contrast checks.
- Around line 39514-39524: The errSubtext element (id: hSiQp / name: errSubtext)
has two outstanding issues: the color `#006644` at fontSize 11 fails contrast on a
dark background, and the copy contains a hardcoded TTL string ("Invite links are
valid for 7 days.") that can drift from backend values; fix by choosing a
higher-contrast color or increasing size/weight so WCAG AA contrast is met for
11px text (or switch to a vetted token from the design palette) and replace the
literal TTL sentence with a dynamic placeholder/variable (e.g., use a
backend-driven {ttlText} or localization key) so the UI displays the canonical
TTL from the backend rather than a hardcoded value.
- Around line 40956-40964: The node gzqpC (name: policyNote) contains a
hardcoded policy string "// expires in 7 days, single-claim"; replace this
hardcoded copy with a variable-driven or templated value sourced from the
canonical backend TTL/claim contract (e.g., use a policyNoteText variable or
placeholder that is populated at build/runtime from the backend config or
localization file) so the UI reflects the confirmed TTL/claim terms; update any
code that renders gzqpC to read that variable instead of the literal string and
ensure the variable name matches the backend field used for TTL/claim text.
- Around line 39673-39678: Replace all occurrences of the color "#006644" in the
"P15 - Share Dialog (Invite Tab)" frame with an accessible color that meets WCAG
AA 4.5:1 contrast for the small text sizes used (fontSize 9–11), or
alternatively increase the text weight/size where appropriate; specifically
update instances referenced as userEmail / i074c, breadcrumb EAQ4X and EbdH7,
column header layers VeXI1, c3Y0s, pV4PG, file row metadata cells, sidebar
labels settingsText and quotaText, footer links, statusText, tabDirectText,
desc, and linksLabel so that each element's computed contrast ratio against its
background meets 4.5:1. Ensure the replacement color is applied to the "fill"
property for those text layers (e.g., where "fill": "#006644" appears) and
verify visually at the target font sizes.

In `@packages/api-client/openapi.json`:
- Around line 1587-1641: The OpenAPI spec for the POST /invites/{token}/claim
(operationId InvitesController_claimInvite) must return the defined
ClaimInviteResponseDto for the 201 response; update the operation's responses so
the 201 content schema references ClaimInviteResponseDto (which describes {
shareId: string }) and ensure the summary/tags remain accurate; verify the
requestBody still references ClaimInviteDto and that security includes bearer as
shown.
- Around line 2885-2926: InviteDataResponseDto previously had encryptedChildKeys
typed as "object" but the diff fixes it to "type": "array" with "items": {
"$ref": "#/components/schemas/InviteChildKeyDto" }; confirm and keep this change
by ensuring InviteDataResponseDto.encryptedChildKeys is an array with the
correct items schema (InviteChildKeyDto) and nullable: true remains if null is
allowed, and verify the property remains listed in the required array.

In `@tests/e2e/page-objects/dialogs/invite-link-tab.page.ts`:
- Around line 151-157: The waitForLoaded method currently waits only for
'.invite-link-item' or '.share-recipients-empty' and will timeout if the API
error state renders '.share-error'; update waitForLoaded to wait for any of the
three selectors ('.invite-link-item', '.invite-link-tab
.share-recipients-empty', '.share-error') and immediately fail-fast when
'.share-error' becomes visible by throwing a clear error (reference the
waitForLoaded method and the '.share-error' selector so the test fails with a
helpful message instead of timing out).

---

Nitpick comments:
In @.planning/phases/15-link-sharing/15-RESEARCH.md:
- Around line 60-61: The fenced code block that lists the project structure (the
block containing "apps/api/src/shares/" and "entities/") is missing a language
identifier (MD040); open the code block's opening triple backticks and add a
language token (e.g., "text") so the block starts with ```text, keeping the
inner content unchanged to satisfy markdownlint.

In `@apps/api/src/shares/invites.controller.spec.ts`:
- Around line 131-140: The test uses an unsafe cast "mockReq as any" to call
controller.claimInvite; replace this by typing mockReq as RequestWithUser
(import RequestWithUser from ../common/types) so the request matches the
controller signature without any casts, update the mockReq declaration to use
RequestWithUser and keep using the same mock shape ({ user: { id: userId } }) so
controller.claimInvite and the expectations (mockSharesService.claimInvite)
compile cleanly without as any.
- Line 27: The test uses a broad cast "sharer: {} as any" which the linter
flags; replace it with a minimally typed mock for the relation instead: declare
a typed partial mock for the "sharer" (e.g. const sharer:
Partial<YourEntityType> = { id: 'mock-id', email: 'mock@example.com' } or
similar minimal properties used by the test) and use that in place of "{} as
any" (reference the "sharer" variable in invites.controller.spec.ts and cast
only if necessary to the exact entity type rather than using any).

In `@apps/web/src/lib/crypto/key-wrapping.ts`:
- Around line 101-110: The progress callback is using a stale pre-captured total
(wrapped) when reporting subfolder progress via onProgress(wrapped +
subWrapped); update the callback so it computes the total at callback execution
instead of using the initial captured value — e.g., read the current outer total
inside the subWrapped callback or have collectChildKeys pass absolute counts
back (use symbols: collectChildKeys, onProgress, wrapped, subWrapped,
metadata.children) so the reported progress is always wrapped (current) +
subWrapped rather than a potentially stale wrapped value.

In `@apps/web/src/styles/invite-page.css`:
- Around line 123-135: The CSS contains duplicated rule sets for
.invite-card__claiming and .invite-card__success; combine them into a single
rule using a comma-separated selector (.invite-card__claiming,
.invite-card__success) to remove duplication (or keep separate only if you plan
differing styles later), ensuring the shared declarations (font-family,
font-size, color, text-align) are defined once.
- Around line 39-41: Extract the hardcoded error color into a CSS custom
property and use it in the three selectors; define something like --color-error:
`#ef4444` (e.g., in :root or the .invite-card root) and replace the literal color
in .invite-card--error, .invite-card__error, and .invite-card__login-error with
var(--color-error) so all three use the same token and are easy to update.

In `@tests/e2e/tests/invite-link-workflow.spec.ts`:
- Around line 38-47: The parseInviteUrl function uses a non-null assertion on
params.get('key') which can yield a null ephemeralKey for malformed URLs; update
parseInviteUrl to validate inputs (ensure hashPart exists, token parsed from
path is present, and params.get('key') !== null) and throw a clear, descriptive
Error (including the original url) when any piece is missing so tests fail fast
and with a helpful message; reference the parseInviteUrl function and its local
vars hashPart, token, params, and ephemeralKey when making the checks.
- Around line 126-129: The helper aliceNavigateBack currently uses a hard-coded
await alice.page.waitForTimeout(500) which can cause flakiness; update
aliceNavigateBack to wait for a deterministic UI/navigation signal instead (for
example: wait for breadcrumb text to change, wait for the parent directory row
to appear/disappear, or use alice.page.waitForURL to detect the URL change) by
replacing the fixed timeout with an explicit wait (e.g., waitForSelector or
waitForFunction targeting the breadcrumb or row state) after the dblclick on
'[data-testid="parent-dir-row"]' so the test proceeds only when navigation is
complete.
- Around line 431-463: Replace the hard-coded waits and one-shot retry with
deterministic waits and a bounded retry loop: instead of
eve.page.waitForTimeout(3000) and waitForTimeout(2000), wait for specific UI
conditions such as parentDirRow().isVisible(),
waitForSharedItem(sharedFolderName), or getFolderItemNames() to include the
expected items; update the post-open logic in navigateToShared/openSharedItem
flow to poll for parentDirRow visibility (or folder item presence) with a short
interval and overall timeout, and turn the single retry block (which calls
navigateToShared, waitForLoaded, waitForSharedItem, navigateIntoFolder) into a
retry-until-success loop that throws after a max timeout to avoid flakiness; use
the existing helpers (parentDirRow, waitForLoaded, waitForSharedItem,
getFolderItemNames, doubleClickFolderItem, navigateIntoFolder, navigateToRoot)
to implement these waits.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1942b80 and 73f6992.

📒 Files selected for processing (73)
  • .planning/REQUIREMENTS.md
  • .planning/ROADMAP.md
  • .planning/STATE.md
  • .planning/phases/15-link-sharing/15-01-PLAN.md
  • .planning/phases/15-link-sharing/15-01-SUMMARY.md
  • .planning/phases/15-link-sharing/15-02-PLAN.md
  • .planning/phases/15-link-sharing/15-02-SUMMARY.md
  • .planning/phases/15-link-sharing/15-03-PLAN.md
  • .planning/phases/15-link-sharing/15-03-SUMMARY.md
  • .planning/phases/15-link-sharing/15-04-PLAN.md
  • .planning/phases/15-link-sharing/15-04-SUMMARY.md
  • .planning/phases/15-link-sharing/15-CONTEXT.md
  • .planning/phases/15-link-sharing/15-RESEARCH.md
  • .planning/phases/15-link-sharing/15-VERIFICATION.md
  • .planning/security/REVIEW-phase15-link-sharing.md
  • apps/api/scripts/generate-openapi.ts
  • apps/api/src/app.module.ts
  • apps/api/src/common/types.ts
  • apps/api/src/device-approval/device-approval.controller.ts
  • apps/api/src/ipfs/ipfs.controller.ts
  • apps/api/src/ipns/ipns.controller.ts
  • apps/api/src/migrations/1740400000000-AddShareInvites.ts
  • apps/api/src/shares/dto/claim-invite.dto.ts
  • apps/api/src/shares/dto/create-invite.dto.ts
  • apps/api/src/shares/dto/invite-response.dto.ts
  • apps/api/src/shares/entities/index.ts
  • apps/api/src/shares/entities/share-invite.entity.ts
  • apps/api/src/shares/invites.controller.spec.ts
  • apps/api/src/shares/invites.controller.ts
  • apps/api/src/shares/share-invites.controller.spec.ts
  • apps/api/src/shares/share-invites.controller.ts
  • apps/api/src/shares/shares.controller.ts
  • apps/api/src/shares/shares.module.ts
  • apps/api/src/shares/shares.service.spec.ts
  • apps/api/src/shares/shares.service.ts
  • apps/api/src/vault/vault.controller.ts
  • apps/web/src/api/invites/invites.ts
  • apps/web/src/api/models/claimChildKeyDto.ts
  • apps/web/src/api/models/claimChildKeyDtoKeyType.ts
  • apps/web/src/api/models/claimInviteDto.ts
  • apps/web/src/api/models/claimInviteResponseDto.ts
  • apps/web/src/api/models/createInviteDto.ts
  • apps/web/src/api/models/createInviteDtoItemType.ts
  • apps/web/src/api/models/index.ts
  • apps/web/src/api/models/inviteChildKeyDto.ts
  • apps/web/src/api/models/inviteChildKeyDtoKeyType.ts
  • apps/web/src/api/models/inviteDataResponseDto.ts
  • apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts
  • apps/web/src/api/models/inviteDataResponseDtoItemType.ts
  • apps/web/src/api/models/inviteDataResponseDtoStatus.ts
  • apps/web/src/api/models/inviteResponseDto.ts
  • apps/web/src/api/models/inviteResponseDtoItemType.ts
  • apps/web/src/api/models/inviteResponseDtoStatus.ts
  • apps/web/src/api/models/inviteStatusResponseDto.ts
  • apps/web/src/api/models/inviteStatusResponseDtoStatus.ts
  • apps/web/src/api/models/shareInvitesControllerListInvitesParams.ts
  • apps/web/src/api/share-invites/share-invites.ts
  • apps/web/src/components/file-browser/InviteLinkTab.tsx
  • apps/web/src/components/file-browser/ShareDialog.tsx
  • apps/web/src/lib/crypto/key-wrapping.ts
  • apps/web/src/routes/InvitePage.tsx
  • apps/web/src/routes/index.tsx
  • apps/web/src/services/invite.service.ts
  • apps/web/src/styles/invite-page.css
  • apps/web/src/styles/share-dialog.css
  • designs/DESIGN.md
  • designs/cipher-box-design.pen
  • packages/api-client/openapi.json
  • tests/e2e/page-objects/dialogs/index.ts
  • tests/e2e/page-objects/dialogs/invite-link-tab.page.ts
  • tests/e2e/page-objects/index.ts
  • tests/e2e/page-objects/pages/invite.page.ts
  • tests/e2e/tests/invite-link-workflow.spec.ts

Comment thread .planning/phases/15-link-sharing/15-CONTEXT.md
Comment thread apps/api/src/shares/dto/claim-invite.dto.ts
Comment thread apps/api/src/shares/dto/claim-invite.dto.ts
Comment thread apps/api/src/shares/share-invites.controller.spec.ts
Comment thread apps/web/src/api/models/inviteDataResponseDtoEncryptedChildKeys.ts Outdated
Comment thread designs/cipher-box-design.pen
FSM1 and others added 3 commits February 24, 2026 00:09
Delete InviteDataResponseDtoEncryptedChildKeys which was left over from
before the DTO was fixed to produce InviteChildKeyDto[]. No code imports it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 74bfe3774b89
Add per-file coverage overrides for 3 new shares controllers whose
NestJS/Swagger decorators create phantom branches (following established
pattern for auth, vault, ipfs controllers). Add unit tests for 0x-prefix
normalization, duplicate-key race condition, and non-duplicate error
rethrow in SharesService. Fix E2E invite-link tests to tolerate headless
CI clipboard unavailability by catching writeText errors and accepting
either success message variant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 052e8e48c9d3
The status endpoint collapses all non-active statuses to 404 to prevent
token-existence oracle attacks, so the client cannot distinguish revoked
from expired. Update assertion to accept either message.

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

@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: 3

♻️ Duplicate comments (1)
apps/web/src/components/file-browser/InviteLinkTab.tsx (1)

84-99: ⚠️ Potential issue | 🟡 Minor

Clipboard failure still hides the full invite URL.

When copy fails, the success message only shows a truncated URL, which isn’t usable for manual copy. Consider showing the full URL in the failure path.

💡 Suggested fix
-      setSuccess(
-        copied
-          ? `link copied to clipboard: ${displayUrl}`
-          : `link created (copy failed -- save this): ${displayUrl}`
-      );
+      setSuccess(
+        copied
+          ? `link copied to clipboard: ${displayUrl}`
+          : `link created (copy failed -- save this): ${url}`
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx` around lines 84 - 99,
The success message currently always uses the truncated displayUrl (const
displayUrl) so when clipboard copy fails users only see a shortened link; update
the failure branch to include the full url variable instead of displayUrl by
changing the setSuccess call so the copied ? branch stays as-is but the false
branch uses the full url (and still includes the existing explanatory text),
locating this logic around navigator.clipboard.writeText, the copied flag,
displayUrl, setSuccess and url in InviteLinkTab.tsx.
🧹 Nitpick comments (5)
apps/api/jest.config.js (1)

84-95: LGTM – new thresholds are consistent with the established controller pattern.

All three entries mirror the auth.controller.ts / vault.controller.ts precedent (lines 80, branches 65) with the same decorator-inflation rationale. The slightly higher branch floor for invites.controller.ts (74 vs 65) is a reasonable distinction if that controller carries fewer Swagger-decorated paths.

One optional observation: having three separate threshold entries (and presumably three separate controller files) for the shares/ module—shares.controller.ts, share-invites.controller.ts, and invites.controller.ts—is worth a quick double-check. If share-invites.controller.ts and invites.controller.ts represent overlapping resource concerns, consolidating them would reduce coverage-config fragmentation over time, but this is a design call rather than a defect.

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

In `@apps/api/jest.config.js` around lines 84 - 95, The coverage thresholds for
'**/shares/shares.controller.ts', '**/shares/share-invites.controller.ts', and
'**/shares/invites.controller.ts' are consistent with existing controller
patterns (80 lines, branches 65/74) and require no change; keep the three
distinct entries as-is, but optionally consider consolidating the two invites
controllers later if they prove to be overlapping resources to reduce
coverage-config fragmentation.
tests/e2e/tests/invite-link-workflow.spec.ts (4)

180-211: Phase numbering is inconsistent: two "Phase 2" headers and test 1.2 sits under the wrong phase.

Test 1.2 Alice creates test content (line 184) sits under a comment block labelled "Phase 2" (line 181), and there is a second "Phase 2: ShareDialog Tab UI" block at line 210. The test name prefix 1.2 also doesn't match its phase header. Renaming the test to 2.0 (or adjusting the section comment to "Phase 1 cont'd") would keep the numbering scheme self-consistent.

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

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 180 - 211, The
phase numbering is inconsistent: the comment block "Phase 2: Alice Creates
Content" contains the test named "1.2 Alice creates test content" while another
"Phase 2: ShareDialog Tab UI" follows; update either the section comment or the
test name to keep numbering consistent—e.g., rename the test "1.2 Alice creates
test content" to "2.0 Alice creates test content" (reference the test identifier
string and the surrounding comment headers "Phase 2: Alice Creates Content" and
"Phase 2: ShareDialog Tab UI"), or change the first comment to "Phase 1
(cont'd)". Ensure the test name prefix and the phase comment match.

339-361: Bare browser contexts leak when an assertion throws before close().

Tests 4.1, 6.2, 7.1, and 7.2 each create a bareContext and call bareContext.close() at the end, but if any assertion or waitFor in between throws, the close is skipped. Wrapping in try/finally prevents context leaks in CI:

Example pattern
   const bareContext = await browser.newContext();
+  try {
     const barePage = await bareContext.newPage();
     const bareInvitePage = new InvitePageObject(barePage);
     // ... assertions ...
-    await bareContext.close();
+  } finally {
+    await bareContext.close();
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 339 - 361, The
test creates a bareContext/barePage and may skip bareContext.close() if an
assertion throws; wrap the lifecycle so the context is always closed: after
creating bareContext (and barePage/InvitePageObject) put the assertions and
awaits (including waitForValidState, expect checks, etc.) inside a try block and
call await bareContext.close() in a finally block to guarantee cleanup; apply
the same pattern to other tests that create bareContext (tests 6.2, 7.1, 7.2) so
InvitePageObject, waitForValidState, and any subsequent expects cannot prevent
closing the context.

39-47: parseInviteUrl silently swallows a missing key param via non-null assertion.

If the invite URL is ever malformed (e.g., missing ?key=), params.get('key')! returns null cast to string, and the real failure surfaces far downstream with a confusing message. Similarly, if # is absent, hashPart is undefined and line 42 throws a generic TypeError.

Adding a guard makes test failures self-explanatory:

Suggested improvement
 function parseInviteUrl(url: string): { token: string; ephemeralKey: string } {
-  // URL format: http://localhost:5173/#/invite/TOKEN?key=KEY
   const hashPart = url.split('#')[1]; // /invite/TOKEN?key=KEY
+  if (!hashPart) throw new Error(`parseInviteUrl: no hash fragment in "${url}"`);
   const [path, query] = hashPart.split('?');
   const token = path.split('/invite/')[1];
   const params = new URLSearchParams(query);
-  const ephemeralKey = params.get('key')!;
+  const ephemeralKey = params.get('key');
+  if (!token || !ephemeralKey) {
+    throw new Error(`parseInviteUrl: could not extract token/key from "${url}"`);
+  }
   return { token, ephemeralKey };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 39 - 47, The
parseInviteUrl function currently assumes a hash and key exist and uses non-null
assertion on params.get('key'); update parseInviteUrl to validate the hashPart
(after url.split('#')), ensure the path contains '/invite/' and token is
present, ensure the query exists and URLSearchParams returns a non-null 'key',
and throw clear, specific errors (e.g., "Missing URL hash", "Missing invite
token", "Missing ephemeral key") referencing the variables hashPart, token,
query/params and ephemeralKey so malformed invite URLs fail fast with
descriptive messages.

126-129: waitForTimeout is a Playwright anti-pattern that introduces flakiness.

aliceNavigateBack (and similar calls on lines 438, 456) rely on fixed-duration sleeps. Playwright's own docs recommend waiting on a concrete signal (URL, element, network-idle) instead. For this helper, waiting on a breadcrumb change or waitForURL would be more robust.

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

In `@tests/e2e/tests/invite-link-workflow.spec.ts` around lines 126 - 129, The
helper aliceNavigateBack uses a fixed sleep (alice.page.waitForTimeout) which is
flaky; replace that sleep with an explicit Playwright wait for a concrete signal
instead—e.g., after
alice.page.locator('[data-testid="parent-dir-row"]').dblclick(), wait for the
breadcrumb/parent-dir breadcrumb selector to change (waitForSelector with state:
'visible' or 'detached'), or use alice.page.waitForURL to assert the expected
URL, or wait for the target directory row to appear via locator.waitFor; update
aliceNavigateBack and the similar helpers (the other functions calling
waitForTimeout) to use one of these concrete waits rather than waitForTimeout.
🤖 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/api/src/shares/invites.controller.ts`:
- Around line 37-56: The Swagger metadata for getInviteStatus does not document
the 404 thrown via NotFoundException; update the controller annotations by
adding an `@ApiResponse`({ status: 404, description: 'Invite not found' })
alongside the existing `@ApiResponse` for InviteStatusResponseDto (refer to the
getInviteStatus method and InviteStatusResponseDto) so the OpenAPI spec reflects
the NotFoundException response.

In `@apps/api/src/shares/shares.service.ts`:
- Around line 416-448: After fetching the invite in claimInvite
(inviteRepo.findOne), explicitly check the invite's expiry and throw
NotFoundException for expired tokens (e.g. if (invite.expiresAt <= new Date())
throw new NotFoundException('Invite not found or expired')) before the
self-claim check and before entering the transaction; keep the existing atomic
UPDATE in the transaction (createQueryBuilder().update(ShareInvite)...) to
detect concurrent claimed/revoked races and still throw ConflictException when
result.affected < 1 so expired tokens are reported as 404 while
claimed/revoked/contended cases remain 409.

In `@apps/web/src/components/file-browser/InviteLinkTab.tsx`:
- Around line 41-64: The current useEffect that fetches invites (triggered when
isOpen and ipnsName) leaves previous invites in state on refetch or on fetch
failure, which can cause stale entries to be displayed or revoked; update the
effect to clear invites at fetch start by calling setInvites([]) (and keep
setInvitesLoading(true)), and also clear invites on error before setting
setError, ensuring you still honor the cancellation flag (cancelled) — modify
the effect around fetchInvitesForItem, setInvites, setInvitesLoading, setError
and the cancelled handling accordingly.

---

Duplicate comments:
In `@apps/web/src/components/file-browser/InviteLinkTab.tsx`:
- Around line 84-99: The success message currently always uses the truncated
displayUrl (const displayUrl) so when clipboard copy fails users only see a
shortened link; update the failure branch to include the full url variable
instead of displayUrl by changing the setSuccess call so the copied ? branch
stays as-is but the false branch uses the full url (and still includes the
existing explanatory text), locating this logic around
navigator.clipboard.writeText, the copied flag, displayUrl, setSuccess and url
in InviteLinkTab.tsx.

---

Nitpick comments:
In `@apps/api/jest.config.js`:
- Around line 84-95: The coverage thresholds for
'**/shares/shares.controller.ts', '**/shares/share-invites.controller.ts', and
'**/shares/invites.controller.ts' are consistent with existing controller
patterns (80 lines, branches 65/74) and require no change; keep the three
distinct entries as-is, but optionally consider consolidating the two invites
controllers later if they prove to be overlapping resources to reduce
coverage-config fragmentation.

In `@tests/e2e/tests/invite-link-workflow.spec.ts`:
- Around line 180-211: The phase numbering is inconsistent: the comment block
"Phase 2: Alice Creates Content" contains the test named "1.2 Alice creates test
content" while another "Phase 2: ShareDialog Tab UI" follows; update either the
section comment or the test name to keep numbering consistent—e.g., rename the
test "1.2 Alice creates test content" to "2.0 Alice creates test content"
(reference the test identifier string and the surrounding comment headers "Phase
2: Alice Creates Content" and "Phase 2: ShareDialog Tab UI"), or change the
first comment to "Phase 1 (cont'd)". Ensure the test name prefix and the phase
comment match.
- Around line 339-361: The test creates a bareContext/barePage and may skip
bareContext.close() if an assertion throws; wrap the lifecycle so the context is
always closed: after creating bareContext (and barePage/InvitePageObject) put
the assertions and awaits (including waitForValidState, expect checks, etc.)
inside a try block and call await bareContext.close() in a finally block to
guarantee cleanup; apply the same pattern to other tests that create bareContext
(tests 6.2, 7.1, 7.2) so InvitePageObject, waitForValidState, and any subsequent
expects cannot prevent closing the context.
- Around line 39-47: The parseInviteUrl function currently assumes a hash and
key exist and uses non-null assertion on params.get('key'); update
parseInviteUrl to validate the hashPart (after url.split('#')), ensure the path
contains '/invite/' and token is present, ensure the query exists and
URLSearchParams returns a non-null 'key', and throw clear, specific errors
(e.g., "Missing URL hash", "Missing invite token", "Missing ephemeral key")
referencing the variables hashPart, token, query/params and ephemeralKey so
malformed invite URLs fail fast with descriptive messages.
- Around line 126-129: The helper aliceNavigateBack uses a fixed sleep
(alice.page.waitForTimeout) which is flaky; replace that sleep with an explicit
Playwright wait for a concrete signal instead—e.g., after
alice.page.locator('[data-testid="parent-dir-row"]').dblclick(), wait for the
breadcrumb/parent-dir breadcrumb selector to change (waitForSelector with state:
'visible' or 'detached'), or use alice.page.waitForURL to assert the expected
URL, or wait for the target directory row to appear via locator.waitFor; update
aliceNavigateBack and the similar helpers (the other functions calling
waitForTimeout) to use one of these concrete waits rather than waitForTimeout.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 73f6992 and 89b9ce7.

📒 Files selected for processing (16)
  • apps/api/jest.config.js
  • apps/api/src/shares/dto/claim-invite.dto.ts
  • apps/api/src/shares/dto/create-invite.dto.ts
  • apps/api/src/shares/dto/invite-response.dto.ts
  • apps/api/src/shares/invites.controller.spec.ts
  • apps/api/src/shares/invites.controller.ts
  • apps/api/src/shares/share-invites.controller.spec.ts
  • apps/api/src/shares/shares.service.spec.ts
  • apps/api/src/shares/shares.service.ts
  • apps/web/src/api/models/index.ts
  • apps/web/src/components/file-browser/InviteLinkTab.tsx
  • apps/web/src/components/file-browser/ShareDialog.tsx
  • apps/web/src/styles/share-dialog.css
  • packages/api-client/openapi.json
  • tests/e2e/page-objects/dialogs/invite-link-tab.page.ts
  • tests/e2e/tests/invite-link-workflow.spec.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/api/src/shares/dto/invite-response.dto.ts
  • apps/web/src/styles/share-dialog.css
  • tests/e2e/page-objects/dialogs/invite-link-tab.page.ts

Comment thread apps/api/src/shares/invites.controller.ts
Comment thread apps/api/src/shares/shares.service.ts
Comment thread apps/web/src/components/file-browser/InviteLinkTab.tsx
…agger

- Add @apiresponse(404) to getInviteStatus swagger metadata
- Add pre-transaction expiry/status check in claimInvite so expired and
  non-active invites return 404 instead of leaking to atomic UPDATE (409)
  which would signal token existence
- Clear stale invite list on refetch start and on error in InviteLinkTab
- Add unit tests for expired/claimed/revoked pre-transaction checks
- Regenerate API client

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

@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: 1

🤖 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/api/src/shares/invites.controller.ts`:
- Around line 52-58: getInviteStatus currently throws NotFound for any
non‑active result and always returns "active", which contradicts
InviteStatusResponseDto; change getInviteStatus to only throw NotFound when
sharesService.getInviteStatus(token) returns null/undefined (invalid token) and
otherwise return the actual result.status (e.g.,
'active'|'claimed'|'revoked'|'expired') so the response matches
InviteStatusResponseDto; update the return to use the value from
sharesService.getInviteStatus(token) and ensure the controller signature and any
docs/DTOs reflect all allowed statuses.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 89b9ce7 and e0662e8.

📒 Files selected for processing (6)
  • apps/api/src/shares/invites.controller.ts
  • apps/api/src/shares/shares.service.spec.ts
  • apps/api/src/shares/shares.service.ts
  • apps/web/src/api/invites/invites.ts
  • apps/web/src/components/file-browser/InviteLinkTab.tsx
  • packages/api-client/openapi.json

Comment thread apps/api/src/shares/invites.controller.ts Outdated
FSM1 and others added 2 commits February 24, 2026 01:24
Reviews all 22 implementation files against the pre-implementation
architectural review. All HIGH findings addressed. Two MEDIUM findings
(referrer policy, swagger enum) and four LOW findings identified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 9933309a5cd6
… narrowing

Address findings from post-implementation security review:

- M-01: Add `<meta name="referrer" content="no-referrer">` defense-in-depth
  for invite URL fragments
- M-02: Narrow swagger enum to `['active']` since controller collapses all
  non-active statuses to 404 (oracle prevention)
- L-01: Narrow client-side invite status types to match actual API contract
  (`'active' | 'expired'`), remove unreachable `'revoked'` from ErrorReason
- L-02: Add ParseTokenPipe (base64url regex, 16-44 char) to all invite
  endpoints for input validation at API boundary

Tests added:
- 15 unit tests for ParseTokenPipe (valid/invalid/edge cases)
- Concurrent double-claim race condition test
- Oracle prevention test (expired → 404, not 409)
- ParseTokenPipe integration in invites.controller.spec

Regenerated API client to reflect swagger changes.
Updated security review report with fix status.

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

@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.

🧹 Nitpick comments (6)
apps/web/index.html (1)

7-7: Good privacy hardening for invite-link token leakage — consider also setting the HTTP response header.

The no-referrer meta tag is the right call for Phase 15: invite tokens embedded in the URL (e.g. /invite/TOKEN) would otherwise be forwarded to any external site the user navigates to via the Referer header. Suppressing it entirely is a reasonable, deliberate choice for a security-focused app.

One gap to be aware of: the meta referrer policy only governs requests initiated by the HTML document (fetch, XHR, navigations, subresource loads). It does not affect HTTP response headers that the server itself emits. Pairing this with a Referrer-Policy: no-referrer HTTP response header — set in the Vite dev-server config — ensures the policy is enforced universally and takes precedence per the spec.

🛡️ Example: add to vite.config.ts server.headers
  server: {
    port: 5173,
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin-allow-popups',
+     'Referrer-Policy': 'no-referrer',
    },
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/index.html` at line 7, Add the HTTP response header
"Referrer-Policy: no-referrer" in the dev server config so the referrer policy
is enforced for server responses as well as the HTML meta tag; update the Vite
dev-server configuration (the server.headers config) to emit Referrer-Policy:
no-referrer, ensuring the server-side header matches the existing <meta
name="referrer" content="no-referrer" /> policy.
apps/api/src/shares/shares.service.spec.ts (1)

1090-1098: Prefer .rejects matcher over try/catch + fail().

fail() was deprecated in Jest 27 and removed in some configurations — if it's not in scope, the test silently passes without reaching the assertion.

♻️ Idiomatic rewrite
-    try {
-      await service.claimInvite('claim-token', recipientId, claimDto);
-      fail('Should have thrown');
-    } catch (err) {
-      expect(err).toBeInstanceOf(NotFoundException);
-      expect(err).not.toBeInstanceOf(ConflictException);
-    }
+    const err = await service
+      .claimInvite('claim-token', recipientId, claimDto)
+      .catch((e) => e);
+    expect(err).toBeInstanceOf(NotFoundException);
+    expect(err).not.toBeInstanceOf(ConflictException);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/shares.service.spec.ts` around lines 1090 - 1098, The
test uses a try/catch with fail() to assert service.claimInvite('claim-token',
recipientId, claimDto) throws, but fail() is deprecated; replace the try/catch
with Jest's promise assertion style: use await
expect(service.claimInvite('claim-token', recipientId,
claimDto)).rejects.toBeInstanceOf(NotFoundException) and also assert it is not
an instance of ConflictException via
rejects.not.toBeInstanceOf(ConflictException) (or split into two awaits) so the
test fails correctly without using fail(); update the test around the
claimInvite invocation and remove the try/catch/fail block.
apps/api/src/shares/invites.controller.ts (1)

85-96: Return type could use the existing InviteDataResponseDto instead of the inline shape.

InviteDataResponseDto already defines this exact shape (see apps/api/src/shares/dto/invite-response.dto.ts:50-81). The inline type duplicates it and can drift if the DTO changes.

♻️ Proposed refactor
-  async getInviteData(`@Param`('token', ParseTokenPipe) token: string): Promise<{
-    status: string;
-    encryptedKey: string;
-    encryptedChildKeys: Array<{
-      keyType: 'file' | 'folder';
-      itemId: string;
-      encryptedKey: string;
-    }> | null;
-    itemType: string;
-    ipnsName: string;
-    itemName: string;
-  }> {
+  async getInviteData(`@Param`('token', ParseTokenPipe) token: string): Promise<InviteDataResponseDto> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/shares/invites.controller.ts` around lines 85 - 96, The inline
return type in getInviteData duplicates an existing DTO; replace the inline
shape with the InviteDataResponseDto type: import InviteDataResponseDto and
change the method signature from Promise<{...}> to
Promise<InviteDataResponseDto>, ensuring any referenced nested types match the
DTO and removing the duplicated inline type definition in the getInviteData
declaration.
apps/api/src/shares/invites.controller.spec.ts (1)

22-22: Replace as any casts with typed alternatives — flagged by both Lint and Verify API Spec checks.

Three occurrences: sharer: {} as any (line 28), and mockReq as any (lines 159 & 172). The request mock already has the exact shape of RequestWithUser; casting it properly removes the lint warnings.

♻️ Proposed fix
+import { RequestWithUser } from '../common/types';
...
-  const mockReq: { user: { id: string } } = { user: { id: userId } };
+  const mockReq = { user: { id: userId } } as unknown as RequestWithUser;
...
   const mockInvite: ShareInvite = {
     ...
-    sharer: {} as any,
+    sharer: {} as unknown as User,
     ...
   };
...
-    const result = await controller.claimInvite(mockReq as any, testToken, dto);
+    const result = await controller.claimInvite(mockReq, testToken, dto);
...
-    await expect(controller.claimInvite(mockReq as any, testToken, dto)).rejects.toThrow(
+    await expect(controller.claimInvite(mockReq, testToken, dto)).rejects.toThrow(

Also applies to: 28-28, 159-159, 172-172

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

In `@apps/api/src/shares/invites.controller.spec.ts` at line 22, The test uses
unsafe "as any" casts for the request and sharer objects; remove those by typing
mockReq as the RequestWithUser type (use the existing RequestWithUser interface)
instead of "mockReq as any", and type the sharer stub (used where you currently
have "sharer: {} as any") with a minimal/partial user type such as Partial<User>
or the actual Sharer/ShareUser type your code expects; update the two other
casts (the uses of mockReq at assertions/setup) to use the strongly typed
mockReq variable rather than casting, ensuring all three occurrences (mockReq
declaration and the two mockReq usages, plus sharer) are replaced with proper
typed variables.
apps/web/src/services/invite.service.ts (2)

139-157: Silent fallback when IPNS resolution fails — recipients get folder key without child key access.

If resolveIpnsRecord returns null, encryptedChildKeys stays undefined and the invite is created with no child keys. The recipient can claim the invite and get the folder key, but cannot decrypt any files inside the folder.

Consider surfacing this as an error to the sharer rather than silently creating a degraded invite:

   const resolved = await resolveIpnsRecord(folderEntry.ipnsName);
-  if (resolved) {
-    const encryptedBytes = ...
-    ...
-    encryptedChildKeys = await collectChildKeys(...);
-  }
+  if (!resolved) {
+    throw new Error('Failed to resolve folder metadata — cannot create invite without child keys');
+  }
+  const encryptedBytes = await fetchFromIpfs(resolved.cid);
+  ...
+  encryptedChildKeys = await collectChildKeys(...);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/invite.service.ts` around lines 139 - 157, The code
currently silently proceeds when resolveIpnsRecord(folderEntry.ipnsName) returns
null leaving encryptedChildKeys undefined so recipients get the folder key but
no child keys; update the invite creation flow to detect a failed IPNS
resolution (resolveIpnsRecord returning null/undefined) and surface an error
instead of continuing — e.g., throw or return an explicit error from the
function so the sharer is notified; reference resolveIpnsRecord,
encryptedChildKeys, collectChildKeys, decryptFolderMetadata and itemFolderKey to
locate where to add the check before decryptFolderMetadata/collectChildKeys are
invoked and ensure the finally block still zeroes itemFolderKey.

104-110: parentFolderId is defined in the params type but never used.

The parameter is required by callers but has no effect inside the function. Either remove it if it's truly unnecessary, or document why it's reserved for future use.

♻️ Proposed fix
 export async function createInviteLink(params: {
   item: FolderChild;
   folderKey: Uint8Array;
   ipnsName: string;
-  parentFolderId: string;
 }): Promise<{ url: string; token: string }> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/invite.service.ts` around lines 104 - 110, The
createInviteLink function's params type includes parentFolderId but the value is
unused; remove parentFolderId from the params object type and from the function
signature (params: { item: FolderChild; folderKey: Uint8Array; ipnsName: string
}) and update all callers to stop passing parentFolderId, or if you intend to
reserve it, explicitly mark it as reserved by documenting it in JSDoc and
renaming to _parentFolderId to avoid linter/unused warnings; reference:
createInviteLink and the parentFolderId param name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/api/src/shares/invites.controller.spec.ts`:
- Line 22: The test uses unsafe "as any" casts for the request and sharer
objects; remove those by typing mockReq as the RequestWithUser type (use the
existing RequestWithUser interface) instead of "mockReq as any", and type the
sharer stub (used where you currently have "sharer: {} as any") with a
minimal/partial user type such as Partial<User> or the actual Sharer/ShareUser
type your code expects; update the two other casts (the uses of mockReq at
assertions/setup) to use the strongly typed mockReq variable rather than
casting, ensuring all three occurrences (mockReq declaration and the two mockReq
usages, plus sharer) are replaced with proper typed variables.

In `@apps/api/src/shares/invites.controller.ts`:
- Around line 85-96: The inline return type in getInviteData duplicates an
existing DTO; replace the inline shape with the InviteDataResponseDto type:
import InviteDataResponseDto and change the method signature from Promise<{...}>
to Promise<InviteDataResponseDto>, ensuring any referenced nested types match
the DTO and removing the duplicated inline type definition in the getInviteData
declaration.

In `@apps/api/src/shares/shares.service.spec.ts`:
- Around line 1090-1098: The test uses a try/catch with fail() to assert
service.claimInvite('claim-token', recipientId, claimDto) throws, but fail() is
deprecated; replace the try/catch with Jest's promise assertion style: use await
expect(service.claimInvite('claim-token', recipientId,
claimDto)).rejects.toBeInstanceOf(NotFoundException) and also assert it is not
an instance of ConflictException via
rejects.not.toBeInstanceOf(ConflictException) (or split into two awaits) so the
test fails correctly without using fail(); update the test around the
claimInvite invocation and remove the try/catch/fail block.

In `@apps/web/index.html`:
- Line 7: Add the HTTP response header "Referrer-Policy: no-referrer" in the dev
server config so the referrer policy is enforced for server responses as well as
the HTML meta tag; update the Vite dev-server configuration (the server.headers
config) to emit Referrer-Policy: no-referrer, ensuring the server-side header
matches the existing <meta name="referrer" content="no-referrer" /> policy.

In `@apps/web/src/services/invite.service.ts`:
- Around line 139-157: The code currently silently proceeds when
resolveIpnsRecord(folderEntry.ipnsName) returns null leaving encryptedChildKeys
undefined so recipients get the folder key but no child keys; update the invite
creation flow to detect a failed IPNS resolution (resolveIpnsRecord returning
null/undefined) and surface an error instead of continuing — e.g., throw or
return an explicit error from the function so the sharer is notified; reference
resolveIpnsRecord, encryptedChildKeys, collectChildKeys, decryptFolderMetadata
and itemFolderKey to locate where to add the check before
decryptFolderMetadata/collectChildKeys are invoked and ensure the finally block
still zeroes itemFolderKey.
- Around line 104-110: The createInviteLink function's params type includes
parentFolderId but the value is unused; remove parentFolderId from the params
object type and from the function signature (params: { item: FolderChild;
folderKey: Uint8Array; ipnsName: string }) and update all callers to stop
passing parentFolderId, or if you intend to reserve it, explicitly mark it as
reserved by documenting it in JSDoc and renaming to _parentFolderId to avoid
linter/unused warnings; reference: createInviteLink and the parentFolderId param
name.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e0662e8 and 1b1c092.

📒 Files selected for processing (13)
  • .planning/security/REVIEW-2026-02-24-phase15-implementation.md
  • apps/api/src/common/pipes/parse-token.pipe.spec.ts
  • apps/api/src/common/pipes/parse-token.pipe.ts
  • apps/api/src/shares/dto/invite-response.dto.ts
  • apps/api/src/shares/invites.controller.spec.ts
  • apps/api/src/shares/invites.controller.ts
  • apps/api/src/shares/shares.service.spec.ts
  • apps/web/index.html
  • apps/web/src/api/models/inviteStatusResponseDto.ts
  • apps/web/src/api/models/inviteStatusResponseDtoStatus.ts
  • apps/web/src/routes/InvitePage.tsx
  • apps/web/src/services/invite.service.ts
  • packages/api-client/openapi.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/src/api/models/inviteStatusResponseDto.ts
  • apps/api/src/shares/dto/invite-response.dto.ts

@FSM1 FSM1 merged commit 76258cf into main Feb 24, 2026
14 checks passed
@FSM1 FSM1 deleted the feat/phase-15-link-sharing branch March 3, 2026 22:25
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