Skip to content

feat(companion): native claim + link/create for unclaimed QR codes - #2753

Merged
DonKoko merged 4 commits into
mainfrom
feat/companion-unclaimed-qr-claim
Jul 28, 2026
Merged

feat(companion): native claim + link/create for unclaimed QR codes#2753
DonKoko merged 4 commits into
mainfrom
feat/companion-unclaimed-qr-claim

Conversation

@carlosvirreira

@carlosvirreira carlosvirreira commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Need

Scanning an unclaimed QR (fresh label-sheet sticker, Qr.organizationId = NULL) in the companion app is a dead end: the mobile resolver 404s and the scanner can only offer "Link in Browser". The single most valuable mobile workflow — scan a new sticker, have an asset seconds later, phone never leaves your hand — bounces the user to the web app mid-task. This was the top item on the post-launch backlog (unlinked-QR dead-end), and label-sheet customers hit it on literally every sticker of a new sheet.

Hypothesis

If the companion can claim an unclaimed code into the active workspace and then either create a new asset or link an existing one — reusing the exact web services (claimQrCode, updateAssetQrCode, createAsset) so semantics stay identical — then the label-sheet onboarding loop completes fully on-device, with no behavior change for web, the audit scanner, or non-admin roles.

What changed

Webapp (mobile API):

  • resolveMobileScannedCode: the unclaimed 404 now carries additive reason: "unclaimed" + qrId (message/status byte-identical; plain not-found and wrong-org carry no reason — the app never offers claim for those). Kills the companion's error-message string matching.
  • New POST /api/mobile/qr/claim { qrId } — wraps claimQrCode(); claims into the caller's current org only (no org selection from mobile). Gate: qr:update → effectively ADMIN/OWNER, same as web.
  • New POST /api/mobile/qr/link-asset { qrId, assetId } — wraps updateAssetQrCode() (QR-swap semantics identical to web); assetId org-scoped via assertAssetsBelongToOrg per org-scope-user-supplied-ids.
  • claimQrCode() hardened with an atomic conditional update WHERE (organizationId: null, assetId: null, kitId: null) — closes the two-orgs-claim-one-code race for web AND mobile.

Companion:

  • Scanner: unclaimed → "Unclaimed Code" card with Create New Asset / Link Existing Asset (ADMIN/OWNER; claim-first, then existing create flow or the new picker). Claimed-but-unlinked cards gain Link Existing Asset too. Non-admins keep the web bridge via openShelfWebUrl (deep-link-allowlist safe). Kit-linked QRs unchanged (web bridge).
  • New /(tabs)/assets/link-qr picker: searchable paginated org-asset list (web-parity: no exclusions; confirm dialog carries the same "current QR will be replaced" warning), one-shot auto re-claim retry if the claim raced out, full empty/error/offline states, a11y labels.
  • Structured reason branching via apiFetch errorDetails; qr:update added to lib/permissions.ts mirroring the server's effective gate.

Deliberately out of scope (no drift): kits stay web-bridged; no claim UI for BASE/SELF_SERVICE; no deep-link allowlist changes (no new claimed paths); Android untouched beyond shared JS.

Test plan

  1. Unit (25 tests, all green): claim route (7), link-asset route (9), resolver (5), both consumer routes (2+2) + claimQrCode race coverage (pre-check 403, P2025 lost-race → 403 not 404, atomic WHERE assertion). Behavior-driven: status codes, reasons, permission denials, org-mismatch — no mock-shape DB assertions.
  2. Live end-to-end (11/11 scenario groups, real server + real Postgres, first-run pass):
    • resolve unclaimed → 404 + reason:"unclaimed" (exact contract)
    • claim as admin → DB: organizationId + userId set (true claimQrCode parity, not the loose createAsset branch)
    • link same-org asset → QR swap matches web semantics; old QR unlinked, org retained
    • claim → asset/create with qrId → no orgless-linked corruption
    • negatives: already-claimed 403; cross-org assetId rejected (IDOR); BASE + SELF_SERVICE denied; nonexistent id clean 404
    • regressions: linked-QR resolve unchanged; audit scanner (get-scanned-item) unchanged; wrong-org 403 carries no reason (no info leak); corrupted orgless-but-linked QR → no claim offered, claim route guards it
  3. On-device (Carlos, 2026-07-27, iPhone Air / iOS 26.5.2, local dev build against local webapp): ✅ PASSED.
    • Scan unclaimed code → "Unclaimed Code" card with both actions → Create New Asset → asset created; DB verified: organizationId + userId + assetId set (claim-first semantics).
    • Second unclaimed code → Link Existing Asset → picker → confirm (replace warning shown) → linked; DB verified.
    • Re-scans of both codes resolve as normal linked assets.
    • Dogfood finding, fixed in this PR: the scanner's floating "Enter code" pill overlapped the unclaimed card's second action button. Fix: pill now hidden while any result card is displayed (scanner.tsx, with why-comment).

Verification / benchmark

  • pnpm webapp:validate: 3,644 tests green; lint 0 errors; typecheck clean.
  • Companion tsc --noEmit + expo lint clean; webapp:doctor + companion:doctor: zero newly-introduced errors.
  • 3 adversarial review rounds: 27 findings → 19 confirmed & fixed (claim race, stale-workspace closure, Android alert soft-lock, malformed-JSON 500, double-tap guards…), 8 refuted.
  • Perf: no new queries on any hot path; claim/link are single-row writes on user-initiated taps; resolver adds zero queries (reason derived from the already-fetched row). No rate limit on the new endpoints — matches sibling asset.create; the bulk bucket's 10/min would throttle claiming a physical label sheet.

Known follow-up (deliberate, documented in-code)

qr.link-asset.ts uses read-then-write state guards (comment block explains the accepted race). Fully atomic guards belong inside shared updateAssetQrCode so web and mobile harden together — separate PR to avoid forking the shared service here.

Note for reviewers: Xcode Debug team change

project.pbxproj Debug DEVELOPMENT_TEAM changed 3V6BGGX7JS27Q4MHFB8K (Shelf Asset
Management, Inc.). The old ID doesn't match the company team and blocked local device builds
(personal teams can't sign the Associated Domains entitlement). Release config untouched; EAS
cloud builds manage credentials independently. Flagging in case 3V6BGGX7JS was intentional
for someone else's local setup.

Reviewer starting points

  • Contract: apps/webapp/app/modules/api/mobile-code-resolve.server.ts (reason field) → apps/companion/lib/api/types.ts
  • Gates: both new routes use requireMobilePermission(qr, update); server is source of truth, lib/permissions.ts only mirrors for UI
  • Claim-before-link ordering in scanner.tsx (never createAsset's loose unclaimed branch)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a “Link QR Code” screen with an asset picker to link scanned unclaimed codes to existing assets.
    • Enabled permission-aware native claim/link from scan results, including recovery for unclaimed codes.
  • Bug Fixes
    • Improved QR error handling using structured “unclaimed” reasons, with correct branching for native vs web flows.
    • Added confirmation + feedback (haptics/sound/accessibility) after successful linking.
    • Prevented scan UI overlap and refined unlinked messaging/actions.
  • Tests
    • Added/expanded mobile QR API tests covering error propagation, permission gating, and atomic claim/link concurrency.

Scanning an unclaimed QR (label-sheet sticker, no organization) in the
companion app dead-ended with a web bridge. The scanner now takes over the
full flow natively, mirroring web semantics by reusing the same services.

Webapp (mobile API):
- resolveMobileScannedCode: unclaimed 404 carries additive reason
  "unclaimed" + qrId; message/status unchanged for all consumers
- new POST /api/mobile/qr/claim wrapping claimQrCode (ADMIN/OWNER only)
- new POST /api/mobile/qr/link-asset wrapping updateAssetQrCode with
  assertAssetsBelongToOrg on the user-supplied assetId
- claimQrCode hardened with an atomic conditional WHERE (claim race,
  benefits web too); 25 co-located unit tests

Companion:
- unclaimed scan result offers Create New Asset / Link Existing Asset
  (claim-first, admin/owner gate mirroring the server)
- claimed-but-unlinked result additionally offers Link Existing Asset
- new /(tabs)/assets/link-qr searchable picker with web-parity list and
  replace-QR confirm warning; auto re-claim retry on claim race
- structured reason branching replaces error-message string matching
- manual-entry pill hidden while a result card is shown (device-test
  finding: it occluded the card's second action)
- Xcode Debug DEVELOPMENT_TEAM corrected to the company team

Tested: 3644 webapp tests green, companion tsc/lint green, live API
matrix 11/11 (IDOR, role denials, races, audit-scanner regression),
on-device create + link flows verified against the dev database.
@github-actions

Copy link
Copy Markdown

🩺 React Doctor — webapp

✅ No new findings on the files changed by this PR.

Run locally with pnpm webapp:doctor for a full scan, or cd apps/webapp && pnpm exec react-doctor . --diff for the same diff-only view.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

🩺 React Doctor — companion

Findings on the files changed by this PR:

  • 0 errors
  • 8 warnings — advisory
⚠️ 8 warnings (click to expand)
  • react-doctor/rn-no-legacy-expo-packages (3)
    • apps/companion/app/(tabs)/assets/link-qr.tsx:34
    • apps/companion/components/scanner/scan-result-card.tsx:3
    • apps/companion/app/(tabs)/scanner.tsx:20
  • react-doctor/prefer-useReducer (2)
    • apps/companion/app/(tabs)/assets/link-qr.tsx:74
    • apps/companion/app/(tabs)/scanner.tsx:179
  • react-doctor/no-giant-component (2)
    • apps/companion/app/(tabs)/assets/link-qr.tsx:74
    • apps/companion/app/(tabs)/scanner.tsx:179
  • react-doctor/rn-prefer-reanimated (1)
    • apps/companion/app/(tabs)/scanner.tsx:10

Run locally with pnpm companion:doctor for a full scan, or cd apps/companion && pnpm exec react-doctor . --diff for the same diff-only view.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 458ecf2def

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/companion/app/(tabs)/scanner.tsx
Comment thread apps/companion/app/(tabs)/assets/link-qr.tsx
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds mobile QR claim and asset-linking APIs, structured QR error propagation, atomic claim handling, scanner actions, and a searchable companion asset picker for linking QR codes.

Changes

QR linking flow

Layer / File(s) Summary
Structured QR resolution contract
apps/webapp/app/modules/api/..., apps/webapp/app/routes/api+/mobile/..., apps/companion/lib/api/...
QR resolution and mobile routes expose structured reason and qrId fields for actionable unclaimed codes, with client parsing, typed responses, and tests.
Claim and link mutation endpoints
apps/webapp/app/routes/api+/mobile/qr.*, apps/webapp/app/modules/qr/...
Adds authenticated claim and link-asset actions with validation, organization checks, permissions, atomic claiming, and route/service tests.
Companion scanner actions
apps/companion/app/(tabs)/scanner.tsx, apps/companion/components/scanner/*, apps/companion/lib/permissions.ts, apps/companion/lib/api/assets.ts
Adds QR permission gating, native claim recovery, create/link actions, typed icons, secondary scan-result actions, and workspace-state resets.
Asset picker linking screen
apps/companion/app/(tabs)/assets/...
Adds a searchable paginated asset picker that confirms QR linking, retries through claim recovery when needed, and navigates to the linked asset.

iOS signing configuration

Layer / File(s) Summary
Debug development team setting
apps/companion/ios/Shelf.xcodeproj/project.pbxproj
Changes the Debug target’s DEVELOPMENT_TEAM identifier.

Mirror maintenance guidance

Layer / File(s) Summary
Cross-app mirror provenance rule
.claude/rules/cross-app-mirrors-need-provenance.md
Documents provenance, effective-behavior, UI-only, and extraction-target requirements for companion mirror logic.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Scanner
  participant MobileAPI
  participant QRService
  participant AssetPicker
  Scanner->>MobileAPI: resolve scanned QR
  MobileAPI-->>Scanner: return QR or structured unclaimed error
  Scanner->>MobileAPI: claim QR
  MobileAPI->>QRService: atomically claim QR
  Scanner->>AssetPicker: open asset linking flow
  AssetPicker->>MobileAPI: link QR to selected asset
  MobileAPI-->>AssetPicker: return linked QR
  AssetPicker-->>Scanner: navigate to asset detail
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: donkoko

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: native companion support for claiming unclaimed QR codes and linking or creating assets.
Docstring Coverage ✅ Passed Docstring coverage is 94.44% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/companion-unclaimed-qr-claim

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
apps/webapp/app/modules/qr/service.server.ts (1)

485-497: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider suppressing Sentry capture for the pre-check "already claimed" throw too.

The new lost-race throw sets shouldBeCaptured: false since it's an expected, benign outcome. The pre-existing pre-check throw (Line 445-454, same file) for the identical "already claimed" outcome doesn't set this, so it defaults to true and gets captured — even though it can also be hit by two concurrent getQr reads racing (the comment above the atomic write explicitly says the pre-check "is not atomic with this write"). Aligning the two would avoid noisy captures for what is effectively the same race outcome.

♻️ Optional alignment
     if (qr.organizationId) {
       throw new ShelfError({
         message:
           "This QR code already belongs to an organization so you cannot claim it.",
         title: "QR code already claimed",
         status: 403,
         additionalData: { id, organizationId, userId },
         label,
         cause: null,
+        shouldBeCaptured: false,
       });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/modules/qr/service.server.ts` around lines 485 - 497, Update
the pre-check “already claimed” ShelfError in the QR claim flow to set
shouldBeCaptured: false, matching the lost-race throw in the shown block. Keep
the existing message, status, and error metadata unchanged.
apps/webapp/app/routes/api+/mobile+/qr.$qrId.ts (1)

34-46: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated mobile error-envelope construction across the two resolve routes. Both loaders build the identical { message, ...(reason ? {reason, qrId} : {}) } shape from a ResolveMobileCodeResult failure; the shared root cause is the missing helper for turning a not-ok ResolveMobileCodeResult into the wire error envelope.

  • apps/webapp/app/routes/api+/mobile+/qr.$qrId.ts#L34-L46: extract a shared helper (e.g. toMobileErrorEnvelope(result) exported from mobile-code-resolve.server.ts) and use it here.
  • apps/webapp/app/routes/api+/mobile+/get-scanned-item.$qrId.ts#L29-L42: use the same shared helper here instead of repeating the spread logic.

As per coding guidelines: "When you encounter duplicated code patterns across files or functions, extract them into focused reusable helpers instead of repeating the logic."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/routes/api`+/mobile+/qr.$qrId.ts around lines 34 - 46,
Extract the duplicated mobile error-envelope construction into an exported
toMobileErrorEnvelope helper in mobile-code-resolve.server.ts, preserving the
message, conditional reason, and qrId fields from a failed
ResolveMobileCodeResult. Update apps/webapp/app/routes/api+/mobile+/qr.$qrId.ts
lines 34-46 and apps/webapp/app/routes/api+/mobile+/get-scanned-item.$qrId.ts
lines 29-42 to use the shared helper instead of building the spread inline.

Source: Coding guidelines

apps/webapp/app/modules/api/mobile-code-resolve.server.ts (1)

156-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the QR claimability predicate.

The resolver, web claim loader, web public scan redirect, and mobile claim action each rely on !qr.assetId && !qr.kitId, while claimQrCode only checks organizationId. Put a shared isQrClaimable(qr) helper in the QR module/commonplace server utilities and consume it from the shared server-side paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/modules/api/mobile-code-resolve.server.ts` around lines 156 -
170, Extract the repeated `!qr.assetId && !qr.kitId` predicate into a shared
`isQrClaimable(qr)` helper in the QR module or common server utilities. Replace
the inline checks in the resolver’s `claimable` calculation and the web claim
loader, public scan redirect, and mobile claim action with this helper, and
update `claimQrCode` to validate it alongside `organizationId`.

Source: Coding guidelines

apps/companion/lib/api/client.ts (1)

200-215: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the extracted message instead of re-reading json?.error?.message.

errorDetails.message is already the validated string; the duplicated any traversal can drift from extractErrorDetails's validation.

♻️ Proposed tidy-up
       const errorDetails = extractErrorDetails(json);
       // 403 = forbidden → user lacks permission, but session is valid
       if (response.status === 403) {
         return {
           data: null,
           error:
-            json?.error?.message ||
+            errorDetails?.message ||
             "You don't have permission to perform this action.",
           errorDetails,
         };
       }
       return {
         data: null,
-        error: json?.error?.message || `Request failed (${response.status})`,
+        error: errorDetails?.message || `Request failed (${response.status})`,
         errorDetails,
       };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/companion/lib/api/client.ts` around lines 200 - 215, Update the error
responses in the request handling flow to use the validated message from
errorDetails.message instead of re-reading json?.error?.message. Apply this to
both the 403 branch and the generic failure return, while preserving their
existing fallback messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/companion/app/`(tabs)/assets/link-qr.tsx:
- Around line 106-131: Update fetchAssets so load-more failures (reset=false) do
not trigger the destructive error state used by the FlatList; preserve the
existing assets and scroll position while surfacing the pagination error
non-destructively. Only set the fatal setError state for reset/first-page
failures, and keep successful reset and append behavior unchanged.

---

Nitpick comments:
In `@apps/companion/lib/api/client.ts`:
- Around line 200-215: Update the error responses in the request handling flow
to use the validated message from errorDetails.message instead of re-reading
json?.error?.message. Apply this to both the 403 branch and the generic failure
return, while preserving their existing fallback messages.

In `@apps/webapp/app/modules/api/mobile-code-resolve.server.ts`:
- Around line 156-170: Extract the repeated `!qr.assetId && !qr.kitId` predicate
into a shared `isQrClaimable(qr)` helper in the QR module or common server
utilities. Replace the inline checks in the resolver’s `claimable` calculation
and the web claim loader, public scan redirect, and mobile claim action with
this helper, and update `claimQrCode` to validate it alongside `organizationId`.

In `@apps/webapp/app/modules/qr/service.server.ts`:
- Around line 485-497: Update the pre-check “already claimed” ShelfError in the
QR claim flow to set shouldBeCaptured: false, matching the lost-race throw in
the shown block. Keep the existing message, status, and error metadata
unchanged.

In `@apps/webapp/app/routes/api`+/mobile+/qr.$qrId.ts:
- Around line 34-46: Extract the duplicated mobile error-envelope construction
into an exported toMobileErrorEnvelope helper in mobile-code-resolve.server.ts,
preserving the message, conditional reason, and qrId fields from a failed
ResolveMobileCodeResult. Update apps/webapp/app/routes/api+/mobile+/qr.$qrId.ts
lines 34-46 and apps/webapp/app/routes/api+/mobile+/get-scanned-item.$qrId.ts
lines 29-42 to use the shared helper instead of building the spread inline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b80fe264-6e08-4196-9e91-76e3a1cc6654

📥 Commits

Reviewing files that changed from the base of the PR and between 98beac7 and 458ecf2.

📒 Files selected for processing (21)
  • apps/companion/app/(tabs)/assets/_layout.tsx
  • apps/companion/app/(tabs)/assets/link-qr.tsx
  • apps/companion/app/(tabs)/scanner.tsx
  • apps/companion/components/scanner/scan-result-card.tsx
  • apps/companion/ios/Shelf.xcodeproj/project.pbxproj
  • apps/companion/lib/api/assets.ts
  • apps/companion/lib/api/client.ts
  • apps/companion/lib/api/types.ts
  • apps/companion/lib/permissions.ts
  • apps/webapp/app/modules/api/mobile-code-resolve.server.test.ts
  • apps/webapp/app/modules/api/mobile-code-resolve.server.ts
  • apps/webapp/app/modules/qr/service.server.test.ts
  • apps/webapp/app/modules/qr/service.server.ts
  • apps/webapp/app/routes/api+/mobile+/get-scanned-item.$qrId.test.ts
  • apps/webapp/app/routes/api+/mobile+/get-scanned-item.$qrId.ts
  • apps/webapp/app/routes/api+/mobile+/qr.$qrId.test.ts
  • apps/webapp/app/routes/api+/mobile+/qr.$qrId.ts
  • apps/webapp/app/routes/api+/mobile+/qr.claim.test.ts
  • apps/webapp/app/routes/api+/mobile+/qr.claim.ts
  • apps/webapp/app/routes/api+/mobile+/qr.link-asset.test.ts
  • apps/webapp/app/routes/api+/mobile+/qr.link-asset.ts

Comment thread apps/companion/app/(tabs)/assets/link-qr.tsx
Review findings from PR #2753 bots:

- scanner: pin the originating org for an in-flight claim and drop the
  continuation if the active workspace changes before it settles; the
  follow-up create/link must never open under a different org than the
  claim targeted (Codex P1)
- link-qr picker: version each asset-list fetch and discard stale
  responses that finish after a newer search/workspace request started
  (Codex P2)
- link-qr picker: a failed load-more no longer wipes the loaded list;
  only first-page failures show the fatal error state (CodeRabbit)
@carlosvirreira

Copy link
Copy Markdown
Contributor Author

Good catch to question — the split is deliberate, but it deserves its rationale on the record (this PR only added the qr rows; the file itself dates from the kits work).

Why the companion has its own permissions file:

  1. It's cosmetic, not authoritative. lib/permissions.ts only decides which buttons render. Every actual mutation is gated server-side by requireMobilePermission against the real Role2PermissionMap. If the client copy drifted, the worst case is a button that 403s — not a privilege hole.

  2. A direct import isn't currently possible. Role2PermissionMap lives inside apps/webapp/app/utils/… — Remix app source, not a workspace package. Metro would have to compile webapp-internal code with its ~ path aliases and server-adjacent imports. The monorepo only shares code through packages/* today (@shelf/database), and permissions were never extracted.

  3. The literal matrix would be wrong anyway. Server behavior = matrix + the ADMIN/OWNER allow-all short-circuit in hasPermission() (permission.validator.server.ts:43). E.g. qr:update appears in no role's web matrix, yet admins/owners can claim on web because of the short-circuit. The companion file encodes the effective result of both — a naive shared import of the raw map would hide the claim buttons from everyone.

That said, the drift risk is real — this is the same class as our companion↔webapp wire-contract drift. Proposed follow-up (kept out of this PR to avoid scope creep): extract the matrix and the resolution logic (matrix + short-circuit) into a shared workspace package (e.g. @shelf/permissions), consumed by webapp server, webapp client validators, and companion. Then the companion file dies and there is exactly one source of truth. Happy to pick that up as the next PR if you agree.

Codifies the rationale from the PR #2753 permissions discussion: any
hand-copied webapp truth in the companion must declare its canonical
source, mirror effective behavior (not raw data), stay UI-cosmetic,
and name its extraction target in packages/*.
@carlosvirreira

Copy link
Copy Markdown
Contributor Author

To be precise about two layers my earlier comment blurred by calling both "permissions" — that's on me:

1. Authorization — authoritative, and shared with web. Every mobile mutation is enforced server-side by requireMobilePermission (mobile-auth.server.ts:194), which is a thin wrapper over the webapp's own validatePermissionhasPermissionRole2PermissionMap — the original validator, not a copy (its JSDoc says exactly this). Both new routes gate through it (qr.claim.ts:75, qr.link-asset.ts:84). Nothing cosmetic about this layer; you're right that it's the webapp original under the hood.

2. UI gating — the file your question was about. apps/companion/lib/permissions.ts is a client-side copy used in 5 places purely to decide which buttons render. "Cosmetic" referred to this file alone: if it drifted, a user would see a button whose request then 403s at layer 1 — an annoyance, never a privilege hole.

Agreed on the fix, as a separate PR: @shelf/permissions workspace package holding Role2PermissionMap plus the resolution logic (matrix + ADMIN/OWNER short-circuit), consumed by the webapp server validator, webapp client validators, and the companion. Layer 1 keeps working unchanged (imports move); layer 2 stops being a copy and the companion mirror is deleted. Extraction PR incoming.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.claude/rules/cross-app-mirrors-need-provenance.md:
- Around line 2-3: Align the rule’s description and scope by either broadening
the `globs` setting to cover every intended secondary app or revising the
description to apply only to `apps/companion/**`; ensure the configured scope
matches the behavior being documented.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c62a74e7-b993-4930-bb5a-5cde77178f05

📥 Commits

Reviewing files that changed from the base of the PR and between 2fd61c1 and 657550f.

📒 Files selected for processing (1)
  • .claude/rules/cross-app-mirrors-need-provenance.md

Comment thread .claude/rules/cross-app-mirrors-need-provenance.md
@carlosvirreira

Copy link
Copy Markdown
Contributor Author

Follow-up delivered: #2757 extracts the matrix + resolution logic (incl. the ADMIN/OWNER short-circuit) into a shared @shelf/permissions package consumed by both apps — the companion mirror this thread questioned is deleted there. Stacked on this PR; will retarget to main once this merges.

@DonKoko DonKoko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Solid work. claimQrCode's atomic WHERE is a real security win that benefits web too, the reason discriminator is genuinely additive (verified: audit scanner unaffected), and claim-before-create correctly dodges createAsset's loose branch. Ran the new tests locally: 32/32 green.

My notes are almost entirely about where the guards live, not whether they're correct.

Would change before merge (1 thing, if you have appetite)

qr.link-asset.ts should call relinkAssetQrCode, not updateAssetQrCode.

relinkAssetQrCode (apps/webapp/app/modules/asset/service.server.ts:6716, used by the web asset-detail relink action) already does every guard this route hand-rolls — QR-not-found, wrong-org 403, kit-linked, asset-linked-elsewhere, org-scoped asset update — and writes the system note, and claims an unclaimed code inline. The route currently reuses the weaker service plus ~60 lines of re-implemented guards, and silently drops the audit note on what is a destructive operation (the asset's existing QR is unlinked with no trace).

Caveat worth deciding consciously: it auto-claims, which makes the picker's reason: "unclaimed" recovery path (apps/companion/app/(tabs)/assets/link-qr.tsx:170) dead code. That's a simplification, but it's a contract change — your call, not a silent one.

Follow-up PRs (not blocking)

  1. Move the guards into the shared service — web is the unguarded caller today. The comment at qr.link-asset.ts:109-122 has the right instinct ("fix it in updateAssetQrCode for web AND mobile together") but the sibling is already exposed: qr+/_private+/$qrId_.link.asset.tsx:170-204's action has no QR guards at all — they live in the loader, which a direct POST never runs (qr+/route.tsx has no loader, _private+ is pathless). So an admin in org A can POST org B's claimed-but-unlinked QR id with their own assetId and re-parent it onto their asset (connect doesn't constrain Qr.organizationId). Pre-existing, needs a known cuid2 id, so Medium-low — but this PR is the natural moment.

  2. Fix createAsset's loose QR connect (service.server.ts:1394-1411). It connects an unclaimed code without setting organizationId/userId, producing rows that resolve as 404 "not linked to any organization" forever. You defend against those rows in three places (mobile-code-resolve.server.ts:160, qr.claim.ts:122, plus docs); fixing the producer deletes all three special cases.

Smaller notes

  • The claim error message never reaches users. claimQrCode's outer catch re-wraps every inner ShelfError with "Failed to claim qr code"; ShelfError inherits status/title from the cause but not message (utils/error.ts:180-185), and the mobile envelope ships only message. Your tests lock this in. One-liner fix: if (isLikeShelfError(cause)) throw cause; at the top of the outer catch.
  • Body-parsing fix applied twice, not shared. The safeParse(await request.json().catch(() => null)) pattern fixes a genuine 500-on-malformed-body; ~36 sibling mobile routes still have it (asset.create.ts:78 et al). Worth extracting parseMobileJsonBody(request, schema) while it's fresh.
  • link-qr.tsx is a third copy of the paginated asset list (assets/index.tsx:121, custody.tsx:66) — and it's the best copy: it adds a fetchVersionRef stale-response guard the other two lack. Extract useAssetList() and the main assets list gets the race fix for free.
  • The picker has no workspace-switch guard the way the scanner now does. Switch org mid-flow and it lists the new org's assets while qrId belongs to the old one; the server 403s on confirm. No data damage, just a confusing dead end — mirror the scanner's activeOrgIdRef pattern.
  • Rate limiting: your reasoning is sound and the IP gate covers /api/mobile/*, but 19 sibling write routes use enforceUserRateLimit(user.id, "bulk"), and claim is irreversible. A write bucket (~60/min) would fit without throttling a label sheet.
  • DEVELOPMENT_TEAM: Debug-only, Release untouched — right call to flag it; just get a 👍 from whoever owned 3V6BGGX7JS.

Verified good, no action needed

Org-scoped assetId per org-scope-user-supplied-ids; claim target always from requireOrganizationAccess (never body-supplied); lib/permissions.ts mirror accurate (confirmed ADMIN/OWNER short-circuit in permission.validator.server.ts:42-48 and BASE/SELF_SERVICE qr: [read] only); deep-link allowlist untouched with openShelfWebUrl for the bridge; retry: false + re-resolve recovery on non-idempotent writes; scanner's originOrgId pin against mid-flight workspace switches; Ionicons as any removal.

@carlosvirreira

Copy link
Copy Markdown
Contributor Author

All addressed in 2 commits — adopted the appetite item plus the small ones in-PR; the four bigger notes became tracked issues.

Adopted: relinkAssetQrCode (your call-it-consciously caveat, decided consciously): the route now delegates to the shared service — all guards, the audit note, and inline claiming come from the same code path as the web relink action; ~60 lines of route guards deleted. Contract change accepted: reason: "unclaimed" no longer exists on link, the picker's recovery retry is deleted as dead code, and the scanner's "Link Existing" now navigates without a pre-claim — nice side effect: an abandoned picker leaves the label unclaimed instead of zombie-claimed.

Also in-PR:

  • claimQrCode passes through its own ShelfErrors — clients now see "This QR code already belongs to an organization…" instead of the generic wrap (tests updated to lock the real messages).
  • New per-user write bucket (60/min) on claim + link-asset.
  • Picker got the scanner's workspace-switch guard (leaves with an explanation instead of the confusing 403 dead end).
  • CodeRabbit's rule-glob/description mismatch fixed.

Follow-up issues filed: #2760 (guards into the shared service + the unguarded web link action), #2761 (createAsset orgless-connect producer), #2762 (parseMobileJsonBody sweep), #2763 (useAssetList extraction — gifts the stale-response guard to the other two lists). The 3V6BGGX7JS 👍 hunt is on Carlos.

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.

2 participants