Recherche globale
Corrige docs/audits/2026-09-11-recherche-globale.md : cinq moteurs de recherche cloisonnes, aucune ressource de l'OS cherchable depuis le dashboard, un ⌘K reserve a /admin. Pilier : app-shell (backend + wiring) et…
Corrige
docs/audits/2026-09-11-recherche-globale.md: cinq moteurs de recherche cloisonnes, aucune ressource de l'OS cherchable depuis le dashboard, un ⌘K reserve a/admin. Pilier :app-shell(backend + wiring) etdesign-system(le composant<GlobalCommandPalette>, soussrc/components/**).
Vue d'ensemble
GET /api/search?q=...
│
▼
resolveSearchActor(userId) src/lib/search/actor.ts
│ (orgs + role + permissions, isPlatformAdmin)
▼
runGlobalSearch(actor, q) src/lib/search/orchestrator.ts
│
├─ isAvailable(actor) ? ── non ─▶ provider jamais appele
│
├─▶ provider.search() (en parallele, timeout 2.5s chacun)
│ stores · organizations · scans · audits · tasks ·
│ conversations · members · marketplace · pages · docs
│
▼
combineScore() + tri + slice(totalLimit)
│
▼
{ query, hits: RankedHit[], degraded: string[] }
Le moteur ne remplace AUCUN des trois moteurs specialises existants — il les reutilise :
-
marketplaceprovider appellesearchListings()(src/services/marketplace/search.ts, Postgres FTS + cache KV) telle quelle. -
pagesprovider litADMIN_ROUTES(src/config/admin-routes.ts), le MEME registre que consulte deja<AdminCommandPalette>. -
docsprovider appellesearchDocs()(src/lib/content/docs-search.ts), le MEME moteur que les outils MCPsearchDocs/getDoc. C'est la regle de ce fichier appliquee une fois de plus : un agent et un humain qui posent la meme question au meme corpus doivent obtenir la meme reponse, et deux implementations divergent le jour ou l'une apprend a matcher un titre de section et pas l'autre.Ce provider cherche les TITRES DE SECTION, pas seulement les titres de page, et c'est la ou est sa valeur : « comment contester un prelevement » ne correspond a aucun titre de page du corpus, il correspond a une section de
/docs/disputes. Les ancres viennent decollectHeadings, la fonction qui produit deja celles que la page rend, donc un hit peut pointer/docs/disputes#the-windowet tomber sur le paragraphe. Une recherche qui ne lit que les titres repond « rien trouve » aux questions que les gens posent vraiment.Public et anonyme : le corpus est servi sans session sur
/docs, donc le scoper par acteur cacherait a un utilisateur connecte ce qu'un inconnu peut lire. -
La recherche vectorielle par boutique (
services/knowledge/search.ts) reste hors de ce moteur : elle repond a une question differente (« que sait l'IA sur CETTE boutique »), pas « ou est la ressource X ».
Le contrat provider (src/lib/search/types.ts)
interface SearchProvider {
id: SearchProviderId
label: string // interne, jamais rendu — voir i18n plus bas
weight: number // [0,1], importance relative de la ressource
minQueryLength?: number
isAvailable(actor): boolean // gate GROSSIER
search(query, actor, limit): Promise<SearchHit[]> // scoping REEL
}
isAvailable() n'est jamais le controle d'acces. C'est un filtre de
cout : un non-admin ne declenche jamais le provider pages (ADMIN
uniquement), donc aucune requete n'est meme tentee. Le VRAI controle
d'acces vit dans le WHERE de chaque search() — un non-admin qui
appellerait storesProvider.search() directement obtiendrait quand meme
un resultat scope a ses propres organisations, jamais la table entiere.
Deux tests le prouvent explicitement contre un Prisma mocke :
providers/stores.test.ts verifie que la clause where.orgId porte les
orgs de l'acteur pour un non-admin et est absente pour un admin.
Ajouter une ressource cherchable = un fichier dans providers/ + une
ligne dans registry.ts. Rien d'autre ne doit changer.
Deux exceptions a ce « rien d'autre », toutes deux tenues par un test
plutot que par la memoire : l'id doit rejoindre l'union
SearchProviderId (registry.test.ts compare les deux), et le libelle
de groupe doit exister dans les six catalogues
(patterns.navigation.globalSearch.groups.<id>) — sinon le loader rend
un libelle derive de la cle, plausible et faux. Le typage exhaustif de
PROVIDER_ICON dans global-command-palette-dialog.tsx attrape
l'icone manquante a la compilation.
L'acteur (src/lib/search/actor.ts)
resolveSearchActor(userId) reproduit deliberement le raccourci
« owner » de getOrgAccess() (lib/security/tenant-auth.ts) : une
organisation POSSEDEE (Organization.ownerId === userId) donne
role: "owner" meme sans ligne OrganizationMember. Un provider qui
aurait requete OrganizationMember seul aurait silencieusement perdu
toute organisation creee mais jamais rejointe comme membre — c'est-a-dire
la totalite des organisations d'un proprietaire sur ce schema.
SearchActorOrg porte role + permissions (l'override JSON lu par
readPermissionOverride), pas seulement orgId : un provider comme
members reutilise hasAccessPermission() sans re-deriver l'acces a
partir de zero.
Classement (src/lib/search/rank.ts)
score = textScore * 0.7 + providerWeight * 0.2 + recency * 0.1
textScore(scoreTextMatch) : exact > prefixe de titre > sous-chaine de titre > exact de sous-titre > prefixe/sous-chaine de sous-titre > chevauchement de tokens pour une requete multi-mots. Normalise sur[0,1], comparable entre TOUS les providers meme si aucun ne voit les lignes des autres.recency(recencyScore) : decroissance exponentielle, demi-vie 30 jours. Une ressource sans horodatable (une page admin statique) recoit 0.5 — ni penalisee ni favorisee.providerWeight: importance relative declaree par le provider (stores: 1,organizations: 0.9,audits: 0.6,scans: 0.55,tasks/members: 0.5,conversations: 0.45,marketplace: 0.4,pages: 0.35).
Le texte domine (0.7) deliberement : un provider « important » ne doit jamais enterrer une correspondance exacte d'un provider secondaire. Poids et recence ne departagent qu'entre hits deja comparables.
Un hit dont textScore <= 0 est ecarte par l'orchestrateur meme si le
provider l'a renvoye : un provider est garant de la PORTEE (qui peut voir
la ligne), jamais de la PERTINENCE — bug de provider ou filtre contains
trop permissif ne remontent donc jamais en tete de liste avec un badge
0.
Echecs partiels (src/lib/search/orchestrator.ts)
Chaque provider tourne sous un timeout (2.5 s) et un try/catch. Un
provider qui expire ou leve une exception est retire du resultat ET
NOMME dans degraded: string[] — jamais absorbe silencieusement dans une
liste plus courte. GET /api/search renvoie ce tableau tel quel, et
<GlobalCommandPaletteDialog> l'affiche comme un bandeau
d'avertissement au-dessus de la liste, distinct de l'etat « aucun
resultat ».
GlobalSearchOptions.providers et .timeoutMs existent uniquement pour
les tests (orchestrator.test.ts injecte des providers factices et un
timeout de quelques millisecondes) — le code de production ne les passe
jamais.
Route (src/app/api/search/route.ts)
GET /api/search?q=..., withSessionAuth (session NextAuth requise,
CSRF + rate limit 60/min par utilisateur/route reutilises tels quels,
aucune politique maison). Pas de requireOrg : un appelant sans
organisation recoit quand meme les hits des providers actor-independants
(marketplace).
UI (src/components/patterns/navigation/global-command-palette-dialog.tsx)
Ne possede pas son propre raccourci ⌘K. Une premiere version montait
un declencheur+listener independant dans <SharedHeader> ; au meme
moment, design-system/0605 livrait <WorkspaceCommandPalette> —
l'autre agent de la meme reconciliation de flotte — qui revendique le
MEME raccourci ⌘K sur les MEMES pages (partout hors /admin), monte au
MEME endroit (center de <SharedHeader>, cf. shell-client.tsx).
Deux listeners keydown sur ⌘K sur les memes pages auraient ouvert les
deux dialogues a chaque frappe. Trouve et resolu au merge de
origin/main (pas a la revue) : <WorkspaceCommandPalette> garde
l'unique raccourci et son propre declencheur ; sa nouvelle entree
« Search everything » (dashboard.commandPalette.searchEverywhere) ferme
son dialogue et ouvre celui-ci a la place — deux dialogues freres dans le
temps, jamais imbriques. Ce fichier reste donc la SEULE piece UI de ce
document : cmdk, le fetch /api/search, le rendu des groupes, tous les
etats. dynamic(..., { ssr: false }) + le meme useArmed (vu, jamais
demonte), desormais porte par <WorkspaceCommandPalette> plutot que par
un declencheur dedie.
<WorkspaceCommandPalette> reste par ailleurs une pure liste d'actions
qui reutilisent une surface existante — jamais un fetch a elle
(src/test/workspace-command-palette.test.ts le garde) : le round-trip
reseau vit entierement ici, un saut plus loin.
Coexistence avec <AdminCommandPalette> : inchangee. <AdminCommandPalette>,
montee par <AdminCategoryRail>, garde son propre listener sous /admin.
Les routes admin restent NEANMOINS cherchables depuis n'importe quelle
autre page via le provider pages, qui lit le meme ADMIN_ROUTES : un
admin qui tape ⌘K depuis /acme/ma-boutique, tape « submissions » dans
« Search everything », retrouve la route sans quitter sa page.
i18n
patterns.navigation.globalSearch (les six locales, messages/*.json) :
libelle du declencheur, placeholder, etats (chargement/vide/erreur/
degrade), et les noms de groupe par providerId. Le provider pages
reste volontairement en anglais : c'est le meme choix que
<AdminCommandPalette> (outillage BoostEcom-interne, jamais expose a un
client), pas un oubli.
Limite connue, assumee : aucune section de navigation ORG-scopee
(Membres, Facturation, Parametres, Deals…) n'est indexee aujourd'hui. Ces
libelles sont customer-facing et auraient exige soit un appel
next-intl cote Route Handler — un pattern qu'aucune autre route
src/app/api/** n'utilise dans ce depot — soit une liste anglaise en
dur qui aurait contourne le contrat six-locales tenu partout ailleurs.
Le choix a ete de ne PAS livrer un raccourci qui casse ce contrat plutot
que de forcer le pattern sans le valider ailleurs d'abord. Un provider
sections dedie, avec son propre registre traduit, est le suivi naturel
— voir backlog/app-shell/.
Ajouter une ressource cherchable
src/lib/search/providers/<nom>.ts— implementerSearchProvider. Copierstores.ts(le plus simple, scope org direct) ouaudits.ts(fusion de deux modeles) selon le cas.- Une ligne dans
SEARCH_PROVIDERS(registry.ts). - Une entree dans
SearchProviderId(types.ts) —registry.test.tsechoue si le registre et le type union divergent dans un sens ou l'autre. - Une entree
groups.<id>danspatterns.navigation.globalSearchdes SIX locales (script Python, jamais d'edition a la main — voirmessages/CLAUDE.md), sauf pour une ressource strictement interne commepages. - Si le provider touche Prisma : un test avec
vi.mock("@/lib/core/database")qui prouve la clause de scoping, sur le modele deproviders/stores.test.ts.