← all branches

feat/discovery

needs attentionviewing older commit
23191ee · incrementalpre-PRreviewed 2026-07-11 02:18 UTC5H · 12M · 7L · 3I
The branch
Purpose
Feature Discovery v3 — surface undiscovered platform capabilities to provider orgs via contextual spotlights and a persistent Descubre hub badge. Builds on the prior spotlight engine to add recognition-over-recall: users see what's left to discover at a glance and can self-initiate spotlights directly.
Goal
Ship the Descubre hub (gap badge + dropdown deep-links), add the add_contract onboarding signal, and make createSecret idempotent on name collisions.
Sub-goals
  • SG-1: listEligibleSpotlights pure function — budget-agnostic gap across all pages
  • SG-2: GET /discovery/gap endpoint + getDiscoveryGapShell
  • SG-3: DescubreMenu — header badge + dropdown, feature-flag gated
  • SG-4: add_contract signal (priority 1, between cfe_setup and monitoring)
  • SG-5: requestedKey + relaxDailyBudget — self-initiated pull from hub (budget-exempt on Vercel previews)
  • SG-6: cfe_setup anchor moved to sidebar Credenciales item (correct action location)
  • SG-7: fix(secrets) — createSecret idempotent on ResourceExistsException + pending-deletion InvalidRequestException
The changes (whole branch)
What
New: DescubreMenu component, GET /discovery/gap endpoint+handler+shell, listEligibleSpotlights pure function, eligibleCandidatesOnPage private helper, add_contract signal (priority 1), pathForPage reverse map, DiscoveryGapResponseSchema. Updated: decideSpotlight gains requestedKey + relaxDailyBudget params; getSpotlightHandler reads Host header for preview relaxation and forwards requested=; FeatureSpotlight reads ?descubre= from searchParams; cfe_setup anchor moved from bills/layout nav-tab to sidebar Credenciales item; flex-wrap on coachmark action row; createSecret made idempotent.
Why
Users were leaving features undiscovered — the spotlight engine could only surface one spotlight at a time, with no way to see what else was available or jump directly to a specific feature. The Descubre hub closes this gap. createSecret fix addresses a production partial-failure scenario where a secret was created but the DB row was lost, causing every retry to fail with ResourceExistsException.
Areas
domains/cross-domain+11360apps/platform+7602domains/core+6000packages/database+1280packages/api+770packages/secrets+4812e2e/platform+480packages/analytics+420
Blast
43 files, +2870/-14. Hot paths: DashboardHeader (gap badge on every authenticated page load), GET /discovery/gap (any org member), decision/shell layer (pure or DB-read-only). No schema migrations in this increment.
feature-flag-gated aws-secrets no-schema-migration
CI· no runner attachedCodeRabbit· no .coderabbit.yamltypecheck· not run in this review passtests· not run in this review pass

Findings · 27

correctness4

high

Hub deep-links silently break on 768–1023 px (tablet gap)

apps/platform/src/components/discovery/DescubreMenu.tsx:71

DescubreMenu renders at md+ (≥768 px) via `hidden md:block`, but FeatureSpotlight only fires at isDesktop (≥1024 px). A 768–1023 px user sees the badge, clicks, navigates with ?descubre=<key>, but the coachmark never appears. Component comment claims 'Same gating as the spotlight surface' but the CSS and JS breakpoints diverge by 256 px.

medium

createSecret restore-then-update not atomic — partial failure leaves restored secret with stale value

packages/secrets/src/client/secrets-manager.client.ts:123

If RestoreSecret succeeds but UpdateSecret throws, the secret is live with the old value. On retry ResourceExistsException fires and UpdateSecret is retried — recoverable, but inconsistent window. Should be documented.

medium

getGapHandler maps errors via mapGetSpotlightError — shared error type risks non-exhaustive switch on divergence

apps/platform/src/api/handlers/discovery.handler.ts:116

Works today because both shells share GetDiscoverySpotlightError. If getDiscoveryGapShell ever adds a distinct variant, TypeScript only catches it if the gap shell declares a different return type.

low

DescubreMenu fires /gap query on mobile even though button is CSS-hidden

apps/platform/src/components/discovery/DescubreMenu.tsx:54

enabled flag does not gate on viewport size. `hidden md:block` hides the render but the TanStack Query hook still runs on mobile, unlike FeatureSpotlight which guards with isDesktop.

security5

medium

Raw DB error messages leaked to callers via serverError()

domains/cross-domain/src/feature-discovery.shells.ts:110

Caught exceptions (postgres errors with table/column/constraint names) are forwarded verbatim into FeatureDiscoveryErrors.databaseError() → mapGetSpotlightError → serverError(message) → HTTP 500 body. Fix: log server-side, return a generic string.

low

relaxDailyBudget spoofable via forged Host header

apps/platform/src/api/handlers/discovery.handler.ts:71

An authenticated user can send Host: anything.vercel.app to bypass the ≤1/day spotlight budget, corrupting analytics signals. Not an access-control bypass. Fix: derive from VERCEL_ENV server-side env var.

info

requestedKey validated by closed Zod enum — no injection risk

packages/api/src/schemas/discovery.schemas.ts:40

?descubre= param parsed by DiscoveryFeatureKeySchema (strict z.enum of 7 known keys). Values outside the enum are rejected before the handler.

info

getGapHandler IDOR absent — requireOrgAccess enforces membership before any read

apps/platform/src/api/handlers/discovery.handler.ts:103

orgId validated as PublicId. requireOrgAccess does DB-backed membership check. Shell receives org.id from DB result, not URL. Runs inside createRLSDb. No IDOR.

info

createSecret TOCTOU race is benign — org-scoped deterministic paths

packages/secrets/src/client/secrets-manager.client.ts:123

Two concurrent first-time saves for the same org converge to the same credential value. No cross-org restoration risk. Matches AWS recommended idempotency pattern.

conventions2

medium

FeatureDiscoveryErrors defined in .shells.ts — canonical-form requires .errors.ts

domains/cross-domain/src/feature-discovery.shells.ts:62

canonical-form.md prescribes a dedicated {entity}.errors.ts for discriminated union errors. FeatureDiscoveryErrors, FeatureDiscoveryDatabaseError, GetDiscoverySpotlightError are co-located in the shell module.

low

getGapHandler omitted from file-level handler list comment

apps/platform/src/api/handlers/discovery.handler.ts:4

The file-level comment lists getSpotlight and recordOutcome but omits getGap.

tests9

high

add_contract signal not covered in listEligibleSpotlights gap tests

domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:597

All 3 listEligibleSpotlights tests use baseSignals (hasContracts=true), so add_contract is never eligible. No test verifies add_contract appears in the gap when hasCfeCredential=true && hasContracts=false. Hub badge count for the new signal is uncovered.

high

listEligibleSpotlights count/ordering contract never asserted — toContain() only

domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:598

Tests only use toContain() — never assert total count or uniqueness. A bug doubling a page's candidates or duplicating a key across pages would be invisible.

high

cfe_setup priority 0 blocks add_contract for non-admins — no explicit test

domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:95

When hasCfeCredential=false and isAdmin=false, cfe_setup is filtered by adminOnly and add_contract is suppressed by CFE suppression. No test covers this interaction for the new signal. Covered accidentally but not intentionally.

high

requestedKey + relaxDailyBudget combination untested

domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:571

No test covers both requestedKey and relaxDailyBudget set simultaneously. Current code short-circuits at requestedKey; a refactor could silently change semantics.

medium

getDiscoveryGapShell has zero integration tests

domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts

getDiscoverySpotlightShell has 5 integration tests (RLS isolation, consumer gate, cross-org probe). getDiscoveryGapShell has zero. Missing: consumer org returns empty, cross-org probe (RLS safety), providers org with CFE credential + no contracts returns add_contract.

medium

pathForPage has no unit tests — fallback is silent misdirection

apps/platform/src/components/discovery/spotlight-catalog.ts:40

No tests verify each known page maps to the correct path or that unknown key returns the fallback without throwing. A new page added to DISCOVERY_PAGES but not PAGE_BY_PATH would silently deep-link to the wrong page.

medium

relaxDailyBudget: same-page budget-spent exposure not tested

domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:552

Existing test uses a different-page exposure. A monitoring exposure (same page, lastSeenAt=NOW) + relaxDailyBudget=true is not verified.

low

add_contract non-admin eligibility not explicitly tested

domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:535

adminOnly=false for add_contract. Only coincidental coverage via default input() factory. An explicit assertion protects against an accidental adminOnly=true change.

low

createSecret InvalidRequestException text-match brittleness untested

packages/secrets/src/client/secrets-manager.client.ts:117

The pending-deletion branch is triggered by error.message.includes('scheduled for deletion'). AWS copy is not a stable interface. Unit tests would pin the string check and verify re-throw for other InvalidRequestException messages.

improvement7

medium

getDiscoveryGapShell duplicates the fetch from getDiscoverySpotlightShell

domains/cross-domain/src/feature-discovery.shells.ts:137

Both shells call getDiscoverySignalState + discoveryQueries.findByProfileId in Promise.all. A shared fetchDiscoveryInputs(db, input) helper would eliminate duplication and make FCIS fetch→decide→write explicit.

medium

providers-only guard appears 4 times across the call stack

domains/cross-domain/src/feature-discovery.shells.ts:86

The `orgSegment !== 'providers'` check is in both shells and both pure decision functions. Shell-level short-circuits are the canonical location; the guard inside decisions is redundant given shell enforcement.

medium

DescubreMenu casts response body to a local type alias instead of the schema-derived type

apps/platform/src/components/discovery/DescubreMenu.tsx:64

The `as ReadonlyArray<{key: SpotlightFeatureKey; page: SpotlightPage}>` cast hides future schema drift. ts-rest client infers from DiscoveryGapResponseSchema which already has the right shape.

medium

isAdmin derivation duplicated inline in both discovery handlers

apps/platform/src/api/handlers/discovery.handler.ts:79

`membership.role === 'admin' || membership.role === 'owner'` appears in both handlers. A shared isAdminRole(role) helper in org-auth middleware would prevent divergence if a new role is added.

medium

createSecret: RestoreSecret → UpdateSecret race window not documented

packages/secrets/src/client/secrets-manager.client.ts:123

If a concurrent process completes deletion between RestoreSecret and UpdateSecret, UpdateSecret throws ResourceNotFoundException — unhandled at this level. The AWS message-text check is also an undocumented unstable string. Both should be commented, or UpdateSecret should be wrapped in a retry.

low

pathForPage uses a linear scan on every Descubre menu render

apps/platform/src/components/discovery/spotlight-catalog.ts:40

PAGE_BY_PATH.find() is O(N) per item per render. A pre-built reverse Map<SpotlightPage, string> constant would be O(1). Trivial at 3 entries; worth doing when adding the 4th page.

low

Gap and spotlight endpoints fetch the same DB data independently on the Descubre click flow

apps/platform/src/components/discovery/DescubreMenu.tsx:56

When a user clicks a Descubre item and navigates to a page with a spotlight, two network fetches go out in parallel for the same underlying data. The gap response is a superset of the spotlight response.

History · 36 commits

  1. c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
  2. 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
  3. ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
  4. 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
  5. 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
  6. cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
  7. 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
  8. ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
  9. 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
  10. 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
  11. 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
  12. 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
  13. 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
  14. d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
  15. 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
  16. 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
  17. 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
  18. b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
  19. 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
  20. 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
  21. 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
  22. 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
  23. a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
  24. 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
  25. 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
  26. 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
  27. 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
  28. fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
  29. d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
  30. c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
  31. 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
  32. 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
  33. 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
  34. 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18current
  35. fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
  36. 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08