@murumets-ee/search 0.15.3 → 0.16.0
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/dist/admin.d.mts +15 -25
- package/dist/admin.d.mts.map +1 -1
- package/dist/admin.mjs.map +1 -1
- package/package.json +2 -2
package/dist/admin.d.mts
CHANGED
|
@@ -196,37 +196,27 @@ interface MechanicsOptions {
|
|
|
196
196
|
maxOffset?: number;
|
|
197
197
|
}
|
|
198
198
|
//#endregion
|
|
199
|
-
//#region src/
|
|
199
|
+
//#region src/admin/routes.d.ts
|
|
200
200
|
/**
|
|
201
|
-
*
|
|
201
|
+
* Structural shape of a `SearchRegistry` from this package's main entry.
|
|
202
202
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
203
|
+
* Declared here as a structural interface (rather than importing the
|
|
204
|
+
* concrete `SearchRegistry` class from `../registry`) so the registry
|
|
205
|
+
* type does NOT cross the package's admin / index entry boundary. tsdown
|
|
206
|
+
* bundles transitive types into each entry's `.d.mts`; importing the
|
|
207
|
+
* class here would produce two separate `declare class SearchRegistry`
|
|
208
|
+
* blocks (one in `dist/admin.d.mts`, one in `dist/index.d.mts`) that
|
|
209
|
+
* TypeScript treats as distinct types because the class has a private
|
|
210
|
+
* field. The structural interface sidesteps that — any concrete
|
|
211
|
+
* `SearchRegistry` instance satisfies it regardless of which entry's
|
|
212
|
+
* type declarations a consumer imports through.
|
|
207
213
|
*/
|
|
208
|
-
|
|
209
|
-
private providers;
|
|
210
|
-
/**
|
|
211
|
-
* Register a provider. Throws on collision — search resources are
|
|
212
|
-
* deny-by-default and a duplicate registration is almost always a bug
|
|
213
|
-
* (two contributors picking the same name, or a plugin loaded twice).
|
|
214
|
-
*/
|
|
215
|
-
register(provider: SearchProvider): void;
|
|
216
|
-
/** Lookup. Returns `undefined` when no provider is registered for `resource`. */
|
|
214
|
+
interface SearchRegistryLike {
|
|
217
215
|
get(resource: string): SearchProvider | undefined;
|
|
218
|
-
/** Membership check — useful for surface introspection. */
|
|
219
|
-
has(resource: string): boolean;
|
|
220
|
-
/** Stable list of registered providers. */
|
|
221
|
-
list(): readonly SearchProvider[];
|
|
222
|
-
/** Number of registered providers. */
|
|
223
|
-
get size(): number;
|
|
224
216
|
}
|
|
225
|
-
//#endregion
|
|
226
|
-
//#region src/admin/routes.d.ts
|
|
227
217
|
interface SearchRoutesConfig {
|
|
228
218
|
/** Consumer-built registry. Keep one instance per surface (admin / public). */
|
|
229
|
-
registry:
|
|
219
|
+
registry: SearchRegistryLike;
|
|
230
220
|
/** Optional override of the default mechanics config. */
|
|
231
221
|
mechanics?: MechanicsOptions;
|
|
232
222
|
/**
|
|
@@ -275,5 +265,5 @@ declare function proxyClientIp(req: Request): string;
|
|
|
275
265
|
*/
|
|
276
266
|
declare function searchRoutes(config: SearchRoutesConfig): AdminRoute;
|
|
277
267
|
//#endregion
|
|
278
|
-
export { type SearchRoutesConfig, proxyClientIp, searchRoutes };
|
|
268
|
+
export { type SearchRegistryLike, type SearchRoutesConfig, proxyClientIp, searchRoutes };
|
|
279
269
|
//# sourceMappingURL=admin.d.mts.map
|
package/dist/admin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/internal/rate-limiter.ts","../src/types.ts","../src/mechanics.ts","../src/
|
|
1
|
+
{"version":3,"file":"admin.d.mts","names":[],"sources":["../src/internal/rate-limiter.ts","../src/types.ts","../src/mechanics.ts","../src/admin/routes.ts"],"mappings":";;;;;;AAQA;;;;;UAAiB,SAAA;;EAEf,MAAA;ECGe;EDDf,UAAA;AAAA;;;;;;AAJF;;;;;;;;ACKA;UAAiB,kBAAA;;EAEf,QAAA;EAAA;EAEA,OAAA;EAEA;EAAA,MAAA;EAIA;EAFA,KAAA;EAEM;EAAN,MAAA;AAAA;;;;AA0BF;;;;;AAGA;KAjBY,UAAA;;;;;;;;;;;;;KAcA,YAAA,GAAe,QAAA,CAAS,MAAA;;UAGnB,WAAA;EAcT;EAZN,KAAA;EAYgB;EAVhB,IAAA,EAAM,UAAA;EAcmB;EAZzB,KAAA;EAaA;EAXA,MAAA;EAuBe;EArBf,OAAA,EAAS,YAAA;;EAET,MAAA;EAqBA;EAnBA,IAAA,EAAM,UAAA;AAAA;;UAIS,UAAA;EACf,EAAA;EAwBO;;;AAIT;;;;EApBE,IAAA;AAAA;;UAIe,eAAA;EA2Bc;EAzB7B,EAAA;EAyBA;EAvBA,KAAA;EAuB6B;EArB7B,WAAA;EAyBe;EAvBf,GAAA;;EAEA,KAAA;EAsBA;EApBA,IAAA,GAAO,MAAA;AAAA;;UAIQ,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AA6BF;;;;AAAA,UAtBiB,gBAAA;EACf,KAAA;EACA,OAAA,WAAkB,WAAA;AAAA;;UAIH,YAAA;EACf,IAAA,WAAe,eAAA;EAwBN;EAtBT,KAAA;EAwBuB;EAtBvB,MAAA,WAAiB,gBAAA;EA+BH;EA7Bd,UAAA;AAAA;;;;;;;UASe,cAAA;;WAEN,QAAA;ECnHsB;;;;;;EAAA,SD0HtB,kBAAA;ECpHT;EAAA,SDsHS,YAAA,EAAc,kBAAA;EClHvB;;;;;;;;ED2HA,MAAA,CAAO,KAAA,EAAO,WAAA,EAAa,MAAA,EAAQ,WAAA,GAAc,OAAA,CAAQ,YAAA;AAAA;;;;AD9I3D;;;;;;;;ACKA;;;UCIiB,gBAAA;EDFf;ECIA,WAAA,GAAc,SAAA;EDAd;ECEA,SAAA,GAAY,SAAA;EDEZ;ECAA,SAAA;EDAM;ECEN,UAAA;EDUoB;ECRpB,eAAA;EDQoB;ECNpB,MAAA;EDoBU;EClBV,cAAA;;EAEA,QAAA;EDgBwC;ECdxC,YAAA;EDiB0B;ECf1B,SAAA;AAAA;;;AF7BF;;;;;;;;ACKA;;;;;;ADLA,UGWiB,kBAAA;EACf,GAAA,CAAI,QAAA,WAAmB,cAAA;AAAA;AAAA,UAGR,kBAAA;EFAT;EEEN,QAAA,EAAU,kBAAA;EFUU;EERpB,SAAA,GAAY,gBAAA;EFQQ;;AActB;;;;;AAGA;;;;;;;;;;;EENE,WAAA,IAAe,GAAA,EAAK,OAAA;AAAA;;;;;;;;AFwBtB;;;;;AAaA;iBEnBgB,aAAA,CAAc,GAAA,EAAK,OAAA;;;;;;;;;;;iBAqBnB,YAAA,CAAa,MAAA,EAAQ,kBAAA,GAAqB,UAAA"}
|
package/dist/admin.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin.mjs","names":[],"sources":["../src/errors.ts","../src/internal/rate-limiter.ts","../src/internal/ttl-cache.ts","../src/mechanics.ts","../src/admin/routes.ts"],"sourcesContent":["/**\n * Mechanics-layer errors. The admin route handler maps these to HTTP\n * statuses; tests assert the `code` constants are stable.\n *\n * Optionally carries a `cause` (ES2022) so the outer admin handler's logger\n * can surface the underlying provider error when one is folded into a\n * higher-level code (e.g. provider rejection arriving after abort gets\n * mapped to `Timeout`, but the original failure is preserved as `cause`).\n */\nexport class SearchError extends Error {\n readonly status: number\n readonly code: string\n constructor(message: string, status: number, code: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'SearchError'\n this.status = status\n this.code = code\n }\n}\n\nexport const SearchErrorCodes = {\n /** Query did not meet `minQueryLength`. */\n QueryTooShort: 'query_too_short',\n /** Query mode unrecognised or unsupported by provider. */\n UnsupportedMode: 'unsupported_mode',\n /** Pagination out of bounds (limit/offset too large or negative). */\n InvalidPagination: 'invalid_pagination',\n /** Per-user or per-IP rate limit exceeded. */\n RateLimited: 'rate_limited',\n /** Provider call exceeded `timeoutMs`. */\n Timeout: 'timeout',\n} as const\n","/**\n * Fixed-window per-key rate limiter with bounded memory.\n *\n * Not as accurate as token-bucket under bursty traffic, but sufficient for\n * the search-overload failure mode the mechanics layer is guarding against\n * (D13 — \"10 testers vs 1000 ad clicks\"). Window resets cleanly; no decay\n * math; eviction by insertion order keeps memory under `maxEntries`.\n */\nexport interface RateLimit {\n /** Tokens (requests) per window. */\n tokens: number\n /** Window duration in milliseconds. */\n intervalMs: number\n}\n\nexport class RateLimiter {\n private buckets = new Map<string, { count: number; windowStart: number }>()\n private readonly tokens: number\n private readonly intervalMs: number\n private readonly maxEntries: number\n\n constructor(limit: RateLimit, maxEntries: number = 10_000) {\n if (limit.tokens <= 0) throw new Error('tokens must be > 0')\n if (limit.intervalMs <= 0) throw new Error('intervalMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.tokens = limit.tokens\n this.intervalMs = limit.intervalMs\n this.maxEntries = maxEntries\n }\n\n /**\n * Try to consume one token for `key`. Returns `true` if allowed, `false`\n * if the window's quota has been exhausted.\n */\n consume(key: string, now: number = Date.now()): boolean {\n let entry = this.buckets.get(key)\n if (!entry || now - entry.windowStart >= this.intervalMs) {\n entry = { count: 0, windowStart: now }\n this.buckets.set(key, entry)\n }\n if (entry.count >= this.tokens) return false\n entry.count++\n // Insertion-order eviction (FIFO). When eviction does kick in, the\n // oldest entry is dropped — which *resets* that key's window on the\n // next call (granting a fresh quota). That's the safe direction:\n // attackers rotating through keys can't use eviction to suppress\n // legitimate users below their limit. Switching to LRU would be\n // exploitable here.\n while (this.buckets.size > this.maxEntries) {\n const oldest = this.buckets.keys().next().value\n if (oldest === undefined) break\n this.buckets.delete(oldest)\n }\n return true\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.buckets.size\n }\n}\n","/**\n * Bounded TTL cache. Insertion-order-based eviction (Maps preserve it) —\n * a \"first-in, first-out\" approximation of LRU that's good enough for the\n * short-TTL hot-query smoothing the search layer needs without paying for\n * a full LRU implementation.\n *\n * Not exported from the package — provider implementations and tests use\n * the higher-level mechanics class.\n */\nexport class TtlCache<V> {\n private entries = new Map<string, { value: V; expiresAt: number }>()\n private readonly ttlMs: number\n private readonly maxEntries: number\n\n constructor(ttlMs: number, maxEntries: number) {\n if (ttlMs <= 0) throw new Error('ttlMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.ttlMs = ttlMs\n this.maxEntries = maxEntries\n }\n\n get(key: string, now: number = Date.now()): V | undefined {\n const entry = this.entries.get(key)\n if (!entry) return undefined\n if (now >= entry.expiresAt) {\n this.entries.delete(key)\n return undefined\n }\n return entry.value\n }\n\n set(key: string, value: V, now: number = Date.now()): void {\n // Re-insert to refresh insertion order — ensures recently-set keys\n // outlive older ones under eviction pressure.\n this.entries.delete(key)\n this.entries.set(key, { value, expiresAt: now + this.ttlMs })\n while (this.entries.size > this.maxEntries) {\n const oldest = this.entries.keys().next().value\n if (oldest === undefined) break\n this.entries.delete(oldest)\n }\n }\n\n delete(key: string): void {\n this.entries.delete(key)\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.entries.size\n }\n}\n","import { SearchError, SearchErrorCodes } from './errors'\nimport { RateLimiter, type RateLimit } from './internal/rate-limiter'\nimport { TtlCache } from './internal/ttl-cache'\nimport type { FacetFilters, SearchInput, SearchMode, SearchProvider, SearchResult, SearchUser } from './types'\n\n/**\n * Mechanics layer — the cross-cutting concerns wrapping every provider call.\n *\n * Per D13: rate limit / timeout / dedupe / cache / min length / size cap ship\n * in v1 because adding them later means rewriting every call site, and the\n * \"10 testers vs 1000 ad clicks\" failure mode kills sites that ship search\n * without them.\n *\n * Not in scope here: separate connection-pool budget. That's a deployment\n * concern for the underlying DB / ES client; this package can't enforce it\n * without hard-coding a backend. Documented at the route handler.\n */\nexport interface MechanicsOptions {\n /** Per-user fixed-window rate limit. Default: 60 / 60s. */\n perUserRate?: RateLimit\n /** Per-IP fixed-window rate limit. Default: 120 / 60s. */\n perIpRate?: RateLimit\n /** Per-query timeout in ms. Default: 5000. */\n timeoutMs?: number\n /** Result-cache TTL in ms. Default: 5000 (short — hot-query smoothing). */\n cacheTtlMs?: number\n /** Result-cache max entries. Default: 500. */\n cacheMaxEntries?: number\n /** Collapse identical concurrent calls into one underlying request. Default: true. */\n dedupe?: boolean\n /** Minimum query length after trim. Default: 2. */\n minQueryLength?: number\n /** Maximum allowed `limit`. Default: 50. */\n maxLimit?: number\n /** Default `limit` when caller omits. Default: 20. */\n defaultLimit?: number\n /** Maximum allowed `offset`. Default: 1000 (deep pagination is suspicious). */\n maxOffset?: number\n}\n\ninterface ResolvedMechanics {\n perUserRate: RateLimit\n perIpRate: RateLimit\n timeoutMs: number\n cacheTtlMs: number\n cacheMaxEntries: number\n dedupe: boolean\n minQueryLength: number\n maxLimit: number\n defaultLimit: number\n maxOffset: number\n}\n\n/**\n * Resolved mechanics defaults.\n *\n * Exported for consumer introspection — admin UIs that want to render\n * \"max 50 results\" hints can read from a single source of truth.\n */\nexport const DEFAULT_MECHANICS: ResolvedMechanics = {\n perUserRate: { tokens: 60, intervalMs: 60_000 },\n perIpRate: { tokens: 120, intervalMs: 60_000 },\n timeoutMs: 5_000,\n cacheTtlMs: 5_000,\n cacheMaxEntries: 500,\n dedupe: true,\n minQueryLength: 2,\n maxLimit: 50,\n defaultLimit: 20,\n maxOffset: 1_000,\n}\n\n/** Raw query parameters as the route handler parses them off the URL/body. */\nexport interface RawSearchParams {\n query: string | null | undefined\n mode?: string | null | undefined\n limit?: string | number | null | undefined\n offset?: string | number | null | undefined\n filters?: FacetFilters\n locale?: string\n user: SearchUser\n}\n\nconst VALID_MODES: ReadonlySet<SearchMode> = new Set<SearchMode>(['term', 'prefix', 'phrase'])\n\nexport class SearchMechanics {\n private readonly opts: ResolvedMechanics\n private readonly perUserLimiter: RateLimiter\n private readonly perIpLimiter: RateLimiter\n private readonly cache: TtlCache<SearchResult>\n // Per-user partitioning happens at the fingerprint level (user.id is\n // included), so two callers with different identities never share an\n // entry here even when their query strings collide.\n private readonly inflight = new Map<string, Promise<SearchResult>>()\n /**\n * Soft cap on in-flight dedupe entries. Once exceeded, new calls run\n * uncoordinated rather than wait — refusing to dedupe is always safe;\n * letting the map grow unbounded is not.\n */\n private static readonly INFLIGHT_MAX = 1_000\n\n constructor(options: MechanicsOptions = {}) {\n this.opts = {\n perUserRate: options.perUserRate ?? DEFAULT_MECHANICS.perUserRate,\n perIpRate: options.perIpRate ?? DEFAULT_MECHANICS.perIpRate,\n timeoutMs: options.timeoutMs ?? DEFAULT_MECHANICS.timeoutMs,\n cacheTtlMs: options.cacheTtlMs ?? DEFAULT_MECHANICS.cacheTtlMs,\n cacheMaxEntries: options.cacheMaxEntries ?? DEFAULT_MECHANICS.cacheMaxEntries,\n dedupe: options.dedupe ?? DEFAULT_MECHANICS.dedupe,\n minQueryLength: options.minQueryLength ?? DEFAULT_MECHANICS.minQueryLength,\n maxLimit: options.maxLimit ?? DEFAULT_MECHANICS.maxLimit,\n defaultLimit: options.defaultLimit ?? DEFAULT_MECHANICS.defaultLimit,\n maxOffset: options.maxOffset ?? DEFAULT_MECHANICS.maxOffset,\n }\n this.perUserLimiter = new RateLimiter(this.opts.perUserRate)\n this.perIpLimiter = new RateLimiter(this.opts.perIpRate)\n this.cache = new TtlCache<SearchResult>(this.opts.cacheTtlMs, this.opts.cacheMaxEntries)\n }\n\n /**\n * Validate + normalize raw params into a `SearchInput` or throw `SearchError`.\n * Pure function: no side effects, no rate-limit consumption.\n */\n prepare(raw: RawSearchParams): SearchInput {\n const query = (raw.query ?? '').trim()\n if (query.length < this.opts.minQueryLength) {\n throw new SearchError(\n `Query must be at least ${this.opts.minQueryLength} characters`,\n 400,\n SearchErrorCodes.QueryTooShort,\n )\n }\n\n const mode = this.resolveMode(raw.mode)\n const limit = this.resolveLimit(raw.limit)\n const offset = this.resolveOffset(raw.offset)\n const filters = raw.filters ?? {}\n\n return {\n query,\n mode,\n limit,\n offset,\n filters,\n user: raw.user,\n ...(raw.locale !== undefined && { locale: raw.locale }),\n }\n }\n\n /**\n * Enforce per-user + per-IP rate limits. Throws `SearchError(429)` on exceed.\n *\n * Both limits must pass — keeps a single user from masking IP-level abuse,\n * and an aggregate IP cap from being saturated by one chatty user.\n */\n enforceRateLimit(userId: string, ip: string, now: number = Date.now()): void {\n if (!this.perUserLimiter.consume(`u:${userId}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n if (!this.perIpLimiter.consume(`ip:${ip}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n }\n\n /**\n * Run a provider through cache + dedupe + timeout. Caller is expected to\n * have already called `prepare()` and `enforceRateLimit()` (and any\n * permission gating) before reaching this.\n */\n async execute(provider: SearchProvider, input: SearchInput): Promise<SearchResult> {\n const key = fingerprint(provider.resource, input)\n\n const cached = this.cache.get(key)\n if (cached) return cached\n\n const dedupeActive = this.opts.dedupe && this.inflight.size < SearchMechanics.INFLIGHT_MAX\n if (this.opts.dedupe) {\n const inflight = this.inflight.get(key)\n if (inflight) return inflight\n }\n\n const promise = this.runWithTimeout(provider, input)\n .then((result) => {\n this.cache.set(key, result)\n return result\n })\n .finally(() => {\n if (dedupeActive) this.inflight.delete(key)\n })\n\n if (dedupeActive) this.inflight.set(key, promise)\n return promise\n }\n\n private async runWithTimeout(\n provider: SearchProvider,\n input: SearchInput,\n ): Promise<SearchResult> {\n const controller = new AbortController()\n const timeoutMs = this.opts.timeoutMs\n\n // Wall-clock guarantee — a misbehaving provider that ignores the abort\n // signal can't keep the request hanging. The `signal` is still passed in\n // so well-behaved providers cancel underlying work; the race ensures the\n // route returns to the caller within `timeoutMs` regardless.\n let timer: ReturnType<typeof setTimeout> | undefined\n const timeoutPromise = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n controller.abort()\n reject(\n new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n ),\n )\n }, timeoutMs)\n })\n\n try {\n return await Promise.race([provider.search(input, controller.signal), timeoutPromise])\n } catch (err) {\n // Provider rejected. If the timer already fired, fold the rejection into\n // a typed timeout error so the route maps cleanly to 504. Preserve the\n // original error as `cause` so the outer admin handler's logger surfaces\n // it during incident review (otherwise a real provider failure that\n // happened to arrive after abort would be silently re-labelled).\n if (controller.signal.aborted && !(err instanceof SearchError)) {\n throw new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n { cause: err },\n )\n }\n throw err\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n }\n }\n\n private resolveMode(raw: string | null | undefined): SearchMode {\n if (raw === undefined || raw === null || raw === '') return 'phrase'\n if (!VALID_MODES.has(raw as SearchMode)) {\n throw new SearchError(`Unknown query mode '${raw}'`, 400, SearchErrorCodes.UnsupportedMode)\n }\n return raw as SearchMode\n }\n\n private resolveLimit(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return this.opts.defaultLimit\n const n = parseStrictInt(raw)\n if (n === null || n <= 0) {\n throw new SearchError('limit must be a positive integer', 400, SearchErrorCodes.InvalidPagination)\n }\n return Math.min(n, this.opts.maxLimit)\n }\n\n private resolveOffset(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return 0\n const n = parseStrictInt(raw)\n if (n === null || n < 0) {\n throw new SearchError('offset must be a non-negative integer', 400, SearchErrorCodes.InvalidPagination)\n }\n if (n > this.opts.maxOffset) {\n throw new SearchError(\n `offset must be <= ${this.opts.maxOffset}`,\n 400,\n SearchErrorCodes.InvalidPagination,\n )\n }\n return n\n }\n}\n\n/**\n * Strict integer parser — rejects `'1.5'`, `'1abc'`, `' '`. Returns `null`\n * when the input isn't a whole integer. Numbers go through the same shape\n * check via `Number.isInteger`.\n */\nfunction parseStrictInt(raw: string | number): number | null {\n if (typeof raw === 'number') {\n return Number.isFinite(raw) && Number.isInteger(raw) ? raw : null\n }\n if (!/^-?\\d+$/.test(raw)) return null\n const n = Number.parseInt(raw, 10)\n return Number.isFinite(n) ? n : null\n}\n\n/**\n * Stable fingerprint for cache + dedupe keying. Includes user id since\n * provider results are role/scope-sensitive — two users firing the same\n * query must not see each other's filtered output.\n */\nfunction fingerprint(resource: string, input: SearchInput): string {\n const filters = Object.keys(input.filters)\n .sort()\n .map((k) => `${k}=${[...(input.filters[k] ?? [])].sort().join(',')}`)\n .join('|')\n return [\n resource,\n input.user.id,\n input.locale ?? '',\n input.mode,\n input.limit,\n input.offset,\n input.query,\n filters,\n ].join('\u0000')\n}\n","import type { AdminRoute, AdminRouteHandler } from '@murumets-ee/core'\nimport { SearchError } from '../errors'\nimport { type MechanicsOptions, SearchMechanics } from '../mechanics'\nimport type { SearchRegistry } from '../registry'\nimport type { FacetFilters, SearchProvider, SearchResult, SearchUser } from '../types'\n\nexport interface SearchRoutesConfig {\n /** Consumer-built registry. Keep one instance per surface (admin / public). */\n registry: SearchRegistry\n /** Optional override of the default mechanics config. */\n mechanics?: MechanicsOptions\n /**\n * Client-IP extractor — used as the bucket key for the per-IP rate limit.\n *\n * **Required for per-IP rate limiting to be effective.** When omitted, all\n * callers share a single global IP bucket (`'0.0.0.0'`) and per-IP throttling\n * is effectively disabled — the per-user limit still applies.\n *\n * Why no convenient default: every transport has its own contract.\n * `x-forwarded-for` is *only* trustworthy if a controlled reverse proxy\n * sets it; on a directly-exposed deployment, the header is attacker-supplied\n * and a default that reads it would silently turn per-IP rate-limiting into\n * per-arbitrary-string rate-limiting (i.e. bypassable). Forcing the consumer\n * to choose keeps the security property explicit.\n *\n * Use {@link proxyClientIp} when running behind a single trusted reverse\n * proxy (Vercel, Cloudflare, AWS ALB, fly.io). For multi-hop deployments,\n * write a small wrapper that picks the first untrusted hop from XFF.\n */\n getClientIp?: (req: Request) => string\n}\n\nconst NO_IP = '0.0.0.0'\n\n/**\n * Helper for deployments behind a single trusted reverse proxy. Reads the\n * client IP from `x-forwarded-for` (first hop) or `x-real-ip`, falling back\n * to `'0.0.0.0'` when neither header is present.\n *\n * **Only safe when the application is unreachable except via a proxy you\n * control that overwrites these headers on every request.** A directly-\n * exposed deployment must NOT use this helper — the headers are then\n * attacker-supplied and per-IP rate-limiting becomes meaningless.\n *\n * For more complex topologies (multi-hop, custom forwarding header like\n * Cloudflare's `cf-connecting-ip`), write your own extractor.\n */\nexport function proxyClientIp(req: Request): string {\n const xff = req.headers.get('x-forwarded-for')\n if (xff) {\n const first = xff.split(',')[0]?.trim()\n if (first) return first\n }\n const xri = req.headers.get('x-real-ip')\n if (xri) return xri.trim()\n return NO_IP\n}\n\n/**\n * Build the `AdminRoute` for search. Register inside a plugin's\n * `server.routes` (or pass directly into the admin api handler):\n *\n * ```ts\n * const registry = new SearchRegistry()\n * registry.register(new IlikeProvider({ resource: 'orders', ... }))\n * createAdminApiHandler({ ...config, routes: [searchRoutes({ registry })] })\n * ```\n */\nexport function searchRoutes(config: SearchRoutesConfig): AdminRoute {\n const mechanics = new SearchMechanics(config.mechanics)\n // No default fall-through to XFF: see SearchRoutesConfig.getClientIp doc\n // for why. When unset, all callers share a single global IP bucket.\n const getIp = config.getClientIp ?? ((): string => NO_IP)\n\n const handler: AdminRouteHandler = async (req, ctx) => {\n const resourceName = ctx.segments[0]\n if (!resourceName) {\n return jsonError('Missing search resource', 400, 'missing_resource')\n }\n if (ctx.segments.length > 1) {\n return jsonError('Search route does not support sub-paths', 404, 'not_found')\n }\n\n const provider = config.registry.get(resourceName)\n if (!provider) {\n // 404 — never leak which resources exist via 403 vs 404 differentiation.\n return jsonError(`Search resource '${resourceName}' not found`, 404, 'not_found')\n }\n\n const permissionResource = provider.permissionResource ?? provider.resource\n if (!ctx.checkPermission(permissionResource, 'view')) {\n // Mirror the 404-on-deny pattern from content-api: a forbidden search\n // resource is indistinguishable from a non-existent one. Avoids leaking\n // which resources are gated for the caller's role.\n return jsonError(`Search resource '${resourceName}' not found`, 404, 'not_found')\n }\n\n const url = new URL(req.url)\n const user: SearchUser = {\n id: ctx.user.id,\n ...(ctx.user.role !== undefined && { role: ctx.user.role }),\n }\n\n try {\n const input = mechanics.prepare({\n query: url.searchParams.get('q'),\n mode: url.searchParams.get('mode'),\n limit: url.searchParams.get('limit'),\n offset: url.searchParams.get('offset'),\n filters: parseFilters(url.searchParams),\n ...(ctx.locale !== undefined && { locale: ctx.locale }),\n user,\n })\n\n mechanics.enforceRateLimit(ctx.user.id, getIp(req))\n\n const result = await mechanics.execute(provider, input)\n return jsonOk({\n resource: provider.resource,\n capabilities: provider.capabilities,\n rows: result.rows,\n total: result.total,\n facets: result.facets,\n durationMs: result.durationMs,\n })\n } catch (err) {\n if (err instanceof SearchError) {\n return jsonError(err.message, err.status, err.code)\n }\n throw err\n }\n }\n\n return {\n prefix: 'search',\n // No `resource` — multi-resource handler does its own per-call check.\n handlers: { GET: handler },\n }\n}\n\n/**\n * Pull facet filters off the query string. Convention: `f.<field>=<value>`,\n * repeatable per field. Example: `?f.brand=Toyota&f.brand=Mercedes&f.year=2024`\n *\n * Two ceilings to bound the resulting map against crafted URLs:\n * - max 16 values per field (limits SQL/ES IN-list size)\n * - max 16 distinct fields (limits cache-fingerprint length and overall\n * map size; a real UI never approaches this)\n *\n * Field names and values are NOT validated against any schema here — that\n * is the provider's responsibility (see {@link FacetFilters} doc).\n */\nconst MAX_FILTER_VALUES_PER_FIELD = 16\nconst MAX_FILTER_FIELDS = 16\n\nfunction parseFilters(params: URLSearchParams): FacetFilters {\n const out: Record<string, string[]> = {}\n for (const [key, value] of params.entries()) {\n if (!key.startsWith('f.')) continue\n const field = key.slice(2)\n if (!field || value === '') continue\n const existing = out[field]\n if (!existing && Object.keys(out).length >= MAX_FILTER_FIELDS) continue\n const list = existing ?? []\n if (list.length >= MAX_FILTER_VALUES_PER_FIELD) continue\n list.push(value)\n out[field] = list\n }\n return out\n}\n\nfunction jsonOk(\n payload: {\n resource: string\n capabilities: SearchProvider['capabilities']\n rows: SearchResult['rows']\n total: number\n facets: SearchResult['facets']\n durationMs: number\n },\n): Response {\n return new Response(JSON.stringify(payload), {\n status: 200,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n })\n}\n\nfunction jsonError(message: string, status: number, code: string): Response {\n return new Response(JSON.stringify({ error: message, code }), {\n status,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"admin.mjs","names":[],"sources":["../src/errors.ts","../src/internal/rate-limiter.ts","../src/internal/ttl-cache.ts","../src/mechanics.ts","../src/admin/routes.ts"],"sourcesContent":["/**\n * Mechanics-layer errors. The admin route handler maps these to HTTP\n * statuses; tests assert the `code` constants are stable.\n *\n * Optionally carries a `cause` (ES2022) so the outer admin handler's logger\n * can surface the underlying provider error when one is folded into a\n * higher-level code (e.g. provider rejection arriving after abort gets\n * mapped to `Timeout`, but the original failure is preserved as `cause`).\n */\nexport class SearchError extends Error {\n readonly status: number\n readonly code: string\n constructor(message: string, status: number, code: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'SearchError'\n this.status = status\n this.code = code\n }\n}\n\nexport const SearchErrorCodes = {\n /** Query did not meet `minQueryLength`. */\n QueryTooShort: 'query_too_short',\n /** Query mode unrecognised or unsupported by provider. */\n UnsupportedMode: 'unsupported_mode',\n /** Pagination out of bounds (limit/offset too large or negative). */\n InvalidPagination: 'invalid_pagination',\n /** Per-user or per-IP rate limit exceeded. */\n RateLimited: 'rate_limited',\n /** Provider call exceeded `timeoutMs`. */\n Timeout: 'timeout',\n} as const\n","/**\n * Fixed-window per-key rate limiter with bounded memory.\n *\n * Not as accurate as token-bucket under bursty traffic, but sufficient for\n * the search-overload failure mode the mechanics layer is guarding against\n * (D13 — \"10 testers vs 1000 ad clicks\"). Window resets cleanly; no decay\n * math; eviction by insertion order keeps memory under `maxEntries`.\n */\nexport interface RateLimit {\n /** Tokens (requests) per window. */\n tokens: number\n /** Window duration in milliseconds. */\n intervalMs: number\n}\n\nexport class RateLimiter {\n private buckets = new Map<string, { count: number; windowStart: number }>()\n private readonly tokens: number\n private readonly intervalMs: number\n private readonly maxEntries: number\n\n constructor(limit: RateLimit, maxEntries: number = 10_000) {\n if (limit.tokens <= 0) throw new Error('tokens must be > 0')\n if (limit.intervalMs <= 0) throw new Error('intervalMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.tokens = limit.tokens\n this.intervalMs = limit.intervalMs\n this.maxEntries = maxEntries\n }\n\n /**\n * Try to consume one token for `key`. Returns `true` if allowed, `false`\n * if the window's quota has been exhausted.\n */\n consume(key: string, now: number = Date.now()): boolean {\n let entry = this.buckets.get(key)\n if (!entry || now - entry.windowStart >= this.intervalMs) {\n entry = { count: 0, windowStart: now }\n this.buckets.set(key, entry)\n }\n if (entry.count >= this.tokens) return false\n entry.count++\n // Insertion-order eviction (FIFO). When eviction does kick in, the\n // oldest entry is dropped — which *resets* that key's window on the\n // next call (granting a fresh quota). That's the safe direction:\n // attackers rotating through keys can't use eviction to suppress\n // legitimate users below their limit. Switching to LRU would be\n // exploitable here.\n while (this.buckets.size > this.maxEntries) {\n const oldest = this.buckets.keys().next().value\n if (oldest === undefined) break\n this.buckets.delete(oldest)\n }\n return true\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.buckets.size\n }\n}\n","/**\n * Bounded TTL cache. Insertion-order-based eviction (Maps preserve it) —\n * a \"first-in, first-out\" approximation of LRU that's good enough for the\n * short-TTL hot-query smoothing the search layer needs without paying for\n * a full LRU implementation.\n *\n * Not exported from the package — provider implementations and tests use\n * the higher-level mechanics class.\n */\nexport class TtlCache<V> {\n private entries = new Map<string, { value: V; expiresAt: number }>()\n private readonly ttlMs: number\n private readonly maxEntries: number\n\n constructor(ttlMs: number, maxEntries: number) {\n if (ttlMs <= 0) throw new Error('ttlMs must be > 0')\n if (maxEntries <= 0) throw new Error('maxEntries must be > 0')\n this.ttlMs = ttlMs\n this.maxEntries = maxEntries\n }\n\n get(key: string, now: number = Date.now()): V | undefined {\n const entry = this.entries.get(key)\n if (!entry) return undefined\n if (now >= entry.expiresAt) {\n this.entries.delete(key)\n return undefined\n }\n return entry.value\n }\n\n set(key: string, value: V, now: number = Date.now()): void {\n // Re-insert to refresh insertion order — ensures recently-set keys\n // outlive older ones under eviction pressure.\n this.entries.delete(key)\n this.entries.set(key, { value, expiresAt: now + this.ttlMs })\n while (this.entries.size > this.maxEntries) {\n const oldest = this.entries.keys().next().value\n if (oldest === undefined) break\n this.entries.delete(oldest)\n }\n }\n\n delete(key: string): void {\n this.entries.delete(key)\n }\n\n /** Test-only helper. */\n get size(): number {\n return this.entries.size\n }\n}\n","import { SearchError, SearchErrorCodes } from './errors'\nimport { RateLimiter, type RateLimit } from './internal/rate-limiter'\nimport { TtlCache } from './internal/ttl-cache'\nimport type { FacetFilters, SearchInput, SearchMode, SearchProvider, SearchResult, SearchUser } from './types'\n\n/**\n * Mechanics layer — the cross-cutting concerns wrapping every provider call.\n *\n * Per D13: rate limit / timeout / dedupe / cache / min length / size cap ship\n * in v1 because adding them later means rewriting every call site, and the\n * \"10 testers vs 1000 ad clicks\" failure mode kills sites that ship search\n * without them.\n *\n * Not in scope here: separate connection-pool budget. That's a deployment\n * concern for the underlying DB / ES client; this package can't enforce it\n * without hard-coding a backend. Documented at the route handler.\n */\nexport interface MechanicsOptions {\n /** Per-user fixed-window rate limit. Default: 60 / 60s. */\n perUserRate?: RateLimit\n /** Per-IP fixed-window rate limit. Default: 120 / 60s. */\n perIpRate?: RateLimit\n /** Per-query timeout in ms. Default: 5000. */\n timeoutMs?: number\n /** Result-cache TTL in ms. Default: 5000 (short — hot-query smoothing). */\n cacheTtlMs?: number\n /** Result-cache max entries. Default: 500. */\n cacheMaxEntries?: number\n /** Collapse identical concurrent calls into one underlying request. Default: true. */\n dedupe?: boolean\n /** Minimum query length after trim. Default: 2. */\n minQueryLength?: number\n /** Maximum allowed `limit`. Default: 50. */\n maxLimit?: number\n /** Default `limit` when caller omits. Default: 20. */\n defaultLimit?: number\n /** Maximum allowed `offset`. Default: 1000 (deep pagination is suspicious). */\n maxOffset?: number\n}\n\ninterface ResolvedMechanics {\n perUserRate: RateLimit\n perIpRate: RateLimit\n timeoutMs: number\n cacheTtlMs: number\n cacheMaxEntries: number\n dedupe: boolean\n minQueryLength: number\n maxLimit: number\n defaultLimit: number\n maxOffset: number\n}\n\n/**\n * Resolved mechanics defaults.\n *\n * Exported for consumer introspection — admin UIs that want to render\n * \"max 50 results\" hints can read from a single source of truth.\n */\nexport const DEFAULT_MECHANICS: ResolvedMechanics = {\n perUserRate: { tokens: 60, intervalMs: 60_000 },\n perIpRate: { tokens: 120, intervalMs: 60_000 },\n timeoutMs: 5_000,\n cacheTtlMs: 5_000,\n cacheMaxEntries: 500,\n dedupe: true,\n minQueryLength: 2,\n maxLimit: 50,\n defaultLimit: 20,\n maxOffset: 1_000,\n}\n\n/** Raw query parameters as the route handler parses them off the URL/body. */\nexport interface RawSearchParams {\n query: string | null | undefined\n mode?: string | null | undefined\n limit?: string | number | null | undefined\n offset?: string | number | null | undefined\n filters?: FacetFilters\n locale?: string\n user: SearchUser\n}\n\nconst VALID_MODES: ReadonlySet<SearchMode> = new Set<SearchMode>(['term', 'prefix', 'phrase'])\n\nexport class SearchMechanics {\n private readonly opts: ResolvedMechanics\n private readonly perUserLimiter: RateLimiter\n private readonly perIpLimiter: RateLimiter\n private readonly cache: TtlCache<SearchResult>\n // Per-user partitioning happens at the fingerprint level (user.id is\n // included), so two callers with different identities never share an\n // entry here even when their query strings collide.\n private readonly inflight = new Map<string, Promise<SearchResult>>()\n /**\n * Soft cap on in-flight dedupe entries. Once exceeded, new calls run\n * uncoordinated rather than wait — refusing to dedupe is always safe;\n * letting the map grow unbounded is not.\n */\n private static readonly INFLIGHT_MAX = 1_000\n\n constructor(options: MechanicsOptions = {}) {\n this.opts = {\n perUserRate: options.perUserRate ?? DEFAULT_MECHANICS.perUserRate,\n perIpRate: options.perIpRate ?? DEFAULT_MECHANICS.perIpRate,\n timeoutMs: options.timeoutMs ?? DEFAULT_MECHANICS.timeoutMs,\n cacheTtlMs: options.cacheTtlMs ?? DEFAULT_MECHANICS.cacheTtlMs,\n cacheMaxEntries: options.cacheMaxEntries ?? DEFAULT_MECHANICS.cacheMaxEntries,\n dedupe: options.dedupe ?? DEFAULT_MECHANICS.dedupe,\n minQueryLength: options.minQueryLength ?? DEFAULT_MECHANICS.minQueryLength,\n maxLimit: options.maxLimit ?? DEFAULT_MECHANICS.maxLimit,\n defaultLimit: options.defaultLimit ?? DEFAULT_MECHANICS.defaultLimit,\n maxOffset: options.maxOffset ?? DEFAULT_MECHANICS.maxOffset,\n }\n this.perUserLimiter = new RateLimiter(this.opts.perUserRate)\n this.perIpLimiter = new RateLimiter(this.opts.perIpRate)\n this.cache = new TtlCache<SearchResult>(this.opts.cacheTtlMs, this.opts.cacheMaxEntries)\n }\n\n /**\n * Validate + normalize raw params into a `SearchInput` or throw `SearchError`.\n * Pure function: no side effects, no rate-limit consumption.\n */\n prepare(raw: RawSearchParams): SearchInput {\n const query = (raw.query ?? '').trim()\n if (query.length < this.opts.minQueryLength) {\n throw new SearchError(\n `Query must be at least ${this.opts.minQueryLength} characters`,\n 400,\n SearchErrorCodes.QueryTooShort,\n )\n }\n\n const mode = this.resolveMode(raw.mode)\n const limit = this.resolveLimit(raw.limit)\n const offset = this.resolveOffset(raw.offset)\n const filters = raw.filters ?? {}\n\n return {\n query,\n mode,\n limit,\n offset,\n filters,\n user: raw.user,\n ...(raw.locale !== undefined && { locale: raw.locale }),\n }\n }\n\n /**\n * Enforce per-user + per-IP rate limits. Throws `SearchError(429)` on exceed.\n *\n * Both limits must pass — keeps a single user from masking IP-level abuse,\n * and an aggregate IP cap from being saturated by one chatty user.\n */\n enforceRateLimit(userId: string, ip: string, now: number = Date.now()): void {\n if (!this.perUserLimiter.consume(`u:${userId}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n if (!this.perIpLimiter.consume(`ip:${ip}`, now)) {\n throw new SearchError('Rate limit exceeded', 429, SearchErrorCodes.RateLimited)\n }\n }\n\n /**\n * Run a provider through cache + dedupe + timeout. Caller is expected to\n * have already called `prepare()` and `enforceRateLimit()` (and any\n * permission gating) before reaching this.\n */\n async execute(provider: SearchProvider, input: SearchInput): Promise<SearchResult> {\n const key = fingerprint(provider.resource, input)\n\n const cached = this.cache.get(key)\n if (cached) return cached\n\n const dedupeActive = this.opts.dedupe && this.inflight.size < SearchMechanics.INFLIGHT_MAX\n if (this.opts.dedupe) {\n const inflight = this.inflight.get(key)\n if (inflight) return inflight\n }\n\n const promise = this.runWithTimeout(provider, input)\n .then((result) => {\n this.cache.set(key, result)\n return result\n })\n .finally(() => {\n if (dedupeActive) this.inflight.delete(key)\n })\n\n if (dedupeActive) this.inflight.set(key, promise)\n return promise\n }\n\n private async runWithTimeout(\n provider: SearchProvider,\n input: SearchInput,\n ): Promise<SearchResult> {\n const controller = new AbortController()\n const timeoutMs = this.opts.timeoutMs\n\n // Wall-clock guarantee — a misbehaving provider that ignores the abort\n // signal can't keep the request hanging. The `signal` is still passed in\n // so well-behaved providers cancel underlying work; the race ensures the\n // route returns to the caller within `timeoutMs` regardless.\n let timer: ReturnType<typeof setTimeout> | undefined\n const timeoutPromise = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n controller.abort()\n reject(\n new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n ),\n )\n }, timeoutMs)\n })\n\n try {\n return await Promise.race([provider.search(input, controller.signal), timeoutPromise])\n } catch (err) {\n // Provider rejected. If the timer already fired, fold the rejection into\n // a typed timeout error so the route maps cleanly to 504. Preserve the\n // original error as `cause` so the outer admin handler's logger surfaces\n // it during incident review (otherwise a real provider failure that\n // happened to arrive after abort would be silently re-labelled).\n if (controller.signal.aborted && !(err instanceof SearchError)) {\n throw new SearchError(\n `Search timed out after ${timeoutMs}ms`,\n 504,\n SearchErrorCodes.Timeout,\n { cause: err },\n )\n }\n throw err\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n }\n }\n\n private resolveMode(raw: string | null | undefined): SearchMode {\n if (raw === undefined || raw === null || raw === '') return 'phrase'\n if (!VALID_MODES.has(raw as SearchMode)) {\n throw new SearchError(`Unknown query mode '${raw}'`, 400, SearchErrorCodes.UnsupportedMode)\n }\n return raw as SearchMode\n }\n\n private resolveLimit(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return this.opts.defaultLimit\n const n = parseStrictInt(raw)\n if (n === null || n <= 0) {\n throw new SearchError('limit must be a positive integer', 400, SearchErrorCodes.InvalidPagination)\n }\n return Math.min(n, this.opts.maxLimit)\n }\n\n private resolveOffset(raw: string | number | null | undefined): number {\n if (raw === undefined || raw === null || raw === '') return 0\n const n = parseStrictInt(raw)\n if (n === null || n < 0) {\n throw new SearchError('offset must be a non-negative integer', 400, SearchErrorCodes.InvalidPagination)\n }\n if (n > this.opts.maxOffset) {\n throw new SearchError(\n `offset must be <= ${this.opts.maxOffset}`,\n 400,\n SearchErrorCodes.InvalidPagination,\n )\n }\n return n\n }\n}\n\n/**\n * Strict integer parser — rejects `'1.5'`, `'1abc'`, `' '`. Returns `null`\n * when the input isn't a whole integer. Numbers go through the same shape\n * check via `Number.isInteger`.\n */\nfunction parseStrictInt(raw: string | number): number | null {\n if (typeof raw === 'number') {\n return Number.isFinite(raw) && Number.isInteger(raw) ? raw : null\n }\n if (!/^-?\\d+$/.test(raw)) return null\n const n = Number.parseInt(raw, 10)\n return Number.isFinite(n) ? n : null\n}\n\n/**\n * Stable fingerprint for cache + dedupe keying. Includes user id since\n * provider results are role/scope-sensitive — two users firing the same\n * query must not see each other's filtered output.\n */\nfunction fingerprint(resource: string, input: SearchInput): string {\n const filters = Object.keys(input.filters)\n .sort()\n .map((k) => `${k}=${[...(input.filters[k] ?? [])].sort().join(',')}`)\n .join('|')\n return [\n resource,\n input.user.id,\n input.locale ?? '',\n input.mode,\n input.limit,\n input.offset,\n input.query,\n filters,\n ].join('\u0000')\n}\n","import type { AdminRoute, AdminRouteHandler } from '@murumets-ee/core'\nimport { SearchError } from '../errors'\nimport { type MechanicsOptions, SearchMechanics } from '../mechanics'\nimport type { FacetFilters, SearchProvider, SearchResult, SearchUser } from '../types'\n\n/**\n * Structural shape of a `SearchRegistry` from this package's main entry.\n *\n * Declared here as a structural interface (rather than importing the\n * concrete `SearchRegistry` class from `../registry`) so the registry\n * type does NOT cross the package's admin / index entry boundary. tsdown\n * bundles transitive types into each entry's `.d.mts`; importing the\n * class here would produce two separate `declare class SearchRegistry`\n * blocks (one in `dist/admin.d.mts`, one in `dist/index.d.mts`) that\n * TypeScript treats as distinct types because the class has a private\n * field. The structural interface sidesteps that — any concrete\n * `SearchRegistry` instance satisfies it regardless of which entry's\n * type declarations a consumer imports through.\n */\nexport interface SearchRegistryLike {\n get(resource: string): SearchProvider | undefined\n}\n\nexport interface SearchRoutesConfig {\n /** Consumer-built registry. Keep one instance per surface (admin / public). */\n registry: SearchRegistryLike\n /** Optional override of the default mechanics config. */\n mechanics?: MechanicsOptions\n /**\n * Client-IP extractor — used as the bucket key for the per-IP rate limit.\n *\n * **Required for per-IP rate limiting to be effective.** When omitted, all\n * callers share a single global IP bucket (`'0.0.0.0'`) and per-IP throttling\n * is effectively disabled — the per-user limit still applies.\n *\n * Why no convenient default: every transport has its own contract.\n * `x-forwarded-for` is *only* trustworthy if a controlled reverse proxy\n * sets it; on a directly-exposed deployment, the header is attacker-supplied\n * and a default that reads it would silently turn per-IP rate-limiting into\n * per-arbitrary-string rate-limiting (i.e. bypassable). Forcing the consumer\n * to choose keeps the security property explicit.\n *\n * Use {@link proxyClientIp} when running behind a single trusted reverse\n * proxy (Vercel, Cloudflare, AWS ALB, fly.io). For multi-hop deployments,\n * write a small wrapper that picks the first untrusted hop from XFF.\n */\n getClientIp?: (req: Request) => string\n}\n\nconst NO_IP = '0.0.0.0'\n\n/**\n * Helper for deployments behind a single trusted reverse proxy. Reads the\n * client IP from `x-forwarded-for` (first hop) or `x-real-ip`, falling back\n * to `'0.0.0.0'` when neither header is present.\n *\n * **Only safe when the application is unreachable except via a proxy you\n * control that overwrites these headers on every request.** A directly-\n * exposed deployment must NOT use this helper — the headers are then\n * attacker-supplied and per-IP rate-limiting becomes meaningless.\n *\n * For more complex topologies (multi-hop, custom forwarding header like\n * Cloudflare's `cf-connecting-ip`), write your own extractor.\n */\nexport function proxyClientIp(req: Request): string {\n const xff = req.headers.get('x-forwarded-for')\n if (xff) {\n const first = xff.split(',')[0]?.trim()\n if (first) return first\n }\n const xri = req.headers.get('x-real-ip')\n if (xri) return xri.trim()\n return NO_IP\n}\n\n/**\n * Build the `AdminRoute` for search. Register inside a plugin's\n * `server.routes` (or pass directly into the admin api handler):\n *\n * ```ts\n * const registry = new SearchRegistry()\n * registry.register(new IlikeProvider({ resource: 'orders', ... }))\n * createAdminApiHandler({ ...config, routes: [searchRoutes({ registry })] })\n * ```\n */\nexport function searchRoutes(config: SearchRoutesConfig): AdminRoute {\n const mechanics = new SearchMechanics(config.mechanics)\n // No default fall-through to XFF: see SearchRoutesConfig.getClientIp doc\n // for why. When unset, all callers share a single global IP bucket.\n const getIp = config.getClientIp ?? ((): string => NO_IP)\n\n const handler: AdminRouteHandler = async (req, ctx) => {\n const resourceName = ctx.segments[0]\n if (!resourceName) {\n return jsonError('Missing search resource', 400, 'missing_resource')\n }\n if (ctx.segments.length > 1) {\n return jsonError('Search route does not support sub-paths', 404, 'not_found')\n }\n\n const provider = config.registry.get(resourceName)\n if (!provider) {\n // 404 — never leak which resources exist via 403 vs 404 differentiation.\n return jsonError(`Search resource '${resourceName}' not found`, 404, 'not_found')\n }\n\n const permissionResource = provider.permissionResource ?? provider.resource\n if (!ctx.checkPermission(permissionResource, 'view')) {\n // Mirror the 404-on-deny pattern from content-api: a forbidden search\n // resource is indistinguishable from a non-existent one. Avoids leaking\n // which resources are gated for the caller's role.\n return jsonError(`Search resource '${resourceName}' not found`, 404, 'not_found')\n }\n\n const url = new URL(req.url)\n const user: SearchUser = {\n id: ctx.user.id,\n ...(ctx.user.role !== undefined && { role: ctx.user.role }),\n }\n\n try {\n const input = mechanics.prepare({\n query: url.searchParams.get('q'),\n mode: url.searchParams.get('mode'),\n limit: url.searchParams.get('limit'),\n offset: url.searchParams.get('offset'),\n filters: parseFilters(url.searchParams),\n ...(ctx.locale !== undefined && { locale: ctx.locale }),\n user,\n })\n\n mechanics.enforceRateLimit(ctx.user.id, getIp(req))\n\n const result = await mechanics.execute(provider, input)\n return jsonOk({\n resource: provider.resource,\n capabilities: provider.capabilities,\n rows: result.rows,\n total: result.total,\n facets: result.facets,\n durationMs: result.durationMs,\n })\n } catch (err) {\n if (err instanceof SearchError) {\n return jsonError(err.message, err.status, err.code)\n }\n throw err\n }\n }\n\n return {\n prefix: 'search',\n // No `resource` — multi-resource handler does its own per-call check.\n handlers: { GET: handler },\n }\n}\n\n/**\n * Pull facet filters off the query string. Convention: `f.<field>=<value>`,\n * repeatable per field. Example: `?f.brand=Toyota&f.brand=Mercedes&f.year=2024`\n *\n * Two ceilings to bound the resulting map against crafted URLs:\n * - max 16 values per field (limits SQL/ES IN-list size)\n * - max 16 distinct fields (limits cache-fingerprint length and overall\n * map size; a real UI never approaches this)\n *\n * Field names and values are NOT validated against any schema here — that\n * is the provider's responsibility (see {@link FacetFilters} doc).\n */\nconst MAX_FILTER_VALUES_PER_FIELD = 16\nconst MAX_FILTER_FIELDS = 16\n\nfunction parseFilters(params: URLSearchParams): FacetFilters {\n const out: Record<string, string[]> = {}\n for (const [key, value] of params.entries()) {\n if (!key.startsWith('f.')) continue\n const field = key.slice(2)\n if (!field || value === '') continue\n const existing = out[field]\n if (!existing && Object.keys(out).length >= MAX_FILTER_FIELDS) continue\n const list = existing ?? []\n if (list.length >= MAX_FILTER_VALUES_PER_FIELD) continue\n list.push(value)\n out[field] = list\n }\n return out\n}\n\nfunction jsonOk(\n payload: {\n resource: string\n capabilities: SearchProvider['capabilities']\n rows: SearchResult['rows']\n total: number\n facets: SearchResult['facets']\n durationMs: number\n },\n): Response {\n return new Response(JSON.stringify(payload), {\n status: 200,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n })\n}\n\nfunction jsonError(message: string, status: number, code: string): Response {\n return new Response(JSON.stringify({ error: message, code }), {\n status,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n })\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/search",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"dist"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@murumets-ee/core": "0.
|
|
20
|
+
"@murumets-ee/core": "0.16.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^20.19.40",
|