@open-mercato/search 0.6.6-develop.5612.1.d382eb2f33 → 0.6.6-develop.5619.1.29f01e2c42
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/search/api/embeddings/route.js +23 -0
- package/dist/modules/search/api/embeddings/route.js.map +2 -2
- package/dist/modules/search/i18n/de.json +1 -0
- package/dist/modules/search/i18n/en.json +1 -0
- package/dist/modules/search/i18n/es.json +1 -0
- package/dist/modules/search/i18n/pl.json +1 -0
- package/dist/vector/lib/ollama-url-safety.js +72 -0
- package/dist/vector/lib/ollama-url-safety.js.map +7 -0
- package/dist/vector/services/embedding.js +11 -0
- package/dist/vector/services/embedding.js.map +2 -2
- package/package.json +4 -4
- package/src/__tests__/embedding.test.ts +40 -0
- package/src/modules/search/api/embeddings/__tests__/route.ollama-base-url.test.ts +145 -0
- package/src/modules/search/api/embeddings/route.ts +24 -0
- package/src/modules/search/i18n/de.json +1 -0
- package/src/modules/search/i18n/en.json +1 -0
- package/src/modules/search/i18n/es.json +1 -0
- package/src/modules/search/i18n/pl.json +1 -0
- package/src/vector/lib/__tests__/ollama-url-safety.test.ts +287 -0
- package/src/vector/lib/ollama-url-safety.ts +85 -0
- package/src/vector/services/embedding.ts +11 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:search] found
|
|
1
|
+
[build:search] found 90 entry points
|
|
2
2
|
[build:search] built successfully
|
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
} from "../../lib/embedding-config.js";
|
|
13
13
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
14
14
|
import { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from "../../../../vector/index.js";
|
|
15
|
+
import {
|
|
16
|
+
assertSafeOllamaBaseUrl,
|
|
17
|
+
UnsafeOllamaBaseUrlError
|
|
18
|
+
} from "../../../../vector/lib/ollama-url-safety.js";
|
|
15
19
|
import { searchDebug, searchDebugWarn, searchError } from "../../../../lib/debug.js";
|
|
16
20
|
import { embeddingsOpenApi } from "../openapi.js";
|
|
17
21
|
const embeddingConfigSchema = z.object({
|
|
@@ -160,6 +164,25 @@ async function POST(req) {
|
|
|
160
164
|
{ status: 400 }
|
|
161
165
|
);
|
|
162
166
|
}
|
|
167
|
+
if (newConfig.providerId === "ollama" && newConfig.baseUrl != null) {
|
|
168
|
+
try {
|
|
169
|
+
assertSafeOllamaBaseUrl(newConfig.baseUrl);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof UnsafeOllamaBaseUrlError) {
|
|
172
|
+
return NextResponse.json(
|
|
173
|
+
{
|
|
174
|
+
error: t(
|
|
175
|
+
"search.api.errors.invalidOllamaBaseUrl",
|
|
176
|
+
"Ollama base URL is not allowed. Set OLLAMA_BASE_URL in the environment, or add the host to OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST."
|
|
177
|
+
),
|
|
178
|
+
reason: err.reason
|
|
179
|
+
},
|
|
180
|
+
{ status: 400 }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
163
186
|
const change = detectConfigChange(
|
|
164
187
|
embeddingConfig,
|
|
165
188
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/search/api/embeddings/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { envDisablesAutoIndexing, resolveAutoIndexingEnabled, SEARCH_AUTO_INDEX_CONFIG_KEY } from '../../lib/auto-indexing'\nimport {\n resolveEmbeddingConfig,\n saveEmbeddingConfig,\n getConfiguredProviders,\n detectConfigChange,\n getEffectiveDimension,\n} from '../../lib/embedding-config'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { EmbeddingProviderConfig, EmbeddingProviderId, VectorDriver } from '../../../../vector'\nimport { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG, EmbeddingService } from '../../../../vector'\nimport { searchDebug, searchDebugWarn, searchError } from '../../../../lib/debug'\nimport { embeddingsOpenApi } from '../openapi'\n\nconst embeddingConfigSchema = z.object({\n providerId: z.enum(['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama']),\n model: z.string(),\n dimension: z.number(),\n outputDimensionality: z.number().optional(),\n baseUrl: z.string().optional(),\n})\n\nconst updateSchema = z.object({\n autoIndexingEnabled: z.boolean().optional(),\n embeddingConfig: embeddingConfigSchema.optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.embeddings.view'] },\n POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },\n}\n\ntype SettingsResponse = {\n settings: {\n openaiConfigured: boolean\n autoIndexingEnabled: boolean\n autoIndexingLocked: boolean\n lockReason: string | null\n embeddingConfig: EmbeddingProviderConfig | null\n configuredProviders: EmbeddingProviderId[]\n indexedDimension: number | null\n reindexRequired: boolean\n documentCount: number | null\n }\n}\n\nconst openAiConfigured = () => Boolean(process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.trim().length > 0)\n\nconst toJson = (payload: SettingsResponse, init?: ResponseInit) => NextResponse.json(payload, init)\n\nconst unauthorized = async () => {\n const { t } = await resolveTranslations()\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n}\n\nconst configUnavailable = async () => {\n const { t } = await resolveTranslations()\n return NextResponse.json({ error: t('search.api.errors.configUnavailable', 'Configuration service unavailable') }, { status: 503 })\n}\n\nasync function getIndexedDimension(container: { resolve: <T = unknown>(name: string) => T }): Promise<number | null> {\n try {\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')\n if (pgvectorDriver?.getTableDimension) {\n return await pgvectorDriver.getTableDimension()\n }\n return null\n } catch {\n return null\n }\n}\n\nasync function getVectorDocumentCount(\n container: { resolve: <T = unknown>(name: string) => T },\n tenantId: string,\n organizationId?: string | null,\n): Promise<number | null> {\n try {\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')\n if (pgvectorDriver?.count) {\n return await pgvectorDriver.count({ tenantId, organizationId: organizationId ?? undefined })\n }\n return null\n } catch {\n return null\n }\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) return await unauthorized()\n\n const container = await createRequestContainer()\n try {\n const lockedByEnv = envDisablesAutoIndexing()\n let autoIndexingEnabled = !lockedByEnv\n if (!lockedByEnv) {\n try {\n autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })\n } catch {\n autoIndexingEnabled = true\n }\n }\n\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n const configuredProviders = getConfiguredProviders()\n const indexedDimension = await getIndexedDimension(container)\n\n const effectiveDimension = embeddingConfig\n ? getEffectiveDimension(embeddingConfig)\n : DEFAULT_EMBEDDING_CONFIG.dimension\n\n const reindexRequired = Boolean(\n indexedDimension &&\n embeddingConfig &&\n indexedDimension !== effectiveDimension\n )\n\n // Get document count for vector index\n const documentCount = auth.tenantId\n ? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)\n : null\n\n return toJson({\n settings: {\n openaiConfigured: openAiConfigured(),\n autoIndexingEnabled: lockedByEnv ? false : autoIndexingEnabled,\n autoIndexingLocked: lockedByEnv,\n lockReason: lockedByEnv ? 'env' : null,\n embeddingConfig,\n configuredProviders,\n indexedDimension,\n reindexRequired,\n documentCount,\n },\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 async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) return await unauthorized()\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ error: t('api.errors.invalidJson', 'Invalid JSON payload.') }, { status: 400 })\n }\n const parsed = updateSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ error: t('api.errors.invalidPayload', 'Invalid payload.') }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n try {\n let service: ModuleConfigService\n try {\n service = (container.resolve('moduleConfigService') as ModuleConfigService)\n } catch {\n return await configUnavailable()\n }\n\n if (parsed.data.autoIndexingEnabled !== undefined) {\n if (envDisablesAutoIndexing()) {\n return NextResponse.json(\n {\n error: t(\n 'search.api.errors.autoIndexingDisabled',\n 'Auto-indexing is disabled via OM_DISABLE_VECTOR_SEARCH_AUTOINDEXING (legacy alias: DISABLE_VECTOR_SEARCH_AUTOINDEXING).',\n ),\n },\n { status: 409 },\n )\n }\n await service.setValue('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, parsed.data.autoIndexingEnabled)\n }\n\n let embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n let reindexRequired = false\n let indexedDimension = await getIndexedDimension(container)\n\n if (parsed.data.embeddingConfig) {\n const newConfig = parsed.data.embeddingConfig\n const providerInfo = EMBEDDING_PROVIDERS[newConfig.providerId]\n\n if (!providerInfo) {\n return NextResponse.json(\n { error: t('search.api.errors.invalidProvider', 'Invalid embedding provider.') },\n { status: 400 },\n )\n }\n\n const configuredProviders = getConfiguredProviders()\n if (!configuredProviders.includes(newConfig.providerId)) {\n return NextResponse.json(\n { error: t('search.api.errors.providerNotConfigured', `Provider ${providerInfo.name} is not configured. Set ${providerInfo.envKeyRequired} environment variable.`) },\n { status: 400 },\n )\n }\n\n const change = detectConfigChange(\n embeddingConfig,\n {\n ...newConfig,\n updatedAt: new Date().toISOString(),\n },\n indexedDimension\n )\n\n if (change.requiresReindex) {\n const newDimension = getEffectiveDimension(change.newConfig)\n searchDebug('search.embeddings.update', 'config change detected, recreating table', {\n requiresReindex: change.requiresReindex,\n reason: change.reason,\n oldDimension: indexedDimension,\n newDimension,\n })\n try {\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')\n if (pgvectorDriver?.recreateWithDimension) {\n await pgvectorDriver.recreateWithDimension(newDimension)\n // Query the actual dimension from the database to confirm\n if (pgvectorDriver.getTableDimension) {\n indexedDimension = await pgvectorDriver.getTableDimension()\n } else {\n indexedDimension = newDimension\n }\n searchDebug('search.embeddings.update', 'table recreated successfully', { indexedDimension })\n } else {\n searchDebugWarn('search.embeddings.update', 'pgvector driver does not have recreateWithDimension method')\n }\n } catch (error) {\n searchError('search.embeddings.update', 'failed to recreate table', {\n error: error instanceof Error ? error.message : error,\n })\n return NextResponse.json(\n { error: t('search.api.errors.recreateFailed', 'Failed to recreate vector table with new dimension.') },\n { status: 500 },\n )\n }\n }\n\n await saveEmbeddingConfig(container, change.newConfig)\n embeddingConfig = change.newConfig\n\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n } catch {\n // Embedding service may not be available in all contexts\n }\n\n reindexRequired = change.requiresReindex\n }\n\n const lockedByEnv = envDisablesAutoIndexing()\n let autoIndexingEnabled = !lockedByEnv\n if (!lockedByEnv) {\n try {\n autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })\n } catch {\n autoIndexingEnabled = true\n }\n }\n\n // Get updated document count\n const updatedDocumentCount = auth.tenantId\n ? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)\n : null\n\n return toJson({\n settings: {\n openaiConfigured: openAiConfigured(),\n autoIndexingEnabled: lockedByEnv ? false : autoIndexingEnabled,\n autoIndexingLocked: lockedByEnv,\n lockReason: lockedByEnv ? 'env' : null,\n embeddingConfig,\n configuredProviders: getConfiguredProviders(),\n indexedDimension,\n reindexRequired,\n documentCount: updatedDocumentCount,\n },\n })\n } catch (error) {\n searchError('search.embeddings.update', 'failed', {\n error: error instanceof Error ? error.message : error,\n })\n return NextResponse.json({ error: t('search.api.errors.updateFailed', 'Failed to update embedding settings.') }, { status: 500 })\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 = embeddingsOpenApi\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { envDisablesAutoIndexing, resolveAutoIndexingEnabled, SEARCH_AUTO_INDEX_CONFIG_KEY } from '../../lib/auto-indexing'\nimport {\n resolveEmbeddingConfig,\n saveEmbeddingConfig,\n getConfiguredProviders,\n detectConfigChange,\n getEffectiveDimension,\n} from '../../lib/embedding-config'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { EmbeddingProviderConfig, EmbeddingProviderId, VectorDriver } from '../../../../vector'\nimport { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG, EmbeddingService } from '../../../../vector'\nimport {\n assertSafeOllamaBaseUrl,\n UnsafeOllamaBaseUrlError,\n} from '../../../../vector/lib/ollama-url-safety'\nimport { searchDebug, searchDebugWarn, searchError } from '../../../../lib/debug'\nimport { embeddingsOpenApi } from '../openapi'\n\nconst embeddingConfigSchema = z.object({\n providerId: z.enum(['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama']),\n model: z.string(),\n dimension: z.number(),\n outputDimensionality: z.number().optional(),\n baseUrl: z.string().optional(),\n})\n\nconst updateSchema = z.object({\n autoIndexingEnabled: z.boolean().optional(),\n embeddingConfig: embeddingConfigSchema.optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['search.embeddings.view'] },\n POST: { requireAuth: true, requireFeatures: ['search.embeddings.manage'] },\n}\n\ntype SettingsResponse = {\n settings: {\n openaiConfigured: boolean\n autoIndexingEnabled: boolean\n autoIndexingLocked: boolean\n lockReason: string | null\n embeddingConfig: EmbeddingProviderConfig | null\n configuredProviders: EmbeddingProviderId[]\n indexedDimension: number | null\n reindexRequired: boolean\n documentCount: number | null\n }\n}\n\nconst openAiConfigured = () => Boolean(process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.trim().length > 0)\n\nconst toJson = (payload: SettingsResponse, init?: ResponseInit) => NextResponse.json(payload, init)\n\nconst unauthorized = async () => {\n const { t } = await resolveTranslations()\n return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n}\n\nconst configUnavailable = async () => {\n const { t } = await resolveTranslations()\n return NextResponse.json({ error: t('search.api.errors.configUnavailable', 'Configuration service unavailable') }, { status: 503 })\n}\n\nasync function getIndexedDimension(container: { resolve: <T = unknown>(name: string) => T }): Promise<number | null> {\n try {\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')\n if (pgvectorDriver?.getTableDimension) {\n return await pgvectorDriver.getTableDimension()\n }\n return null\n } catch {\n return null\n }\n}\n\nasync function getVectorDocumentCount(\n container: { resolve: <T = unknown>(name: string) => T },\n tenantId: string,\n organizationId?: string | null,\n): Promise<number | null> {\n try {\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')\n if (pgvectorDriver?.count) {\n return await pgvectorDriver.count({ tenantId, organizationId: organizationId ?? undefined })\n }\n return null\n } catch {\n return null\n }\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) return await unauthorized()\n\n const container = await createRequestContainer()\n try {\n const lockedByEnv = envDisablesAutoIndexing()\n let autoIndexingEnabled = !lockedByEnv\n if (!lockedByEnv) {\n try {\n autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })\n } catch {\n autoIndexingEnabled = true\n }\n }\n\n const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n const configuredProviders = getConfiguredProviders()\n const indexedDimension = await getIndexedDimension(container)\n\n const effectiveDimension = embeddingConfig\n ? getEffectiveDimension(embeddingConfig)\n : DEFAULT_EMBEDDING_CONFIG.dimension\n\n const reindexRequired = Boolean(\n indexedDimension &&\n embeddingConfig &&\n indexedDimension !== effectiveDimension\n )\n\n // Get document count for vector index\n const documentCount = auth.tenantId\n ? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)\n : null\n\n return toJson({\n settings: {\n openaiConfigured: openAiConfigured(),\n autoIndexingEnabled: lockedByEnv ? false : autoIndexingEnabled,\n autoIndexingLocked: lockedByEnv,\n lockReason: lockedByEnv ? 'env' : null,\n embeddingConfig,\n configuredProviders,\n indexedDimension,\n reindexRequired,\n documentCount,\n },\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 async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) return await unauthorized()\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ error: t('api.errors.invalidJson', 'Invalid JSON payload.') }, { status: 400 })\n }\n const parsed = updateSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ error: t('api.errors.invalidPayload', 'Invalid payload.') }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n try {\n let service: ModuleConfigService\n try {\n service = (container.resolve('moduleConfigService') as ModuleConfigService)\n } catch {\n return await configUnavailable()\n }\n\n if (parsed.data.autoIndexingEnabled !== undefined) {\n if (envDisablesAutoIndexing()) {\n return NextResponse.json(\n {\n error: t(\n 'search.api.errors.autoIndexingDisabled',\n 'Auto-indexing is disabled via OM_DISABLE_VECTOR_SEARCH_AUTOINDEXING (legacy alias: DISABLE_VECTOR_SEARCH_AUTOINDEXING).',\n ),\n },\n { status: 409 },\n )\n }\n await service.setValue('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, parsed.data.autoIndexingEnabled)\n }\n\n let embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })\n let reindexRequired = false\n let indexedDimension = await getIndexedDimension(container)\n\n if (parsed.data.embeddingConfig) {\n const newConfig = parsed.data.embeddingConfig\n const providerInfo = EMBEDDING_PROVIDERS[newConfig.providerId]\n\n if (!providerInfo) {\n return NextResponse.json(\n { error: t('search.api.errors.invalidProvider', 'Invalid embedding provider.') },\n { status: 400 },\n )\n }\n\n const configuredProviders = getConfiguredProviders()\n if (!configuredProviders.includes(newConfig.providerId)) {\n return NextResponse.json(\n { error: t('search.api.errors.providerNotConfigured', `Provider ${providerInfo.name} is not configured. Set ${providerInfo.envKeyRequired} environment variable.`) },\n { status: 400 },\n )\n }\n\n if (newConfig.providerId === 'ollama' && newConfig.baseUrl != null) {\n try {\n assertSafeOllamaBaseUrl(newConfig.baseUrl)\n } catch (err) {\n if (err instanceof UnsafeOllamaBaseUrlError) {\n return NextResponse.json(\n {\n error: t(\n 'search.api.errors.invalidOllamaBaseUrl',\n 'Ollama base URL is not allowed. Set OLLAMA_BASE_URL in the environment, or add the host to OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.',\n ),\n reason: err.reason,\n },\n { status: 400 },\n )\n }\n throw err\n }\n }\n\n const change = detectConfigChange(\n embeddingConfig,\n {\n ...newConfig,\n updatedAt: new Date().toISOString(),\n },\n indexedDimension\n )\n\n if (change.requiresReindex) {\n const newDimension = getEffectiveDimension(change.newConfig)\n searchDebug('search.embeddings.update', 'config change detected, recreating table', {\n requiresReindex: change.requiresReindex,\n reason: change.reason,\n oldDimension: indexedDimension,\n newDimension,\n })\n try {\n const drivers = container.resolve<VectorDriver[]>('vectorDrivers')\n const pgvectorDriver = drivers.find((d) => d.id === 'pgvector')\n if (pgvectorDriver?.recreateWithDimension) {\n await pgvectorDriver.recreateWithDimension(newDimension)\n // Query the actual dimension from the database to confirm\n if (pgvectorDriver.getTableDimension) {\n indexedDimension = await pgvectorDriver.getTableDimension()\n } else {\n indexedDimension = newDimension\n }\n searchDebug('search.embeddings.update', 'table recreated successfully', { indexedDimension })\n } else {\n searchDebugWarn('search.embeddings.update', 'pgvector driver does not have recreateWithDimension method')\n }\n } catch (error) {\n searchError('search.embeddings.update', 'failed to recreate table', {\n error: error instanceof Error ? error.message : error,\n })\n return NextResponse.json(\n { error: t('search.api.errors.recreateFailed', 'Failed to recreate vector table with new dimension.') },\n { status: 500 },\n )\n }\n }\n\n await saveEmbeddingConfig(container, change.newConfig)\n embeddingConfig = change.newConfig\n\n try {\n const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')\n embeddingService.updateConfig(embeddingConfig)\n } catch {\n // Embedding service may not be available in all contexts\n }\n\n reindexRequired = change.requiresReindex\n }\n\n const lockedByEnv = envDisablesAutoIndexing()\n let autoIndexingEnabled = !lockedByEnv\n if (!lockedByEnv) {\n try {\n autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })\n } catch {\n autoIndexingEnabled = true\n }\n }\n\n // Get updated document count\n const updatedDocumentCount = auth.tenantId\n ? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)\n : null\n\n return toJson({\n settings: {\n openaiConfigured: openAiConfigured(),\n autoIndexingEnabled: lockedByEnv ? false : autoIndexingEnabled,\n autoIndexingLocked: lockedByEnv,\n lockReason: lockedByEnv ? 'env' : null,\n embeddingConfig,\n configuredProviders: getConfiguredProviders(),\n indexedDimension,\n reindexRequired,\n documentCount: updatedDocumentCount,\n },\n })\n } catch (error) {\n searchError('search.embeddings.update', 'failed', {\n error: error instanceof Error ? error.message : error,\n })\n return NextResponse.json({ error: t('search.api.errors.updateFailed', 'Failed to update embedding settings.') }, { status: 500 })\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 = embeddingsOpenApi\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AAEnC,SAAS,yBAAyB,4BAA4B,oCAAoC;AAClG;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,2BAA2B;AAEpC,SAAS,qBAAqB,gCAAkD;AAChF;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa,iBAAiB,mBAAmB;AAC1D,SAAS,yBAAyB;AAElC,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,YAAY,EAAE,KAAK,CAAC,UAAU,UAAU,WAAW,UAAU,WAAW,QAAQ,CAAC;AAAA,EACjF,OAAO,EAAE,OAAO;AAAA,EAChB,WAAW,EAAE,OAAO;AAAA,EACpB,sBAAsB,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1C,SAAS,EAAE,OAAO,EAAE,SAAS;AAC/B,CAAC;AAED,MAAM,eAAe,EAAE,OAAO;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,sBAAsB,SAAS;AAClD,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,wBAAwB,EAAE;AAAA,EACtE,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAgBA,MAAM,mBAAmB,MAAM,QAAQ,QAAQ,IAAI,kBAAkB,QAAQ,IAAI,eAAe,KAAK,EAAE,SAAS,CAAC;AAEjH,MAAM,SAAS,CAAC,SAA2B,SAAwB,aAAa,KAAK,SAAS,IAAI;AAElG,MAAM,eAAe,YAAY;AAC/B,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,SAAO,aAAa,KAAK,EAAE,OAAO,EAAE,2BAA2B,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AACnG;AAEA,MAAM,oBAAoB,YAAY;AACpC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,SAAO,aAAa,KAAK,EAAE,OAAO,EAAE,uCAAuC,mCAAmC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AACpI;AAEA,eAAe,oBAAoB,WAAkF;AACnH,MAAI;AACF,UAAM,UAAU,UAAU,QAAwB,eAAe;AACjE,UAAM,iBAAiB,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,UAAU;AAC9D,QAAI,gBAAgB,mBAAmB;AACrC,aAAO,MAAM,eAAe,kBAAkB;AAAA,IAChD;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,uBACb,WACA,UACA,gBACwB;AACxB,MAAI;AACF,UAAM,UAAU,UAAU,QAAwB,eAAe;AACjE,UAAM,iBAAiB,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,UAAU;AAC9D,QAAI,gBAAgB,OAAO;AACzB,aAAO,MAAM,eAAe,MAAM,EAAE,UAAU,gBAAgB,kBAAkB,OAAU,CAAC;AAAA,IAC7F;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,IAAK,QAAO,MAAM,aAAa;AAE1C,QAAM,YAAY,MAAM,uBAAuB;AAC/C,MAAI;AACF,UAAM,cAAc,wBAAwB;AAC5C,QAAI,sBAAsB,CAAC;AAC3B,QAAI,CAAC,aAAa;AAChB,UAAI;AACF,8BAAsB,MAAM,2BAA2B,WAAW,EAAE,cAAc,KAAK,CAAC;AAAA,MAC1F,QAAQ;AACN,8BAAsB;AAAA,MACxB;AAAA,IACF;AAEA,UAAM,kBAAkB,MAAM,uBAAuB,WAAW,EAAE,cAAc,KAAK,CAAC;AACtF,UAAM,sBAAsB,uBAAuB;AACnD,UAAM,mBAAmB,MAAM,oBAAoB,SAAS;AAE5D,UAAM,qBAAqB,kBACvB,sBAAsB,eAAe,IACrC,yBAAyB;AAE7B,UAAM,kBAAkB;AAAA,MACtB,oBACA,mBACA,qBAAqB;AAAA,IACvB;AAGA,UAAM,gBAAgB,KAAK,WACvB,MAAM,uBAAuB,WAAW,KAAK,UAAU,KAAK,KAAK,IACjE;AAEJ,WAAO,OAAO;AAAA,MACZ,UAAU;AAAA,QACR,kBAAkB,iBAAiB;AAAA,QACnC,qBAAqB,cAAc,QAAQ;AAAA,QAC3C,oBAAoB;AAAA,QACpB,YAAY,cAAc,QAAQ;AAAA,QAClC;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF,CAAC;AAAA,EACH,UAAE;AACA,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF;AACF;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,IAAK,QAAO,MAAM,aAAa;AAE1C,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,IAAI,KAAK;AAAA,EACxB,QAAQ;AACN,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,0BAA0B,uBAAuB,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3G;AACA,QAAM,SAAS,aAAa,UAAU,IAAI;AAC1C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,6BAA6B,kBAAkB,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACzG;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,MAAI;AACF,QAAI;AACJ,QAAI;AACF,gBAAW,UAAU,QAAQ,qBAAqB;AAAA,IACpD,QAAQ;AACN,aAAO,MAAM,kBAAkB;AAAA,IACjC;AAEA,QAAI,OAAO,KAAK,wBAAwB,QAAW;AACjD,UAAI,wBAAwB,GAAG;AAC7B,eAAO,aAAa;AAAA,UAClB;AAAA,YACE,OAAO;AAAA,cACL;AAAA,cACA;AAAA,YACF;AAAA,UACF;AAAA,UACA,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AACA,YAAM,QAAQ,SAAS,UAAU,8BAA8B,OAAO,KAAK,mBAAmB;AAAA,IAChG;AAEA,QAAI,kBAAkB,MAAM,uBAAuB,WAAW,EAAE,cAAc,KAAK,CAAC;AACpF,QAAI,kBAAkB;AACtB,QAAI,mBAAmB,MAAM,oBAAoB,SAAS;AAE1D,QAAI,OAAO,KAAK,iBAAiB;AAC/B,YAAM,YAAY,OAAO,KAAK;AAC9B,YAAM,eAAe,oBAAoB,UAAU,UAAU;AAE7D,UAAI,CAAC,cAAc;AACjB,eAAO,aAAa;AAAA,UAClB,EAAE,OAAO,EAAE,qCAAqC,6BAA6B,EAAE;AAAA,UAC/E,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AAEA,YAAM,sBAAsB,uBAAuB;AACnD,UAAI,CAAC,oBAAoB,SAAS,UAAU,UAAU,GAAG;AACvD,eAAO,aAAa;AAAA,UAClB,EAAE,OAAO,EAAE,2CAA2C,YAAY,aAAa,IAAI,2BAA2B,aAAa,cAAc,wBAAwB,EAAE;AAAA,UACnK,EAAE,QAAQ,IAAI;AAAA,QAChB;AAAA,MACF;AAEA,UAAI,UAAU,eAAe,YAAY,UAAU,WAAW,MAAM;AAClE,YAAI;AACF,kCAAwB,UAAU,OAAO;AAAA,QAC3C,SAAS,KAAK;AACZ,cAAI,eAAe,0BAA0B;AAC3C,mBAAO,aAAa;AAAA,cAClB;AAAA,gBACE,OAAO;AAAA,kBACL;AAAA,kBACA;AAAA,gBACF;AAAA,gBACA,QAAQ,IAAI;AAAA,cACd;AAAA,cACA,EAAE,QAAQ,IAAI;AAAA,YAChB;AAAA,UACF;AACA,gBAAM;AAAA,QACR;AAAA,MACF;AAEA,YAAM,SAAS;AAAA,QACb;AAAA,QACA;AAAA,UACE,GAAG;AAAA,UACH,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,QACpC;AAAA,QACA;AAAA,MACF;AAEA,UAAI,OAAO,iBAAiB;AAC1B,cAAM,eAAe,sBAAsB,OAAO,SAAS;AAC3D,oBAAY,4BAA4B,4CAA4C;AAAA,UAClF,iBAAiB,OAAO;AAAA,UACxB,QAAQ,OAAO;AAAA,UACf,cAAc;AAAA,UACd;AAAA,QACF,CAAC;AACD,YAAI;AACF,gBAAM,UAAU,UAAU,QAAwB,eAAe;AACjE,gBAAM,iBAAiB,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,UAAU;AAC9D,cAAI,gBAAgB,uBAAuB;AACzC,kBAAM,eAAe,sBAAsB,YAAY;AAEvD,gBAAI,eAAe,mBAAmB;AACpC,iCAAmB,MAAM,eAAe,kBAAkB;AAAA,YAC5D,OAAO;AACL,iCAAmB;AAAA,YACrB;AACA,wBAAY,4BAA4B,gCAAgC,EAAE,iBAAiB,CAAC;AAAA,UAC9F,OAAO;AACL,4BAAgB,4BAA4B,4DAA4D;AAAA,UAC1G;AAAA,QACF,SAAS,OAAO;AACd,sBAAY,4BAA4B,4BAA4B;AAAA,YAClE,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,UAClD,CAAC;AACD,iBAAO,aAAa;AAAA,YAClB,EAAE,OAAO,EAAE,oCAAoC,qDAAqD,EAAE;AAAA,YACtG,EAAE,QAAQ,IAAI;AAAA,UAChB;AAAA,QACF;AAAA,MACF;AAEA,YAAM,oBAAoB,WAAW,OAAO,SAAS;AACrD,wBAAkB,OAAO;AAEzB,UAAI;AACF,cAAM,mBAAmB,UAAU,QAA0B,wBAAwB;AACrF,yBAAiB,aAAa,eAAe;AAAA,MAC/C,QAAQ;AAAA,MAER;AAEA,wBAAkB,OAAO;AAAA,IAC3B;AAEA,UAAM,cAAc,wBAAwB;AAC5C,QAAI,sBAAsB,CAAC;AAC3B,QAAI,CAAC,aAAa;AAChB,UAAI;AACF,8BAAsB,MAAM,2BAA2B,WAAW,EAAE,cAAc,KAAK,CAAC;AAAA,MAC1F,QAAQ;AACN,8BAAsB;AAAA,MACxB;AAAA,IACF;AAGA,UAAM,uBAAuB,KAAK,WAC9B,MAAM,uBAAuB,WAAW,KAAK,UAAU,KAAK,KAAK,IACjE;AAEJ,WAAO,OAAO;AAAA,MACZ,UAAU;AAAA,QACR,kBAAkB,iBAAiB;AAAA,QACnC,qBAAqB,cAAc,QAAQ;AAAA,QAC3C,oBAAoB;AAAA,QACpB,YAAY,cAAc,QAAQ;AAAA,QAClC;AAAA,QACA,qBAAqB,uBAAuB;AAAA,QAC5C;AAAA,QACA;AAAA,QACA,eAAe;AAAA,MACjB;AAAA,IACF,CAAC;AAAA,EACH,SAAS,OAAO;AACd,gBAAY,4BAA4B,UAAU;AAAA,MAChD,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAClD,CAAC;AACD,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,kCAAkC,sCAAsC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClI,UAAE;AACA,UAAM,aAAa;AACnB,QAAI,OAAO,WAAW,YAAY,YAAY;AAC5C,YAAM,WAAW,QAAQ;AAAA,IAC3B;AAAA,EACF;AACF;AAEO,MAAM,UAAU;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Indexdaten konnten nicht abgerufen werden.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "Suchindex ist nicht verfügbar.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "Such-Indexer ist nicht verfügbar.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "Ollama-Basis-URL ist nicht erlaubt. Setzen Sie OLLAMA_BASE_URL in der Umgebung oder fügen Sie den Host zu OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST hinzu.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Ungültiger Embedding-Anbieter.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Ungültige Suchstrategien angegeben.",
|
|
10
11
|
"search.api.errors.lockFailed": "Indexsperre konnte nicht erworben werden.",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Failed to fetch index data.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "Search index is unavailable.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "Search indexer is unavailable.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "Ollama base URL is not allowed. Set OLLAMA_BASE_URL in the environment, or add the host to OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Invalid embedding provider.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Invalid search strategies specified.",
|
|
10
11
|
"search.api.errors.lockFailed": "Failed to acquire index lock.",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Error al obtener los datos del índice.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "El índice de búsqueda no está disponible.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "El indexador de búsqueda no está disponible.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "La URL base de Ollama no está permitida. Establezca OLLAMA_BASE_URL en el entorno o añada el host a OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Proveedor de embeddings no válido.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Estrategias de búsqueda no válidas.",
|
|
10
11
|
"search.api.errors.lockFailed": "Error al adquirir el bloqueo del índice.",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Nie udało się pobrać danych indeksu.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "Indeks wyszukiwania jest niedostępny.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "Indekser wyszukiwania jest niedostępny.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "Bazowy URL Ollama nie jest dozwolony. Ustaw OLLAMA_BASE_URL w środowisku lub dodaj host do OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Nieprawidłowy dostawca embeddingów.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Określono nieprawidłowe strategie wyszukiwania.",
|
|
10
11
|
"search.api.errors.lockFailed": "Nie udało się uzyskać blokady indeksu.",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertStaticallySafeOutboundUrl
|
|
3
|
+
} from "@open-mercato/shared/lib/url-safety";
|
|
4
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
5
|
+
const SUBJECT = "Ollama base URL";
|
|
6
|
+
class UnsafeOllamaBaseUrlError extends Error {
|
|
7
|
+
constructor(reason, message) {
|
|
8
|
+
super(message ?? `Ollama base URL rejected: ${reason}`);
|
|
9
|
+
this.name = "UnsafeOllamaBaseUrlError";
|
|
10
|
+
this.reason = reason;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const ollamaErrorFactory = (reason, message) => new UnsafeOllamaBaseUrlError(reason, message);
|
|
14
|
+
function getOllamaBaseUrlAllowlist() {
|
|
15
|
+
const raw = process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST ?? "";
|
|
16
|
+
return new Set(
|
|
17
|
+
raw.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
function isAllowPrivateOllamaBaseUrlEnabled() {
|
|
21
|
+
return parseBooleanWithDefault(process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE, false);
|
|
22
|
+
}
|
|
23
|
+
function assertSafeOllamaBaseUrl(rawUrl) {
|
|
24
|
+
if (typeof rawUrl !== "string" || rawUrl.trim().length === 0) {
|
|
25
|
+
throw new UnsafeOllamaBaseUrlError("missing_host", `${SUBJECT} is required`);
|
|
26
|
+
}
|
|
27
|
+
const allowPrivate = allowlistMatches(rawUrl, getOllamaBaseUrlAllowlist()) || isAllowPrivateOllamaBaseUrlEnabled() || process.env.NODE_ENV !== "production" && isLoopbackOnlyUrl(rawUrl);
|
|
28
|
+
assertStaticallySafeOutboundUrl(rawUrl, {
|
|
29
|
+
errorFactory: ollamaErrorFactory,
|
|
30
|
+
subject: SUBJECT,
|
|
31
|
+
allowPrivate
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function allowlistMatches(rawUrl, allowlist) {
|
|
35
|
+
if (allowlist.size === 0) return false;
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(rawUrl);
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
let host = parsed.hostname.toLowerCase();
|
|
43
|
+
if (host.startsWith("[") && host.endsWith("]")) {
|
|
44
|
+
host = host.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
47
|
+
const hostPort = `${host}:${port}`;
|
|
48
|
+
return allowlist.has(host) || allowlist.has(hostPort);
|
|
49
|
+
}
|
|
50
|
+
function isLoopbackOnlyUrl(rawUrl) {
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = new URL(rawUrl);
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
let host = parsed.hostname.toLowerCase();
|
|
58
|
+
if (host.startsWith("[") && host.endsWith("]")) {
|
|
59
|
+
host = host.slice(1, -1);
|
|
60
|
+
}
|
|
61
|
+
if (host === "localhost") return true;
|
|
62
|
+
if (host === "::1") return true;
|
|
63
|
+
if (/^127\.\d+\.\d+\.\d+$/.test(host)) return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
UnsafeOllamaBaseUrlError,
|
|
68
|
+
assertSafeOllamaBaseUrl,
|
|
69
|
+
getOllamaBaseUrlAllowlist,
|
|
70
|
+
isAllowPrivateOllamaBaseUrlEnabled
|
|
71
|
+
};
|
|
72
|
+
//# sourceMappingURL=ollama-url-safety.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/vector/lib/ollama-url-safety.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n assertStaticallySafeOutboundUrl,\n type UrlSafetyReason,\n} from '@open-mercato/shared/lib/url-safety'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nconst SUBJECT = 'Ollama base URL'\n\nexport class UnsafeOllamaBaseUrlError extends Error {\n public readonly reason: string\n\n constructor(reason: string, message?: string) {\n super(message ?? `Ollama base URL rejected: ${reason}`)\n this.name = 'UnsafeOllamaBaseUrlError'\n this.reason = reason\n }\n}\n\nconst ollamaErrorFactory = (reason: UrlSafetyReason, message: string) =>\n new UnsafeOllamaBaseUrlError(reason, message)\n\nexport function getOllamaBaseUrlAllowlist(): ReadonlySet<string> {\n const raw = process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST ?? ''\n return new Set(\n raw\n .split(',')\n .map((entry) => entry.trim().toLowerCase())\n .filter((entry) => entry.length > 0),\n )\n}\n\nexport function isAllowPrivateOllamaBaseUrlEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE, false)\n}\n\nexport function assertSafeOllamaBaseUrl(rawUrl: string): void {\n if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) {\n throw new UnsafeOllamaBaseUrlError('missing_host', `${SUBJECT} is required`)\n }\n\n const allowPrivate =\n allowlistMatches(rawUrl, getOllamaBaseUrlAllowlist()) ||\n isAllowPrivateOllamaBaseUrlEnabled() ||\n (process.env.NODE_ENV !== 'production' && isLoopbackOnlyUrl(rawUrl))\n\n assertStaticallySafeOutboundUrl(rawUrl, {\n errorFactory: ollamaErrorFactory,\n subject: SUBJECT,\n allowPrivate,\n })\n}\n\nfunction allowlistMatches(rawUrl: string, allowlist: ReadonlySet<string>): boolean {\n if (allowlist.size === 0) return false\n let parsed: URL\n try {\n parsed = new URL(rawUrl)\n } catch {\n return false\n }\n let host = parsed.hostname.toLowerCase()\n if (host.startsWith('[') && host.endsWith(']')) {\n host = host.slice(1, -1)\n }\n const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')\n const hostPort = `${host}:${port}`\n return allowlist.has(host) || allowlist.has(hostPort)\n}\n\nfunction isLoopbackOnlyUrl(rawUrl: string): boolean {\n let parsed: URL\n try {\n parsed = new URL(rawUrl)\n } catch {\n return false\n }\n let host = parsed.hostname.toLowerCase()\n if (host.startsWith('[') && host.endsWith(']')) {\n host = host.slice(1, -1)\n }\n if (host === 'localhost') return true\n if (host === '::1') return true\n if (/^127\\.\\d+\\.\\d+\\.\\d+$/.test(host)) return true\n return false\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,EACE;AAAA,OAEK;AACP,SAAS,+BAA+B;AAExC,MAAM,UAAU;AAET,MAAM,iCAAiC,MAAM;AAAA,EAGlD,YAAY,QAAgB,SAAkB;AAC5C,UAAM,WAAW,6BAA6B,MAAM,EAAE;AACtD,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AAEA,MAAM,qBAAqB,CAAC,QAAyB,YACnD,IAAI,yBAAyB,QAAQ,OAAO;AAEvC,SAAS,4BAAiD;AAC/D,QAAM,MAAM,QAAQ,IAAI,uCAAuC;AAC/D,SAAO,IAAI;AAAA,IACT,IACG,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,EAAE,YAAY,CAAC,EACzC,OAAO,CAAC,UAAU,MAAM,SAAS,CAAC;AAAA,EACvC;AACF;AAEO,SAAS,qCAA8C;AAC5D,SAAO,wBAAwB,QAAQ,IAAI,gCAAgC,KAAK;AAClF;AAEO,SAAS,wBAAwB,QAAsB;AAC5D,MAAI,OAAO,WAAW,YAAY,OAAO,KAAK,EAAE,WAAW,GAAG;AAC5D,UAAM,IAAI,yBAAyB,gBAAgB,GAAG,OAAO,cAAc;AAAA,EAC7E;AAEA,QAAM,eACJ,iBAAiB,QAAQ,0BAA0B,CAAC,KACpD,mCAAmC,KAClC,QAAQ,IAAI,aAAa,gBAAgB,kBAAkB,MAAM;AAEpE,kCAAgC,QAAQ;AAAA,IACtC,cAAc;AAAA,IACd,SAAS;AAAA,IACT;AAAA,EACF,CAAC;AACH;AAEA,SAAS,iBAAiB,QAAgB,WAAyC;AACjF,MAAI,UAAU,SAAS,EAAG,QAAO;AACjC,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,MAAM;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,OAAO,OAAO,SAAS,YAAY;AACvC,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,GAAG;AAC9C,WAAO,KAAK,MAAM,GAAG,EAAE;AAAA,EACzB;AACA,QAAM,OAAO,OAAO,SAAS,OAAO,aAAa,WAAW,QAAQ;AACpE,QAAM,WAAW,GAAG,IAAI,IAAI,IAAI;AAChC,SAAO,UAAU,IAAI,IAAI,KAAK,UAAU,IAAI,QAAQ;AACtD;AAEA,SAAS,kBAAkB,QAAyB;AAClD,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,MAAM;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,OAAO,OAAO,SAAS,YAAY;AACvC,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,GAAG;AAC9C,WAAO,KAAK,MAAM,GAAG,EAAE;AAAA,EACzB;AACA,MAAI,SAAS,YAAa,QAAO;AACjC,MAAI,SAAS,MAAO,QAAO;AAC3B,MAAI,uBAAuB,KAAK,IAAI,EAAG,QAAO;AAC9C,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -6,6 +6,7 @@ import { createCohere } from "@ai-sdk/cohere";
|
|
|
6
6
|
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
|
|
7
7
|
import { createOllama } from "ai-sdk-ollama";
|
|
8
8
|
import { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from "../types.js";
|
|
9
|
+
import { assertSafeOllamaBaseUrl, UnsafeOllamaBaseUrlError } from "../lib/ollama-url-safety.js";
|
|
9
10
|
const DEFAULT_EMBEDDING_TIMEOUT_MS = 3e3;
|
|
10
11
|
function resolveEmbeddingTimeoutMs() {
|
|
11
12
|
const rawValue = process.env.VECTOR_EMBEDDING_TIMEOUT_MS;
|
|
@@ -122,6 +123,16 @@ class EmbeddingService {
|
|
|
122
123
|
}
|
|
123
124
|
case "ollama": {
|
|
124
125
|
const baseURL = this.config.baseUrl ?? process.env.OLLAMA_BASE_URL ?? "http://localhost:11434";
|
|
126
|
+
try {
|
|
127
|
+
assertSafeOllamaBaseUrl(baseURL);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof UnsafeOllamaBaseUrlError) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[vector.embedding] Ollama base URL rejected (${err.reason}). Set OLLAMA_BASE_URL or OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
125
136
|
client = createOllama({ baseURL });
|
|
126
137
|
break;
|
|
127
138
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/vector/services/embedding.ts"],
|
|
4
|
-
"sourcesContent": ["import { embed } from 'ai'\nimport type { EmbeddingModel } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\n\n// Local type definition to avoid @ai-sdk/provider version conflicts\n// Matches SharedV3ProviderOptions = Record<string, JSONObject>\ntype JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }\ntype JSONObject = { [key: string]: JSONValue }\ntype ProviderOptions = Record<string, JSONObject>\nimport { createGoogleGenerativeAI } from '@ai-sdk/google'\nimport { createMistral } from '@ai-sdk/mistral'\nimport { createCohere } from '@ai-sdk/cohere'\nimport { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'\nimport { createOllama } from 'ai-sdk-ollama'\nimport type { EmbeddingProviderId, EmbeddingProviderConfig } from '../types'\nimport { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from '../types'\n\nexport type EmbeddingServiceOptions = {\n apiKey?: string\n model?: string\n config?: EmbeddingProviderConfig\n}\n\ntype OllamaClient = ReturnType<typeof createOllama>\n\ntype ProviderClient = ReturnType<typeof createOpenAI>\n | ReturnType<typeof createGoogleGenerativeAI>\n | ReturnType<typeof createMistral>\n | ReturnType<typeof createCohere>\n | ReturnType<typeof createAmazonBedrock>\n | OllamaClient\n\nconst DEFAULT_EMBEDDING_TIMEOUT_MS = 3_000\n\nfunction resolveEmbeddingTimeoutMs(): number {\n const rawValue = process.env.VECTOR_EMBEDDING_TIMEOUT_MS\n if (!rawValue) return DEFAULT_EMBEDDING_TIMEOUT_MS\n const parsed = Number.parseInt(rawValue, 10)\n if (!Number.isFinite(parsed) || parsed <= 0) {\n return DEFAULT_EMBEDDING_TIMEOUT_MS\n }\n return parsed\n}\n\nfunction timeoutError(providerId: EmbeddingProviderId, timeoutMs: number): Error {\n const providerInfo = EMBEDDING_PROVIDERS[providerId]\n return new Error(\n `${providerInfo.name} request timed out after ${timeoutMs}ms. Check ${providerInfo.envKeyRequired}.`,\n )\n}\n\nexport class EmbeddingService {\n private config: EmbeddingProviderConfig\n private clientCache: Map<EmbeddingProviderId, ProviderClient> = new Map()\n\n constructor(private readonly opts: EmbeddingServiceOptions = {}) {\n if (opts.config) {\n this.config = opts.config\n } else {\n this.config = {\n providerId: 'openai',\n model: opts.model ?? DEFAULT_EMBEDDING_CONFIG.model,\n dimension: DEFAULT_EMBEDDING_CONFIG.dimension,\n updatedAt: new Date().toISOString(),\n }\n }\n }\n\n updateConfig(config: EmbeddingProviderConfig): void {\n this.config = config\n this.clientCache.clear()\n }\n\n get currentConfig(): EmbeddingProviderConfig {\n return { ...this.config }\n }\n\n get dimension(): number {\n return this.config.outputDimensionality ?? this.config.dimension\n }\n\n get available(): boolean {\n return this.isProviderConfigured(this.config.providerId)\n }\n\n private isProviderConfigured(providerId: EmbeddingProviderId): boolean {\n switch (providerId) {\n case 'openai':\n return Boolean(this.opts.apiKey ?? process.env.OPENAI_API_KEY)\n case 'google':\n return Boolean(process.env.GOOGLE_GENERATIVE_AI_API_KEY)\n case 'mistral':\n return Boolean(process.env.MISTRAL_API_KEY)\n case 'cohere':\n return Boolean(process.env.COHERE_API_KEY)\n case 'bedrock':\n return Boolean(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)\n case 'ollama':\n return true\n default:\n return false\n }\n }\n\n private getClient(providerId: EmbeddingProviderId): ProviderClient {\n const cached = this.clientCache.get(providerId)\n if (cached) {\n return cached\n }\n\n let client: ProviderClient\n switch (providerId) {\n case 'openai': {\n const apiKey = this.opts.apiKey ?? process.env.OPENAI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing OPENAI_API_KEY environment variable')\n }\n client = createOpenAI({ apiKey })\n break\n }\n case 'google': {\n const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing GOOGLE_GENERATIVE_AI_API_KEY environment variable')\n }\n client = createGoogleGenerativeAI({ apiKey })\n break\n }\n case 'mistral': {\n const apiKey = process.env.MISTRAL_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing MISTRAL_API_KEY environment variable')\n }\n client = createMistral({ apiKey })\n break\n }\n case 'cohere': {\n const apiKey = process.env.COHERE_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing COHERE_API_KEY environment variable')\n }\n client = createCohere({ apiKey })\n break\n }\n case 'bedrock': {\n const accessKeyId = process.env.AWS_ACCESS_KEY_ID\n const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY\n if (!accessKeyId || !secretAccessKey) {\n throw new Error('[vector.embedding] Missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY environment variables')\n }\n client = createAmazonBedrock({\n accessKeyId,\n secretAccessKey,\n region: process.env.AWS_REGION ?? 'us-east-1',\n })\n break\n }\n case 'ollama': {\n const baseURL = this.config.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'\n client = createOllama({ baseURL })\n break\n }\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n\n this.clientCache.set(providerId, client)\n return client\n }\n\n private getEmbeddingModel() {\n const client = this.getClient(this.config.providerId)\n const { providerId, model, outputDimensionality } = this.config\n\n switch (providerId) {\n case 'openai':\n return (client as ReturnType<typeof createOpenAI>).embedding(model)\n case 'google':\n return (client as ReturnType<typeof createGoogleGenerativeAI>).textEmbeddingModel(model)\n case 'mistral':\n return (client as ReturnType<typeof createMistral>).textEmbeddingModel(model)\n case 'cohere':\n return (client as ReturnType<typeof createCohere>).textEmbeddingModel(model)\n case 'bedrock':\n return (client as ReturnType<typeof createAmazonBedrock>).embedding(model)\n case 'ollama':\n return (client as OllamaClient).embedding(model)\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n }\n\n private getProviderOptions(): ProviderOptions | undefined {\n const { providerId, outputDimensionality, model } = this.config\n\n if (!outputDimensionality) {\n if (providerId === 'cohere') {\n return { cohere: { inputType: 'search_document' } }\n }\n return undefined\n }\n\n switch (providerId) {\n case 'openai':\n if (model === 'text-embedding-3-large' || model === 'text-embedding-3-small') {\n return { openai: { dimensions: outputDimensionality } }\n }\n return undefined\n case 'google':\n return { google: { outputDimensionality } }\n case 'bedrock':\n return { bedrock: { dimensions: outputDimensionality } }\n case 'cohere':\n return { cohere: { inputType: 'search_document' } }\n default:\n return undefined\n }\n }\n\n async createEmbedding(input: string | string[]): Promise<number[]> {\n const merged = Array.isArray(input)\n ? input.map((part) => String(part ?? '')).filter((part) => part.length > 0).join('\\n\\n')\n : String(input ?? '')\n if (!merged.length) {\n throw new Error('[vector.embedding] Refusing to embed empty payload')\n }\n\n if (!this.available) {\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n throw new Error(`[vector.embedding] Provider ${providerInfo.name} is not configured. Set ${providerInfo.envKeyRequired} environment variable.`)\n }\n\n const model = this.getEmbeddingModel() as EmbeddingModel\n const providerOptions = this.getProviderOptions()\n const timeoutMs = resolveEmbeddingTimeoutMs()\n\n const abortController = new AbortController()\n let timeoutHandle: ReturnType<typeof setTimeout> | null = null\n try {\n const result = await Promise.race([\n embed({\n model,\n value: merged,\n abortSignal: abortController.signal,\n ...(providerOptions && { providerOptions }),\n }),\n new Promise<never>((_, reject) => {\n timeoutHandle = setTimeout(\n () => {\n // Abort the in-flight request so a dead/unreachable provider releases\n // its socket \u2014 and, in a worker, the per-job DB connection held while\n // this awaits \u2014 promptly, instead of lingering until the platform's\n // default network timeout and pinning pool capacity under a storm.\n abortController.abort()\n reject(timeoutError(this.config.providerId, timeoutMs))\n },\n timeoutMs,\n )\n }),\n ])\n const emb = Array.isArray(result.embedding)\n ? result.embedding\n : Array.from(result.embedding as ArrayLike<number>)\n return emb.map((n) => Number.isFinite(n) ? Number(n) : 0)\n } catch (err: unknown) {\n const error = err as { statusCode?: number; status?: number; response?: { status?: number; statusCode?: number; data?: { error?: { message?: string; code?: string }; message?: string } }; data?: { error?: { message?: string; code?: string } }; body?: { error?: { message?: string; code?: string } }; message?: string }\n const statusCandidate =\n error?.statusCode ?? error?.status ?? error?.response?.status ?? error?.response?.statusCode\n const status =\n typeof statusCandidate === 'number'\n ? Number.isFinite(statusCandidate) ? statusCandidate : undefined\n : typeof statusCandidate === 'string'\n ? Number.parseInt(statusCandidate, 10)\n : undefined\n const apiError = error?.data?.error ?? error?.body?.error ?? error?.response?.data?.error\n const apiMessage = apiError?.message ?? error?.response?.data?.message\n const apiCode = typeof apiError?.code === 'string' ? apiError.code : undefined\n const rawMessage = typeof apiMessage === 'string'\n ? apiMessage\n : (typeof error?.message === 'string' ? error.message : 'Embedding request failed')\n\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n let guidance: string\n switch (apiCode) {\n case 'insufficient_quota':\n guidance = `${providerInfo.name} usage quota exceeded. Please review your plan and billing.`\n break\n case 'invalid_api_key':\n guidance = `Invalid ${providerInfo.name} API key. Update the key and retry.`\n break\n case 'account_deactivated':\n guidance = `${providerInfo.name} account is disabled. Contact support or provide a different key.`\n break\n default:\n guidance = rawMessage.startsWith('[vector.embedding] ')\n ? rawMessage.slice('[vector.embedding] '.length)\n : rawMessage.includes('https://')\n ? rawMessage\n : rawMessage.includes(providerInfo.envKeyRequired)\n ? rawMessage\n : `${rawMessage}. Check ${providerInfo.envKeyRequired}.`\n }\n const wrapped = new Error(`[vector.embedding] ${guidance}`) as Error & { status?: number; code?: string; cause?: unknown }\n if (typeof status === 'number' && Number.isFinite(status)) {\n const normalizedStatus = status === 401 || status === 403 ? 502 : status\n if (normalizedStatus >= 400 && normalizedStatus < 600) {\n wrapped.status = normalizedStatus\n }\n }\n if (apiCode) {\n wrapped.code = apiCode\n }\n wrapped.cause = err\n throw wrapped\n } finally {\n if (timeoutHandle !== null) {\n clearTimeout(timeoutHandle)\n }\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,aAAa;AAEtB,SAAS,oBAAoB;AAO7B,SAAS,gCAAgC;AACzC,SAAS,qBAAqB;AAC9B,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAE7B,SAAS,qBAAqB,gCAAgC;
|
|
4
|
+
"sourcesContent": ["import { embed } from 'ai'\nimport type { EmbeddingModel } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\n\n// Local type definition to avoid @ai-sdk/provider version conflicts\n// Matches SharedV3ProviderOptions = Record<string, JSONObject>\ntype JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }\ntype JSONObject = { [key: string]: JSONValue }\ntype ProviderOptions = Record<string, JSONObject>\nimport { createGoogleGenerativeAI } from '@ai-sdk/google'\nimport { createMistral } from '@ai-sdk/mistral'\nimport { createCohere } from '@ai-sdk/cohere'\nimport { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'\nimport { createOllama } from 'ai-sdk-ollama'\nimport type { EmbeddingProviderId, EmbeddingProviderConfig } from '../types'\nimport { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from '../types'\nimport { assertSafeOllamaBaseUrl, UnsafeOllamaBaseUrlError } from '../lib/ollama-url-safety'\n\nexport type EmbeddingServiceOptions = {\n apiKey?: string\n model?: string\n config?: EmbeddingProviderConfig\n}\n\ntype OllamaClient = ReturnType<typeof createOllama>\n\ntype ProviderClient = ReturnType<typeof createOpenAI>\n | ReturnType<typeof createGoogleGenerativeAI>\n | ReturnType<typeof createMistral>\n | ReturnType<typeof createCohere>\n | ReturnType<typeof createAmazonBedrock>\n | OllamaClient\n\nconst DEFAULT_EMBEDDING_TIMEOUT_MS = 3_000\n\nfunction resolveEmbeddingTimeoutMs(): number {\n const rawValue = process.env.VECTOR_EMBEDDING_TIMEOUT_MS\n if (!rawValue) return DEFAULT_EMBEDDING_TIMEOUT_MS\n const parsed = Number.parseInt(rawValue, 10)\n if (!Number.isFinite(parsed) || parsed <= 0) {\n return DEFAULT_EMBEDDING_TIMEOUT_MS\n }\n return parsed\n}\n\nfunction timeoutError(providerId: EmbeddingProviderId, timeoutMs: number): Error {\n const providerInfo = EMBEDDING_PROVIDERS[providerId]\n return new Error(\n `${providerInfo.name} request timed out after ${timeoutMs}ms. Check ${providerInfo.envKeyRequired}.`,\n )\n}\n\nexport class EmbeddingService {\n private config: EmbeddingProviderConfig\n private clientCache: Map<EmbeddingProviderId, ProviderClient> = new Map()\n\n constructor(private readonly opts: EmbeddingServiceOptions = {}) {\n if (opts.config) {\n this.config = opts.config\n } else {\n this.config = {\n providerId: 'openai',\n model: opts.model ?? DEFAULT_EMBEDDING_CONFIG.model,\n dimension: DEFAULT_EMBEDDING_CONFIG.dimension,\n updatedAt: new Date().toISOString(),\n }\n }\n }\n\n updateConfig(config: EmbeddingProviderConfig): void {\n this.config = config\n this.clientCache.clear()\n }\n\n get currentConfig(): EmbeddingProviderConfig {\n return { ...this.config }\n }\n\n get dimension(): number {\n return this.config.outputDimensionality ?? this.config.dimension\n }\n\n get available(): boolean {\n return this.isProviderConfigured(this.config.providerId)\n }\n\n private isProviderConfigured(providerId: EmbeddingProviderId): boolean {\n switch (providerId) {\n case 'openai':\n return Boolean(this.opts.apiKey ?? process.env.OPENAI_API_KEY)\n case 'google':\n return Boolean(process.env.GOOGLE_GENERATIVE_AI_API_KEY)\n case 'mistral':\n return Boolean(process.env.MISTRAL_API_KEY)\n case 'cohere':\n return Boolean(process.env.COHERE_API_KEY)\n case 'bedrock':\n return Boolean(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)\n case 'ollama':\n return true\n default:\n return false\n }\n }\n\n private getClient(providerId: EmbeddingProviderId): ProviderClient {\n const cached = this.clientCache.get(providerId)\n if (cached) {\n return cached\n }\n\n let client: ProviderClient\n switch (providerId) {\n case 'openai': {\n const apiKey = this.opts.apiKey ?? process.env.OPENAI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing OPENAI_API_KEY environment variable')\n }\n client = createOpenAI({ apiKey })\n break\n }\n case 'google': {\n const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing GOOGLE_GENERATIVE_AI_API_KEY environment variable')\n }\n client = createGoogleGenerativeAI({ apiKey })\n break\n }\n case 'mistral': {\n const apiKey = process.env.MISTRAL_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing MISTRAL_API_KEY environment variable')\n }\n client = createMistral({ apiKey })\n break\n }\n case 'cohere': {\n const apiKey = process.env.COHERE_API_KEY\n if (!apiKey) {\n throw new Error('[vector.embedding] Missing COHERE_API_KEY environment variable')\n }\n client = createCohere({ apiKey })\n break\n }\n case 'bedrock': {\n const accessKeyId = process.env.AWS_ACCESS_KEY_ID\n const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY\n if (!accessKeyId || !secretAccessKey) {\n throw new Error('[vector.embedding] Missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY environment variables')\n }\n client = createAmazonBedrock({\n accessKeyId,\n secretAccessKey,\n region: process.env.AWS_REGION ?? 'us-east-1',\n })\n break\n }\n case 'ollama': {\n const baseURL = this.config.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'\n try {\n assertSafeOllamaBaseUrl(baseURL)\n } catch (err) {\n if (err instanceof UnsafeOllamaBaseUrlError) {\n throw new Error(\n `[vector.embedding] Ollama base URL rejected (${err.reason}). Set OLLAMA_BASE_URL or OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.`,\n )\n }\n throw err\n }\n client = createOllama({ baseURL })\n break\n }\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n\n this.clientCache.set(providerId, client)\n return client\n }\n\n private getEmbeddingModel() {\n const client = this.getClient(this.config.providerId)\n const { providerId, model, outputDimensionality } = this.config\n\n switch (providerId) {\n case 'openai':\n return (client as ReturnType<typeof createOpenAI>).embedding(model)\n case 'google':\n return (client as ReturnType<typeof createGoogleGenerativeAI>).textEmbeddingModel(model)\n case 'mistral':\n return (client as ReturnType<typeof createMistral>).textEmbeddingModel(model)\n case 'cohere':\n return (client as ReturnType<typeof createCohere>).textEmbeddingModel(model)\n case 'bedrock':\n return (client as ReturnType<typeof createAmazonBedrock>).embedding(model)\n case 'ollama':\n return (client as OllamaClient).embedding(model)\n default:\n throw new Error(`[vector.embedding] Unknown provider: ${providerId}`)\n }\n }\n\n private getProviderOptions(): ProviderOptions | undefined {\n const { providerId, outputDimensionality, model } = this.config\n\n if (!outputDimensionality) {\n if (providerId === 'cohere') {\n return { cohere: { inputType: 'search_document' } }\n }\n return undefined\n }\n\n switch (providerId) {\n case 'openai':\n if (model === 'text-embedding-3-large' || model === 'text-embedding-3-small') {\n return { openai: { dimensions: outputDimensionality } }\n }\n return undefined\n case 'google':\n return { google: { outputDimensionality } }\n case 'bedrock':\n return { bedrock: { dimensions: outputDimensionality } }\n case 'cohere':\n return { cohere: { inputType: 'search_document' } }\n default:\n return undefined\n }\n }\n\n async createEmbedding(input: string | string[]): Promise<number[]> {\n const merged = Array.isArray(input)\n ? input.map((part) => String(part ?? '')).filter((part) => part.length > 0).join('\\n\\n')\n : String(input ?? '')\n if (!merged.length) {\n throw new Error('[vector.embedding] Refusing to embed empty payload')\n }\n\n if (!this.available) {\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n throw new Error(`[vector.embedding] Provider ${providerInfo.name} is not configured. Set ${providerInfo.envKeyRequired} environment variable.`)\n }\n\n const model = this.getEmbeddingModel() as EmbeddingModel\n const providerOptions = this.getProviderOptions()\n const timeoutMs = resolveEmbeddingTimeoutMs()\n\n const abortController = new AbortController()\n let timeoutHandle: ReturnType<typeof setTimeout> | null = null\n try {\n const result = await Promise.race([\n embed({\n model,\n value: merged,\n abortSignal: abortController.signal,\n ...(providerOptions && { providerOptions }),\n }),\n new Promise<never>((_, reject) => {\n timeoutHandle = setTimeout(\n () => {\n // Abort the in-flight request so a dead/unreachable provider releases\n // its socket \u2014 and, in a worker, the per-job DB connection held while\n // this awaits \u2014 promptly, instead of lingering until the platform's\n // default network timeout and pinning pool capacity under a storm.\n abortController.abort()\n reject(timeoutError(this.config.providerId, timeoutMs))\n },\n timeoutMs,\n )\n }),\n ])\n const emb = Array.isArray(result.embedding)\n ? result.embedding\n : Array.from(result.embedding as ArrayLike<number>)\n return emb.map((n) => Number.isFinite(n) ? Number(n) : 0)\n } catch (err: unknown) {\n const error = err as { statusCode?: number; status?: number; response?: { status?: number; statusCode?: number; data?: { error?: { message?: string; code?: string }; message?: string } }; data?: { error?: { message?: string; code?: string } }; body?: { error?: { message?: string; code?: string } }; message?: string }\n const statusCandidate =\n error?.statusCode ?? error?.status ?? error?.response?.status ?? error?.response?.statusCode\n const status =\n typeof statusCandidate === 'number'\n ? Number.isFinite(statusCandidate) ? statusCandidate : undefined\n : typeof statusCandidate === 'string'\n ? Number.parseInt(statusCandidate, 10)\n : undefined\n const apiError = error?.data?.error ?? error?.body?.error ?? error?.response?.data?.error\n const apiMessage = apiError?.message ?? error?.response?.data?.message\n const apiCode = typeof apiError?.code === 'string' ? apiError.code : undefined\n const rawMessage = typeof apiMessage === 'string'\n ? apiMessage\n : (typeof error?.message === 'string' ? error.message : 'Embedding request failed')\n\n const providerInfo = EMBEDDING_PROVIDERS[this.config.providerId]\n let guidance: string\n switch (apiCode) {\n case 'insufficient_quota':\n guidance = `${providerInfo.name} usage quota exceeded. Please review your plan and billing.`\n break\n case 'invalid_api_key':\n guidance = `Invalid ${providerInfo.name} API key. Update the key and retry.`\n break\n case 'account_deactivated':\n guidance = `${providerInfo.name} account is disabled. Contact support or provide a different key.`\n break\n default:\n guidance = rawMessage.startsWith('[vector.embedding] ')\n ? rawMessage.slice('[vector.embedding] '.length)\n : rawMessage.includes('https://')\n ? rawMessage\n : rawMessage.includes(providerInfo.envKeyRequired)\n ? rawMessage\n : `${rawMessage}. Check ${providerInfo.envKeyRequired}.`\n }\n const wrapped = new Error(`[vector.embedding] ${guidance}`) as Error & { status?: number; code?: string; cause?: unknown }\n if (typeof status === 'number' && Number.isFinite(status)) {\n const normalizedStatus = status === 401 || status === 403 ? 502 : status\n if (normalizedStatus >= 400 && normalizedStatus < 600) {\n wrapped.status = normalizedStatus\n }\n }\n if (apiCode) {\n wrapped.code = apiCode\n }\n wrapped.cause = err\n throw wrapped\n } finally {\n if (timeoutHandle !== null) {\n clearTimeout(timeoutHandle)\n }\n }\n }\n}\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.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5619.1.29f01e2c42",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -126,9 +126,9 @@
|
|
|
126
126
|
"zod": "^4.4.3"
|
|
127
127
|
},
|
|
128
128
|
"peerDependencies": {
|
|
129
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
130
|
-
"@open-mercato/queue": "0.6.6-develop.
|
|
131
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
129
|
+
"@open-mercato/core": "0.6.6-develop.5619.1.29f01e2c42",
|
|
130
|
+
"@open-mercato/queue": "0.6.6-develop.5619.1.29f01e2c42",
|
|
131
|
+
"@open-mercato/shared": "0.6.6-develop.5619.1.29f01e2c42"
|
|
132
132
|
},
|
|
133
133
|
"devDependencies": {
|
|
134
134
|
"@types/jest": "^30.0.0",
|
|
@@ -13,6 +13,8 @@ describe('EmbeddingService', () => {
|
|
|
13
13
|
beforeEach(() => {
|
|
14
14
|
jest.clearAllMocks()
|
|
15
15
|
process.env = { ...originalEnv }
|
|
16
|
+
delete process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST
|
|
17
|
+
delete process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE
|
|
16
18
|
})
|
|
17
19
|
|
|
18
20
|
afterAll(() => {
|
|
@@ -77,4 +79,42 @@ describe('EmbeddingService', () => {
|
|
|
77
79
|
|
|
78
80
|
await expect(service.createEmbedding('test input')).resolves.toEqual([0.25, 0.5, 0.75])
|
|
79
81
|
})
|
|
82
|
+
|
|
83
|
+
it('rejects persisted Ollama baseUrl pointing at a private IP in production', async () => {
|
|
84
|
+
process.env.NODE_ENV = 'production'
|
|
85
|
+
process.env.VECTOR_EMBEDDING_TIMEOUT_MS = '100'
|
|
86
|
+
|
|
87
|
+
const service = new EmbeddingService({
|
|
88
|
+
config: {
|
|
89
|
+
providerId: 'ollama',
|
|
90
|
+
model: 'nomic-embed-text',
|
|
91
|
+
dimension: 768,
|
|
92
|
+
baseUrl: 'http://169.254.169.254/',
|
|
93
|
+
updatedAt: new Date().toISOString(),
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await expect(service.createEmbedding('test input')).rejects.toThrow(
|
|
98
|
+
/Ollama base URL rejected \(private_ip_literal\)/,
|
|
99
|
+
)
|
|
100
|
+
expect(mockedEmbed).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('allows persisted Ollama baseUrl on loopback in development', async () => {
|
|
104
|
+
process.env.NODE_ENV = 'development'
|
|
105
|
+
process.env.VECTOR_EMBEDDING_TIMEOUT_MS = '100'
|
|
106
|
+
mockedEmbed.mockResolvedValue({ embedding: [0.1] } as Awaited<ReturnType<typeof embed>>)
|
|
107
|
+
|
|
108
|
+
const service = new EmbeddingService({
|
|
109
|
+
config: {
|
|
110
|
+
providerId: 'ollama',
|
|
111
|
+
model: 'nomic-embed-text',
|
|
112
|
+
dimension: 768,
|
|
113
|
+
baseUrl: 'http://127.0.0.1:11434',
|
|
114
|
+
updatedAt: new Date().toISOString(),
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await expect(service.createEmbedding('test input')).resolves.toEqual([0.1])
|
|
119
|
+
})
|
|
80
120
|
})
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
12
|
+
resolveTranslations: async () => ({
|
|
13
|
+
t: (_key: string, fallback?: string) => fallback ?? _key,
|
|
14
|
+
}),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const mockResolveEmbeddingConfig = jest.fn()
|
|
18
|
+
const mockSaveEmbeddingConfig = jest.fn()
|
|
19
|
+
const mockGetConfiguredProviders = jest.fn()
|
|
20
|
+
const mockDetectConfigChange = jest.fn()
|
|
21
|
+
const mockGetEffectiveDimension = jest.fn()
|
|
22
|
+
jest.mock('../../../lib/embedding-config', () => ({
|
|
23
|
+
resolveEmbeddingConfig: (...args: unknown[]) => mockResolveEmbeddingConfig(...args),
|
|
24
|
+
saveEmbeddingConfig: (...args: unknown[]) => mockSaveEmbeddingConfig(...args),
|
|
25
|
+
getConfiguredProviders: (...args: unknown[]) => mockGetConfiguredProviders(...args),
|
|
26
|
+
detectConfigChange: (...args: unknown[]) => mockDetectConfigChange(...args),
|
|
27
|
+
getEffectiveDimension: (...args: unknown[]) => mockGetEffectiveDimension(...args),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
jest.mock('../../../lib/auto-indexing', () => ({
|
|
31
|
+
envDisablesAutoIndexing: () => false,
|
|
32
|
+
resolveAutoIndexingEnabled: jest.fn().mockResolvedValue(true),
|
|
33
|
+
SEARCH_AUTO_INDEX_CONFIG_KEY: 'auto_indexing_enabled',
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
import { POST } from '../route'
|
|
37
|
+
|
|
38
|
+
const ORIGINAL_ENV = { ...process.env }
|
|
39
|
+
|
|
40
|
+
function makeReq(body: unknown): Request {
|
|
41
|
+
return new Request('http://localhost/api/search/embeddings', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'content-type': 'application/json' },
|
|
44
|
+
body: JSON.stringify(body),
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('POST /api/search/embeddings — Ollama baseUrl SSRF guard', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.clearAllMocks()
|
|
51
|
+
process.env = { ...ORIGINAL_ENV }
|
|
52
|
+
delete process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST
|
|
53
|
+
delete process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE
|
|
54
|
+
|
|
55
|
+
mockGetAuthFromRequest.mockResolvedValue({
|
|
56
|
+
sub: 'user-1',
|
|
57
|
+
tenantId: 't1',
|
|
58
|
+
orgId: 'org-A',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const moduleConfigService = {
|
|
62
|
+
setValue: jest.fn().mockResolvedValue(undefined),
|
|
63
|
+
}
|
|
64
|
+
mockCreateRequestContainer.mockResolvedValue({
|
|
65
|
+
resolve: jest.fn((name: string) => {
|
|
66
|
+
if (name === 'moduleConfigService') return moduleConfigService
|
|
67
|
+
if (name === 'vectorDrivers') return []
|
|
68
|
+
throw new Error(`unexpected resolve(${name})`)
|
|
69
|
+
}),
|
|
70
|
+
dispose: jest.fn(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
mockResolveEmbeddingConfig.mockResolvedValue(null)
|
|
74
|
+
mockGetConfiguredProviders.mockReturnValue(['ollama', 'openai'])
|
|
75
|
+
mockGetEffectiveDimension.mockReturnValue(768)
|
|
76
|
+
mockDetectConfigChange.mockImplementation((_existing: unknown, next: unknown) => ({
|
|
77
|
+
newConfig: next,
|
|
78
|
+
requiresReindex: false,
|
|
79
|
+
reason: 'none',
|
|
80
|
+
}))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
afterAll(() => {
|
|
84
|
+
process.env = ORIGINAL_ENV
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('rejects a baseUrl pointing at cloud metadata IP in production with 400', async () => {
|
|
88
|
+
process.env.NODE_ENV = 'production'
|
|
89
|
+
|
|
90
|
+
const res = await POST(
|
|
91
|
+
makeReq({
|
|
92
|
+
embeddingConfig: {
|
|
93
|
+
providerId: 'ollama',
|
|
94
|
+
model: 'nomic-embed-text',
|
|
95
|
+
dimension: 768,
|
|
96
|
+
baseUrl: 'http://169.254.169.254/',
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
expect(res.status).toBe(400)
|
|
102
|
+
const body = await res.json()
|
|
103
|
+
expect(body.reason).toBe('private_ip_literal')
|
|
104
|
+
expect(mockSaveEmbeddingConfig).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('accepts an allowlisted host in production', async () => {
|
|
108
|
+
process.env.NODE_ENV = 'production'
|
|
109
|
+
process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST = 'ollama.internal.example.com:11434'
|
|
110
|
+
|
|
111
|
+
const res = await POST(
|
|
112
|
+
makeReq({
|
|
113
|
+
embeddingConfig: {
|
|
114
|
+
providerId: 'ollama',
|
|
115
|
+
model: 'nomic-embed-text',
|
|
116
|
+
dimension: 768,
|
|
117
|
+
baseUrl: 'http://ollama.internal.example.com:11434',
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(res.status).toBe(200)
|
|
123
|
+
expect(mockSaveEmbeddingConfig).toHaveBeenCalledTimes(1)
|
|
124
|
+
const savedConfig = mockSaveEmbeddingConfig.mock.calls[0][1] as { baseUrl?: string }
|
|
125
|
+
expect(savedConfig.baseUrl).toBe('http://ollama.internal.example.com:11434')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('accepts loopback baseUrl in development', async () => {
|
|
129
|
+
process.env.NODE_ENV = 'development'
|
|
130
|
+
|
|
131
|
+
const res = await POST(
|
|
132
|
+
makeReq({
|
|
133
|
+
embeddingConfig: {
|
|
134
|
+
providerId: 'ollama',
|
|
135
|
+
model: 'nomic-embed-text',
|
|
136
|
+
dimension: 768,
|
|
137
|
+
baseUrl: 'http://localhost:11434',
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(res.status).toBe(200)
|
|
143
|
+
expect(mockSaveEmbeddingConfig).toHaveBeenCalledTimes(1)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
15
15
|
import type { EmbeddingProviderConfig, EmbeddingProviderId, VectorDriver } from '../../../../vector'
|
|
16
16
|
import { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG, EmbeddingService } from '../../../../vector'
|
|
17
|
+
import {
|
|
18
|
+
assertSafeOllamaBaseUrl,
|
|
19
|
+
UnsafeOllamaBaseUrlError,
|
|
20
|
+
} from '../../../../vector/lib/ollama-url-safety'
|
|
17
21
|
import { searchDebug, searchDebugWarn, searchError } from '../../../../lib/debug'
|
|
18
22
|
import { embeddingsOpenApi } from '../openapi'
|
|
19
23
|
|
|
@@ -212,6 +216,26 @@ export async function POST(req: Request) {
|
|
|
212
216
|
)
|
|
213
217
|
}
|
|
214
218
|
|
|
219
|
+
if (newConfig.providerId === 'ollama' && newConfig.baseUrl != null) {
|
|
220
|
+
try {
|
|
221
|
+
assertSafeOllamaBaseUrl(newConfig.baseUrl)
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof UnsafeOllamaBaseUrlError) {
|
|
224
|
+
return NextResponse.json(
|
|
225
|
+
{
|
|
226
|
+
error: t(
|
|
227
|
+
'search.api.errors.invalidOllamaBaseUrl',
|
|
228
|
+
'Ollama base URL is not allowed. Set OLLAMA_BASE_URL in the environment, or add the host to OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.',
|
|
229
|
+
),
|
|
230
|
+
reason: err.reason,
|
|
231
|
+
},
|
|
232
|
+
{ status: 400 },
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
throw err
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
215
239
|
const change = detectConfigChange(
|
|
216
240
|
embeddingConfig,
|
|
217
241
|
{
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Indexdaten konnten nicht abgerufen werden.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "Suchindex ist nicht verfügbar.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "Such-Indexer ist nicht verfügbar.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "Ollama-Basis-URL ist nicht erlaubt. Setzen Sie OLLAMA_BASE_URL in der Umgebung oder fügen Sie den Host zu OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST hinzu.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Ungültiger Embedding-Anbieter.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Ungültige Suchstrategien angegeben.",
|
|
10
11
|
"search.api.errors.lockFailed": "Indexsperre konnte nicht erworben werden.",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Failed to fetch index data.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "Search index is unavailable.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "Search indexer is unavailable.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "Ollama base URL is not allowed. Set OLLAMA_BASE_URL in the environment, or add the host to OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Invalid embedding provider.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Invalid search strategies specified.",
|
|
10
11
|
"search.api.errors.lockFailed": "Failed to acquire index lock.",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Error al obtener los datos del índice.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "El índice de búsqueda no está disponible.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "El indexador de búsqueda no está disponible.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "La URL base de Ollama no está permitida. Establezca OLLAMA_BASE_URL en el entorno o añada el host a OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Proveedor de embeddings no válido.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Estrategias de búsqueda no válidas.",
|
|
10
11
|
"search.api.errors.lockFailed": "Error al adquirir el bloqueo del índice.",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"search.api.errors.indexFetchFailed": "Nie udało się pobrać danych indeksu.",
|
|
6
6
|
"search.api.errors.indexUnavailable": "Indeks wyszukiwania jest niedostępny.",
|
|
7
7
|
"search.api.errors.indexerUnavailable": "Indekser wyszukiwania jest niedostępny.",
|
|
8
|
+
"search.api.errors.invalidOllamaBaseUrl": "Bazowy URL Ollama nie jest dozwolony. Ustaw OLLAMA_BASE_URL w środowisku lub dodaj host do OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.",
|
|
8
9
|
"search.api.errors.invalidProvider": "Nieprawidłowy dostawca embeddingów.",
|
|
9
10
|
"search.api.errors.invalidStrategies": "Określono nieprawidłowe strategie wyszukiwania.",
|
|
10
11
|
"search.api.errors.lockFailed": "Nie udało się uzyskać blokady indeksu.",
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertSafeOllamaBaseUrl,
|
|
3
|
+
UnsafeOllamaBaseUrlError,
|
|
4
|
+
getOllamaBaseUrlAllowlist,
|
|
5
|
+
isAllowPrivateOllamaBaseUrlEnabled,
|
|
6
|
+
} from '../ollama-url-safety'
|
|
7
|
+
|
|
8
|
+
const ORIGINAL_ENV = { ...process.env }
|
|
9
|
+
|
|
10
|
+
function setEnv(overrides: Record<string, string | undefined>): void {
|
|
11
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
12
|
+
if (value === undefined) {
|
|
13
|
+
delete process.env[key]
|
|
14
|
+
} else {
|
|
15
|
+
process.env[key] = value
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function reasonOf(fn: () => void): string {
|
|
21
|
+
try {
|
|
22
|
+
fn()
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err instanceof UnsafeOllamaBaseUrlError) return err.reason
|
|
25
|
+
throw err
|
|
26
|
+
}
|
|
27
|
+
throw new Error('expected UnsafeOllamaBaseUrlError but none was thrown')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('assertSafeOllamaBaseUrl', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
process.env = { ...ORIGINAL_ENV }
|
|
33
|
+
delete process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST
|
|
34
|
+
delete process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE
|
|
35
|
+
process.env.NODE_ENV = 'development'
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
process.env = ORIGINAL_ENV
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('dev loopback allowance', () => {
|
|
43
|
+
it('accepts http://localhost:11434 in development', () => {
|
|
44
|
+
process.env.NODE_ENV = 'development'
|
|
45
|
+
expect(() => assertSafeOllamaBaseUrl('http://localhost:11434')).not.toThrow()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('accepts http://127.0.0.1:11434 in development', () => {
|
|
49
|
+
process.env.NODE_ENV = 'development'
|
|
50
|
+
expect(() => assertSafeOllamaBaseUrl('http://127.0.0.1:11434')).not.toThrow()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('accepts http://[::1]:11434 in development', () => {
|
|
54
|
+
process.env.NODE_ENV = 'development'
|
|
55
|
+
expect(() => assertSafeOllamaBaseUrl('http://[::1]:11434')).not.toThrow()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('rejects http://localhost:11434 in production', () => {
|
|
59
|
+
process.env.NODE_ENV = 'production'
|
|
60
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://localhost:11434'))).toBe(
|
|
61
|
+
'blocked_hostname',
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('rejects http://127.0.0.1:11434 in production', () => {
|
|
66
|
+
process.env.NODE_ENV = 'production'
|
|
67
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://127.0.0.1:11434'))).toBe(
|
|
68
|
+
'private_ip_literal',
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('private/link-local/reserved IP rejection', () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
process.env.NODE_ENV = 'production'
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('rejects 169.254.169.254 (cloud metadata)', () => {
|
|
79
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://169.254.169.254/'))).toBe(
|
|
80
|
+
'private_ip_literal',
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('rejects RFC1918 10/8', () => {
|
|
85
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://10.0.0.5/'))).toBe(
|
|
86
|
+
'private_ip_literal',
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rejects RFC1918 172.16/12', () => {
|
|
91
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://172.16.0.1/'))).toBe(
|
|
92
|
+
'private_ip_literal',
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('rejects RFC1918 192.168/16', () => {
|
|
97
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://192.168.1.10/'))).toBe(
|
|
98
|
+
'private_ip_literal',
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects IPv6 loopback [::1]', () => {
|
|
103
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://[::1]/'))).toBe(
|
|
104
|
+
'private_ip_literal',
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('rejects IPv6 link-local fe80::/10', () => {
|
|
109
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://[fe80::1]/'))).toBe(
|
|
110
|
+
'private_ip_literal',
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('rejects IPv6 ULA fc00::/7', () => {
|
|
115
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://[fc00::1]/'))).toBe(
|
|
116
|
+
'private_ip_literal',
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('rejects IPv4-mapped loopback ::ffff:127.0.0.1', () => {
|
|
121
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://[::ffff:127.0.0.1]/'))).toBe(
|
|
122
|
+
'private_ip_literal',
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('rejects *.internal hostnames', () => {
|
|
127
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://ollama.internal/'))).toBe(
|
|
128
|
+
'blocked_hostname',
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('rejects *.localhost hostnames', () => {
|
|
133
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://service.localhost/'))).toBe(
|
|
134
|
+
'blocked_hostname',
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('scheme and credentials rejection', () => {
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
process.env.NODE_ENV = 'production'
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('rejects userinfo (credentials) in URL', () => {
|
|
145
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('https://user:pass@ollama.example.com/'))).toBe(
|
|
146
|
+
'credentials_in_url',
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('rejects file:// scheme', () => {
|
|
151
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('file:///etc/passwd'))).toBe(
|
|
152
|
+
'forbidden_protocol',
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('rejects gopher:// scheme', () => {
|
|
157
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('gopher://example.com/'))).toBe(
|
|
158
|
+
'forbidden_protocol',
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('rejects malformed URLs', () => {
|
|
163
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('not-a-url'))).toBe('invalid_url')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('rejects empty strings', () => {
|
|
167
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl(''))).toBe('missing_host')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('rejects whitespace-only strings', () => {
|
|
171
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl(' '))).toBe('missing_host')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('allowlist overrides', () => {
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
process.env.NODE_ENV = 'production'
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('accepts host listed in OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST (host only)', () => {
|
|
181
|
+
process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST = 'ollama.internal.example.com'
|
|
182
|
+
expect(() =>
|
|
183
|
+
assertSafeOllamaBaseUrl('http://ollama.internal.example.com:11434/'),
|
|
184
|
+
).not.toThrow()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('accepts host:port match in allowlist', () => {
|
|
188
|
+
process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST = 'ollama.internal.example.com:11434'
|
|
189
|
+
expect(() =>
|
|
190
|
+
assertSafeOllamaBaseUrl('http://ollama.internal.example.com:11434/'),
|
|
191
|
+
).not.toThrow()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('does not accept a non-matching host even with allowlist set', () => {
|
|
195
|
+
process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST = 'ollama.internal.example.com'
|
|
196
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://10.0.0.5/'))).toBe(
|
|
197
|
+
'private_ip_literal',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('OM_SEARCH_OLLAMA_ALLOW_PRIVATE relaxes private-IP check', () => {
|
|
202
|
+
process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE = 'true'
|
|
203
|
+
expect(() => assertSafeOllamaBaseUrl('http://10.0.0.5/')).not.toThrow()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('allowlist match still enforces scheme allowlist (forbidden_protocol)', () => {
|
|
207
|
+
process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST = 'ollama.internal.example.com'
|
|
208
|
+
expect(
|
|
209
|
+
reasonOf(() => assertSafeOllamaBaseUrl('gopher://ollama.internal.example.com/')),
|
|
210
|
+
).toBe('forbidden_protocol')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('allowlist match still rejects credentials in URL', () => {
|
|
214
|
+
process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST = 'ollama.internal.example.com'
|
|
215
|
+
expect(
|
|
216
|
+
reasonOf(() =>
|
|
217
|
+
assertSafeOllamaBaseUrl('http://user:secret@ollama.internal.example.com/'),
|
|
218
|
+
),
|
|
219
|
+
).toBe('credentials_in_url')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('OM_SEARCH_OLLAMA_ALLOW_PRIVATE still enforces scheme allowlist', () => {
|
|
223
|
+
process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE = 'true'
|
|
224
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('file:///etc/passwd'))).toBe(
|
|
225
|
+
'forbidden_protocol',
|
|
226
|
+
)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('OM_SEARCH_OLLAMA_ALLOW_PRIVATE still rejects credentials in URL', () => {
|
|
230
|
+
process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE = 'true'
|
|
231
|
+
expect(reasonOf(() => assertSafeOllamaBaseUrl('http://user:pass@10.0.0.5/'))).toBe(
|
|
232
|
+
'credentials_in_url',
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('valid public URLs', () => {
|
|
238
|
+
it('accepts a public https host in production', () => {
|
|
239
|
+
process.env.NODE_ENV = 'production'
|
|
240
|
+
expect(() => assertSafeOllamaBaseUrl('https://ollama.example.com/')).not.toThrow()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('getOllamaBaseUrlAllowlist', () => {
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
process.env = { ...ORIGINAL_ENV }
|
|
248
|
+
delete process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
afterAll(() => {
|
|
252
|
+
process.env = ORIGINAL_ENV
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('returns empty set when env unset', () => {
|
|
256
|
+
expect(getOllamaBaseUrlAllowlist().size).toBe(0)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('parses comma-separated entries and lowercases them', () => {
|
|
260
|
+
process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST = 'Foo.Example.com, Bar:443 , baz '
|
|
261
|
+
const allowlist = getOllamaBaseUrlAllowlist()
|
|
262
|
+
expect(allowlist.has('foo.example.com')).toBe(true)
|
|
263
|
+
expect(allowlist.has('bar:443')).toBe(true)
|
|
264
|
+
expect(allowlist.has('baz')).toBe(true)
|
|
265
|
+
expect(allowlist.size).toBe(3)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('isAllowPrivateOllamaBaseUrlEnabled', () => {
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
process.env = { ...ORIGINAL_ENV }
|
|
272
|
+
delete process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
afterAll(() => {
|
|
276
|
+
process.env = ORIGINAL_ENV
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('defaults to false', () => {
|
|
280
|
+
expect(isAllowPrivateOllamaBaseUrlEnabled()).toBe(false)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('parses truthy boolean tokens', () => {
|
|
284
|
+
process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE = 'true'
|
|
285
|
+
expect(isAllowPrivateOllamaBaseUrlEnabled()).toBe(true)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertStaticallySafeOutboundUrl,
|
|
3
|
+
type UrlSafetyReason,
|
|
4
|
+
} from '@open-mercato/shared/lib/url-safety'
|
|
5
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
6
|
+
|
|
7
|
+
const SUBJECT = 'Ollama base URL'
|
|
8
|
+
|
|
9
|
+
export class UnsafeOllamaBaseUrlError extends Error {
|
|
10
|
+
public readonly reason: string
|
|
11
|
+
|
|
12
|
+
constructor(reason: string, message?: string) {
|
|
13
|
+
super(message ?? `Ollama base URL rejected: ${reason}`)
|
|
14
|
+
this.name = 'UnsafeOllamaBaseUrlError'
|
|
15
|
+
this.reason = reason
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ollamaErrorFactory = (reason: UrlSafetyReason, message: string) =>
|
|
20
|
+
new UnsafeOllamaBaseUrlError(reason, message)
|
|
21
|
+
|
|
22
|
+
export function getOllamaBaseUrlAllowlist(): ReadonlySet<string> {
|
|
23
|
+
const raw = process.env.OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST ?? ''
|
|
24
|
+
return new Set(
|
|
25
|
+
raw
|
|
26
|
+
.split(',')
|
|
27
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
28
|
+
.filter((entry) => entry.length > 0),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isAllowPrivateOllamaBaseUrlEnabled(): boolean {
|
|
33
|
+
return parseBooleanWithDefault(process.env.OM_SEARCH_OLLAMA_ALLOW_PRIVATE, false)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assertSafeOllamaBaseUrl(rawUrl: string): void {
|
|
37
|
+
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) {
|
|
38
|
+
throw new UnsafeOllamaBaseUrlError('missing_host', `${SUBJECT} is required`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const allowPrivate =
|
|
42
|
+
allowlistMatches(rawUrl, getOllamaBaseUrlAllowlist()) ||
|
|
43
|
+
isAllowPrivateOllamaBaseUrlEnabled() ||
|
|
44
|
+
(process.env.NODE_ENV !== 'production' && isLoopbackOnlyUrl(rawUrl))
|
|
45
|
+
|
|
46
|
+
assertStaticallySafeOutboundUrl(rawUrl, {
|
|
47
|
+
errorFactory: ollamaErrorFactory,
|
|
48
|
+
subject: SUBJECT,
|
|
49
|
+
allowPrivate,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function allowlistMatches(rawUrl: string, allowlist: ReadonlySet<string>): boolean {
|
|
54
|
+
if (allowlist.size === 0) return false
|
|
55
|
+
let parsed: URL
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(rawUrl)
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
let host = parsed.hostname.toLowerCase()
|
|
62
|
+
if (host.startsWith('[') && host.endsWith(']')) {
|
|
63
|
+
host = host.slice(1, -1)
|
|
64
|
+
}
|
|
65
|
+
const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80')
|
|
66
|
+
const hostPort = `${host}:${port}`
|
|
67
|
+
return allowlist.has(host) || allowlist.has(hostPort)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isLoopbackOnlyUrl(rawUrl: string): boolean {
|
|
71
|
+
let parsed: URL
|
|
72
|
+
try {
|
|
73
|
+
parsed = new URL(rawUrl)
|
|
74
|
+
} catch {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
let host = parsed.hostname.toLowerCase()
|
|
78
|
+
if (host.startsWith('[') && host.endsWith(']')) {
|
|
79
|
+
host = host.slice(1, -1)
|
|
80
|
+
}
|
|
81
|
+
if (host === 'localhost') return true
|
|
82
|
+
if (host === '::1') return true
|
|
83
|
+
if (/^127\.\d+\.\d+\.\d+$/.test(host)) return true
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
@@ -14,6 +14,7 @@ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'
|
|
|
14
14
|
import { createOllama } from 'ai-sdk-ollama'
|
|
15
15
|
import type { EmbeddingProviderId, EmbeddingProviderConfig } from '../types'
|
|
16
16
|
import { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from '../types'
|
|
17
|
+
import { assertSafeOllamaBaseUrl, UnsafeOllamaBaseUrlError } from '../lib/ollama-url-safety'
|
|
17
18
|
|
|
18
19
|
export type EmbeddingServiceOptions = {
|
|
19
20
|
apiKey?: string
|
|
@@ -157,6 +158,16 @@ export class EmbeddingService {
|
|
|
157
158
|
}
|
|
158
159
|
case 'ollama': {
|
|
159
160
|
const baseURL = this.config.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'
|
|
161
|
+
try {
|
|
162
|
+
assertSafeOllamaBaseUrl(baseURL)
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof UnsafeOllamaBaseUrlError) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`[vector.embedding] Ollama base URL rejected (${err.reason}). Set OLLAMA_BASE_URL or OM_SEARCH_OLLAMA_BASE_URL_ALLOWLIST.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
throw err
|
|
170
|
+
}
|
|
160
171
|
client = createOllama({ baseURL })
|
|
161
172
|
break
|
|
162
173
|
}
|