@open-mercato/search 0.6.8-develop.6969.1.7a32706312 → 0.6.8-develop.6971.1.20c09ca9ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -10,7 +10,7 @@ When working on search functionality, use this guide. It covers indexing, queryi
10
10
  4. **MUST** include `checksumSource` in every `buildSource` return value so the indexer can detect changes and skip redundant re-embedding.
11
11
  5. **MUST** use the `entityId` format `module:entity_name` and ensure it matches the entity registry exactly.
12
12
  6. **MUST** use `fieldPolicy.hashOnly` for PII fields (email, phone, tax_id) that need exact-match filtering but not fuzzy search.
13
- 7. **MUST** declare `aclFeatures` on every entity, naming the owning module's view feature(s) — the same gate that entity's own list/read route enforces. `search.global` only authorizes *using* search; `aclFeatures` is what authorizes *reading the records*. Global search and the AI tools fail closed, so an entity without it silently vanishes from results for every non-superadmin.
13
+ 7. **MUST** declare `aclFeatures` on every entity, naming the owning module's view feature(s) — the same gate that entity's own list/read route enforces. `search.global` only authorizes *using* search; `aclFeatures` is what authorizes *reading the records*. Global search, the hybrid `GET /api/search/search` endpoint and the AI tools all fail closed, so an entity without it silently vanishes from results for every non-superadmin.
14
14
 
15
15
  ## Ask First
16
16
 
@@ -497,6 +497,10 @@ await searchIndexer.reindexAll({ tenantId, purgeFirst: true })
497
497
  | `q` | string | Yes | MUST be non-empty; this is the search query |
498
498
  | `limit` | number | No | MUST NOT exceed 100 (default: 50) |
499
499
  | `strategies` | string | No | Comma-separated: `fulltext,vector,tokens` |
500
+ | `entityTypes` | string | No | Comma-separated entity ids; intersected with the entity types the caller may read |
501
+
502
+ Requires `search.view`, and — like global search — returns only the entity types the
503
+ caller holds the declared `aclFeatures` for. Superadmins are exempt.
500
504
 
501
505
  ```bash
502
506
  curl "https://your-app.com/api/search?q=john%20doe&limit=20" \
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/__integration__/TC-SEARCH-003.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n createRoleFixture,\n createUserFixture,\n deleteRoleIfExists,\n deleteUserIfExists,\n setRoleAclFeatures,\n} from '@open-mercato/core/helpers/integration/authFixtures'\n\n/**\n * TC-SEARCH-003: Search API requires authentication and feature gates (401/403)\n * Source: issue #2483 (expand `search` integration coverage)\n *\n * Real routes (the `search` module id prefixes every route under /api/search):\n * - GET /api/search/search requireAuth + requireFeatures ['search.view']\n * - POST /api/search/reindex requireAuth + requireFeatures ['search.reindex']\n *\n * The framework route wrapper enforces requireAuth (401) and requireFeatures\n * (403) BEFORE the handler body runs, so these gates hold independently of each\n * route's own inline checks. Seeded role features: admin => search.* ,\n * employee => vector.* only, superadmin => all. A user that holds search.view\n * but NOT search.reindex must therefore be provisioned explicitly to prove the\n * granular reindex gate (employee cannot stand in \u2014 it lacks search.view too).\n */\nconst VALID_PASSWORD = 'Valid1!Pass'\n\ntest.describe('TC-SEARCH-003: search API auth & feature gates (401/403)', () => {\n test('gates search.view and search.reindex by authentication and feature', async ({ request }) => {\n test.slow()\n\n const stamp = Date.now()\n let adminToken: string | null = null\n let superToken: string | null = null\n let roleId: string | null = null\n let viewerUserId: string | null = null\n const roleName = `qa-search-003-viewer-${stamp}`\n const viewerEmail = `qa-search-003-viewer-${stamp}@acme.com`\n\n try {\n // 1. Unauthenticated search => 401. q is non-empty, so only the auth gate\n // can produce a 401 here (the handler's empty-query 400 is unreachable\n // because the framework wrapper denies first).\n const unauthSearch = await request.get('/api/search/search?q=test')\n expect(unauthSearch.status(), 'unauthenticated GET /api/search/search must be 401').toBe(401)\n\n // 2. Unauthenticated reindex => 401.\n const unauthReindex = await request.post('/api/search/reindex', { data: {} })\n expect(unauthReindex.status(), 'unauthenticated POST /api/search/reindex must be 401').toBe(401)\n\n adminToken = await getAuthToken(request, 'admin')\n const scope = getTokenScope(adminToken)\n\n // 3. A user with search.view but NOT search.reindex: search is allowed\n // (never 401/403), reindex is forbidden (403).\n roleId = await createRoleFixture(request, adminToken, { name: roleName, tenantId: scope.tenantId })\n await setRoleAclFeatures(request, adminToken, { roleId, features: ['search.view'] })\n viewerUserId = await createUserFixture(request, adminToken, {\n email: viewerEmail,\n password: VALID_PASSWORD,\n organizationId: scope.organizationId,\n roles: [roleName],\n name: 'QA Search 003 Viewer',\n })\n const viewerToken = await getAuthToken(request, viewerEmail, VALID_PASSWORD)\n\n const viewerSearch = await apiRequest(request, 'GET', '/api/search/search?q=qa-search-003', { token: viewerToken })\n expect(viewerSearch.status(), 'viewer with search.view must not be unauthorized on search').not.toBe(401)\n expect(viewerSearch.status(), 'viewer with search.view must not be forbidden on search').not.toBe(403)\n // The search route resolves searchService and returns 200 with results\n // (empty when no strategy returns hits) \u2014 it does not 503 here \u2014 so a passing\n // gate yields 200.\n expect(viewerSearch.status(), 'search.view passes the gate, so the search succeeds with 200').toBe(200)\n\n const viewerReindex = await apiRequest(request, 'POST', '/api/search/reindex', { token: viewerToken, data: {} })\n expect(viewerReindex.status(), 'viewer lacking search.reindex must be forbidden on reindex').toBe(403)\n\n // 4. Superadmin can initiate reindex \u2014 never blocked by auth/feature gates.\n // useQueue:false makes the route clear its own lock in `finally`, so this\n // leaves no lingering fulltext lock for other tests; scoping to a single\n // entity keeps the synchronous reindex cheap.\n superToken = await getAuthToken(request, 'superadmin')\n const superReindex = await apiRequest(request, 'POST', '/api/search/reindex', {\n token: superToken,\n data: { entityId: 'customers:customer_company_profile', useQueue: false },\n })\n expect(superReindex.status(), 'superadmin reindex must not be unauthorized').not.toBe(401)\n expect(superReindex.status(), 'superadmin reindex must not be forbidden').not.toBe(403)\n expect([200, 503], 'superadmin reindex returns ok (200) or service-unavailable (503)').toContain(\n superReindex.status(),\n )\n } finally {\n // Best-effort: release any fulltext lock, then tear down fixtures.\n if (superToken) {\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token: superToken }).catch(() => undefined)\n }\n await deleteUserIfExists(request, adminToken, viewerUserId)\n await deleteRoleIfExists(request, adminToken, roleId)\n }\n })\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,qBAAmC;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAiBP,MAAM,iBAAiB;AAEvB,KAAK,SAAS,4DAA4D,MAAM;AAC9E,OAAK,sEAAsE,OAAO,EAAE,QAAQ,MAAM;AAChG,SAAK,KAAK;AAEV,UAAM,QAAQ,KAAK,IAAI;AACvB,QAAI,aAA4B;AAChC,QAAI,aAA4B;AAChC,QAAI,SAAwB;AAC5B,QAAI,eAA8B;AAClC,UAAM,WAAW,wBAAwB,KAAK;AAC9C,UAAM,cAAc,wBAAwB,KAAK;AAEjD,QAAI;AAIF,YAAM,eAAe,MAAM,QAAQ,IAAI,2BAA2B;AAClE,aAAO,aAAa,OAAO,GAAG,oDAAoD,EAAE,KAAK,GAAG;AAG5F,YAAM,gBAAgB,MAAM,QAAQ,KAAK,uBAAuB,EAAE,MAAM,CAAC,EAAE,CAAC;AAC5E,aAAO,cAAc,OAAO,GAAG,sDAAsD,EAAE,KAAK,GAAG;AAE/F,mBAAa,MAAM,aAAa,SAAS,OAAO;AAChD,YAAM,QAAQ,cAAc,UAAU;AAItC,eAAS,MAAM,kBAAkB,SAAS,YAAY,EAAE,MAAM,UAAU,UAAU,MAAM,SAAS,CAAC;AAClG,YAAM,mBAAmB,SAAS,YAAY,EAAE,QAAQ,UAAU,CAAC,aAAa,EAAE,CAAC;AACnF,qBAAe,MAAM,kBAAkB,SAAS,YAAY;AAAA,QAC1D,OAAO;AAAA,QACP,UAAU;AAAA,QACV,gBAAgB,MAAM;AAAA,QACtB,OAAO,CAAC,QAAQ;AAAA,QAChB,MAAM;AAAA,MACR,CAAC;AACD,YAAM,cAAc,MAAM,aAAa,SAAS,aAAa,cAAc;AAE3E,YAAM,eAAe,MAAM,WAAW,SAAS,OAAO,sCAAsC,EAAE,OAAO,YAAY,CAAC;AAClH,aAAO,aAAa,OAAO,GAAG,4DAA4D,EAAE,IAAI,KAAK,GAAG;AACxG,aAAO,aAAa,OAAO,GAAG,yDAAyD,EAAE,IAAI,KAAK,GAAG;AAIrG,aAAO,aAAa,OAAO,GAAG,8DAA8D,EAAE,KAAK,GAAG;AAEtG,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,aAAa,MAAM,CAAC,EAAE,CAAC;AAC/G,aAAO,cAAc,OAAO,GAAG,4DAA4D,EAAE,KAAK,GAAG;AAMrG,mBAAa,MAAM,aAAa,SAAS,YAAY;AACrD,YAAM,eAAe,MAAM,WAAW,SAAS,QAAQ,uBAAuB;AAAA,QAC5E,OAAO;AAAA,QACP,MAAM,EAAE,UAAU,sCAAsC,UAAU,MAAM;AAAA,MAC1E,CAAC;AACD,aAAO,aAAa,OAAO,GAAG,6CAA6C,EAAE,IAAI,KAAK,GAAG;AACzF,aAAO,aAAa,OAAO,GAAG,0CAA0C,EAAE,IAAI,KAAK,GAAG;AACtF,aAAO,CAAC,KAAK,GAAG,GAAG,kEAAkE,EAAE;AAAA,QACrF,aAAa,OAAO;AAAA,MACtB;AAAA,IACF,UAAE;AAEA,UAAI,YAAY;AACd,cAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,OAAO,WAAW,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,MAC9G;AACA,YAAM,mBAAmB,SAAS,YAAY,YAAY;AAC1D,YAAM,mBAAmB,SAAS,YAAY,MAAM;AAAA,IACtD;AAAA,EACF,CAAC;AACH,CAAC;",
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n createRoleFixture,\n createUserFixture,\n deleteRoleIfExists,\n deleteUserIfExists,\n setRoleAclFeatures,\n} from '@open-mercato/core/helpers/integration/authFixtures'\n\n/**\n * TC-SEARCH-003: Search API requires authentication and feature gates (401/403)\n * Source: issue #2483 (expand `search` integration coverage)\n *\n * Real routes (the `search` module id prefixes every route under /api/search):\n * - GET /api/search/search requireAuth + requireFeatures ['search.view']\n * - POST /api/search/reindex requireAuth + requireFeatures ['search.reindex']\n *\n * The framework route wrapper enforces requireAuth (401) and requireFeatures\n * (403) BEFORE the handler body runs, so these gates hold independently of each\n * route's own inline checks. Seeded role features: admin => search.* ,\n * employee => vector.* only, superadmin => all. A user that holds search.view\n * but NOT search.reindex must therefore be provisioned explicitly to prove the\n * granular reindex gate (employee cannot stand in \u2014 it lacks search.view too).\n */\nconst VALID_PASSWORD = 'Valid1!Pass'\n\ntest.describe('TC-SEARCH-003: search API auth & feature gates (401/403)', () => {\n test('gates search.view and search.reindex by authentication and feature', async ({ request }) => {\n test.slow()\n\n const stamp = Date.now()\n let adminToken: string | null = null\n let superToken: string | null = null\n let roleId: string | null = null\n let viewerUserId: string | null = null\n const roleName = `qa-search-003-viewer-${stamp}`\n const viewerEmail = `qa-search-003-viewer-${stamp}@acme.com`\n\n try {\n // 1. Unauthenticated search => 401. q is non-empty, so only the auth gate\n // can produce a 401 here (the handler's empty-query 400 is unreachable\n // because the framework wrapper denies first).\n const unauthSearch = await request.get('/api/search/search?q=test')\n expect(unauthSearch.status(), 'unauthenticated GET /api/search/search must be 401').toBe(401)\n\n // 2. Unauthenticated reindex => 401.\n const unauthReindex = await request.post('/api/search/reindex', { data: {} })\n expect(unauthReindex.status(), 'unauthenticated POST /api/search/reindex must be 401').toBe(401)\n\n adminToken = await getAuthToken(request, 'admin')\n const scope = getTokenScope(adminToken)\n\n // 3. A user with search.view but NOT search.reindex: search is allowed\n // (never 401/403), reindex is forbidden (403).\n roleId = await createRoleFixture(request, adminToken, { name: roleName, tenantId: scope.tenantId })\n await setRoleAclFeatures(request, adminToken, { roleId, features: ['search.view'] })\n viewerUserId = await createUserFixture(request, adminToken, {\n email: viewerEmail,\n password: VALID_PASSWORD,\n organizationId: scope.organizationId,\n roles: [roleName],\n name: 'QA Search 003 Viewer',\n })\n const viewerToken = await getAuthToken(request, viewerEmail, VALID_PASSWORD)\n\n const viewerSearch = await apiRequest(request, 'GET', '/api/search/search?q=qa-search-003', { token: viewerToken })\n expect(viewerSearch.status(), 'viewer with search.view must not be unauthorized on search').not.toBe(401)\n expect(viewerSearch.status(), 'viewer with search.view must not be forbidden on search').not.toBe(403)\n // The search route resolves searchService, rbacService and searchIndexer \u2014\n // all registered in this environment, so the fail-closed 503 added for\n // issue #5168 is unreachable here \u2014 and returns 200 with results. This\n // viewer holds no per-entity view feature, so the per-entity ACL filter\n // narrows the readable set to nothing and the results come back empty;\n // the gate assertion is about the status code, which stays 200.\n expect(viewerSearch.status(), 'search.view passes the gate, so the search succeeds with 200').toBe(200)\n\n const viewerReindex = await apiRequest(request, 'POST', '/api/search/reindex', { token: viewerToken, data: {} })\n expect(viewerReindex.status(), 'viewer lacking search.reindex must be forbidden on reindex').toBe(403)\n\n // 4. Superadmin can initiate reindex \u2014 never blocked by auth/feature gates.\n // useQueue:false makes the route clear its own lock in `finally`, so this\n // leaves no lingering fulltext lock for other tests; scoping to a single\n // entity keeps the synchronous reindex cheap.\n superToken = await getAuthToken(request, 'superadmin')\n const superReindex = await apiRequest(request, 'POST', '/api/search/reindex', {\n token: superToken,\n data: { entityId: 'customers:customer_company_profile', useQueue: false },\n })\n expect(superReindex.status(), 'superadmin reindex must not be unauthorized').not.toBe(401)\n expect(superReindex.status(), 'superadmin reindex must not be forbidden').not.toBe(403)\n expect([200, 503], 'superadmin reindex returns ok (200) or service-unavailable (503)').toContain(\n superReindex.status(),\n )\n } finally {\n // Best-effort: release any fulltext lock, then tear down fixtures.\n if (superToken) {\n await apiRequest(request, 'POST', '/api/search/reindex/cancel', { token: superToken }).catch(() => undefined)\n }\n await deleteUserIfExists(request, adminToken, viewerUserId)\n await deleteRoleIfExists(request, adminToken, roleId)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,qBAAmC;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAiBP,MAAM,iBAAiB;AAEvB,KAAK,SAAS,4DAA4D,MAAM;AAC9E,OAAK,sEAAsE,OAAO,EAAE,QAAQ,MAAM;AAChG,SAAK,KAAK;AAEV,UAAM,QAAQ,KAAK,IAAI;AACvB,QAAI,aAA4B;AAChC,QAAI,aAA4B;AAChC,QAAI,SAAwB;AAC5B,QAAI,eAA8B;AAClC,UAAM,WAAW,wBAAwB,KAAK;AAC9C,UAAM,cAAc,wBAAwB,KAAK;AAEjD,QAAI;AAIF,YAAM,eAAe,MAAM,QAAQ,IAAI,2BAA2B;AAClE,aAAO,aAAa,OAAO,GAAG,oDAAoD,EAAE,KAAK,GAAG;AAG5F,YAAM,gBAAgB,MAAM,QAAQ,KAAK,uBAAuB,EAAE,MAAM,CAAC,EAAE,CAAC;AAC5E,aAAO,cAAc,OAAO,GAAG,sDAAsD,EAAE,KAAK,GAAG;AAE/F,mBAAa,MAAM,aAAa,SAAS,OAAO;AAChD,YAAM,QAAQ,cAAc,UAAU;AAItC,eAAS,MAAM,kBAAkB,SAAS,YAAY,EAAE,MAAM,UAAU,UAAU,MAAM,SAAS,CAAC;AAClG,YAAM,mBAAmB,SAAS,YAAY,EAAE,QAAQ,UAAU,CAAC,aAAa,EAAE,CAAC;AACnF,qBAAe,MAAM,kBAAkB,SAAS,YAAY;AAAA,QAC1D,OAAO;AAAA,QACP,UAAU;AAAA,QACV,gBAAgB,MAAM;AAAA,QACtB,OAAO,CAAC,QAAQ;AAAA,QAChB,MAAM;AAAA,MACR,CAAC;AACD,YAAM,cAAc,MAAM,aAAa,SAAS,aAAa,cAAc;AAE3E,YAAM,eAAe,MAAM,WAAW,SAAS,OAAO,sCAAsC,EAAE,OAAO,YAAY,CAAC;AAClH,aAAO,aAAa,OAAO,GAAG,4DAA4D,EAAE,IAAI,KAAK,GAAG;AACxG,aAAO,aAAa,OAAO,GAAG,yDAAyD,EAAE,IAAI,KAAK,GAAG;AAOrG,aAAO,aAAa,OAAO,GAAG,8DAA8D,EAAE,KAAK,GAAG;AAEtG,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,uBAAuB,EAAE,OAAO,aAAa,MAAM,CAAC,EAAE,CAAC;AAC/G,aAAO,cAAc,OAAO,GAAG,4DAA4D,EAAE,KAAK,GAAG;AAMrG,mBAAa,MAAM,aAAa,SAAS,YAAY;AACrD,YAAM,eAAe,MAAM,WAAW,SAAS,QAAQ,uBAAuB;AAAA,QAC5E,OAAO;AAAA,QACP,MAAM,EAAE,UAAU,sCAAsC,UAAU,MAAM;AAAA,MAC1E,CAAC;AACD,aAAO,aAAa,OAAO,GAAG,6CAA6C,EAAE,IAAI,KAAK,GAAG;AACzF,aAAO,aAAa,OAAO,GAAG,0CAA0C,EAAE,IAAI,KAAK,GAAG;AACtF,aAAO,CAAC,KAAK,GAAG,GAAG,kEAAkE,EAAE;AAAA,QACrF,aAAa,OAAO;AAAA,MACtB;AAAA,IACF,UAAE;AAEA,UAAI,YAAY;AACd,cAAM,WAAW,SAAS,QAAQ,8BAA8B,EAAE,OAAO,WAAW,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,MAC9G;AACA,YAAM,mBAAmB,SAAS,YAAY,YAAY;AAC1D,YAAM,mBAAmB,SAAS,YAAY,MAAM;AAAA,IACtD;AAAA,EACF,CAAC;AACH,CAAC;",
6
6
  "names": []
7
7
  }
@@ -241,11 +241,11 @@ const vectorReindexResponseSchema = z.object({
241
241
  const searchOpenApi = {
242
242
  tag: "Search",
243
243
  summary: "Search across all indexed entities",
244
- description: "Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground.",
244
+ description: "Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground. Results are limited to the entity types the caller holds the declared view features for; superadmins are exempt.",
245
245
  methods: {
246
246
  GET: {
247
247
  summary: "Search across all indexed entities",
248
- description: "Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground.",
248
+ description: "Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground. Results are limited to the entity types the caller holds the declared view features for; superadmins are exempt.",
249
249
  tags: ["Search"],
250
250
  query: searchQueryParamsSchema,
251
251
  responses: [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/search/api/openapi.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\n// ============================================================================\n// Common Schemas\n// ============================================================================\n\nexport const searchStrategyIdSchema = z.enum(['fulltext', 'vector', 'tokens'])\n\nexport const searchResultPresenterSchema = z.object({\n title: z.string(),\n subtitle: z.string().optional(),\n icon: z.string().optional(),\n badge: z.string().optional(),\n})\n\nexport const searchResultLinkSchema = z.object({\n href: z.string(),\n label: z.string(),\n kind: z.enum(['primary', 'secondary']),\n})\n\nexport const searchResultSchema = z.object({\n entityId: z.string().describe('Entity identifier (e.g., \"customers:customer_person_profile\")'),\n recordId: z.string().describe('Primary key of the record'),\n score: z.number().describe('Relevance score (0-1)'),\n source: searchStrategyIdSchema.describe('Which strategy returned this result'),\n presenter: searchResultPresenterSchema.optional(),\n url: z.string().optional(),\n links: z.array(searchResultLinkSchema).optional(),\n metadata: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const errorResponseSchema = z.object({\n error: z.string(),\n})\n\n// ============================================================================\n// Search Endpoint Schemas (/api/search)\n// ============================================================================\n\nexport const searchQueryParamsSchema = z.object({\n q: z.string().describe('Search query (required)'),\n limit: z.coerce.number().min(1).max(100).optional().describe('Maximum results to return (default: 50, max: 100)'),\n strategies: z.string().optional().describe('Comma-separated strategies to use: fulltext, vector, tokens (e.g., \"fulltext,vector\")'),\n entityTypes: z.string().optional().describe('Comma-separated entity types to filter results (e.g., \"customers:customer_person_profile,catalog:catalog_product,sales:sales_order\")'),\n})\n\nexport const searchResponseSchema = z.object({\n results: z.array(searchResultSchema),\n strategiesUsed: z.array(searchStrategyIdSchema),\n timing: z.number().describe('Search duration in milliseconds'),\n query: z.string(),\n limit: z.number(),\n})\n\n// ============================================================================\n// Global Search Endpoint Schemas (/api/search/global)\n// ============================================================================\n\nexport const globalSearchQueryParamsSchema = z.object({\n q: z.string().describe('Search query (required)'),\n limit: z.coerce.number().min(1).max(100).optional().describe('Maximum results to return (default: 50, max: 100)'),\n entityTypes: z.string().optional().describe('Comma-separated entity types to filter results (e.g., \"customers:customer_person_profile,catalog:catalog_product,sales:sales_order\")'),\n})\n\nexport const globalSearchResponseSchema = z.object({\n results: z.array(searchResultSchema),\n strategiesUsed: z.array(searchStrategyIdSchema),\n strategiesEnabled: z.array(searchStrategyIdSchema),\n timing: z.number().describe('Search duration in milliseconds'),\n query: z.string(),\n limit: z.number(),\n})\n\n// ============================================================================\n// Settings Endpoint Schemas (/api/search/settings)\n// ============================================================================\n\nexport const strategyStatusSchema = z.object({\n id: z.string(),\n name: z.string(),\n priority: z.number(),\n available: z.boolean(),\n})\n\nexport const fulltextStatsSchema = z.object({\n numberOfDocuments: z.number(),\n isIndexing: z.boolean(),\n fieldDistribution: z.record(z.string(), z.number()),\n})\n\nexport const reindexLockSchema = z.object({\n type: z.enum(['fulltext', 'vector']),\n action: z.string(),\n startedAt: z.string(),\n elapsedMinutes: z.number(),\n processedCount: z.number().nullable().optional(),\n totalCount: z.number().nullable().optional(),\n})\n\nexport const searchSettingsSchema = z.object({\n strategies: z.array(strategyStatusSchema),\n fulltextConfigured: z.boolean(),\n fulltextStats: fulltextStatsSchema.nullable(),\n vectorConfigured: z.boolean(),\n tokensEnabled: z.boolean(),\n defaultStrategies: z.array(z.string()),\n reindexLock: reindexLockSchema.nullable().describe('Deprecated: Use fulltextReindexLock or vectorReindexLock'),\n fulltextReindexLock: reindexLockSchema.nullable(),\n vectorReindexLock: reindexLockSchema.nullable(),\n})\n\nexport const settingsResponseSchema = z.object({\n settings: searchSettingsSchema,\n})\n\n// ============================================================================\n// Global Search Settings Schemas (/api/search/settings/global-search)\n// ============================================================================\n\nexport const globalSearchSettingsResponseSchema = z.object({\n enabledStrategies: z.array(searchStrategyIdSchema),\n})\n\nexport const globalSearchSettingsUpdateSchema = z.object({\n enabledStrategies: z.array(searchStrategyIdSchema).min(1),\n})\n\nexport const globalSearchSettingsUpdateResponseSchema = z.object({\n ok: z.boolean(),\n enabledStrategies: z.array(searchStrategyIdSchema),\n})\n\n// ============================================================================\n// Reindex Endpoint Schemas (/api/search/reindex)\n// ============================================================================\n\nexport const reindexRequestSchema = z.object({\n action: z.enum(['clear', 'recreate', 'reindex']).optional().describe('Action to perform (default: reindex)'),\n entityId: z.string().optional().describe('Specific entity ID to reindex (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n useQueue: z.boolean().optional().describe('Whether to use queue (default: true)'),\n})\n\nexport const reindexResultSchema = z.object({\n entitiesProcessed: z.number().optional(),\n recordsIndexed: z.number().optional(),\n jobsEnqueued: z.number().optional(),\n errors: z.array(z.object({\n entityId: z.string(),\n error: z.string(),\n })).optional(),\n})\n\nexport const reindexResponseSchema = z.object({\n ok: z.boolean(),\n action: z.enum(['clear', 'recreate', 'reindex']),\n entityId: z.string().nullable(),\n useQueue: z.boolean().optional(),\n result: reindexResultSchema.optional(),\n stats: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional(),\n})\n\nexport const reindexConflictResponseSchema = z.object({\n error: z.string(),\n lock: reindexLockSchema,\n})\n\n// ============================================================================\n// Index Endpoint Schemas (/api/search/index)\n// ============================================================================\n\nexport const indexEntrySchema = z.object({\n id: z.string(),\n entityId: z.string(),\n recordId: z.string(),\n tenantId: z.string(),\n organizationId: z.string().nullable().optional(),\n title: z.string().optional(),\n subtitle: z.string().optional(),\n createdAt: z.string().optional(),\n})\n\nexport const indexListQueryParamsSchema = z.object({\n entityId: z.string().optional().describe('Filter by entity ID (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n limit: z.coerce.number().min(1).max(200).optional().describe('Maximum entries to return (default: 50, max: 200)'),\n offset: z.coerce.number().min(0).optional().describe('Offset for pagination (default: 0)'),\n})\n\nexport const indexListResponseSchema = z.object({\n entries: z.array(indexEntrySchema),\n limit: z.number(),\n offset: z.number(),\n})\n\nexport const indexPurgeQueryParamsSchema = z.object({\n entityId: z.string().optional().describe('Specific entity ID to purge (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n confirmAll: z.enum(['true']).optional().describe('Required when purging all entities'),\n})\n\nexport const indexPurgeResponseSchema = z.object({\n ok: z.boolean(),\n})\n\n// ============================================================================\n// Fulltext Settings Schemas (/api/search/settings/fulltext)\n// ============================================================================\n\nexport const fulltextEnvVarStatusSchema = z.object({\n set: z.boolean(),\n hint: z.string(),\n})\n\nexport const fulltextOptionalEnvVarStatusSchema = z.object({\n set: z.boolean(),\n value: z.union([z.string(), z.boolean()]).optional(),\n default: z.union([z.string(), z.boolean()]).optional(),\n hint: z.string(),\n})\n\nexport const fulltextSettingsResponseSchema = z.object({\n driver: z.enum(['meilisearch']).nullable(),\n configured: z.boolean(),\n envVars: z.object({\n MEILISEARCH_HOST: fulltextEnvVarStatusSchema,\n MEILISEARCH_API_KEY: fulltextEnvVarStatusSchema,\n }),\n optionalEnvVars: z.object({\n MEILISEARCH_INDEX_PREFIX: fulltextOptionalEnvVarStatusSchema,\n SEARCH_EXCLUDE_ENCRYPTED_FIELDS: fulltextOptionalEnvVarStatusSchema,\n }),\n})\n\n// ============================================================================\n// Vector Store Settings Schemas (/api/search/settings/vector-store)\n// ============================================================================\n\nexport const vectorDriverEnvVarSchema = z.object({\n name: z.string(),\n set: z.boolean(),\n hint: z.string(),\n})\n\nexport const vectorDriverStatusSchema = z.object({\n id: z.enum(['pgvector', 'qdrant', 'chromadb']),\n name: z.string(),\n configured: z.boolean(),\n implemented: z.boolean(),\n available: z.boolean().nullable(),\n unavailableReason: z.string().nullable(),\n envVars: z.array(vectorDriverEnvVarSchema),\n})\n\nexport const vectorStoreSettingsResponseSchema = z.object({\n currentDriver: z.enum(['pgvector', 'qdrant', 'chromadb']),\n configured: z.boolean(),\n drivers: z.array(vectorDriverStatusSchema),\n})\n\n// ============================================================================\n// Embeddings Endpoint Schemas (/api/search/embeddings)\n// ============================================================================\n\nexport const embeddingProviderIdSchema = z.enum(['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama'])\n\nexport const embeddingConfigSchema = z.object({\n providerId: embeddingProviderIdSchema,\n model: z.string(),\n dimension: z.number(),\n outputDimensionality: z.number().optional(),\n baseUrl: z.string().optional(),\n updatedAt: z.string().optional(),\n})\n\nexport const embeddingsSettingsSchema = z.object({\n openaiConfigured: z.boolean(),\n autoIndexingEnabled: z.boolean(),\n autoIndexingLocked: z.boolean(),\n lockReason: z.string().nullable(),\n embeddingConfig: embeddingConfigSchema.nullable(),\n configuredProviders: z.array(embeddingProviderIdSchema),\n indexedDimension: z.number().nullable(),\n reindexRequired: z.boolean(),\n documentCount: z.number().nullable(),\n})\n\nexport const embeddingsSettingsResponseSchema = z.object({\n settings: embeddingsSettingsSchema,\n})\n\nexport const embeddingsSettingsUpdateSchema = z.object({\n autoIndexingEnabled: z.boolean().optional(),\n embeddingConfig: z.object({\n providerId: embeddingProviderIdSchema,\n model: z.string(),\n dimension: z.number(),\n outputDimensionality: z.number().optional(),\n baseUrl: z.string().optional(),\n }).optional(),\n})\n\n// ============================================================================\n// Reindex Cancel Schemas (/api/search/reindex/cancel)\n// ============================================================================\n\nexport const reindexCancelResponseSchema = z.object({\n ok: z.boolean(),\n jobsRemoved: z.number(),\n})\n\n// ============================================================================\n// Vector Reindex Schemas (/api/search/embeddings/reindex)\n// ============================================================================\n\nexport const vectorReindexRequestSchema = z.object({\n entityId: z.string().optional().describe('Specific entity ID to reindex (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n purgeFirst: z.boolean().optional().describe('Purge existing entries before reindexing'),\n})\n\nexport const vectorReindexResponseSchema = z.object({\n ok: z.boolean(),\n recordsIndexed: z.number().optional(),\n jobsEnqueued: z.number().optional(),\n entitiesProcessed: z.number().optional(),\n errors: z.array(z.object({\n entityId: z.string(),\n error: z.string(),\n })).optional(),\n})\n\n// ============================================================================\n// OpenAPI Route Docs\n// ============================================================================\n\nexport const searchOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Search across all indexed entities',\n description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground.',\n methods: {\n GET: {\n summary: 'Search across all indexed entities',\n description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground.',\n tags: ['Search'],\n query: searchQueryParamsSchema,\n responses: [\n { status: 200, description: 'Search results', schema: searchResponseSchema },\n { status: 400, description: 'Missing query parameter', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Search failed', schema: errorResponseSchema },\n { status: 503, description: 'Search service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const globalSearchOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Global search (Cmd+K)',\n description: 'Performs a global search using saved tenant strategies. Does NOT accept strategies from URL.',\n methods: {\n GET: {\n summary: 'Global search (Cmd+K)',\n description: 'Performs a global search using saved tenant strategies. Does NOT accept strategies from URL.',\n tags: ['Search'],\n query: globalSearchQueryParamsSchema,\n responses: [\n { status: 200, description: 'Search results', schema: globalSearchResponseSchema },\n { status: 400, description: 'Missing query parameter', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Search failed', schema: errorResponseSchema },\n { status: 503, description: 'Search service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const settingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Get search settings and status',\n description: 'Returns search module configuration, available strategies, and reindex lock status.',\n methods: {\n GET: {\n summary: 'Get search settings and status',\n description: 'Returns search module configuration, available strategies, and reindex lock status.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Search settings', schema: settingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const globalSearchSettingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Global search strategy settings',\n description: 'Manage enabled strategies for Cmd+K global search.',\n methods: {\n GET: {\n summary: 'Get global search strategies',\n description: 'Returns the enabled strategies for Cmd+K global search.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Global search settings', schema: globalSearchSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n POST: {\n summary: 'Update global search strategies',\n description: 'Sets which strategies are enabled for Cmd+K global search.',\n tags: ['Search'],\n requestBody: { schema: globalSearchSettingsUpdateSchema },\n responses: [\n { status: 200, description: 'Updated settings', schema: globalSearchSettingsUpdateResponseSchema },\n { status: 400, description: 'Invalid request', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Internal error', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const reindexOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Trigger fulltext reindex',\n description: 'Starts a fulltext (Meilisearch) reindex operation. Can clear, recreate, or fully reindex.',\n methods: {\n POST: {\n summary: 'Trigger fulltext reindex',\n description: 'Starts a fulltext (Meilisearch) reindex operation. Can clear, recreate, or fully reindex.',\n tags: ['Search'],\n requestBody: { schema: reindexRequestSchema },\n responses: [\n { status: 200, description: 'Reindex result', schema: reindexResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 409, description: 'Reindex already in progress', schema: reindexConflictResponseSchema },\n { status: 500, description: 'Reindex failed', schema: errorResponseSchema },\n { status: 503, description: 'Search service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const reindexCancelOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Cancel fulltext reindex',\n description: 'Cancels an in-progress fulltext reindex operation.',\n methods: {\n POST: {\n summary: 'Cancel fulltext reindex',\n description: 'Cancels an in-progress fulltext reindex operation.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Cancel result', schema: reindexCancelResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const indexOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Vector index management',\n description: 'List and purge vector search index entries.',\n methods: {\n GET: {\n summary: 'List vector index entries',\n description: 'Returns paginated list of entries in the vector search index.',\n tags: ['Search'],\n query: indexListQueryParamsSchema,\n responses: [\n { status: 200, description: 'Index entries', schema: indexListResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Failed to fetch index', schema: errorResponseSchema },\n { status: 503, description: 'Vector strategy unavailable', schema: errorResponseSchema },\n ],\n },\n DELETE: {\n summary: 'Purge vector index',\n description: 'Purges entries from the vector search index. Requires confirmAll=true when purging all entities.',\n tags: ['Search'],\n query: indexPurgeQueryParamsSchema,\n responses: [\n { status: 200, description: 'Purge result', schema: indexPurgeResponseSchema },\n { status: 400, description: 'Missing confirmAll parameter', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Purge failed', schema: errorResponseSchema },\n { status: 503, description: 'Search indexer unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const fulltextSettingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Get fulltext search configuration',\n description: 'Returns Meilisearch configuration status and index statistics.',\n methods: {\n GET: {\n summary: 'Get fulltext search configuration',\n description: 'Returns Meilisearch configuration status and index statistics.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Fulltext settings', schema: fulltextSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const vectorStoreSettingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Get vector store configuration',\n description: 'Returns vector store configuration status.',\n methods: {\n GET: {\n summary: 'Get vector store configuration',\n description: 'Returns vector store configuration status.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Vector store settings', schema: vectorStoreSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const embeddingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Embeddings configuration',\n description: 'Manage embedding provider and model configuration.',\n methods: {\n GET: {\n summary: 'Get embeddings configuration',\n description: 'Returns current embedding provider and model configuration.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Embeddings settings', schema: embeddingsSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n POST: {\n summary: 'Update embeddings configuration',\n description: 'Updates the embedding provider and model settings.',\n tags: ['Search'],\n requestBody: { schema: embeddingsSettingsUpdateSchema },\n responses: [\n { status: 200, description: 'Updated settings', schema: embeddingsSettingsResponseSchema },\n { status: 400, description: 'Invalid request', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 409, description: 'Auto-indexing disabled via environment', schema: errorResponseSchema },\n { status: 500, description: 'Update failed', schema: errorResponseSchema },\n { status: 503, description: 'Configuration service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const embeddingsReindexOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Trigger vector reindex',\n description: 'Starts a vector embedding reindex operation.',\n methods: {\n POST: {\n summary: 'Trigger vector reindex',\n description: 'Starts a vector embedding reindex operation.',\n tags: ['Search'],\n requestBody: { schema: vectorReindexRequestSchema },\n responses: [\n { status: 200, description: 'Reindex result', schema: vectorReindexResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 409, description: 'Reindex already in progress', schema: reindexConflictResponseSchema },\n { status: 500, description: 'Reindex failed', schema: errorResponseSchema },\n { status: 503, description: 'Search indexer unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const embeddingsReindexCancelOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Cancel vector reindex',\n description: 'Cancels an in-progress vector reindex operation.',\n methods: {\n POST: {\n summary: 'Cancel vector reindex',\n description: 'Cancels an in-progress vector reindex operation.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Cancel result', schema: reindexCancelResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n"],
4
+ "sourcesContent": ["import { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\n// ============================================================================\n// Common Schemas\n// ============================================================================\n\nexport const searchStrategyIdSchema = z.enum(['fulltext', 'vector', 'tokens'])\n\nexport const searchResultPresenterSchema = z.object({\n title: z.string(),\n subtitle: z.string().optional(),\n icon: z.string().optional(),\n badge: z.string().optional(),\n})\n\nexport const searchResultLinkSchema = z.object({\n href: z.string(),\n label: z.string(),\n kind: z.enum(['primary', 'secondary']),\n})\n\nexport const searchResultSchema = z.object({\n entityId: z.string().describe('Entity identifier (e.g., \"customers:customer_person_profile\")'),\n recordId: z.string().describe('Primary key of the record'),\n score: z.number().describe('Relevance score (0-1)'),\n source: searchStrategyIdSchema.describe('Which strategy returned this result'),\n presenter: searchResultPresenterSchema.optional(),\n url: z.string().optional(),\n links: z.array(searchResultLinkSchema).optional(),\n metadata: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const errorResponseSchema = z.object({\n error: z.string(),\n})\n\n// ============================================================================\n// Search Endpoint Schemas (/api/search)\n// ============================================================================\n\nexport const searchQueryParamsSchema = z.object({\n q: z.string().describe('Search query (required)'),\n limit: z.coerce.number().min(1).max(100).optional().describe('Maximum results to return (default: 50, max: 100)'),\n strategies: z.string().optional().describe('Comma-separated strategies to use: fulltext, vector, tokens (e.g., \"fulltext,vector\")'),\n entityTypes: z.string().optional().describe('Comma-separated entity types to filter results (e.g., \"customers:customer_person_profile,catalog:catalog_product,sales:sales_order\")'),\n})\n\nexport const searchResponseSchema = z.object({\n results: z.array(searchResultSchema),\n strategiesUsed: z.array(searchStrategyIdSchema),\n timing: z.number().describe('Search duration in milliseconds'),\n query: z.string(),\n limit: z.number(),\n})\n\n// ============================================================================\n// Global Search Endpoint Schemas (/api/search/global)\n// ============================================================================\n\nexport const globalSearchQueryParamsSchema = z.object({\n q: z.string().describe('Search query (required)'),\n limit: z.coerce.number().min(1).max(100).optional().describe('Maximum results to return (default: 50, max: 100)'),\n entityTypes: z.string().optional().describe('Comma-separated entity types to filter results (e.g., \"customers:customer_person_profile,catalog:catalog_product,sales:sales_order\")'),\n})\n\nexport const globalSearchResponseSchema = z.object({\n results: z.array(searchResultSchema),\n strategiesUsed: z.array(searchStrategyIdSchema),\n strategiesEnabled: z.array(searchStrategyIdSchema),\n timing: z.number().describe('Search duration in milliseconds'),\n query: z.string(),\n limit: z.number(),\n})\n\n// ============================================================================\n// Settings Endpoint Schemas (/api/search/settings)\n// ============================================================================\n\nexport const strategyStatusSchema = z.object({\n id: z.string(),\n name: z.string(),\n priority: z.number(),\n available: z.boolean(),\n})\n\nexport const fulltextStatsSchema = z.object({\n numberOfDocuments: z.number(),\n isIndexing: z.boolean(),\n fieldDistribution: z.record(z.string(), z.number()),\n})\n\nexport const reindexLockSchema = z.object({\n type: z.enum(['fulltext', 'vector']),\n action: z.string(),\n startedAt: z.string(),\n elapsedMinutes: z.number(),\n processedCount: z.number().nullable().optional(),\n totalCount: z.number().nullable().optional(),\n})\n\nexport const searchSettingsSchema = z.object({\n strategies: z.array(strategyStatusSchema),\n fulltextConfigured: z.boolean(),\n fulltextStats: fulltextStatsSchema.nullable(),\n vectorConfigured: z.boolean(),\n tokensEnabled: z.boolean(),\n defaultStrategies: z.array(z.string()),\n reindexLock: reindexLockSchema.nullable().describe('Deprecated: Use fulltextReindexLock or vectorReindexLock'),\n fulltextReindexLock: reindexLockSchema.nullable(),\n vectorReindexLock: reindexLockSchema.nullable(),\n})\n\nexport const settingsResponseSchema = z.object({\n settings: searchSettingsSchema,\n})\n\n// ============================================================================\n// Global Search Settings Schemas (/api/search/settings/global-search)\n// ============================================================================\n\nexport const globalSearchSettingsResponseSchema = z.object({\n enabledStrategies: z.array(searchStrategyIdSchema),\n})\n\nexport const globalSearchSettingsUpdateSchema = z.object({\n enabledStrategies: z.array(searchStrategyIdSchema).min(1),\n})\n\nexport const globalSearchSettingsUpdateResponseSchema = z.object({\n ok: z.boolean(),\n enabledStrategies: z.array(searchStrategyIdSchema),\n})\n\n// ============================================================================\n// Reindex Endpoint Schemas (/api/search/reindex)\n// ============================================================================\n\nexport const reindexRequestSchema = z.object({\n action: z.enum(['clear', 'recreate', 'reindex']).optional().describe('Action to perform (default: reindex)'),\n entityId: z.string().optional().describe('Specific entity ID to reindex (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n useQueue: z.boolean().optional().describe('Whether to use queue (default: true)'),\n})\n\nexport const reindexResultSchema = z.object({\n entitiesProcessed: z.number().optional(),\n recordsIndexed: z.number().optional(),\n jobsEnqueued: z.number().optional(),\n errors: z.array(z.object({\n entityId: z.string(),\n error: z.string(),\n })).optional(),\n})\n\nexport const reindexResponseSchema = z.object({\n ok: z.boolean(),\n action: z.enum(['clear', 'recreate', 'reindex']),\n entityId: z.string().nullable(),\n useQueue: z.boolean().optional(),\n result: reindexResultSchema.optional(),\n stats: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional(),\n})\n\nexport const reindexConflictResponseSchema = z.object({\n error: z.string(),\n lock: reindexLockSchema,\n})\n\n// ============================================================================\n// Index Endpoint Schemas (/api/search/index)\n// ============================================================================\n\nexport const indexEntrySchema = z.object({\n id: z.string(),\n entityId: z.string(),\n recordId: z.string(),\n tenantId: z.string(),\n organizationId: z.string().nullable().optional(),\n title: z.string().optional(),\n subtitle: z.string().optional(),\n createdAt: z.string().optional(),\n})\n\nexport const indexListQueryParamsSchema = z.object({\n entityId: z.string().optional().describe('Filter by entity ID (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n limit: z.coerce.number().min(1).max(200).optional().describe('Maximum entries to return (default: 50, max: 200)'),\n offset: z.coerce.number().min(0).optional().describe('Offset for pagination (default: 0)'),\n})\n\nexport const indexListResponseSchema = z.object({\n entries: z.array(indexEntrySchema),\n limit: z.number(),\n offset: z.number(),\n})\n\nexport const indexPurgeQueryParamsSchema = z.object({\n entityId: z.string().optional().describe('Specific entity ID to purge (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n confirmAll: z.enum(['true']).optional().describe('Required when purging all entities'),\n})\n\nexport const indexPurgeResponseSchema = z.object({\n ok: z.boolean(),\n})\n\n// ============================================================================\n// Fulltext Settings Schemas (/api/search/settings/fulltext)\n// ============================================================================\n\nexport const fulltextEnvVarStatusSchema = z.object({\n set: z.boolean(),\n hint: z.string(),\n})\n\nexport const fulltextOptionalEnvVarStatusSchema = z.object({\n set: z.boolean(),\n value: z.union([z.string(), z.boolean()]).optional(),\n default: z.union([z.string(), z.boolean()]).optional(),\n hint: z.string(),\n})\n\nexport const fulltextSettingsResponseSchema = z.object({\n driver: z.enum(['meilisearch']).nullable(),\n configured: z.boolean(),\n envVars: z.object({\n MEILISEARCH_HOST: fulltextEnvVarStatusSchema,\n MEILISEARCH_API_KEY: fulltextEnvVarStatusSchema,\n }),\n optionalEnvVars: z.object({\n MEILISEARCH_INDEX_PREFIX: fulltextOptionalEnvVarStatusSchema,\n SEARCH_EXCLUDE_ENCRYPTED_FIELDS: fulltextOptionalEnvVarStatusSchema,\n }),\n})\n\n// ============================================================================\n// Vector Store Settings Schemas (/api/search/settings/vector-store)\n// ============================================================================\n\nexport const vectorDriverEnvVarSchema = z.object({\n name: z.string(),\n set: z.boolean(),\n hint: z.string(),\n})\n\nexport const vectorDriverStatusSchema = z.object({\n id: z.enum(['pgvector', 'qdrant', 'chromadb']),\n name: z.string(),\n configured: z.boolean(),\n implemented: z.boolean(),\n available: z.boolean().nullable(),\n unavailableReason: z.string().nullable(),\n envVars: z.array(vectorDriverEnvVarSchema),\n})\n\nexport const vectorStoreSettingsResponseSchema = z.object({\n currentDriver: z.enum(['pgvector', 'qdrant', 'chromadb']),\n configured: z.boolean(),\n drivers: z.array(vectorDriverStatusSchema),\n})\n\n// ============================================================================\n// Embeddings Endpoint Schemas (/api/search/embeddings)\n// ============================================================================\n\nexport const embeddingProviderIdSchema = z.enum(['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama'])\n\nexport const embeddingConfigSchema = z.object({\n providerId: embeddingProviderIdSchema,\n model: z.string(),\n dimension: z.number(),\n outputDimensionality: z.number().optional(),\n baseUrl: z.string().optional(),\n updatedAt: z.string().optional(),\n})\n\nexport const embeddingsSettingsSchema = z.object({\n openaiConfigured: z.boolean(),\n autoIndexingEnabled: z.boolean(),\n autoIndexingLocked: z.boolean(),\n lockReason: z.string().nullable(),\n embeddingConfig: embeddingConfigSchema.nullable(),\n configuredProviders: z.array(embeddingProviderIdSchema),\n indexedDimension: z.number().nullable(),\n reindexRequired: z.boolean(),\n documentCount: z.number().nullable(),\n})\n\nexport const embeddingsSettingsResponseSchema = z.object({\n settings: embeddingsSettingsSchema,\n})\n\nexport const embeddingsSettingsUpdateSchema = z.object({\n autoIndexingEnabled: z.boolean().optional(),\n embeddingConfig: z.object({\n providerId: embeddingProviderIdSchema,\n model: z.string(),\n dimension: z.number(),\n outputDimensionality: z.number().optional(),\n baseUrl: z.string().optional(),\n }).optional(),\n})\n\n// ============================================================================\n// Reindex Cancel Schemas (/api/search/reindex/cancel)\n// ============================================================================\n\nexport const reindexCancelResponseSchema = z.object({\n ok: z.boolean(),\n jobsRemoved: z.number(),\n})\n\n// ============================================================================\n// Vector Reindex Schemas (/api/search/embeddings/reindex)\n// ============================================================================\n\nexport const vectorReindexRequestSchema = z.object({\n entityId: z.string().optional().describe('Specific entity ID to reindex (e.g., \"customers:customer_person_profile\", \"catalog:catalog_product\")'),\n purgeFirst: z.boolean().optional().describe('Purge existing entries before reindexing'),\n})\n\nexport const vectorReindexResponseSchema = z.object({\n ok: z.boolean(),\n recordsIndexed: z.number().optional(),\n jobsEnqueued: z.number().optional(),\n entitiesProcessed: z.number().optional(),\n errors: z.array(z.object({\n entityId: z.string(),\n error: z.string(),\n })).optional(),\n})\n\n// ============================================================================\n// OpenAPI Route Docs\n// ============================================================================\n\nexport const searchOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Search across all indexed entities',\n description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground. Results are limited to the entity types the caller holds the declared view features for; superadmins are exempt.',\n methods: {\n GET: {\n summary: 'Search across all indexed entities',\n description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground. Results are limited to the entity types the caller holds the declared view features for; superadmins are exempt.',\n tags: ['Search'],\n query: searchQueryParamsSchema,\n responses: [\n { status: 200, description: 'Search results', schema: searchResponseSchema },\n { status: 400, description: 'Missing query parameter', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Search failed', schema: errorResponseSchema },\n { status: 503, description: 'Search service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const globalSearchOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Global search (Cmd+K)',\n description: 'Performs a global search using saved tenant strategies. Does NOT accept strategies from URL.',\n methods: {\n GET: {\n summary: 'Global search (Cmd+K)',\n description: 'Performs a global search using saved tenant strategies. Does NOT accept strategies from URL.',\n tags: ['Search'],\n query: globalSearchQueryParamsSchema,\n responses: [\n { status: 200, description: 'Search results', schema: globalSearchResponseSchema },\n { status: 400, description: 'Missing query parameter', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Search failed', schema: errorResponseSchema },\n { status: 503, description: 'Search service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const settingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Get search settings and status',\n description: 'Returns search module configuration, available strategies, and reindex lock status.',\n methods: {\n GET: {\n summary: 'Get search settings and status',\n description: 'Returns search module configuration, available strategies, and reindex lock status.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Search settings', schema: settingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const globalSearchSettingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Global search strategy settings',\n description: 'Manage enabled strategies for Cmd+K global search.',\n methods: {\n GET: {\n summary: 'Get global search strategies',\n description: 'Returns the enabled strategies for Cmd+K global search.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Global search settings', schema: globalSearchSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n POST: {\n summary: 'Update global search strategies',\n description: 'Sets which strategies are enabled for Cmd+K global search.',\n tags: ['Search'],\n requestBody: { schema: globalSearchSettingsUpdateSchema },\n responses: [\n { status: 200, description: 'Updated settings', schema: globalSearchSettingsUpdateResponseSchema },\n { status: 400, description: 'Invalid request', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Internal error', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const reindexOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Trigger fulltext reindex',\n description: 'Starts a fulltext (Meilisearch) reindex operation. Can clear, recreate, or fully reindex.',\n methods: {\n POST: {\n summary: 'Trigger fulltext reindex',\n description: 'Starts a fulltext (Meilisearch) reindex operation. Can clear, recreate, or fully reindex.',\n tags: ['Search'],\n requestBody: { schema: reindexRequestSchema },\n responses: [\n { status: 200, description: 'Reindex result', schema: reindexResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 409, description: 'Reindex already in progress', schema: reindexConflictResponseSchema },\n { status: 500, description: 'Reindex failed', schema: errorResponseSchema },\n { status: 503, description: 'Search service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const reindexCancelOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Cancel fulltext reindex',\n description: 'Cancels an in-progress fulltext reindex operation.',\n methods: {\n POST: {\n summary: 'Cancel fulltext reindex',\n description: 'Cancels an in-progress fulltext reindex operation.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Cancel result', schema: reindexCancelResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const indexOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Vector index management',\n description: 'List and purge vector search index entries.',\n methods: {\n GET: {\n summary: 'List vector index entries',\n description: 'Returns paginated list of entries in the vector search index.',\n tags: ['Search'],\n query: indexListQueryParamsSchema,\n responses: [\n { status: 200, description: 'Index entries', schema: indexListResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Failed to fetch index', schema: errorResponseSchema },\n { status: 503, description: 'Vector strategy unavailable', schema: errorResponseSchema },\n ],\n },\n DELETE: {\n summary: 'Purge vector index',\n description: 'Purges entries from the vector search index. Requires confirmAll=true when purging all entities.',\n tags: ['Search'],\n query: indexPurgeQueryParamsSchema,\n responses: [\n { status: 200, description: 'Purge result', schema: indexPurgeResponseSchema },\n { status: 400, description: 'Missing confirmAll parameter', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 500, description: 'Purge failed', schema: errorResponseSchema },\n { status: 503, description: 'Search indexer unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const fulltextSettingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Get fulltext search configuration',\n description: 'Returns Meilisearch configuration status and index statistics.',\n methods: {\n GET: {\n summary: 'Get fulltext search configuration',\n description: 'Returns Meilisearch configuration status and index statistics.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Fulltext settings', schema: fulltextSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const vectorStoreSettingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Get vector store configuration',\n description: 'Returns vector store configuration status.',\n methods: {\n GET: {\n summary: 'Get vector store configuration',\n description: 'Returns vector store configuration status.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Vector store settings', schema: vectorStoreSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const embeddingsOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Embeddings configuration',\n description: 'Manage embedding provider and model configuration.',\n methods: {\n GET: {\n summary: 'Get embeddings configuration',\n description: 'Returns current embedding provider and model configuration.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Embeddings settings', schema: embeddingsSettingsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n POST: {\n summary: 'Update embeddings configuration',\n description: 'Updates the embedding provider and model settings.',\n tags: ['Search'],\n requestBody: { schema: embeddingsSettingsUpdateSchema },\n responses: [\n { status: 200, description: 'Updated settings', schema: embeddingsSettingsResponseSchema },\n { status: 400, description: 'Invalid request', schema: errorResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 409, description: 'Auto-indexing disabled via environment', schema: errorResponseSchema },\n { status: 500, description: 'Update failed', schema: errorResponseSchema },\n { status: 503, description: 'Configuration service unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const embeddingsReindexOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Trigger vector reindex',\n description: 'Starts a vector embedding reindex operation.',\n methods: {\n POST: {\n summary: 'Trigger vector reindex',\n description: 'Starts a vector embedding reindex operation.',\n tags: ['Search'],\n requestBody: { schema: vectorReindexRequestSchema },\n responses: [\n { status: 200, description: 'Reindex result', schema: vectorReindexResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n { status: 409, description: 'Reindex already in progress', schema: reindexConflictResponseSchema },\n { status: 500, description: 'Reindex failed', schema: errorResponseSchema },\n { status: 503, description: 'Search indexer unavailable', schema: errorResponseSchema },\n ],\n },\n },\n}\n\nexport const embeddingsReindexCancelOpenApi: OpenApiRouteDoc = {\n tag: 'Search',\n summary: 'Cancel vector reindex',\n description: 'Cancels an in-progress vector reindex operation.',\n methods: {\n POST: {\n summary: 'Cancel vector reindex',\n description: 'Cancels an in-progress vector reindex operation.',\n tags: ['Search'],\n responses: [\n { status: 200, description: 'Cancel result', schema: reindexCancelResponseSchema },\n { status: 401, description: 'Unauthorized', schema: errorResponseSchema },\n ],\n },\n },\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -5,7 +5,11 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
5
5
  import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
6
6
  import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
7
7
  import { resolveEmbeddingConfig } from "../../lib/embedding-config.js";
8
- import { searchError } from "../../../../lib/debug.js";
8
+ import {
9
+ filterSearchResultsByEntityAccess,
10
+ resolveReadableEntityTypes
11
+ } from "../../lib/entity-access.js";
12
+ import { searchDebug, searchError } from "../../../../lib/debug.js";
9
13
  import { searchOpenApi } from "../openapi.js";
10
14
  const metadata = {
11
15
  GET: { requireAuth: true, requireFeatures: ["search.view"] }
@@ -55,6 +59,18 @@ async function GET(req) {
55
59
  { status: 503 }
56
60
  );
57
61
  }
62
+ if (!container.hasRegistration("rbacService") || !container.hasRegistration("searchIndexer")) {
63
+ searchError("search.api.search", "entity-acl-unavailable", {
64
+ rbacService: container.hasRegistration("rbacService"),
65
+ searchIndexer: container.hasRegistration("searchIndexer")
66
+ });
67
+ return NextResponse.json(
68
+ { error: t("search.api.errors.serviceUnavailable", "Search service unavailable") },
69
+ { status: 503 }
70
+ );
71
+ }
72
+ const rbac = container.resolve("rbacService");
73
+ const searchIndexer = container.resolve("searchIndexer");
58
74
  try {
59
75
  const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null });
60
76
  if (embeddingConfig) {
@@ -76,15 +92,35 @@ async function GET(req) {
76
92
  }
77
93
  const scopeFilter = resolveOrganizationScopeFilter(scope, auth);
78
94
  const organizationId = typeof scope.selectedId === "string" && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : void 0;
95
+ const acl = await rbac.loadAcl(auth.sub, {
96
+ tenantId: scope.tenantId ?? auth.tenantId ?? null,
97
+ organizationId: organizationId ?? null
98
+ });
99
+ const subject = { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin };
100
+ const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, entityTypes);
101
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
102
+ return NextResponse.json({
103
+ results: [],
104
+ strategiesUsed: [],
105
+ timing: Date.now() - startTime,
106
+ query,
107
+ limit
108
+ });
109
+ }
79
110
  const searchOptions = {
80
111
  tenantId: auth.tenantId,
81
112
  organizationId,
82
113
  organizationIds: scopeFilter.organizationIds,
83
114
  limit,
84
115
  strategies,
85
- entityTypes
116
+ entityTypes: readableEntityTypes
86
117
  };
87
- const results = await searchService.search(query, searchOptions);
118
+ const rawResults = await searchService.search(query, searchOptions);
119
+ const results = filterSearchResultsByEntityAccess(rawResults, searchIndexer, subject, {
120
+ onDeny: (deniedEntityId, reason) => {
121
+ searchDebug("search.api.search", "entity-filtered", { entityId: deniedEntityId, reason });
122
+ }
123
+ });
88
124
  const timing = Date.now() - startTime;
89
125
  const strategiesUsed = [...new Set(results.map((r) => r.source))];
90
126
  return NextResponse.json({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/search/api/search/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { SearchService } from '@open-mercato/search'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { EmbeddingService } from '../../../../vector'\nimport { resolveEmbeddingConfig } from '../../lib/embedding-config'\nimport { searchError } from '../../../../lib/debug'\nimport { searchOpenApi } from '../openapi'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.view'] },\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseStrategies(value: string | null): SearchStrategyId[] | undefined {\n if (!value) return undefined\n const strategies = value.split(',').map((s) => s.trim()).filter(Boolean) as SearchStrategyId[]\n return strategies.length > 0 ? strategies : undefined\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const strategies = parseStrategies(url.searchParams.get('strategies'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Load embedding config for vector strategy (same as Vector Search playground)\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n timing: 0,\n query,\n limit,\n })\n }\n\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n organizationIds: scopeFilter.organizationIds,\n limit,\n strategies,\n entityTypes,\n }\n\n const results = await searchService.search(query, searchOptions)\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n // Log full error details server-side only\n searchError('search.api.search', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n // Return generic message to client - don't expose internal error details\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = searchOpenApi\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { SearchService } from '@open-mercato/search'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\nimport type { EmbeddingService } from '../../../../vector'\nimport { resolveEmbeddingConfig } from '../../lib/embedding-config'\nimport {\n filterSearchResultsByEntityAccess,\n resolveReadableEntityTypes,\n type SearchEntityConfigLookup,\n} from '../../lib/entity-access'\nimport { searchDebug, searchError } from '../../../../lib/debug'\nimport { searchOpenApi } from '../openapi'\n\n/**\n * `search.view` \u2014 the search-administration feature behind the Vector Search\n * playground. It authorizes using this diagnostic surface, not reading every\n * indexed record, so the per-entity `aclFeatures` gate below still applies.\n */\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.view'] },\n}\n\ntype RbacLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nfunction parseLimit(value: string | null): number {\n if (!value) return 50\n const parsed = Number.parseInt(value, 10)\n if (Number.isNaN(parsed) || parsed <= 0) return 50\n return Math.min(parsed, 100)\n}\n\nfunction parseStrategies(value: string | null): SearchStrategyId[] | undefined {\n if (!value) return undefined\n const strategies = value.split(',').map((s) => s.trim()).filter(Boolean) as SearchStrategyId[]\n return strategies.length > 0 ? strategies : undefined\n}\n\nfunction parseEntityTypes(value: string | null): string[] | undefined {\n if (!value) return undefined\n const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)\n return entityTypes.length > 0 ? entityTypes : undefined\n}\n\nexport async function GET(req: Request) {\n const { t } = await resolveTranslations()\n const url = new URL(req.url)\n const query = (url.searchParams.get('q') || '').trim()\n const limit = parseLimit(url.searchParams.get('limit'))\n const strategies = parseStrategies(url.searchParams.get('strategies'))\n const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))\n\n if (!query) {\n return NextResponse.json(\n { error: t('search.api.errors.missingQuery', 'Missing query') },\n { status: 400 }\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json(\n { error: t('api.errors.unauthorized', 'Unauthorized') },\n { status: 401 }\n )\n }\n\n const container = await createRequestContainer()\n try {\n const searchService = container.resolve('searchService') as SearchService | undefined\n if (!searchService) {\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n\n // Fail closed: without the RBAC service or the entity registry there is no way\n // to tell which entity types this caller may read, so refuse rather than search.\n if (!container.hasRegistration('rbacService') || !container.hasRegistration('searchIndexer')) {\n searchError('search.api.search', 'entity-acl-unavailable', {\n rbacService: container.hasRegistration('rbacService'),\n searchIndexer: container.hasRegistration('searchIndexer'),\n })\n return NextResponse.json(\n { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },\n { status: 503 }\n )\n }\n const rbac = container.resolve('rbacService') as RbacLike\n const searchIndexer = container.resolve('searchIndexer') as SearchEntityConfigLookup\n\n // Load embedding config for vector strategy (same as Vector Search playground)\n try {\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n if (embeddingConfig) {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n }\n } catch {\n // Embedding config not available, vector strategy may not work\n }\n\n const startTime = Date.now()\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (Array.isArray(scope.filterIds) && scope.filterIds.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n timing: 0,\n query,\n limit,\n })\n }\n\n const scopeFilter = resolveOrganizationScopeFilter(scope, auth)\n const organizationId =\n typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined\n\n // `search.view` authorizes the playground, not reading every indexed record.\n // Narrow the query to the entity types this caller may read so the result\n // budget is not spent on records that would only be filtered out.\n const acl = await rbac.loadAcl(auth.sub, {\n tenantId: scope.tenantId ?? auth.tenantId ?? null,\n organizationId: organizationId ?? null,\n })\n const subject = { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin }\n const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, entityTypes)\n if (readableEntityTypes && readableEntityTypes.length === 0) {\n return NextResponse.json({\n results: [],\n strategiesUsed: [],\n timing: Date.now() - startTime,\n query,\n limit,\n })\n }\n\n const searchOptions = {\n tenantId: auth.tenantId,\n organizationId,\n organizationIds: scopeFilter.organizationIds,\n limit,\n strategies,\n entityTypes: readableEntityTypes,\n }\n\n const rawResults = await searchService.search(query, searchOptions)\n\n // Defense in depth: a strategy that ignores `entityTypes` must still not leak\n // a presenter title, subtitle or deep link past the per-entity gate.\n const results = filterSearchResultsByEntityAccess(rawResults, searchIndexer, subject, {\n onDeny: (deniedEntityId, reason) => {\n searchDebug('search.api.search', 'entity-filtered', { entityId: deniedEntityId, reason })\n },\n })\n\n const timing = Date.now() - startTime\n\n // Collect unique strategies that returned results\n const strategiesUsed = [...new Set(results.map((r) => r.source))]\n\n return NextResponse.json({\n results,\n strategiesUsed,\n timing,\n query,\n limit,\n })\n } catch (error: unknown) {\n // Log full error details server-side only\n searchError('search.api.search', 'failed', {\n error: error instanceof Error ? error.message : error,\n stack: error instanceof Error ? error.stack : undefined,\n })\n // Return generic message to client - don't expose internal error details\n return NextResponse.json(\n { error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },\n { status: 500 }\n )\n } finally {\n const disposable = container as unknown as { dispose?: () => Promise<void> }\n if (typeof disposable.dispose === 'function') {\n await disposable.dispose()\n }\n }\n}\n\nexport const openApi = searchOpenApi\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/search",
3
- "version": "0.6.8-develop.6969.1.7a32706312",
3
+ "version": "0.6.8-develop.6971.1.20c09ca9ea",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -127,9 +127,9 @@
127
127
  "zod": "^4.4.3"
128
128
  },
129
129
  "peerDependencies": {
130
- "@open-mercato/core": "0.6.8-develop.6969.1.7a32706312",
131
- "@open-mercato/queue": "0.6.8-develop.6969.1.7a32706312",
132
- "@open-mercato/shared": "0.6.8-develop.6969.1.7a32706312"
130
+ "@open-mercato/core": "0.6.8-develop.6971.1.20c09ca9ea",
131
+ "@open-mercato/queue": "0.6.8-develop.6971.1.20c09ca9ea",
132
+ "@open-mercato/shared": "0.6.8-develop.6971.1.20c09ca9ea"
133
133
  },
134
134
  "devDependencies": {
135
135
  "@types/jest": "^30.0.0",
@@ -68,9 +68,12 @@ test.describe('TC-SEARCH-003: search API auth & feature gates (401/403)', () =>
68
68
  const viewerSearch = await apiRequest(request, 'GET', '/api/search/search?q=qa-search-003', { token: viewerToken })
69
69
  expect(viewerSearch.status(), 'viewer with search.view must not be unauthorized on search').not.toBe(401)
70
70
  expect(viewerSearch.status(), 'viewer with search.view must not be forbidden on search').not.toBe(403)
71
- // The search route resolves searchService and returns 200 with results
72
- // (empty when no strategy returns hits) it does not 503 here — so a passing
73
- // gate yields 200.
71
+ // The search route resolves searchService, rbacService and searchIndexer
72
+ // all registered in this environment, so the fail-closed 503 added for
73
+ // issue #5168 is unreachable here — and returns 200 with results. This
74
+ // viewer holds no per-entity view feature, so the per-entity ACL filter
75
+ // narrows the readable set to nothing and the results come back empty;
76
+ // the gate assertion is about the status code, which stays 200.
74
77
  expect(viewerSearch.status(), 'search.view passes the gate, so the search succeeds with 200').toBe(200)
75
78
 
76
79
  const viewerReindex = await apiRequest(request, 'POST', '/api/search/reindex', { token: viewerToken, data: {} })
@@ -43,18 +43,38 @@ describe('global search ACL contract', () => {
43
43
  describe('searchable entity ACL coverage', () => {
44
44
  const repoRoot = join(__dirname, '..', '..', '..', '..', '..', '..')
45
45
 
46
+ // `entityId:` is written either as a string literal or as a module-level const,
47
+ // so the token is resolved against the file rather than matched literally.
48
+ function resolveEntityIdToken(source: string, token: string): string | null {
49
+ const literal = token.match(/^'(.+)'$/)
50
+ if (literal) return literal[1]
51
+ const declaration = source.match(
52
+ new RegExp(`^const ${token} = '([^']+)'`, 'm'),
53
+ )
54
+ return declaration ? declaration[1] : null
55
+ }
56
+
46
57
  function readEntityBlock(file: string, entityId: string): string {
47
58
  const source = readFileSync(file, 'utf8')
48
- const start = source.indexOf(` entityId: '${entityId}',`)
49
- expect(start).toBeGreaterThan(-1)
59
+ const declaration = [...source.matchAll(/^ {6}entityId: (.+),$/gm)].find(
60
+ (match) => resolveEntityIdToken(source, match[1]) === entityId,
61
+ )
62
+ expect(declaration).toBeDefined()
63
+ const start = declaration!.index!
50
64
  const nextEntity = source.indexOf('\n {', start + 1)
51
65
  return source.slice(start, nextEntity === -1 ? undefined : nextEntity)
52
66
  }
53
67
 
54
68
  function findSearchConfigFiles(): string[] {
69
+ // `apps/mercato` and the create-app template are scanned too: the example
70
+ // module's `example:todo` is indexed but had no `search.ts` at all, so the
71
+ // original #5168 fix made it fail closed and broke TC-EXAMPLE-001. The scan
72
+ // stopping at packages/ is why that gap reached CI unnoticed.
55
73
  const roots = [
56
74
  join(repoRoot, 'packages', 'core', 'src', 'modules'),
57
75
  join(repoRoot, 'packages', 'checkout', 'src', 'modules'),
76
+ join(repoRoot, 'apps', 'mercato', 'src', 'modules'),
77
+ join(repoRoot, 'packages', 'create-app', 'template', 'src', 'modules'),
58
78
  ].filter((dir) => existsSync(dir))
59
79
 
60
80
  return roots.flatMap((root) =>
@@ -96,6 +116,19 @@ describe('searchable entity ACL coverage', () => {
96
116
  expect(offer).toContain("aclFeatures: ['sales.channels.manage']")
97
117
  })
98
118
 
119
+ it('gates example todos on the feature their own list route enforces', () => {
120
+ // Regression for the revert that pulled #5169 out of #5167: with the hybrid
121
+ // route filtering by `aclFeatures`, an indexed-but-unconfigured `example:todo`
122
+ // is denied to every non-superadmin, which is what broke TC-EXAMPLE-001. The
123
+ // fix is the config below, not a weaker filter — so both copies must keep it.
124
+ for (const root of [
125
+ join(repoRoot, 'apps', 'mercato', 'src', 'modules', 'example', 'search.ts'),
126
+ join(repoRoot, 'packages', 'create-app', 'template', 'src', 'modules', 'example', 'search.ts'),
127
+ ]) {
128
+ expect(readEntityBlock(root, 'example:todo')).toContain("aclFeatures: ['example.todos.view']")
129
+ }
130
+ })
131
+
99
132
  it('keeps record-scoped and polymorphic entities disabled until search can enforce their row access', () => {
100
133
  const messages = join(repoRoot, 'packages', 'core', 'src', 'modules', 'messages', 'search.ts')
101
134
  const sales = join(repoRoot, 'packages', 'core', 'src', 'modules', 'sales', 'search.ts')
@@ -0,0 +1,208 @@
1
+ const mockGetAuthFromRequest = jest.fn()
2
+ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
3
+ getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
4
+ }))
5
+
6
+ const mockCreateRequestContainer = jest.fn()
7
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
8
+ createRequestContainer: (...args: unknown[]) => mockCreateRequestContainer(...args),
9
+ }))
10
+
11
+ const mockResolveOrganizationScopeForRequest = jest.fn()
12
+ jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
13
+ resolveOrganizationScopeForRequest: (...args: unknown[]) => mockResolveOrganizationScopeForRequest(...args),
14
+ }))
15
+
16
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
17
+ resolveTranslations: async () => ({
18
+ t: (key: string, fallback?: string) => fallback ?? key,
19
+ }),
20
+ }))
21
+
22
+ jest.mock('../../lib/embedding-config', () => ({
23
+ resolveEmbeddingConfig: jest.fn().mockResolvedValue(null),
24
+ }))
25
+
26
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
27
+ import type { SearchEntityConfig, SearchResult, SearchStrategyId } from '../../../../types'
28
+ import { GET } from '../search/route'
29
+
30
+ const DEMO_ENTITY_ID = 'demo:thing' as EntityId
31
+
32
+ function buildResults(): SearchResult[] {
33
+ return ['fulltext-record', 'vector-record', 'tokens-record'].map((recordId, index) => ({
34
+ entityId: DEMO_ENTITY_ID,
35
+ recordId,
36
+ organizationId: 'org-1',
37
+ score: 1,
38
+ source: (['fulltext', 'vector', 'tokens'] as SearchStrategyId[])[index],
39
+ presenter: { title: recordId, badge: 'Person' },
40
+ links: [{ href: `/backend/demo/${recordId}`, label: 'Open person', kind: 'primary' }],
41
+ }))
42
+ }
43
+
44
+ /**
45
+ * The route resolves `rbacService` and `searchIndexer` to drop results whose entity
46
+ * type the caller has no view feature for (issue #5168), so the container mock has
47
+ * to answer for both.
48
+ */
49
+ function createContainer(
50
+ searchService: { search: jest.Mock },
51
+ acl: { features: string[]; isSuperAdmin?: boolean },
52
+ configs: SearchEntityConfig[] = [
53
+ { entityId: DEMO_ENTITY_ID, enabled: true, aclFeatures: ['demo.view'] },
54
+ ],
55
+ ) {
56
+ const configMap = new Map(configs.map((config) => [config.entityId, config]))
57
+ const registrations: Record<string, unknown> = {
58
+ searchService,
59
+ searchIndexer: {
60
+ getEntityConfig: (entityId: string) => configMap.get(entityId as EntityId),
61
+ getAllEntityConfigs: () => [...configMap.values()],
62
+ },
63
+ rbacService: {
64
+ loadAcl: async () => ({
65
+ isSuperAdmin: acl.isSuperAdmin ?? false,
66
+ features: acl.features,
67
+ organizations: null,
68
+ }),
69
+ },
70
+ }
71
+ return {
72
+ hasRegistration: (name: string) => name in registrations,
73
+ resolve: jest.fn((name: string) => registrations[name]),
74
+ dispose: jest.fn().mockResolvedValue(undefined),
75
+ }
76
+ }
77
+
78
+ describe('GET /api/search/search per-entity access control', () => {
79
+ beforeEach(() => {
80
+ jest.clearAllMocks()
81
+ mockGetAuthFromRequest.mockResolvedValue({
82
+ tenantId: 'tenant-1',
83
+ orgId: 'org-1',
84
+ sub: 'user-1',
85
+ isSuperAdmin: false,
86
+ })
87
+ mockResolveOrganizationScopeForRequest.mockResolvedValue({
88
+ selectedId: 'org-1',
89
+ filterIds: ['org-1'],
90
+ allowedIds: ['org-1'],
91
+ tenantId: 'tenant-1',
92
+ })
93
+ })
94
+
95
+ function createSearchService() {
96
+ return { search: jest.fn().mockResolvedValue(buildResults()) }
97
+ }
98
+
99
+ async function search(acl: { features: string[]; isSuperAdmin?: boolean }) {
100
+ const searchService = createSearchService()
101
+ mockCreateRequestContainer.mockResolvedValue(createContainer(searchService, acl))
102
+ const response = await GET(new Request('http://localhost/api/search/search?q=person'))
103
+ return {
104
+ searchService,
105
+ status: response.status,
106
+ body: await response.json() as { results: SearchResult[]; strategiesUsed: SearchStrategyId[] },
107
+ }
108
+ }
109
+
110
+ it('withholds results for entity types the caller cannot view', async () => {
111
+ // `search.view` alone opens the Vector Search playground; it must not expose
112
+ // presenter titles, subtitles or deep links for records the caller has no view
113
+ // feature for.
114
+ const { status, body, searchService } = await search({ features: ['search.view'] })
115
+
116
+ expect(status).toBe(200)
117
+ expect(body.results).toEqual([])
118
+ expect(body.strategiesUsed).toEqual([])
119
+ // Nothing readable short-circuits before a strategy is ever asked.
120
+ expect(searchService.search).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('returns results once the caller holds the entity view feature', async () => {
124
+ const { status, body } = await search({ features: ['search.view', 'demo.view'] })
125
+
126
+ expect(status).toBe(200)
127
+ expect(body.results).toHaveLength(3)
128
+ })
129
+
130
+ it('returns results for a superadmin without any explicit grant', async () => {
131
+ const { status, body } = await search({ features: [], isSuperAdmin: true })
132
+
133
+ expect(status).toBe(200)
134
+ expect(body.results).toHaveLength(3)
135
+ })
136
+
137
+ it('narrows the query to the readable entity types instead of only filtering afterwards', async () => {
138
+ // Filtering after the fact would spend `limit` on unreadable records and leave
139
+ // the playground looking empty, so the restriction has to reach the strategies.
140
+ const { searchService } = await search({ features: ['search.view', 'demo.view'] })
141
+
142
+ expect(searchService.search).toHaveBeenCalledTimes(1)
143
+ const options = searchService.search.mock.calls[0][1] as { entityTypes?: string[] }
144
+ expect(options.entityTypes).toEqual([DEMO_ENTITY_ID])
145
+ })
146
+
147
+ it('intersects an explicitly requested entity type with the readable set', async () => {
148
+ const searchService = createSearchService()
149
+ mockCreateRequestContainer.mockResolvedValue(
150
+ createContainer(searchService, { features: ['search.view', 'demo.view'] }, [
151
+ { entityId: DEMO_ENTITY_ID, enabled: true, aclFeatures: ['demo.view'] },
152
+ { entityId: 'secret:thing' as EntityId, enabled: true, aclFeatures: ['secret.view'] },
153
+ ]),
154
+ )
155
+
156
+ const response = await GET(
157
+ new Request(`http://localhost/api/search/search?q=person&entityTypes=${DEMO_ENTITY_ID},secret:thing`),
158
+ )
159
+
160
+ expect(response.status).toBe(200)
161
+ const options = searchService.search.mock.calls[0][1] as { entityTypes?: string[] }
162
+ expect(options.entityTypes).toEqual([DEMO_ENTITY_ID])
163
+ })
164
+
165
+ it('drops results a strategy returned for an unreadable entity type', async () => {
166
+ // Defense in depth: `entityTypes` is a request to the strategies, not a guarantee.
167
+ const searchService = {
168
+ search: jest.fn().mockResolvedValue([
169
+ ...buildResults(),
170
+ {
171
+ entityId: 'secret:thing' as EntityId,
172
+ recordId: 'secret-record',
173
+ score: 1,
174
+ source: 'tokens' as SearchStrategyId,
175
+ presenter: { title: 'secret-record' },
176
+ },
177
+ ]),
178
+ }
179
+ mockCreateRequestContainer.mockResolvedValue(
180
+ createContainer(searchService, { features: ['search.view', 'demo.view'] }, [
181
+ { entityId: DEMO_ENTITY_ID, enabled: true, aclFeatures: ['demo.view'] },
182
+ { entityId: 'secret:thing' as EntityId, enabled: true, aclFeatures: ['secret.view'] },
183
+ ]),
184
+ )
185
+
186
+ const response = await GET(new Request('http://localhost/api/search/search?q=person'))
187
+ const body = await response.json() as { results: SearchResult[] }
188
+
189
+ expect(response.status).toBe(200)
190
+ expect(body.results).toHaveLength(3)
191
+ expect(body.results.every((result) => result.entityId === DEMO_ENTITY_ID)).toBe(true)
192
+ })
193
+
194
+ it('fails closed with 503 when the RBAC service or the entity registry is missing', async () => {
195
+ const searchService = createSearchService()
196
+ const registrations: Record<string, unknown> = { searchService }
197
+ mockCreateRequestContainer.mockResolvedValue({
198
+ hasRegistration: (name: string) => name in registrations,
199
+ resolve: jest.fn((name: string) => registrations[name]),
200
+ dispose: jest.fn().mockResolvedValue(undefined),
201
+ })
202
+
203
+ const response = await GET(new Request('http://localhost/api/search/search?q=person'))
204
+
205
+ expect(response.status).toBe(503)
206
+ expect(searchService.search).not.toHaveBeenCalled()
207
+ })
208
+ })
@@ -48,6 +48,27 @@ type MockOrganizationScope = {
48
48
  tenantId: string | null
49
49
  }
50
50
 
51
+ /**
52
+ * The hybrid search route resolves `rbacService` + `searchIndexer` to drop results
53
+ * whose entity type the caller has no view feature for (issue #5168). These org-scoping
54
+ * cases are about organization filtering, so the caller is a superadmin and the
55
+ * per-entity gate stays out of the way.
56
+ */
57
+ function createSearchContainer(searchService: { search: jest.Mock }) {
58
+ const registrations: Record<string, unknown> = {
59
+ searchService,
60
+ searchIndexer: { getEntityConfig: () => undefined, getAllEntityConfigs: () => [] },
61
+ rbacService: {
62
+ loadAcl: jest.fn().mockResolvedValue({ isSuperAdmin: true, features: ['*'], organizations: null }),
63
+ },
64
+ }
65
+ return {
66
+ hasRegistration: (name: string) => name in registrations,
67
+ resolve: jest.fn((name: string) => registrations[name]),
68
+ dispose: jest.fn(),
69
+ }
70
+ }
71
+
51
72
  describe('Search API organizationId scoping', () => {
52
73
  beforeEach(() => {
53
74
  jest.clearAllMocks()
@@ -59,10 +80,7 @@ describe('Search API organizationId scoping', () => {
59
80
  const searchService = {
60
81
  search: jest.fn().mockResolvedValue([{ entityId: 'x:y', recordId: '1', score: 1, source: 'tokens' }]),
61
82
  }
62
- const container = {
63
- resolve: jest.fn((name: string) => (name === 'searchService' ? searchService : undefined)),
64
- dispose: jest.fn(),
65
- }
83
+ const container = createSearchContainer(searchService)
66
84
  mockCreateRequestContainer.mockResolvedValue(container)
67
85
  mockResolveOrganizationScopeForRequest.mockResolvedValue({
68
86
  selectedId: 'org-A',
@@ -129,10 +147,7 @@ describe('Search API organizationId scoping', () => {
129
147
  const searchService = {
130
148
  search: jest.fn().mockResolvedValue([]),
131
149
  }
132
- const container = {
133
- resolve: jest.fn((name: string) => (name === 'searchService' ? searchService : undefined)),
134
- dispose: jest.fn(),
135
- }
150
+ const container = createSearchContainer(searchService)
136
151
  mockCreateRequestContainer.mockResolvedValue(container)
137
152
  mockResolveOrganizationScopeForRequest.mockResolvedValue({
138
153
  selectedId: null,
@@ -335,11 +335,11 @@ export const vectorReindexResponseSchema = z.object({
335
335
  export const searchOpenApi: OpenApiRouteDoc = {
336
336
  tag: 'Search',
337
337
  summary: 'Search across all indexed entities',
338
- description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground.',
338
+ description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground. Results are limited to the entity types the caller holds the declared view features for; superadmins are exempt.',
339
339
  methods: {
340
340
  GET: {
341
341
  summary: 'Search across all indexed entities',
342
- description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground.',
342
+ description: 'Performs a search using configured strategies (fulltext, vector, tokens). Use for search playground. Results are limited to the entity types the caller holds the declared view features for; superadmins are exempt.',
343
343
  tags: ['Search'],
344
344
  query: searchQueryParamsSchema,
345
345
  responses: [
@@ -8,13 +8,30 @@ import type { SearchService } from '@open-mercato/search'
8
8
  import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
9
9
  import type { EmbeddingService } from '../../../../vector'
10
10
  import { resolveEmbeddingConfig } from '../../lib/embedding-config'
11
- import { searchError } from '../../../../lib/debug'
11
+ import {
12
+ filterSearchResultsByEntityAccess,
13
+ resolveReadableEntityTypes,
14
+ type SearchEntityConfigLookup,
15
+ } from '../../lib/entity-access'
16
+ import { searchDebug, searchError } from '../../../../lib/debug'
12
17
  import { searchOpenApi } from '../openapi'
13
18
 
19
+ /**
20
+ * `search.view` — the search-administration feature behind the Vector Search
21
+ * playground. It authorizes using this diagnostic surface, not reading every
22
+ * indexed record, so the per-entity `aclFeatures` gate below still applies.
23
+ */
14
24
  export const metadata = {
15
25
  GET: { requireAuth: true, requireFeatures: ['search.view'] },
16
26
  }
17
27
 
28
+ type RbacLike = {
29
+ loadAcl: (
30
+ userId: string,
31
+ scope: { tenantId: string | null; organizationId: string | null },
32
+ ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>
33
+ }
34
+
18
35
  function parseLimit(value: string | null): number {
19
36
  if (!value) return 50
20
37
  const parsed = Number.parseInt(value, 10)
@@ -67,6 +84,21 @@ export async function GET(req: Request) {
67
84
  )
68
85
  }
69
86
 
87
+ // Fail closed: without the RBAC service or the entity registry there is no way
88
+ // to tell which entity types this caller may read, so refuse rather than search.
89
+ if (!container.hasRegistration('rbacService') || !container.hasRegistration('searchIndexer')) {
90
+ searchError('search.api.search', 'entity-acl-unavailable', {
91
+ rbacService: container.hasRegistration('rbacService'),
92
+ searchIndexer: container.hasRegistration('searchIndexer'),
93
+ })
94
+ return NextResponse.json(
95
+ { error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },
96
+ { status: 503 }
97
+ )
98
+ }
99
+ const rbac = container.resolve('rbacService') as RbacLike
100
+ const searchIndexer = container.resolve('searchIndexer') as SearchEntityConfigLookup
101
+
70
102
  // Load embedding config for vector strategy (same as Vector Search playground)
71
103
  try {
72
104
  const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
@@ -94,16 +126,44 @@ export async function GET(req: Request) {
94
126
  const scopeFilter = resolveOrganizationScopeFilter(scope, auth)
95
127
  const organizationId =
96
128
  typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined
129
+
130
+ // `search.view` authorizes the playground, not reading every indexed record.
131
+ // Narrow the query to the entity types this caller may read so the result
132
+ // budget is not spent on records that would only be filtered out.
133
+ const acl = await rbac.loadAcl(auth.sub, {
134
+ tenantId: scope.tenantId ?? auth.tenantId ?? null,
135
+ organizationId: organizationId ?? null,
136
+ })
137
+ const subject = { grantedFeatures: acl.features, isSuperAdmin: acl.isSuperAdmin }
138
+ const readableEntityTypes = resolveReadableEntityTypes(searchIndexer, subject, entityTypes)
139
+ if (readableEntityTypes && readableEntityTypes.length === 0) {
140
+ return NextResponse.json({
141
+ results: [],
142
+ strategiesUsed: [],
143
+ timing: Date.now() - startTime,
144
+ query,
145
+ limit,
146
+ })
147
+ }
148
+
97
149
  const searchOptions = {
98
150
  tenantId: auth.tenantId,
99
151
  organizationId,
100
152
  organizationIds: scopeFilter.organizationIds,
101
153
  limit,
102
154
  strategies,
103
- entityTypes,
155
+ entityTypes: readableEntityTypes,
104
156
  }
105
157
 
106
- const results = await searchService.search(query, searchOptions)
158
+ const rawResults = await searchService.search(query, searchOptions)
159
+
160
+ // Defense in depth: a strategy that ignores `entityTypes` must still not leak
161
+ // a presenter title, subtitle or deep link past the per-entity gate.
162
+ const results = filterSearchResultsByEntityAccess(rawResults, searchIndexer, subject, {
163
+ onDeny: (deniedEntityId, reason) => {
164
+ searchDebug('search.api.search', 'entity-filtered', { entityId: deniedEntityId, reason })
165
+ },
166
+ })
107
167
 
108
168
  const timing = Date.now() - startTime
109
169