@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.
- package/dist/modules/api_keys/services/apiKeyService.js +23 -7
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +69 -6
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/integrations/api/[id]/credentials/route.js +24 -4
- package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
- package/dist/modules/integrations/lib/credentials-service.js +16 -4
- package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
- package/dist/modules/integrations/lib/log-service.js +33 -0
- package/dist/modules/integrations/lib/log-service.js.map +2 -2
- package/dist/modules/payment_gateways/workers/status-poller.js +5 -1
- package/dist/modules/payment_gateways/workers/status-poller.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +61 -8
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/services/apiKeyService.ts +43 -11
- package/src/modules/data_sync/AGENTS.md +2 -1
- package/src/modules/data_sync/lib/sync-engine.ts +122 -7
- package/src/modules/integrations/api/[id]/credentials/route.ts +39 -3
- package/src/modules/integrations/lib/credentials-service.ts +51 -5
- package/src/modules/integrations/lib/log-service.ts +57 -0
- package/src/modules/payment_gateways/workers/status-poller.ts +5 -1
- package/src/modules/query_index/lib/search-tokens.ts +129 -6
|
@@ -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": "AAAA,SAAwC,WAAW;AACnD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAC1C,SAAS,kBAAkB,6BAA8C;AACzE,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,aAAa,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAE/E,MAAM,oBAAoB;AAE1B,SAAS,MAAS,OAAY,MAAqB;AACjD,MAAI,QAAQ,EAAG,QAAO,CAAC,KAAK;AAC5B,QAAM,MAAa,CAAC;AACpB,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,KAAM,KAAI,KAAK,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC;AAC9E,SAAO;AACT;AAuBA,MAAM,gBAAgB,EAAE,gBAAgB,MAAM,UAAU,KAAK;AAItD,MAAM,uBAAuB,MAAe;AACjD,SAAO,kBAAkB,QAAQ,IAAI,mBAAmB,EAAE,MAAM;AAClE;AAEA,MAAM,QAAQ,CAAC,OAAe,YAAqC;AACjE,MAAI,CAAC,qBAAqB,EAAG;AAC7B,MAAI;AACF,WAAO,MAAM,sBAAsB,EAAE,OAAO,QAAQ,CAAC;AAAA,EACvD,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,kBAAkB,OAA0B;AACnD,MAAI,OAAO,UAAU,SAAU,QAAO,CAAC,KAAK;AAC5C,MAAI,MAAM,QAAQ,KAAK,GAAG;AACxB,UAAM,MAAgB,CAAC;AACvB,eAAW,SAAS,OAAO;AACzB,UAAI,OAAO,UAAU,SAAU,KAAI,KAAK,KAAK;AAAA,IAC/C;AACA,WAAO;AAAA,EACT;AACA,SAAO,CAAC;AACV;AAEA,IAAI,kBAAqC;AAuBzC,SAAS,wBAAiC;AACxC,sBAAoB,iBAAiB;AACrC,SAAO,sBAAsB,eAAe,MAAM;AACpD;AAkBA,SAAS,mBACP,KACA,OACa;AACb,QAAM,SAAS,oBAAI,IAAY;AAC/B,MAAI,CAAC,SAAS,CAAC,IAAK,QAAO;AAC3B,aAAW,CAAC,OAAO,KAAK,KAAK,OAAO,QAAQ,GAAG,GAAG;AAChD,UAAM,SAAS,kBAAkB,KAAK;AACtC,QAAI,OAAO,UAAU,OAAO,KAAK,CAAC,SAAS,0BAA0B,IAAI,CAAC,EAAG,QAAO,IAAI,KAAK;AAAA,EAC/F;AACA,SAAO;AACT;AAEA,MAAM,2BAA2B,oBAAI,IAAY;AAEjD,SAAS,sBAAsB,YAAoB,UAAyB,QAA2B;AACrG,MAAI,CAAC,OAAO,KAAM;AAIlB,QAAM,MAAM,GAAG,UAAU,IAAI,YAAY,EAAE;AAC3C,MAAI,yBAAyB,IAAI,GAAG,EAAG;AACvC,2BAAyB,IAAI,GAAG;AAChC,SAAO;AAAA,IACL;AAAA,IAGA,EAAE,YAAY,UAAU,QAAQ,MAAM,KAAK,MAAM,EAAE,KAAK,CAAC,MAAM,UAAU,KAAK,cAAc,KAAK,CAAC,EAAE;AAAA,EACtG;AACF;AAGO,SAAS,4BAAkC;AAChD,2BAAyB,MAAM;AAC/B,oBAAkB;AACpB;AAEA,SAAS,iBACP,OACA,OACA,QACA,YACS;AACT,MAAI,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO;AAC/D,QAAM,QAAQ,MAAM,YAAY;AAChC,MAAI,UAAU,QAAQ,MAAM,SAAS,KAAK,KAAK,MAAM,SAAS,KAAK,EAAG,QAAO;AAC7E,MAAI,MAAM,SAAS,KAAK,EAAG,QAAO;AAClC,MAAI,CAAC,cAAc,cAAc,cAAc,aAAa,iBAAiB,EAAE,SAAS,KAAK,EAAG,QAAO;AACvG,MAAI,yBAAyB,OAAO,YAAY,MAAM,EAAG,QAAO;AAChE,SAAO,kBAAkB,KAAK,EAAE,KAAK,CAAC,SAAS,KAAK,SAAS,CAAC;AAChE;AAEO,SAAS,qBAAqB,QAA6C;AAChF,QAAM,SAAS,OAAO,UAAU,oBAAoB;AACpD,MAAI,CAAC,OAAO,QAAS,QAAO,CAAC;AAC7B,MAAI,CAAC,OAAO,IAAK,QAAO,CAAC;AACzB,QAAM,SAA2B,CAAC;AAClC,QAAM,eAAe,qBAAqB,KAAK,OAAO,eAAe;AACrE,QAAM,aAAqD,CAAC;AAC5D,QAAM,QAAQ;AAAA,IACZ,gBAAgB,OAAO,kBAAkB,cAAc;AAAA,IACvD,UAAU,OAAO,YAAY,cAAc;AAAA,EAC7C;AACA,QAAM,SAAS,yBAAyB,MAAM;AAC9C,QAAM,cAAc,OAAO,qBAAqB,IAAI,OAAO,qBAAqB,OAAO;AACvF,QAAM,aAAa,OAAO,oBAAoB,IAAI,OAAO,oBAAoB,OAAO;AACpF,QAAM,mBAAmB,mBAAmB,OAAO,KAAK,OAAO,mBAAmB,sBAAsB,CAAC;AACzG,wBAAsB,OAAO,YAAY,MAAM,UAAU,gBAAgB;AAEzE,aAAW,CAAC,OAAO,QAAQ,KAAK,OAAO,QAAQ,OAAO,GAAG,GAAG;AAC1D,QAAI,OAAO,UAAU,YAAa;AAClC,QAAI,iBAAiB,IAAI,KAAK,EAAG;AACjC,QAAI,CAAC,iBAAiB,OAAO,UAAU,QAAQ,OAAO,UAAU,EAAG;AACnE,UAAM,SAAS,kBAAkB,QAAQ;AACzC,UAAM,OAAO,oBAAI,IAAY;AAC7B,QAAI,kBAAkB;AACtB,eAAW,QAAQ,QAAQ;AACzB,UAAI,OAAO,UAAU,eAAe,mBAAmB,WAAY;AACnE,YAAM,iBAAiB,KAAK,IAAI,cAAc,OAAO,QAAQ,aAAa,eAAe;AACzF,YAAM,iBAAiB,kBAAkB;AACzC,YAAM,cAAc,OAAO,SAAS,cAAc,IAC9C,EAAE,GAAG,QAAQ,mBAAmB,eAAe,IAC/C;AACJ,YAAM,EAAE,QAAQ,YAAY,OAAO,IAAI,aAAa,MAAM,WAAW;AACrE,eAAS,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK,GAAG;AAC7C,YAAI,OAAO,UAAU,eAAe,mBAAmB,WAAY;AACnE,cAAM,QAAQ,WAAW,CAAC;AAC1B,cAAM,OAAO,OAAO,CAAC;AACrB,cAAM,YAAY,GAAG,KAAK,IAAI,IAAI;AAClC,YAAI,KAAK,IAAI,SAAS,EAAG;AACzB,aAAK,IAAI,SAAS;AAClB,2BAAmB;AACnB,cAAM,mBAAmB,EAAE,YAAY,OAAO,YAAY,UAAU,OAAO,UAAU,OAAO,KAAK,CAAC;AAClG,eAAO,KAAK;AAAA,UACV,aAAa,OAAO;AAAA,UACpB,WAAW,OAAO,OAAO,QAAQ;AAAA,UACjC,iBAAiB,MAAM;AAAA,UACvB,WAAW,MAAM;AAAA,UACjB;AAAA,UACA,YAAY;AAAA,UACZ,OAAO,OAAO,iBAAiB,QAAQ;AAAA,QACzC,CAAC;AACD,YAAI,cAAc;AAChB,qBAAW,KAAK,EAAE,OAAO,KAAK,CAAC;AAAA,QACjC;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,MAAI,cAAc;AAChB,UAAM,eAAe;AAAA,MACnB,YAAY,OAAO;AAAA,MACnB,UAAU,OAAO;AAAA,MACjB,YAAY,WAAW;AAAA,MACvB,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AACA,QAAM,iBAAiB,EAAE,YAAY,OAAO,YAAY,UAAU,OAAO,UAAU,YAAY,OAAO,OAAO,CAAC;AAE9G,SAAO;AACT;AAEA,SAAS,gBACP,UACA,KACA,YACmB;AACnB,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,QAAM,QAA2B,CAAC;AAClC,QAAM,SAAS,oBAAI,IAAY;AAC/B,aAAW,SAAS,OAAO,KAAK,GAAG,GAAG;AAGpC,QAAI,YAAY,IAAI,KAAK,EAAG;AAC5B,UAAM,MAAM,GAAG,QAAQ,IAAI,KAAK;AAChC,QAAI,OAAO,IAAI,GAAG,EAAG;AACrB,WAAO,IAAI,GAAG;AACd,UAAM,KAAK,CAAC,UAAU,KAAK,CAAC;AAAA,EAC9B;AACA,SAAO;AACT;AAMA,MAAM,sBAAsB,OAAO,aAAa,CAAC;AAKjD,SAAS,eAAe,KAA2B;AACjD,SAAO;AAAA,IACL,OAAO,IAAI,SAAS,EAAE;AAAA,IACtB,OAAO,IAAI,cAAc,EAAE;AAAA,IAC3B,IAAI,SAAS,OAAO,KAAK,OAAO,IAAI,KAAK;AAAA,EAC3C,EAAE,KAAK,mBAAmB;AAC5B;AAKA,SAAS,QAAQ,MAAmD;AAClE,QAAM,QAAQ,oBAAI,IAAoB;AACtC,aAAW,OAAO,MAAM;AACtB,UAAM,YAAY,eAAe,GAAG;AACpC,UAAM,IAAI,YAAY,MAAM,IAAI,SAAS,KAAK,KAAK,CAAC;AAAA,EACtD;AACA,SAAO;AACT;AAEA,SAAS,YAAY,GAAoC,GAA6C;AACpG,QAAM,OAAO,KAAK,oBAAI,IAAoB;AAC1C,QAAM,QAAQ,KAAK,oBAAI,IAAoB;AAC3C,MAAI,KAAK,SAAS,MAAM,KAAM,QAAO;AACrC,aAAW,CAAC,KAAK,KAAK,KAAK,KAAK,QAAQ,GAAG;AACzC,QAAI,MAAM,IAAI,GAAG,MAAM,MAAO,QAAO;AAAA,EACvC;AACA,SAAO;AACT;AAEA,SAAS,eACP,MACA,OACkC;AAClC,QAAM,UAAU,oBAAI,IAAiC;AACrD,aAAW,OAAO,MAAM;AACtB,UAAM,MAAM,MAAM,GAAG;AACrB,UAAM,QAAQ,QAAQ,IAAI,GAAG,KAAK,oBAAI,IAAoB;AAC1D,UAAM,YAAY,eAAe,GAAG;AACpC,UAAM,IAAI,YAAY,MAAM,IAAI,SAAS,KAAK,KAAK,CAAC;AACpD,YAAQ,IAAI,KAAK,KAAK;AAAA,EACxB;AACA,SAAO;AACT;AAEA,eAAsB,6BACpB,IACA,QACA,SACe;AACf,QAAM,kBAAkB,OAAO,mBAAmB,sBAAsB;AACxE,QAAM,OAAO,qBAAqB,EAAE,GAAG,QAAQ,gBAAgB,CAAC;AAChE,QAAM,SAAS,OAAO,UAAU,oBAAoB;AACpD,MAAI,CAAC,OAAO,QAAS;AACrB,QAAM,iBAAiB,OAAO,kBAAkB;AAChD,QAAM,WAAW,OAAO,YAAY;AACpC,QAAM,mBAAmB,mBAAmB,OAAO,KAAK,eAAe;AACvE,QAAM,aAAa,gBAAgB,OAAO,OAAO,QAAQ,GAAG,OAAO,KAAK,gBAAgB;AAKxF,MAAI,OAAO,OAAO,iBAAiB,QAAQ,CAAC,WAAW,QAAQ;AAC7D,UAAM,8BAA8B,EAAE,YAAY,OAAO,YAAY,UAAU,OAAO,SAAS,CAAC;AAChG;AAAA,EACF;AAOA,QAAM,kBAAkB,CAAC,UAAoB;AAC3C,QAAI,SAAS,MACV,MAAM,eAAsB,KAAK,OAAO,UAAU,EAClD,MAAM,2CAAoD,cAAc,EAAE,EAC1E,MAAM,qCAA8C,QAAQ,EAAE,EAC9D,MAAM,aAAoB,KAAK,OAAO,OAAO,QAAQ,CAAC;AACzD,QAAI,WAAW,QAAQ;AACrB,eAAS,OAAO,MAAM,SAAgB,MAAM,WAAW,IAAI,CAAC,CAAC,EAAE,KAAK,MAAM,KAAK,CAAC;AAAA,IAClF;AACA,WAAO;AAAA,EACT;AAKA,QAAM,SAAS,SAAS,OAAO;AAI/B,QAAM,kBAAkB,MAAM;AAAA,IAC5B,OAAO,WAAW,eAAsB,EAAE,OAAO,cAAsB,GAAG,aAAa,CAAQ;AAAA,EACjG,EAAE,QAAQ;AACV,QAAM,cAAc,OAAQ,gBAA0B,CAAC,GAAG,eAAe,CAAC;AAE1E,MAAI,YAAY,gBAAgB,KAAK;AACrC,MAAI,aAAa,KAAK,QAAQ;AAC5B,UAAM,SAAS,MAAM;AAAA,MACnB,OAAO,WAAW,eAAsB,EAAE,OAAO,CAAC,SAAgB,cAAqB,OAAc,CAAC;AAAA,IACxG,EAIG,MAAM,KAAK,MAAM,EACjB,QAAQ;AACX,gBAAY,YAAY,QAAQ,IAAI,GAAG,QAAQ,MAAe,CAAC;AAAA,EACjE;AACA,MAAI,WAAW;AACb,UAAM,eAAe,EAAE,YAAY,OAAO,YAAY,UAAU,OAAO,UAAU,YAAY,KAAK,OAAO,CAAC;AAC1G;AAAA,EACF;AAEA,QAAM,cAAc,OAAO,aAAiD;AAC1E,QAAI,cAAc,SACf,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,OAAO,UAAU,EAClD,MAAM,2CAAoD,cAAc,EAAE,EAC1E,MAAM,qCAA8C,QAAQ,EAAE;AACjE,QAAI,WAAW,QAAQ;AACrB,oBAAc,YAAY,MAAM,CAAC,OAAY,GAAG;AAAA,QAC9C,WAAW,IAAI,CAAC,CAAC,KAAK,KAAK,MAAM,GAAG,IAAI;AAAA,UACtC,GAAG,aAAoB,KAAK,GAAG;AAAA,UAC/B,GAAG,SAAgB,KAAK,KAAK;AAAA,QAC/B,CAAC,CAAC;AAAA,MACJ,CAAC;AAAA,IACH,OAAO;AACL,oBAAc,YAAY,MAAM,aAAoB,KAAK,OAAO,OAAO,QAAQ,CAAC;AAAA,IAClF;AACA,UAAM,YAAY,QAAQ;AAC1B,QAAI,CAAC,KAAK,OAAQ;AAClB,UAAM,WAAW,KAAK,IAAI,CAAC,SAAS,EAAE,GAAG,KAAK,YAAY,WAAW,EAAE;AACvE,eAAW,SAAS,MAAM,UAAU,iBAAiB,GAAG;AACtD,YAAM,SAAS,WAAW,eAAsB,EAAE,OAAO,KAAY,EAAE,QAAQ;AAAA,IACjF;AAAA,EACF;AAEA,MAAI,SAAS,KAAK;AAChB,UAAM,YAAY,QAAQ,GAAG;AAC7B;AAAA,EACF;AAEA,QAAM,GAAG,YAAY,EAAE,QAAQ,WAAW;AAC5C;AAEA,eAAsB,4BACpB,IACA,QACA,SACe;AACf,QAAM,iBAAiB,OAAO,kBAAkB;AAChD,QAAM,WAAW,OAAO,YAAY;AACpC,QAAM,WAAW,SAAS,OAAO;AACjC,QAAM,SACH,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,OAAO,UAAU,EAClD,MAAM,aAAoB,KAAK,OAAO,OAAO,QAAQ,CAAC,EACtD,MAAM,2CAAoD,cAAc,EAAE,EAC1E,MAAM,qCAA8C,QAAQ,EAAE,EAC9D,QAAQ;AACb;AAEA,eAAsB,4BACpB,IACA,aACe;AACf,MAAI,CAAC,YAAY,OAAQ;AACzB,QAAM,SAAS,oBAAoB;AACnC,MAAI,CAAC,OAAO,QAAS;AAQrB,QAAM,kBAAkB,sBAAsB;AAC9C,QAAM,qBAAqB,oBAAI,IAAY;AAC3C,QAAM,WAAW,YAAY,OAAO,CAAC,YAAY;AAC/C,UAAM,mBAAmB,mBAAmB,QAAQ,KAAK,eAAe;AACxE,QAAI,CAAC,iBAAiB,KAAM,QAAO;AACnC,0BAAsB,QAAQ,YAAY,QAAQ,YAAY,MAAM,gBAAgB;AACpF,uBAAmB,IAAI,OAAO,QAAQ,QAAQ,CAAC;AAC/C,WAAO;AAAA,EACT,CAAC;AACD,MAAI,CAAC,SAAS,OAAQ;AAEtB,QAAM,OAAO,SAAS,QAAQ,CAAC,YAAY,qBAAqB,EAAE,GAAG,SAAS,QAAQ,gBAAgB,CAAC,CAAC;AACxG,MAAI,CAAC,KAAK,QAAQ;AAChB,UAAM,aAAa,SAAS,CAAC,GAAG;AAChC,QAAI,CAAC,WAAY;AACjB,UAAM,MAAM,SAAS,IAAI,CAAC,MAAM,OAAO,EAAE,QAAQ,CAAC;AAClD,UAAM,GACH,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,UAAU,EAC3C,MAAM,aAAoB,MAAM,GAAG,EACnC,QAAQ;AACX;AAAA,EACF;AAEA,QAAM,WAAW,CAAC,KAAoB,WAA0B,GAAG,OAAO,UAAU,IAAI,UAAU,UAAU;AAC5G,QAAM,eAAe,oBAAI,IAA0F;AAEnH,aAAW,WAAW,UAAU;AAC9B,UAAM,MAAM,QAAQ,kBAAkB;AACtC,UAAM,SAAS,QAAQ,YAAY;AACnC,UAAM,MAAM,SAAS,KAAK,MAAM;AAChC,UAAM,SAAS,aAAa,IAAI,GAAG,KAAK,EAAE,gBAAgB,KAAK,UAAU,QAAQ,KAAK,oBAAI,IAAY,EAAE;AACxG,WAAO,IAAI,IAAI,OAAO,QAAQ,QAAQ,CAAC;AACvC,iBAAa,IAAI,KAAK,MAAM;AAAA,EAC9B;AAEA,QAAM,cAAc,CAAC,QACnB,GAAG,SAAS,IAAI,mBAAmB,MAAM,IAAI,aAAa,IAAI,CAAC,IAAI,OAAO,IAAI,SAAS,CAAC;AAC1F,QAAM,aAAa,eAAe,MAAM,WAAW;AASnD,QAAM,qBAAqB,oBAAI,IAAyB;AACxD,aAAW,CAAC,KAAK,MAAM,KAAK,aAAa,QAAQ,GAAG;AAClD,UAAM,MAAM,MAAM,KAAK,OAAO,GAAG;AACjC,UAAM,iBAAiB,oBAAI,IAAoB;AAC/C,eAAW,MAAM,KAAK;AACpB,UAAI,QAAQ;AACZ,YAAM,QAAQ,WAAW,IAAI,GAAG,GAAG,IAAI,EAAE,EAAE;AAC3C,UAAI,MAAO,YAAW,SAAS,MAAM,OAAO,EAAG,UAAS;AACxD,qBAAe,IAAI,IAAI,KAAK;AAAA,IAC9B;AAMA,UAAM,eAAe,MAAM,GACxB,WAAW,eAAsB,EACjC,OAAO,CAAC,aAAoB,cAAsB,GAAG,aAAa,CAAQ,CAAC,EAC3E,MAAM,eAAsB,KAAK,SAAS,CAAC,EAAE,UAAU,EACvD,MAAM,2CAAoD,OAAO,cAAc,EAAE,EACjF,MAAM,qCAA8C,OAAO,QAAQ,EAAE,EACrE,MAAM,aAAoB,MAAM,GAAG,EACnC,QAAQ,WAAkB,EAC1B,QAAQ;AACX,UAAM,kBAAkB,oBAAI,IAAoB;AAChD,eAAW,OAAO,cAAuB;AACvC,sBAAgB,IAAI,OAAO,IAAI,SAAS,GAAG,OAAO,IAAI,WAAW,CAAC;AAAA,IACpE;AAEA,UAAM,UAAU,oBAAI,IAAY;AAGhC,UAAM,oBAAoB,IAAI,OAAO,CAAC,OAAO;AAC3C,YAAM,aAAa,eAAe,IAAI,EAAE,KAAK;AAC7C,WAAK,gBAAgB,IAAI,EAAE,KAAK,OAAO,YAAY;AACjD,gBAAQ,IAAI,EAAE;AACd,eAAO;AAAA,MACT;AACA,aAAO,aAAa;AAAA,IACtB,CAAC;AAED,QAAI,kBAAkB,QAAQ;AAC5B,YAAM,YAAY,kBAAkB,OAAO,CAAC,KAAK,OAAO,OAAO,eAAe,IAAI,EAAE,KAAK,IAAI,CAAC;AAC9F,YAAM,SAAS,MAAM,GAClB,WAAW,eAAsB,EACjC,OAAO,CAAC,aAAoB,SAAgB,cAAqB,OAAc,CAAC,EAChF,MAAM,eAAsB,KAAK,SAAS,CAAC,EAAE,UAAU,EACvD,MAAM,2CAAoD,OAAO,cAAc,EAAE,EACjF,MAAM,qCAA8C,OAAO,QAAQ,EAAE,EACrE,MAAM,aAAoB,MAAM,iBAAiB,EAIjD,MAAM,SAAS,EACf,QAAQ;AACX,YAAM,cAAc,eAAe,QAAiB,CAAC,QAAQ,OAAO,IAAI,SAAS,CAAC;AAClF,iBAAW,MAAM,mBAAmB;AAClC,YAAI,CAAC,YAAY,WAAW,IAAI,GAAG,GAAG,IAAI,EAAE,EAAE,GAAG,YAAY,IAAI,EAAE,CAAC,EAAG,SAAQ,IAAI,EAAE;AAAA,MACvF;AAAA,IACF;AACA,uBAAmB,IAAI,KAAK,OAAO;AAAA,EACrC;AAEA,QAAM,oBAAoB,oBAAI,IAAY;AAC1C,aAAW,CAAC,KAAK,OAAO,KAAK,mBAAmB,QAAQ,GAAG;AACzD,eAAW,MAAM,QAAS,mBAAkB,IAAI,GAAG,GAAG,IAAI,EAAE,EAAE;AAAA,EAChE;AACA,QAAM,cAAc;AAAA,IAClB,YAAY,SAAS,CAAC,EAAE;AAAA,IACxB,aAAa,SAAS;AAAA,IACtB,cAAc,kBAAkB;AAAA,IAChC,gCAAgC,mBAAmB;AAAA,EACrD,CAAC;AACD,MAAI,CAAC,kBAAkB,KAAM;AAE7B,QAAM,GAAG,YAAY,EAAE,QAAQ,OAAO,QAAQ;AAC5C,eAAW,CAAC,KAAK,MAAM,KAAK,aAAa,QAAQ,GAAG;AAClD,YAAM,UAAU,mBAAmB,IAAI,GAAG;AAC1C,UAAI,CAAC,SAAS,KAAM;AAGpB,YAAM,cAAc,IACjB,WAAW,eAAsB,EACjC,MAAM,eAAsB,KAAK,SAAS,CAAC,EAAE,UAAU,EACvD,MAAM,2CAAoD,OAAO,cAAc,EAAE,EACjF,MAAM,qCAA8C,OAAO,QAAQ,EAAE,EACrE,MAAM,aAAoB,MAAM,MAAM,KAAK,OAAO,CAAC;AACtD,YAAM,YAAY,QAAQ;AAAA,IAC5B;AACA,UAAM,wBAAwB,KAC3B,OAAO,CAAC,QAAQ,kBAAkB,IAAI,YAAY,GAAG,CAAC,CAAC,EACvD,IAAI,CAAC,SAAS,EAAE,GAAG,KAAK,YAAY,WAAW,EAAE;AACpD,eAAW,SAAS,MAAM,uBAAuB,iBAAiB,GAAG;AACnE,YAAM,IAAI,WAAW,eAAsB,EAAE,OAAO,KAAY,EAAE,QAAQ;AAAA,IAC5E;AAAA,EACF,CAAC;AACH;",
|
|
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.
|
|
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.
|
|
256
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
257
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
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.
|
|
263
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
264
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
49
|
-
* Returns null if
|
|
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
|
-
|
|
69
|
+
stored: string,
|
|
53
70
|
tenantId: string | null
|
|
54
71
|
): Promise<string | null> {
|
|
55
|
-
if (!tenantId || !
|
|
72
|
+
if (!tenantId || !stored) return null
|
|
56
73
|
|
|
57
74
|
const kms = createKmsService()
|
|
58
|
-
|
|
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(
|
|
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
|
-
|
|
236
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|