feat/discovery
needs attentionviewing older commit23191ee · incrementalpre-PRreviewed 2026-07-11 02:18 UTC5H · 12M · 7L · 3I- 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
- 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+1136−0apps/platform+760−2domains/core+600−0packages/database+128−0packages/api+77−0packages/secrets+48−12e2e/platform+48−0packages/analytics+42−0
- 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.
Findings · 27
correctness4
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.
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.
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.
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
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.
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.
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.
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.
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
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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
- c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
- 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
- ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
- 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
- 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
- cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
- 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
- ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
- 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
- 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
- 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
- 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
- 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
- d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
- 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
- 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
- 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
- b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
- 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
- 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
- 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
- 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
- a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
- 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
- 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
- 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
- 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
- fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
- d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
- c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18current
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08