@open-mercato/core 0.7.1-develop.7183.1.db9678eeb8 → 0.7.1-develop.7186.1.6e080a5017

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/query_index/lib/search-tokens.ts"],
4
- "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\ntype TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: TokenRowLike): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {\n const tally = new Map<string, number>()\n for (const row of rows) {\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n }\n return tally\n}\n\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends TokenRowLike>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const rows = buildSearchTokenRows(params)\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)\n\n // Same comparison #5402 gave the batch path, over the scope this path actually writes: the\n // delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison\n // has to be narrowed the same way. Reading wider would let a token row under a field this\n // document does not carry \u2014 the `cf_` twin the search module used to write, say \u2014 read as a\n // difference forever and defeat the skip on every write.\n const scopeTokenQuery = (query: any): any => {\n let scoped = query\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .where('entity_id' as any, '=', String(params.recordId))\n if (fieldPairs.length) {\n scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))\n }\n return scoped\n }\n\n // Read through the caller's transaction when there is one. A separate connection cannot see that\n // transaction's own uncommitted writes, so it could report rows a pending delete has already\n // removed and talk this call out of re-inserting them.\n const reader = options?.trx ?? db\n\n // Count probe first, as in the batch path: it returns one row whatever the table holds, so a\n // record whose stored rows have run away (#4681) is settled without materializing them.\n const storedCountRows = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),\n ).execute()\n const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)\n\n let unchanged = storedCount === rows.length\n if (unchanged && rows.length) {\n const stored = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),\n )\n // Counts already match, so this cannot truncate. It bounds the read if a concurrent writer\n // inserts between the probe and here; a truncated read compares as changed, which costs a\n // rewrite rather than a wrong skip.\n .limit(rows.length)\n .execute()\n unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))\n }\n if (unchanged) {\n debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })\n return\n }\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!payloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'\nimport { createKmsService, resolveEncryptionMode, type KmsService } from '@open-mercato/shared/lib/encryption/kms'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n /** Resolved once per write by the exported entry points; see {@link shouldGuardCiphertext}. */\n guardCiphertext?: boolean\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nlet guardKmsService: KmsService | null = null\n\n/**\n * Whether the ciphertext guard below is allowed to run for this write.\n *\n * The guard recognises an envelope by its SHAPE, which is forgeable: `<16 b64>:<b64>:<24 b64>:v1`\n * is a string any user can type into a searchable field. `tenantDataEncryptionService` removed the\n * same structural test for that reason (#2720). So the guard may only run where the shape is the\n * ONLY test available -- which is exactly where no DEK is reachable:\n *\n * - `active` -- the indexer decrypted the document before handing it here, so a value still\n * shaped like an envelope is plaintext somebody typed. Indexing it is correct,\n * and skipping it would let that person freeze their own record's tokens at a\n * past state. The guard stays off, which also keeps it off the hot path of\n * every normal deployment.\n * - `disabled` -- `decryptIndexDocForSearch` is a no-op, so ciphertext arrives undecrypted.\n * - `unavailable` -- the decrypt was attempted and could not complete, same outcome.\n *\n * Resolved once per write rather than per document, over a KMS built once per process:\n * {@link createKmsService} logs when it falls back, and a reindex calls this once per record. The\n * toggle itself is still re-read every call by {@link resolveEncryptionMode}; only the KMS is\n * cached, and it already requires a restart to change, since DEK and map caches are in-process.\n */\nfunction shouldGuardCiphertext(): boolean {\n guardKmsService ??= createKmsService()\n return resolveEncryptionMode(guardKmsService) !== 'active'\n}\n\n/**\n * Fields whose value is an AES-GCM envelope rather than the text it is supposed to hold.\n *\n * Search tokens are hashes of PLAINTEXT: the indexer decrypts a document before tokenising it\n * (`indexer.ts` -> `decryptIndexDocForSearch`), which is what lets the token index survive\n * encryption being switched on or off. That decrypt step is a no-op once\n * `TENANT_DATA_ENCRYPTION=no`, so an operator who flips the toggle before running\n * `mercato entities decrypt-database` starts feeding ciphertext into the tokeniser. The tokens\n * that come out are hashes of base64 noise and match nothing, and because a write REPLACES a\n * record's tokens, the good plaintext tokens already in the table would be deleted to make room\n * for them -- turning a recoverable misordering into permanent search loss.\n *\n * Detecting the envelope by shape lets the write skip those fields and leave what is already\n * indexed alone. `guard` gates that detection; see {@link shouldGuardCiphertext} for why it is not\n * unconditional.\n */\nfunction ciphertextFieldsOf(\n doc: Record<string, unknown> | null | undefined,\n guard: boolean,\n): Set<string> {\n const fields = new Set<string>()\n if (!guard || !doc) return fields\n for (const [field, value] of Object.entries(doc)) {\n const values = collectTextValues(value)\n if (values.length && values.some((text) => looksLikeEncryptedPayload(text))) fields.add(field)\n }\n return fields\n}\n\nconst warnedCiphertextEntities = new Set<string>()\n\nfunction warnCiphertextSkipped(entityType: string, tenantId: string | null, fields: Set<string>): void {\n if (!fields.size) return\n // Once per entity type per tenant per process: a full reindex would otherwise emit this per\n // record, while keying on the entity type alone would let the first affected tenant in a shared\n // process consume the one warning every other tenant's operator needed.\n const key = `${entityType}|${tenantId ?? ''}`\n if (warnedCiphertextEntities.has(key)) return\n warnedCiphertextEntities.add(key)\n logger.warn(\n 'Search indexing skipped ciphertext fields and preserved their existing tokens. '\n + 'This means TENANT_DATA_ENCRYPTION was switched off while encrypted data was still at rest. '\n + 'Run `mercato entities decrypt-database` and reindex; until then these fields are not searchable.',\n { entityType, tenantId, fields: Array.from(fields).sort((left, right) => left.localeCompare(right)) },\n )\n}\n\n/** Test seam: both the warning above and the KMS behind the guard are once-per-process. */\nexport function resetCiphertextGuardState(): void {\n warnedCiphertextEntities.clear()\n guardKmsService = null\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n const ciphertextFields = ciphertextFieldsOf(params.doc, params.guardCiphertext ?? shouldGuardCiphertext())\n warnCiphertextSkipped(params.entityType, scope.tenantId, ciphertextFields)\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (ciphertextFields.has(field)) continue\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(\n recordId: string,\n doc?: Record<string, unknown> | null,\n skipFields?: Set<string>,\n): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n // The delete below is scoped to these pairs, so omitting a field here is what preserves the\n // tokens already stored for it rather than merely declining to write new ones.\n if (skipFields?.has(field)) continue\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\ntype TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: TokenRowLike): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {\n const tally = new Map<string, number>()\n for (const row of rows) {\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n }\n return tally\n}\n\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends TokenRowLike>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const guardCiphertext = params.guardCiphertext ?? shouldGuardCiphertext()\n const rows = buildSearchTokenRows({ ...params, guardCiphertext })\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const ciphertextFields = ciphertextFieldsOf(params.doc, guardCiphertext)\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc, ciphertextFields)\n\n // An empty pair list normally means the document is gone, and the delete below then purges the\n // record wholesale. It can now also mean every field was skipped as ciphertext, where a purge\n // would destroy precisely the tokens the skip exists to protect. Distinguish the two.\n if (params.doc && ciphertextFields.size && !fieldPairs.length) {\n debug('record.preserve-ciphertext', { entityType: params.entityType, recordId: params.recordId })\n return\n }\n\n // Same comparison #5402 gave the batch path, over the scope this path actually writes: the\n // delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison\n // has to be narrowed the same way. Reading wider would let a token row under a field this\n // document does not carry \u2014 the `cf_` twin the search module used to write, say \u2014 read as a\n // difference forever and defeat the skip on every write.\n const scopeTokenQuery = (query: any): any => {\n let scoped = query\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .where('entity_id' as any, '=', String(params.recordId))\n if (fieldPairs.length) {\n scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))\n }\n return scoped\n }\n\n // Read through the caller's transaction when there is one. A separate connection cannot see that\n // transaction's own uncommitted writes, so it could report rows a pending delete has already\n // removed and talk this call out of re-inserting them.\n const reader = options?.trx ?? db\n\n // Count probe first, as in the batch path: it returns one row whatever the table holds, so a\n // record whose stored rows have run away (#4681) is settled without materializing them.\n const storedCountRows = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),\n ).execute()\n const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)\n\n let unchanged = storedCount === rows.length\n if (unchanged && rows.length) {\n const stored = await scopeTokenQuery(\n reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),\n )\n // Counts already match, so this cannot truncate. It bounds the read if a concurrent writer\n // inserts between the probe and here; a truncated read compares as changed, which costs a\n // rewrite rather than a wrong skip.\n .limit(rows.length)\n .execute()\n unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))\n }\n if (unchanged) {\n debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })\n return\n }\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n allPayloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!allPayloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n // A record carrying ciphertext drops out of the batch entirely, rather than being rewritten\n // without its encrypted fields. This path deletes by `entity_id` -- it cannot express \"replace\n // these fields and leave those alone\" the way the per-record path can -- so partial handling\n // here would still delete the tokens we are trying to protect. Skipping the record leaves every\n // one of its tokens, encrypted-field and plaintext-field alike, exactly as it was. The state is\n // transient by construction: `decrypt-database` followed by a reindex rebuilds all of it.\n const guardCiphertext = shouldGuardCiphertext()\n const preservedRecordIds = new Set<string>()\n const payloads = allPayloads.filter((payload) => {\n const ciphertextFields = ciphertextFieldsOf(payload.doc, guardCiphertext)\n if (!ciphertextFields.size) return true\n warnCiphertextSkipped(payload.entityType, payload.tenantId ?? null, ciphertextFields)\n preservedRecordIds.add(String(payload.recordId))\n return false\n })\n if (!payloads.length) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config, guardCiphertext }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n preservedCiphertextRecordCount: preservedRecordIds.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\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/core",
3
- "version": "0.7.1-develop.7183.1.db9678eeb8",
3
+ "version": "0.7.1-develop.7186.1.6e080a5017",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -252,16 +252,16 @@
252
252
  "zod": "^4.4.3"
253
253
  },
254
254
  "peerDependencies": {
255
- "@open-mercato/ai-assistant": "0.7.1-develop.7183.1.db9678eeb8",
256
- "@open-mercato/shared": "0.7.1-develop.7183.1.db9678eeb8",
257
- "@open-mercato/ui": "0.7.1-develop.7183.1.db9678eeb8",
255
+ "@open-mercato/ai-assistant": "0.7.1-develop.7186.1.6e080a5017",
256
+ "@open-mercato/shared": "0.7.1-develop.7186.1.6e080a5017",
257
+ "@open-mercato/ui": "0.7.1-develop.7186.1.6e080a5017",
258
258
  "react": "^19.0.0",
259
259
  "react-dom": "^19.0.0"
260
260
  },
261
261
  "devDependencies": {
262
- "@open-mercato/ai-assistant": "0.7.1-develop.7183.1.db9678eeb8",
263
- "@open-mercato/shared": "0.7.1-develop.7183.1.db9678eeb8",
264
- "@open-mercato/ui": "0.7.1-develop.7183.1.db9678eeb8",
262
+ "@open-mercato/ai-assistant": "0.7.1-develop.7186.1.6e080a5017",
263
+ "@open-mercato/shared": "0.7.1-develop.7186.1.6e080a5017",
264
+ "@open-mercato/ui": "0.7.1-develop.7186.1.6e080a5017",
265
265
  "@testing-library/dom": "^10.4.1",
266
266
  "@testing-library/jest-dom": "^7.0.1",
267
267
  "@testing-library/react": "^16.3.3",
@@ -4,8 +4,8 @@ import { hash, compare } from 'bcryptjs'
4
4
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
5
5
  import { Role } from '@open-mercato/core/modules/auth/data/entities'
6
6
  import { ApiKey } from '../data/entities'
7
- import { createKmsService } from '@open-mercato/shared/lib/encryption/kms'
8
- import { encryptWithAesGcm, decryptWithAesGcm } from '@open-mercato/shared/lib/encryption/aes'
7
+ import { createKmsService, resolveEncryptionMode } from '@open-mercato/shared/lib/encryption/kms'
8
+ import { encryptWithAesGcm, decryptWithAesGcm, looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'
9
9
  import { getSharedApiKeyAuthCache } from '@open-mercato/shared/lib/auth/apiKeyAuthCache'
10
10
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -19,8 +19,16 @@ const BCRYPT_COST = 10
19
19
  // =============================================================================
20
20
 
21
21
  /**
22
- * Encrypt an API key secret for storage.
23
- * Uses tenant-specific DEK if available, otherwise returns null.
22
+ * Seal an ephemeral session API key secret for storage in `session_secret_encrypted`.
23
+ *
24
+ * Returns null when the secret cannot be stored at all, which costs the caller MCP session-token
25
+ * auth (the secret is unrecoverable and `findSessionApiKeyWithSecret` gives up).
26
+ *
27
+ * Under `TENANT_DATA_ENCRYPTION=no` the secret is stored as-is. That is the same bargain the rest
28
+ * of the system already strikes in that mode -- emails, integration credentials and the search
29
+ * index all sit in plaintext -- and it is what keeps the AI chat working when an operator opts
30
+ * out. A DEK that is merely unreachable is a different situation and still yields null: writing a
31
+ * secret in the clear because Vault happens to be down is not a downgrade anyone asked for.
24
32
  */
25
33
  async function encryptSessionSecret(
26
34
  secret: string,
@@ -29,7 +37,16 @@ async function encryptSessionSecret(
29
37
  if (!tenantId) return null
30
38
 
31
39
  const kms = createKmsService()
32
- if (!kms.isHealthy()) return null
40
+ const mode = resolveEncryptionMode(kms)
41
+ if (mode === 'disabled') return secret
42
+ if (mode === 'unavailable') {
43
+ logger.warn(
44
+ 'Tenant data encryption is enabled but no DEK is reachable; session secret not stored. '
45
+ + 'MCP session-token auth will fail until the KMS recovers.',
46
+ { tenantId },
47
+ )
48
+ return null
49
+ }
33
50
 
34
51
  const dek = await kms.getTenantDek(tenantId)
35
52
  if (!dek) {
@@ -45,22 +62,37 @@ async function encryptSessionSecret(
45
62
  }
46
63
 
47
64
  /**
48
- * Decrypt an API key secret from storage.
49
- * Returns null if decryption fails or no DEK available.
65
+ * Recover a session API key secret written by {@link encryptSessionSecret}.
66
+ * Returns null if it cannot be recovered.
50
67
  */
51
68
  async function decryptSessionSecret(
52
- encrypted: string,
69
+ stored: string,
53
70
  tenantId: string | null
54
71
  ): Promise<string | null> {
55
- if (!tenantId || !encrypted) return null
72
+ if (!tenantId || !stored) return null
56
73
 
57
74
  const kms = createKmsService()
58
- if (!kms.isHealthy()) return null
75
+ const mode = resolveEncryptionMode(kms)
76
+ if (mode === 'disabled') {
77
+ // Written in the clear by the branch above -- unless it predates the toggle being flipped, in
78
+ // which case it is a sealed envelope no key can open and null is the honest answer.
79
+ return looksLikeEncryptedPayload(stored) ? null : stored
80
+ }
81
+ if (mode === 'unavailable') {
82
+ logger.warn('Tenant data encryption is enabled but no DEK is reachable; cannot recover session secret', { tenantId })
83
+ return null
84
+ }
85
+
86
+ // Mirror of the `disabled` branch: a secret written in the clear while the toggle was off is
87
+ // still recoverable after it is switched back on. Without this `decryptWithAesGcm` reads the
88
+ // plaintext as a malformed envelope and returns null, so the flip would silently break every
89
+ // live session rather than only the ones sealed under the old setting.
90
+ if (!looksLikeEncryptedPayload(stored)) return stored
59
91
 
60
92
  const dek = await kms.getTenantDek(tenantId)
61
93
  if (!dek) return null
62
94
 
63
- return decryptWithAesGcm(encrypted, dek.key)
95
+ return decryptWithAesGcm(stored, dek.key)
64
96
  }
65
97
 
66
98
  export type CreateApiKeyInput = {
@@ -241,6 +241,7 @@ If the sync provider needs bootstrap credentials, mappings, locales, channels, o
241
241
  - **Resume**: Retry reads the last successful cursor, resumes from there
242
242
  - **Progress**: Linked to `ProgressJob` via `progressJobId` for `ProgressTopBar` display
243
243
  - **Cancellation**: The engine polls `progressService.isCancellationRequested()` in the batch handler AND on the heartbeat tick while a batch is still in flight, aborting `StreamImportInput.signal` / `StreamExportInput.signal`. Adapters SHOULD honour the signal wherever the work is divisible (per page, per record, around a long flush) and `return` — with the `return` ABOVE the `yield`, never below it, or the engine commits a cursor for a half-applied page. Adapters that ignore the signal keep the old between-batches behavior.
244
+ - **Error reporting**: Every `level: 'error'` row the engine writes is also reported to the active telemetry backend, grouped by a `code`. A `failed` import item MAY carry `data.errorCode` (a stable `module.reason` token, never an interpolated string) alongside `data.errorMessage`; the engine ENFORCES that shape — it is a metric label, so an interpolated value would blow up cardinality and, unlike attributes, would egress unredacted — and substitutes `data_sync.item_failed` for anything else, including a missing value. A run that finishes with `failedCount > 0` additionally reports one `data_sync.run_partial_failure` summary, independent of `adapter.operationalTelemetry` — that flag decides how chatty the operational log is, never whether a failure is observable. Policy: [`error-reporting.mdx`](../../../../../apps/docs/docs/framework/runtime/error-reporting.mdx)
244
245
  - **Tracing**: The engine emits one **root** span per batch (`data_sync.import.batch` / `data_sync.export.batch`) linked back to the run, covering the adapter's read *and* the engine's bookkeeping. Adapters MUST NOT hand-roll their own batch span — they cannot root it, so a multi-day run would ride on the single sampling decision taken for the request that triggered it. Inner spans an adapter creates nest under the batch span normally. The final read — the one that finds the stream drained — is traced as `data_sync.import.drain` / `data_sync.export.drain`, so N batches emit exactly N `*.batch` spans plus one `*.drain`.
245
246
  - **Stream shape**: The engine drives the adapter's async iterator explicitly (`batch-stream.ts`) so the span wraps `next()`, where a generator does its real work before yielding. Closing follows the language's own `IteratorClose` rules, so `finally` blocks in an adapter generator behave exactly as under `for await`: no `return()` when the stream exhausts or `next()` throws (already closed), `return()` with its failure surfaced on an early stop, and `return()` with its failure swallowed when the engine's own handler threw (that error wins). Keep cleanup in `finally`.
246
247
 
@@ -257,7 +258,7 @@ If the sync provider needs bootstrap credentials, mappings, locales, channels, o
257
258
  | Event ID | Emitted When |
258
259
  |---|---|
259
260
  | `data_sync.run.started` | Sync run begins processing |
260
- | `data_sync.run.completed` | Sync run finishes successfully |
261
+ | `data_sync.run.completed` | Sync run finishes successfully (payload carries `createdCount`/`updatedCount`/`skippedCount`/`failedCount`) |
261
262
  | `data_sync.run.failed` | Sync run fails |
262
263
  | `data_sync.run.cancelled` | Sync run is cancelled |
263
264
 
@@ -14,12 +14,78 @@ import { forEachBatch } from './batch-stream'
14
14
  import { createLogger } from '@open-mercato/shared/lib/logger'
15
15
  import {
16
16
  captureTelemetryTrace,
17
+ getTelemetryRuntime,
17
18
  type TelemetrySpanAttributes,
18
19
  } from '@open-mercato/shared/lib/telemetry/runtime'
20
+ import { groupableCode } from '@open-mercato/shared/lib/telemetry/error-code'
19
21
  import type { SyncRun } from '../data/entities'
20
22
 
21
23
  const logger = createLogger('data_sync').child({ component: 'sync-engine' })
22
24
 
25
+ /**
26
+ * A run that finished with failed items. Raised so a partial success is one
27
+ * reported error with a count, at the granularity an operator acts on — a
28
+ * different fact from any single item's failure, which the per-item error rows
29
+ * report on their own.
30
+ */
31
+ export class SyncRunPartialFailureError extends Error {
32
+ constructor(message: string) {
33
+ super(message)
34
+ this.name = 'SyncRunPartialFailureError'
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The fingerprint for a run that ended in a fault.
40
+ *
41
+ * One code for now. PR #5450's `classifySyncError` splits faults into transient
42
+ * and terminal; when it lands, this is the single place that becomes
43
+ * `data_sync.run_transient` / `data_sync.run_terminal`.
44
+ */
45
+ const RUN_FAILED_CODE = 'data_sync.run_failed'
46
+
47
+ /** Run identity for a reported error, mirroring `runSpanAttributes` minus the provider key. */
48
+ function runEventAttributes(run: SyncRun, scope: SyncScope): TelemetrySpanAttributes {
49
+ return {
50
+ 'data_sync.run_id': run.id,
51
+ 'data_sync.integration_id': run.integrationId,
52
+ 'data_sync.entity_type': run.entityType,
53
+ 'data_sync.direction': run.direction,
54
+ 'om.tenant_id': scope.tenantId,
55
+ 'om.organization_id': scope.organizationId,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The failure fingerprint for a dead-lettered item.
61
+ *
62
+ * An adapter that classifies its own failures sets `errorCode` on the item's data
63
+ * (a stable `module.reason` token, never an interpolated string); anything that is
64
+ * not that shape falls back rather than being trusted, and the fallback is a real
65
+ * code rather than `unknown`, so grouping works even for an adapter that supplies
66
+ * nothing.
67
+ */
68
+ function itemErrorCode(data: Record<string, unknown>, fallback: string): string {
69
+ return groupableCode(data.errorCode, fallback)
70
+ }
71
+
72
+ /**
73
+ * Report a `data_sync` failure that is otherwise only recorded (a dropped
74
+ * promise, a run's own summary). Wrapped: observability may never decide the fate
75
+ * of a batch that is already committed.
76
+ */
77
+ function reportSyncError(
78
+ error: unknown,
79
+ code: string,
80
+ attributes: TelemetrySpanAttributes,
81
+ ): void {
82
+ try {
83
+ getTelemetryRuntime()?.reportError(error, { module: 'data_sync', code, attributes })
84
+ } catch (telemetryError) {
85
+ logger.warn('Failed to report a data sync error to telemetry', { code, err: telemetryError as Error })
86
+ }
87
+ }
88
+
23
89
  type RunParameters = Record<string, RunParameterValue>
24
90
 
25
91
  type SyncScope = {
@@ -232,14 +298,29 @@ export function createSyncEngine(deps: EngineDeps) {
232
298
  async function refreshCoverageSnapshots(entityTypes: string[] | undefined, scope: SyncScope): Promise<void> {
233
299
  if (!entityTypes || entityTypes.length === 0) return
234
300
 
235
- await Promise.allSettled(
236
- Array.from(new Set(entityTypes.filter((value) => typeof value === 'string' && value.trim().length > 0)))
237
- .map((entityType) => refreshCoverageSnapshot(deps.em, {
238
- entityType,
239
- tenantId: scope.tenantId,
240
- organizationId: scope.organizationId,
241
- })),
301
+ const types = Array.from(
302
+ new Set(entityTypes.filter((value) => typeof value === 'string' && value.trim().length > 0)),
242
303
  )
304
+ const outcomes = await Promise.allSettled(
305
+ types.map((entityType) => refreshCoverageSnapshot(deps.em, {
306
+ entityType,
307
+ tenantId: scope.tenantId,
308
+ organizationId: scope.organizationId,
309
+ })),
310
+ )
311
+ // `allSettled` keeps a failed refresh from failing a committed batch, which is
312
+ // right — but on its own it also discards the reason entirely, leaving no row,
313
+ // no log and no signal. Reporting is the whole difference between degrading and
314
+ // going silent.
315
+ outcomes.forEach((outcome, index) => {
316
+ if (outcome.status !== 'rejected') return
317
+ logger.warn('Coverage snapshot refresh failed', { entityType: types[index], err: outcome.reason as Error })
318
+ reportSyncError(outcome.reason, 'data_sync.coverage_refresh_failed', {
319
+ entityType: types[index],
320
+ 'om.tenant_id': scope.tenantId,
321
+ 'om.organization_id': scope.organizationId,
322
+ })
323
+ })
243
324
  }
244
325
 
245
326
  async function logImportItemFailures(
@@ -272,6 +353,7 @@ export function createSyncEngine(deps: EngineDeps) {
272
353
  runId,
273
354
  level: 'error',
274
355
  message,
356
+ code: itemErrorCode(item.data, 'data_sync.item_failed'),
275
357
  payload: item.data,
276
358
  },
277
359
  scope,
@@ -297,6 +379,7 @@ export function createSyncEngine(deps: EngineDeps) {
297
379
  runId,
298
380
  level: 'error',
299
381
  message,
382
+ code: 'data_sync.export_item_failed',
300
383
  payload: { kind: 'export-item-failure', summary: result.error },
301
384
  },
302
385
  scope,
@@ -304,6 +387,13 @@ export function createSyncEngine(deps: EngineDeps) {
304
387
  }
305
388
  }
306
389
 
390
+ /**
391
+ * The adapter-gated operational log. Deliberately carries no `code`: these rows
392
+ * are run status records, and every fault they narrate was already written — and
393
+ * therefore already reported — by a direct `level: 'error'` write that owns the
394
+ * fingerprint. A code here would double-report every fault for adapters that
395
+ * have `operationalTelemetry` on.
396
+ */
307
397
  async function writeOperationalLog(params: {
308
398
  integrationId: string
309
399
  runId: string
@@ -486,6 +576,25 @@ export function createSyncEngine(deps: EngineDeps) {
486
576
  }
487
577
 
488
578
  if (status === 'completed') {
579
+ // A run that finished with failures is a partial success, and the operator
580
+ // finds out here or not at all: the per-item rows carry the reasons but no
581
+ // count, and this is the only place that knows the run is over. Reported
582
+ // outside `writeOperationalLog` on purpose — that path is gated on an adapter
583
+ // opt-in, and an adapter flag may decide how chatty the operational log is,
584
+ // never whether a failure is observable.
585
+ if (run.failedCount > 0) {
586
+ reportSyncError(
587
+ new SyncRunPartialFailureError(`Sync run completed with ${run.failedCount} failed item(s)`),
588
+ 'data_sync.run_partial_failure',
589
+ {
590
+ ...runEventAttributes(run, scope),
591
+ 'data_sync.failed_count': run.failedCount,
592
+ 'data_sync.created_count': run.createdCount,
593
+ 'data_sync.updated_count': run.updatedCount,
594
+ 'data_sync.skipped_count': run.skippedCount,
595
+ },
596
+ )
597
+ }
489
598
  await emitDataSyncEvent('data_sync.run.completed', {
490
599
  runId,
491
600
  integrationId: run.integrationId,
@@ -493,6 +602,10 @@ export function createSyncEngine(deps: EngineDeps) {
493
602
  direction: run.direction,
494
603
  tenantId: scope.tenantId,
495
604
  organizationId: scope.organizationId,
605
+ createdCount: run.createdCount,
606
+ updatedCount: run.updatedCount,
607
+ skippedCount: run.skippedCount,
608
+ failedCount: run.failedCount,
496
609
  })
497
610
  return
498
611
  }
@@ -726,6 +839,7 @@ export function createSyncEngine(deps: EngineDeps) {
726
839
  runId: run.id,
727
840
  level: 'error',
728
841
  message,
842
+ code: RUN_FAILED_CODE,
729
843
  },
730
844
  scope,
731
845
  )
@@ -948,6 +1062,7 @@ export function createSyncEngine(deps: EngineDeps) {
948
1062
  runId: run.id,
949
1063
  level: 'error',
950
1064
  message,
1065
+ code: RUN_FAILED_CODE,
951
1066
  },
952
1067
  scope,
953
1068
  )
@@ -9,6 +9,7 @@ import { emitIntegrationsEvent } from '../../../events'
9
9
  import { saveCredentialsSchema } from '../../../data/validators'
10
10
  import {
11
11
  isCredentialsEncryptionUnavailableError,
12
+ isCredentialsSealedWhileDisabledError,
12
13
  type CredentialsService,
13
14
  } from '../../../lib/credentials-service'
14
15
  import { collectCredentialUrlValidationErrors } from '../../../lib/credentials-field-validation'
@@ -22,9 +23,31 @@ import {
22
23
  runIntegrationMutationGuards,
23
24
  } from '../../guards'
24
25
  import { organizationScopeRequiredResponse, resolveActiveOrganizationId } from '@open-mercato/shared/lib/auth/organizationScope'
26
+ import { createLogger } from '@open-mercato/shared/lib/logger'
25
27
 
26
28
  const idParamsSchema = z.object({ id: z.string().min(1) })
27
29
 
30
+ const logger = createLogger('integrations').child({ component: 'credentials-route' })
31
+
32
+ /**
33
+ * Credentials sealed before `TENANT_DATA_ENCRYPTION` was switched off cannot be opened by any key,
34
+ * and nothing unseals them for the operator: `mercato entities decrypt-database` decrypts the
35
+ * columns an encryption map covers, while this envelope sits *inside* the decrypted `credentials`
36
+ * value. Re-entering them is the only remedy, so the admin surface has to stay usable — a 503 on
37
+ * both the read and the save would leave the integration permanently unfixable from the UI.
38
+ *
39
+ * The form therefore loads as if nothing were configured. Adapters keep seeing the error, since
40
+ * they read through the service directly.
41
+ */
42
+ function reportSealedCredentials(integrationId: string, tenantId: string): void {
43
+ logger.warn(
44
+ 'Integration credentials are sealed under an encryption key that is no longer available '
45
+ + '(TENANT_DATA_ENCRYPTION was switched off after they were saved). The admin form will show '
46
+ + 'them as unconfigured; re-save the credentials to store them in the clear.',
47
+ { integrationId, tenantId },
48
+ )
49
+ }
50
+
28
51
  export const metadata = {
29
52
  GET: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },
30
53
  PUT: { requireAuth: true, requireFeatures: ['integrations.credentials.manage'] },
@@ -74,10 +97,15 @@ export async function GET(req: Request, ctx: { params?: Promise<{ id?: string }>
74
97
  values = await credentialsService.resolve(integration.id, scope)
75
98
  updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)
76
99
  } catch (error) {
77
- if (isCredentialsEncryptionUnavailableError(error)) {
100
+ if (isCredentialsSealedWhileDisabledError(error)) {
101
+ reportSealedCredentials(integration.id, auth.tenantId)
102
+ values = null
103
+ updatedAt = await credentialsService.resolveUpdatedAt(integration.id, scope)
104
+ } else if (isCredentialsEncryptionUnavailableError(error)) {
78
105
  return NextResponse.json({ error: 'Integration credentials encryption is unavailable' }, { status: 503 })
106
+ } else {
107
+ throw error
79
108
  }
80
- throw error
81
109
  }
82
110
 
83
111
  const schema = credentialsService.getSchema(integration.id)
@@ -183,7 +211,15 @@ export async function PUT(req: Request, ctx: { params?: Promise<{ id?: string }>
183
211
  }
184
212
 
185
213
  try {
186
- const existing = await credentialsService.resolve(integration.id, scope)
214
+ let existing: Record<string, unknown> | null = null
215
+ try {
216
+ existing = await credentialsService.resolve(integration.id, scope)
217
+ } catch (error) {
218
+ // Nothing to merge against, but the save itself must go through: this is the state the
219
+ // re-entry is meant to escape from.
220
+ if (!isCredentialsSealedWhileDisabledError(error)) throw error
221
+ reportSealedCredentials(integration.id, auth.tenantId)
222
+ }
187
223
  const credentialsToSave = mergeMaskedSecretCredentials(
188
224
  schema,
189
225
  payloadData.credentials,