@open-mercato/core 0.10.1-develop.7714.1.9dbb8200d8 → 0.10.1-develop.7715.1.9483283afa
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.
|
@@ -109,6 +109,41 @@ async function readLiveEncryptionMapVersion(em, input) {
|
|
|
109
109
|
const current = rows[0]?.updated_at;
|
|
110
110
|
return current ? toDate(current) : null;
|
|
111
111
|
}
|
|
112
|
+
async function appendMissingFieldsToOrganizationMaps(em, input) {
|
|
113
|
+
const updatedOrganizationIds = /* @__PURE__ */ new Set();
|
|
114
|
+
for (const rule of input.fields) {
|
|
115
|
+
const field = typeof rule?.field === "string" ? rule.field.trim() : "";
|
|
116
|
+
if (!field) continue;
|
|
117
|
+
const appended = rule.hashField ? { field, hashField: rule.hashField } : { field };
|
|
118
|
+
const rows = await em.execute(
|
|
119
|
+
`
|
|
120
|
+
update "encryption_maps"
|
|
121
|
+
set "fields_json" = coalesce("fields_json", '[]'::jsonb) || ?::jsonb, "updated_at" = now()
|
|
122
|
+
where "entity_id" = ?
|
|
123
|
+
and "tenant_id" = ?
|
|
124
|
+
and "organization_id" is not null
|
|
125
|
+
and "deleted_at" is null
|
|
126
|
+
and jsonb_typeof(coalesce("fields_json", '[]'::jsonb)) = 'array'
|
|
127
|
+
and not exists (
|
|
128
|
+
select 1
|
|
129
|
+
from jsonb_array_elements(case when jsonb_typeof("fields_json") = 'array' then "fields_json" else '[]'::jsonb end) as existing_rule
|
|
130
|
+
where existing_rule->>'field' = ?
|
|
131
|
+
)
|
|
132
|
+
returning "organization_id"
|
|
133
|
+
`,
|
|
134
|
+
[JSON.stringify([appended]), input.entityId, input.tenantId, field]
|
|
135
|
+
);
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
if (row?.organization_id) updatedOrganizationIds.add(String(row.organization_id));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (updatedOrganizationIds.size) forgetEncryptionPolicyMemo(em);
|
|
141
|
+
return Array.from(updatedOrganizationIds, (organizationId) => ({
|
|
142
|
+
entityId: input.entityId,
|
|
143
|
+
tenantId: input.tenantId,
|
|
144
|
+
organizationId
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
112
147
|
async function upsertEncryptionMapSpecs(em, tenantId, organizationId, specs, logger = () => {
|
|
113
148
|
}, onMaterializedScope) {
|
|
114
149
|
const materializedScopes = [];
|
|
@@ -128,6 +163,17 @@ async function upsertEncryptionMapSpecs(em, tenantId, organizationId, specs, log
|
|
|
128
163
|
materializedScopes.push(materializedScope);
|
|
129
164
|
await onMaterializedScope?.(materializedScope);
|
|
130
165
|
logger(`\u{1F512} Seeded encryption map for ${spec.entityId} \u2728`);
|
|
166
|
+
if (organizationId !== null) continue;
|
|
167
|
+
const organizationScopes = await appendMissingFieldsToOrganizationMaps(em, {
|
|
168
|
+
entityId: spec.entityId,
|
|
169
|
+
tenantId,
|
|
170
|
+
fields: spec.fields
|
|
171
|
+
});
|
|
172
|
+
for (const organizationScope of organizationScopes) {
|
|
173
|
+
materializedScopes.push(organizationScope);
|
|
174
|
+
await onMaterializedScope?.(organizationScope);
|
|
175
|
+
logger(`\u{1F512} Added missing fields to the ${spec.entityId} map of organization ${organizationScope.organizationId}`);
|
|
176
|
+
}
|
|
131
177
|
}
|
|
132
178
|
return materializedScopes;
|
|
133
179
|
}
|
|
@@ -152,6 +198,7 @@ async function invalidateEncryptionMapScopes(encryptionService, scopes) {
|
|
|
152
198
|
}
|
|
153
199
|
export {
|
|
154
200
|
EncryptionMapVersionConflictError,
|
|
201
|
+
appendMissingFieldsToOrganizationMaps,
|
|
155
202
|
invalidateEncryptionMapScopes,
|
|
156
203
|
mergeEncryptionMapFields,
|
|
157
204
|
resolveCanonicalEncryptionMap,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/entities/lib/encryption-maps.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ModuleEncryptionFieldRule, ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'\nimport { forgetEncryptionPolicyMemo } from '@open-mercato/shared/lib/encryption/policyMemo'\nimport { getDefaultEncryptionMaps, type Module } from '@open-mercato/shared/modules/registry'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\n\nexport type EncryptionMapLike = {\n id: string\n entityId: string\n tenantId?: string | null\n organizationId?: string | null\n fieldsJson?: ModuleEncryptionFieldRule[] | null\n isActive: boolean\n createdAt: Date\n updatedAt: Date\n deletedAt?: Date | null\n}\n\nexport type CanonicalEncryptionMap = EncryptionMapLike & {\n fieldsJson: ModuleEncryptionFieldRule[]\n}\n\nexport type UpsertCanonicalEncryptionMapInput = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n fields: ModuleEncryptionFieldRule[]\n isActive: boolean\n /**\n * When set, an existing live row is only updated while its `updated_at` still equals this\n * version (millisecond precision, matching the optimistic-lock token). A concurrent write that\n * moved the version makes the upsert throw {@link EncryptionMapVersionConflictError} instead of\n * overwriting it.\n */\n expectedUpdatedAt?: Date | null\n}\n\nexport type UpsertedEncryptionMap = {\n id: string\n updatedAt: Date\n}\n\nexport class EncryptionMapVersionConflictError extends Error {\n constructor(\n readonly expectedUpdatedAt: Date,\n readonly currentUpdatedAt: Date | null,\n ) {\n super('[internal] Encryption map version changed before the conditional update')\n this.name = 'EncryptionMapVersionConflictError'\n }\n}\n\nfunction toDate(value: Date | string): Date {\n return value instanceof Date ? value : new Date(value)\n}\n\nfunction orderedMaps<T extends EncryptionMapLike>(records: readonly T[]): T[] {\n return [...records].sort((left, right) => {\n const createdDelta = left.createdAt.getTime() - right.createdAt.getTime()\n return createdDelta || left.id.localeCompare(right.id)\n })\n}\n\nexport function mergeEncryptionMapFields(records: readonly EncryptionMapLike[]): ModuleEncryptionFieldRule[] {\n const merged: ModuleEncryptionFieldRule[] = []\n const byField = new Map<string, ModuleEncryptionFieldRule>()\n for (const record of orderedMaps(records)) {\n for (const rule of record.fieldsJson ?? []) {\n const field = typeof rule?.field === 'string' ? rule.field.trim() : ''\n if (!field) continue\n const existing = byField.get(field)\n if (!existing) {\n const copy = { field, hashField: typeof rule.hashField === 'string' ? rule.hashField : null }\n byField.set(field, copy)\n merged.push(copy)\n continue\n }\n if (!existing.hashField && typeof rule.hashField === 'string' && rule.hashField) {\n existing.hashField = rule.hashField\n }\n }\n }\n return merged\n}\n\nexport function resolveCanonicalEncryptionMap<T extends EncryptionMapLike>(records: readonly T[]): CanonicalEncryptionMap | null {\n const ordered = orderedMaps(records)\n const canonical = ordered[0]\n if (!canonical) return null\n const active = ordered.filter((record) => record.isActive)\n return {\n ...canonical,\n isActive: active.length > 0,\n fieldsJson: mergeEncryptionMapFields(active.length > 0 ? active : ordered),\n }\n}\n\nexport async function upsertCanonicalEncryptionMap(\n em: EntityManager,\n input: UpsertCanonicalEncryptionMapInput,\n): Promise<UpsertedEncryptionMap> {\n const expectedUpdatedAt = input.expectedUpdatedAt ?? null\n const versionCondition = expectedUpdatedAt\n ? `where date_trunc('milliseconds', \"encryption_maps\".\"updated_at\") = date_trunc('milliseconds', ?::timestamptz)`\n : ''\n const params: unknown[] = [\n input.entityId,\n input.tenantId,\n input.organizationId,\n JSON.stringify(input.fields),\n input.isActive,\n ]\n if (expectedUpdatedAt) params.push(expectedUpdatedAt.toISOString())\n const rows = await em.execute<Array<{ id: string; updated_at: Date | string }>>(\n `\n insert into \"encryption_maps\"\n (\"id\", \"entity_id\", \"tenant_id\", \"organization_id\", \"fields_json\", \"is_active\", \"created_at\", \"updated_at\")\n values\n (gen_random_uuid(), ?, ?, ?, ?::jsonb, ?, now(), now())\n on conflict (\"entity_id\", \"tenant_id\", \"organization_id\") where \"deleted_at\" is null\n do update set\n \"fields_json\" = excluded.\"fields_json\",\n \"is_active\" = excluded.\"is_active\",\n \"updated_at\" = now()\n ${versionCondition}\n returning \"id\", \"updated_at\"\n `,\n params,\n )\n forgetEncryptionPolicyMemo(em)\n const saved = rows[0]\n if (!saved?.id || !saved.updated_at) {\n if (expectedUpdatedAt) {\n throw new EncryptionMapVersionConflictError(\n expectedUpdatedAt,\n await readLiveEncryptionMapVersion(em, input),\n )\n }\n throw new Error('[internal] Encryption map upsert did not return the canonical row')\n }\n return {\n id: saved.id,\n updatedAt: toDate(saved.updated_at),\n }\n}\n\nasync function readLiveEncryptionMapVersion(\n em: EntityManager,\n input: UpsertCanonicalEncryptionMapInput,\n): Promise<Date | null> {\n const rows = await em.execute<Array<{ updated_at: Date | string | null }>>(\n `\n select \"updated_at\"\n from \"encryption_maps\"\n where \"entity_id\" = ?\n and \"tenant_id\" is not distinct from ?\n and \"organization_id\" is not distinct from ?\n and \"deleted_at\" is null\n order by \"created_at\" asc, \"id\" asc\n limit 1\n `,\n [input.entityId, input.tenantId, input.organizationId],\n )\n const current = rows[0]?.updated_at\n return current ? toDate(current) : null\n}\n\n// Idempotently upsert a specific set of encryption-map specs for one (tenant, org) scope. Exported so\n// upgrade actions can backfill a newly-added encrypted entity for pre-existing tenants (whose maps were\n// seeded once at tenant creation and never re-run) without depending on the full CLI module registry.\nexport async function upsertEncryptionMapSpecs(\n em: EntityManager,\n tenantId: string,\n organizationId: string | null,\n specs: ModuleEncryptionMap[],\n logger: (msg: string) => void = () => {},\n onMaterializedScope?: (scope: { entityId: string; tenantId: string; organizationId: string | null }) => void | Promise<void>,\n): Promise<Array<{ entityId: string; tenantId: string; organizationId: string | null }>> {\n const materializedScopes: Array<{ entityId: string; tenantId: string; organizationId: string | null }> = []\n for (const spec of specs) {\n if (spec.keyScope === 'system') {\n logger(`Skipping ${spec.entityId}: system-scoped map, resolved from module code rather than a tenant row.`)\n continue\n }\n await upsertCanonicalEncryptionMap(em, {\n entityId: spec.entityId,\n tenantId,\n organizationId,\n fields: spec.fields,\n isActive: true,\n })\n const materializedScope = { entityId: spec.entityId, tenantId, organizationId }\n materializedScopes.push(materializedScope)\n await onMaterializedScope?.(materializedScope)\n logger(`\uD83D\uDD12 Seeded encryption map for ${spec.entityId} \u2728`)\n }\n return materializedScopes\n}\n\n// Exported so other modules (e.g. the directory subscribers that seed maps for a\n// newly-created organization) can seed the default module encryption maps for one\n// (tenant, org) scope. Callers resolve their own module list: a CLI command uses\n// `getCliModules()`/`getModules()` (cli.ts is an allowed CLI-registry entrypoint), a runtime\n// subscriber MUST use the app registry (`getModules()`) directly \u2014 this lib never reads the\n// CLI-only registry itself (`cli-registry-boundary.test.ts` in @open-mercato/shared enforces\n// that only `packages/cli/**` or a file literally named `cli.ts` may call `getCliModules()`).\nexport async function upsertEncryptionMaps(\n em: EntityManager,\n tenantId: string,\n organizationId: string | null,\n modules: Module[],\n logger: (msg: string) => void,\n onMaterializedScope?: (scope: { entityId: string; tenantId: string; organizationId: string | null }) => void | Promise<void>,\n) {\n return upsertEncryptionMapSpecs(\n em,\n tenantId,\n organizationId,\n getDefaultEncryptionMaps(modules),\n logger,\n onMaterializedScope,\n )\n}\n\nexport async function invalidateEncryptionMapScopes(\n encryptionService: Pick<TenantDataEncryptionService, 'invalidateMap'>,\n scopes: ReadonlyArray<{ entityId: string; tenantId: string | null; organizationId: string | null }>,\n): Promise<void> {\n const seen = new Set<string>()\n for (const scope of scopes) {\n const key = `${scope.entityId}\\u0000${scope.tenantId ?? ''}\\u0000${scope.organizationId ?? ''}`\n if (seen.has(key)) continue\n seen.add(key)\n await encryptionService.invalidateMap(scope.entityId, scope.tenantId, scope.organizationId)\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { ModuleEncryptionFieldRule, ModuleEncryptionMap } from '@open-mercato/shared/modules/encryption'\nimport { forgetEncryptionPolicyMemo } from '@open-mercato/shared/lib/encryption/policyMemo'\nimport { getDefaultEncryptionMaps, type Module } from '@open-mercato/shared/modules/registry'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\n\nexport type EncryptionMapLike = {\n id: string\n entityId: string\n tenantId?: string | null\n organizationId?: string | null\n fieldsJson?: ModuleEncryptionFieldRule[] | null\n isActive: boolean\n createdAt: Date\n updatedAt: Date\n deletedAt?: Date | null\n}\n\nexport type CanonicalEncryptionMap = EncryptionMapLike & {\n fieldsJson: ModuleEncryptionFieldRule[]\n}\n\nexport type UpsertCanonicalEncryptionMapInput = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n fields: ModuleEncryptionFieldRule[]\n isActive: boolean\n /**\n * When set, an existing live row is only updated while its `updated_at` still equals this\n * version (millisecond precision, matching the optimistic-lock token). A concurrent write that\n * moved the version makes the upsert throw {@link EncryptionMapVersionConflictError} instead of\n * overwriting it.\n */\n expectedUpdatedAt?: Date | null\n}\n\nexport type UpsertedEncryptionMap = {\n id: string\n updatedAt: Date\n}\n\nexport class EncryptionMapVersionConflictError extends Error {\n constructor(\n readonly expectedUpdatedAt: Date,\n readonly currentUpdatedAt: Date | null,\n ) {\n super('[internal] Encryption map version changed before the conditional update')\n this.name = 'EncryptionMapVersionConflictError'\n }\n}\n\nfunction toDate(value: Date | string): Date {\n return value instanceof Date ? value : new Date(value)\n}\n\nfunction orderedMaps<T extends EncryptionMapLike>(records: readonly T[]): T[] {\n return [...records].sort((left, right) => {\n const createdDelta = left.createdAt.getTime() - right.createdAt.getTime()\n return createdDelta || left.id.localeCompare(right.id)\n })\n}\n\nexport function mergeEncryptionMapFields(records: readonly EncryptionMapLike[]): ModuleEncryptionFieldRule[] {\n const merged: ModuleEncryptionFieldRule[] = []\n const byField = new Map<string, ModuleEncryptionFieldRule>()\n for (const record of orderedMaps(records)) {\n for (const rule of record.fieldsJson ?? []) {\n const field = typeof rule?.field === 'string' ? rule.field.trim() : ''\n if (!field) continue\n const existing = byField.get(field)\n if (!existing) {\n const copy = { field, hashField: typeof rule.hashField === 'string' ? rule.hashField : null }\n byField.set(field, copy)\n merged.push(copy)\n continue\n }\n if (!existing.hashField && typeof rule.hashField === 'string' && rule.hashField) {\n existing.hashField = rule.hashField\n }\n }\n }\n return merged\n}\n\nexport function resolveCanonicalEncryptionMap<T extends EncryptionMapLike>(records: readonly T[]): CanonicalEncryptionMap | null {\n const ordered = orderedMaps(records)\n const canonical = ordered[0]\n if (!canonical) return null\n const active = ordered.filter((record) => record.isActive)\n return {\n ...canonical,\n isActive: active.length > 0,\n fieldsJson: mergeEncryptionMapFields(active.length > 0 ? active : ordered),\n }\n}\n\nexport async function upsertCanonicalEncryptionMap(\n em: EntityManager,\n input: UpsertCanonicalEncryptionMapInput,\n): Promise<UpsertedEncryptionMap> {\n const expectedUpdatedAt = input.expectedUpdatedAt ?? null\n const versionCondition = expectedUpdatedAt\n ? `where date_trunc('milliseconds', \"encryption_maps\".\"updated_at\") = date_trunc('milliseconds', ?::timestamptz)`\n : ''\n const params: unknown[] = [\n input.entityId,\n input.tenantId,\n input.organizationId,\n JSON.stringify(input.fields),\n input.isActive,\n ]\n if (expectedUpdatedAt) params.push(expectedUpdatedAt.toISOString())\n const rows = await em.execute<Array<{ id: string; updated_at: Date | string }>>(\n `\n insert into \"encryption_maps\"\n (\"id\", \"entity_id\", \"tenant_id\", \"organization_id\", \"fields_json\", \"is_active\", \"created_at\", \"updated_at\")\n values\n (gen_random_uuid(), ?, ?, ?, ?::jsonb, ?, now(), now())\n on conflict (\"entity_id\", \"tenant_id\", \"organization_id\") where \"deleted_at\" is null\n do update set\n \"fields_json\" = excluded.\"fields_json\",\n \"is_active\" = excluded.\"is_active\",\n \"updated_at\" = now()\n ${versionCondition}\n returning \"id\", \"updated_at\"\n `,\n params,\n )\n forgetEncryptionPolicyMemo(em)\n const saved = rows[0]\n if (!saved?.id || !saved.updated_at) {\n if (expectedUpdatedAt) {\n throw new EncryptionMapVersionConflictError(\n expectedUpdatedAt,\n await readLiveEncryptionMapVersion(em, input),\n )\n }\n throw new Error('[internal] Encryption map upsert did not return the canonical row')\n }\n return {\n id: saved.id,\n updatedAt: toDate(saved.updated_at),\n }\n}\n\nasync function readLiveEncryptionMapVersion(\n em: EntityManager,\n input: UpsertCanonicalEncryptionMapInput,\n): Promise<Date | null> {\n const rows = await em.execute<Array<{ updated_at: Date | string | null }>>(\n `\n select \"updated_at\"\n from \"encryption_maps\"\n where \"entity_id\" = ?\n and \"tenant_id\" is not distinct from ?\n and \"organization_id\" is not distinct from ?\n and \"deleted_at\" is null\n order by \"created_at\" asc, \"id\" asc\n limit 1\n `,\n [input.entityId, input.tenantId, input.organizationId],\n )\n const current = rows[0]?.updated_at\n return current ? toDate(current) : null\n}\n\nexport type OrganizationEncryptionMapScope = {\n entityId: string\n tenantId: string\n organizationId: string\n}\n\n/**\n * Appends the declared field rules a tenant's organization-level maps for `entityId` lack.\n *\n * Tenant setup seeds a map per organization, and the encryption service prefers an\n * organization map over the tenant map without merging the two. Re-seeding only the tenant map\n * therefore leaves those organizations on their old field list, and a newly declared field is\n * written as plaintext there. Existing rules are never removed or changed: the same contract as\n * `buildEncryptionMapBackfillSql`, so an admin's own additions survive.\n */\nexport async function appendMissingFieldsToOrganizationMaps(\n em: EntityManager,\n input: { entityId: string; tenantId: string; fields: ModuleEncryptionFieldRule[] },\n): Promise<OrganizationEncryptionMapScope[]> {\n const updatedOrganizationIds = new Set<string>()\n for (const rule of input.fields) {\n const field = typeof rule?.field === 'string' ? rule.field.trim() : ''\n if (!field) continue\n const appended = rule.hashField ? { field, hashField: rule.hashField } : { field }\n const rows = await em.execute<Array<{ organization_id: string }>>(\n `\n update \"encryption_maps\"\n set \"fields_json\" = coalesce(\"fields_json\", '[]'::jsonb) || ?::jsonb, \"updated_at\" = now()\n where \"entity_id\" = ?\n and \"tenant_id\" = ?\n and \"organization_id\" is not null\n and \"deleted_at\" is null\n and jsonb_typeof(coalesce(\"fields_json\", '[]'::jsonb)) = 'array'\n and not exists (\n select 1\n from jsonb_array_elements(case when jsonb_typeof(\"fields_json\") = 'array' then \"fields_json\" else '[]'::jsonb end) as existing_rule\n where existing_rule->>'field' = ?\n )\n returning \"organization_id\"\n `,\n [JSON.stringify([appended]), input.entityId, input.tenantId, field],\n )\n for (const row of rows) {\n if (row?.organization_id) updatedOrganizationIds.add(String(row.organization_id))\n }\n }\n if (updatedOrganizationIds.size) forgetEncryptionPolicyMemo(em)\n return Array.from(updatedOrganizationIds, (organizationId) => ({\n entityId: input.entityId,\n tenantId: input.tenantId,\n organizationId,\n }))\n}\n\n// Idempotently upsert a specific set of encryption-map specs for one (tenant, org) scope. Exported so\n// upgrade actions can backfill a newly-added encrypted entity for pre-existing tenants (whose maps were\n// seeded once at tenant creation and never re-run) without depending on the full CLI module registry.\nexport async function upsertEncryptionMapSpecs(\n em: EntityManager,\n tenantId: string,\n organizationId: string | null,\n specs: ModuleEncryptionMap[],\n logger: (msg: string) => void = () => {},\n onMaterializedScope?: (scope: { entityId: string; tenantId: string; organizationId: string | null }) => void | Promise<void>,\n): Promise<Array<{ entityId: string; tenantId: string; organizationId: string | null }>> {\n const materializedScopes: Array<{ entityId: string; tenantId: string; organizationId: string | null }> = []\n for (const spec of specs) {\n if (spec.keyScope === 'system') {\n logger(`Skipping ${spec.entityId}: system-scoped map, resolved from module code rather than a tenant row.`)\n continue\n }\n await upsertCanonicalEncryptionMap(em, {\n entityId: spec.entityId,\n tenantId,\n organizationId,\n fields: spec.fields,\n isActive: true,\n })\n const materializedScope = { entityId: spec.entityId, tenantId, organizationId }\n materializedScopes.push(materializedScope)\n await onMaterializedScope?.(materializedScope)\n logger(`\uD83D\uDD12 Seeded encryption map for ${spec.entityId} \u2728`)\n if (organizationId !== null) continue\n const organizationScopes = await appendMissingFieldsToOrganizationMaps(em, {\n entityId: spec.entityId,\n tenantId,\n fields: spec.fields,\n })\n for (const organizationScope of organizationScopes) {\n materializedScopes.push(organizationScope)\n await onMaterializedScope?.(organizationScope)\n logger(`\uD83D\uDD12 Added missing fields to the ${spec.entityId} map of organization ${organizationScope.organizationId}`)\n }\n }\n return materializedScopes\n}\n\n// Exported so other modules (e.g. the directory subscribers that seed maps for a\n// newly-created organization) can seed the default module encryption maps for one\n// (tenant, org) scope. Callers resolve their own module list: a CLI command uses\n// `getCliModules()`/`getModules()` (cli.ts is an allowed CLI-registry entrypoint), a runtime\n// subscriber MUST use the app registry (`getModules()`) directly \u2014 this lib never reads the\n// CLI-only registry itself (`cli-registry-boundary.test.ts` in @open-mercato/shared enforces\n// that only `packages/cli/**` or a file literally named `cli.ts` may call `getCliModules()`).\nexport async function upsertEncryptionMaps(\n em: EntityManager,\n tenantId: string,\n organizationId: string | null,\n modules: Module[],\n logger: (msg: string) => void,\n onMaterializedScope?: (scope: { entityId: string; tenantId: string; organizationId: string | null }) => void | Promise<void>,\n) {\n return upsertEncryptionMapSpecs(\n em,\n tenantId,\n organizationId,\n getDefaultEncryptionMaps(modules),\n logger,\n onMaterializedScope,\n )\n}\n\nexport async function invalidateEncryptionMapScopes(\n encryptionService: Pick<TenantDataEncryptionService, 'invalidateMap'>,\n scopes: ReadonlyArray<{ entityId: string; tenantId: string | null; organizationId: string | null }>,\n): Promise<void> {\n const seen = new Set<string>()\n for (const scope of scopes) {\n const key = `${scope.entityId}\\u0000${scope.tenantId ?? ''}\\u0000${scope.organizationId ?? ''}`\n if (seen.has(key)) continue\n seen.add(key)\n await encryptionService.invalidateMap(scope.entityId, scope.tenantId, scope.organizationId)\n }\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,kCAAkC;AAC3C,SAAS,gCAA6C;AAuC/C,MAAM,0CAA0C,MAAM;AAAA,EAC3D,YACW,mBACA,kBACT;AACA,UAAM,yEAAyE;AAHtE;AACA;AAGT,SAAK,OAAO;AAAA,EACd;AACF;AAEA,SAAS,OAAO,OAA4B;AAC1C,SAAO,iBAAiB,OAAO,QAAQ,IAAI,KAAK,KAAK;AACvD;AAEA,SAAS,YAAyC,SAA4B;AAC5E,SAAO,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,MAAM,UAAU;AACxC,UAAM,eAAe,KAAK,UAAU,QAAQ,IAAI,MAAM,UAAU,QAAQ;AACxE,WAAO,gBAAgB,KAAK,GAAG,cAAc,MAAM,EAAE;AAAA,EACvD,CAAC;AACH;AAEO,SAAS,yBAAyB,SAAoE;AAC3G,QAAM,SAAsC,CAAC;AAC7C,QAAM,UAAU,oBAAI,IAAuC;AAC3D,aAAW,UAAU,YAAY,OAAO,GAAG;AACzC,eAAW,QAAQ,OAAO,cAAc,CAAC,GAAG;AAC1C,YAAM,QAAQ,OAAO,MAAM,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AACpE,UAAI,CAAC,MAAO;AACZ,YAAM,WAAW,QAAQ,IAAI,KAAK;AAClC,UAAI,CAAC,UAAU;AACb,cAAM,OAAO,EAAE,OAAO,WAAW,OAAO,KAAK,cAAc,WAAW,KAAK,YAAY,KAAK;AAC5F,gBAAQ,IAAI,OAAO,IAAI;AACvB,eAAO,KAAK,IAAI;AAChB;AAAA,MACF;AACA,UAAI,CAAC,SAAS,aAAa,OAAO,KAAK,cAAc,YAAY,KAAK,WAAW;AAC/E,iBAAS,YAAY,KAAK;AAAA,MAC5B;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,8BAA2D,SAAsD;AAC/H,QAAM,UAAU,YAAY,OAAO;AACnC,QAAM,YAAY,QAAQ,CAAC;AAC3B,MAAI,CAAC,UAAW,QAAO;AACvB,QAAM,SAAS,QAAQ,OAAO,CAAC,WAAW,OAAO,QAAQ;AACzD,SAAO;AAAA,IACL,GAAG;AAAA,IACH,UAAU,OAAO,SAAS;AAAA,IAC1B,YAAY,yBAAyB,OAAO,SAAS,IAAI,SAAS,OAAO;AAAA,EAC3E;AACF;AAEA,eAAsB,6BACpB,IACA,OACgC;AAChC,QAAM,oBAAoB,MAAM,qBAAqB;AACrD,QAAM,mBAAmB,oBACrB,kHACA;AACJ,QAAM,SAAoB;AAAA,IACxB,MAAM;AAAA,IACN,MAAM;AAAA,IACN,MAAM;AAAA,IACN,KAAK,UAAU,MAAM,MAAM;AAAA,IAC3B,MAAM;AAAA,EACR;AACA,MAAI,kBAAmB,QAAO,KAAK,kBAAkB,YAAY,CAAC;AAClE,QAAM,OAAO,MAAM,GAAG;AAAA,IACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAUI,gBAAgB;AAAA;AAAA;AAAA,IAGpB;AAAA,EACF;AACA,6BAA2B,EAAE;AAC7B,QAAM,QAAQ,KAAK,CAAC;AACpB,MAAI,CAAC,OAAO,MAAM,CAAC,MAAM,YAAY;AACnC,QAAI,mBAAmB;AACrB,YAAM,IAAI;AAAA,QACR;AAAA,QACA,MAAM,6BAA6B,IAAI,KAAK;AAAA,MAC9C;AAAA,IACF;AACA,UAAM,IAAI,MAAM,mEAAmE;AAAA,EACrF;AACA,SAAO;AAAA,IACL,IAAI,MAAM;AAAA,IACV,WAAW,OAAO,MAAM,UAAU;AAAA,EACpC;AACF;AAEA,eAAe,6BACb,IACA,OACsB;AACtB,QAAM,OAAO,MAAM,GAAG;AAAA,IACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAUA,CAAC,MAAM,UAAU,MAAM,UAAU,MAAM,cAAc;AAAA,EACvD;AACA,QAAM,UAAU,KAAK,CAAC,GAAG;AACzB,SAAO,UAAU,OAAO,OAAO,IAAI;AACrC;AAiBA,eAAsB,sCACpB,IACA,OAC2C;AAC3C,QAAM,yBAAyB,oBAAI,IAAY;AAC/C,aAAW,QAAQ,MAAM,QAAQ;AAC/B,UAAM,QAAQ,OAAO,MAAM,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AACpE,QAAI,CAAC,MAAO;AACZ,UAAM,WAAW,KAAK,YAAY,EAAE,OAAO,WAAW,KAAK,UAAU,IAAI,EAAE,MAAM;AACjF,UAAM,OAAO,MAAM,GAAG;AAAA,MACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAeA,CAAC,KAAK,UAAU,CAAC,QAAQ,CAAC,GAAG,MAAM,UAAU,MAAM,UAAU,KAAK;AAAA,IACpE;AACA,eAAW,OAAO,MAAM;AACtB,UAAI,KAAK,gBAAiB,wBAAuB,IAAI,OAAO,IAAI,eAAe,CAAC;AAAA,IAClF;AAAA,EACF;AACA,MAAI,uBAAuB,KAAM,4BAA2B,EAAE;AAC9D,SAAO,MAAM,KAAK,wBAAwB,CAAC,oBAAoB;AAAA,IAC7D,UAAU,MAAM;AAAA,IAChB,UAAU,MAAM;AAAA,IAChB;AAAA,EACF,EAAE;AACJ;AAKA,eAAsB,yBACpB,IACA,UACA,gBACA,OACA,SAAgC,MAAM;AAAC,GACvC,qBACuF;AACvF,QAAM,qBAAmG,CAAC;AAC1G,aAAW,QAAQ,OAAO;AACxB,QAAI,KAAK,aAAa,UAAU;AAC9B,aAAO,YAAY,KAAK,QAAQ,0EAA0E;AAC1G;AAAA,IACF;AACA,UAAM,6BAA6B,IAAI;AAAA,MACrC,UAAU,KAAK;AAAA,MACf;AAAA,MACA;AAAA,MACA,QAAQ,KAAK;AAAA,MACb,UAAU;AAAA,IACZ,CAAC;AACD,UAAM,oBAAoB,EAAE,UAAU,KAAK,UAAU,UAAU,eAAe;AAC9E,uBAAmB,KAAK,iBAAiB;AACzC,UAAM,sBAAsB,iBAAiB;AAC7C,WAAO,uCAAgC,KAAK,QAAQ,SAAI;AACxD,QAAI,mBAAmB,KAAM;AAC7B,UAAM,qBAAqB,MAAM,sCAAsC,IAAI;AAAA,MACzE,UAAU,KAAK;AAAA,MACf;AAAA,MACA,QAAQ,KAAK;AAAA,IACf,CAAC;AACD,eAAW,qBAAqB,oBAAoB;AAClD,yBAAmB,KAAK,iBAAiB;AACzC,YAAM,sBAAsB,iBAAiB;AAC7C,aAAO,yCAAkC,KAAK,QAAQ,wBAAwB,kBAAkB,cAAc,EAAE;AAAA,IAClH;AAAA,EACF;AACA,SAAO;AACT;AASA,eAAsB,qBACpB,IACA,UACA,gBACA,SACA,QACA,qBACA;AACA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,yBAAyB,OAAO;AAAA,IAChC;AAAA,IACA;AAAA,EACF;AACF;AAEA,eAAsB,8BACpB,mBACA,QACe;AACf,QAAM,OAAO,oBAAI,IAAY;AAC7B,aAAW,SAAS,QAAQ;AAC1B,UAAM,MAAM,GAAG,MAAM,QAAQ,KAAS,MAAM,YAAY,EAAE,KAAS,MAAM,kBAAkB,EAAE;AAC7F,QAAI,KAAK,IAAI,GAAG,EAAG;AACnB,SAAK,IAAI,GAAG;AACZ,UAAM,kBAAkB,cAAc,MAAM,UAAU,MAAM,UAAU,MAAM,cAAc;AAAA,EAC5F;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7715.1.9483283afa",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -265,16 +265,16 @@
|
|
|
265
265
|
"zod": "^4.4.3"
|
|
266
266
|
},
|
|
267
267
|
"peerDependencies": {
|
|
268
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
269
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
268
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7715.1.9483283afa",
|
|
269
|
+
"@open-mercato/shared": "0.10.1-develop.7715.1.9483283afa",
|
|
270
|
+
"@open-mercato/ui": "0.10.1-develop.7715.1.9483283afa",
|
|
271
271
|
"react": "^19.0.0",
|
|
272
272
|
"react-dom": "^19.0.0"
|
|
273
273
|
},
|
|
274
274
|
"devDependencies": {
|
|
275
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
276
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
275
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7715.1.9483283afa",
|
|
276
|
+
"@open-mercato/shared": "0.10.1-develop.7715.1.9483283afa",
|
|
277
|
+
"@open-mercato/ui": "0.10.1-develop.7715.1.9483283afa",
|
|
278
278
|
"@testing-library/dom": "^10.4.1",
|
|
279
279
|
"@testing-library/jest-dom": "^7.0.1",
|
|
280
280
|
"@testing-library/react": "^16.3.3",
|
|
@@ -165,6 +165,60 @@ async function readLiveEncryptionMapVersion(
|
|
|
165
165
|
return current ? toDate(current) : null
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
export type OrganizationEncryptionMapScope = {
|
|
169
|
+
entityId: string
|
|
170
|
+
tenantId: string
|
|
171
|
+
organizationId: string
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Appends the declared field rules a tenant's organization-level maps for `entityId` lack.
|
|
176
|
+
*
|
|
177
|
+
* Tenant setup seeds a map per organization, and the encryption service prefers an
|
|
178
|
+
* organization map over the tenant map without merging the two. Re-seeding only the tenant map
|
|
179
|
+
* therefore leaves those organizations on their old field list, and a newly declared field is
|
|
180
|
+
* written as plaintext there. Existing rules are never removed or changed: the same contract as
|
|
181
|
+
* `buildEncryptionMapBackfillSql`, so an admin's own additions survive.
|
|
182
|
+
*/
|
|
183
|
+
export async function appendMissingFieldsToOrganizationMaps(
|
|
184
|
+
em: EntityManager,
|
|
185
|
+
input: { entityId: string; tenantId: string; fields: ModuleEncryptionFieldRule[] },
|
|
186
|
+
): Promise<OrganizationEncryptionMapScope[]> {
|
|
187
|
+
const updatedOrganizationIds = new Set<string>()
|
|
188
|
+
for (const rule of input.fields) {
|
|
189
|
+
const field = typeof rule?.field === 'string' ? rule.field.trim() : ''
|
|
190
|
+
if (!field) continue
|
|
191
|
+
const appended = rule.hashField ? { field, hashField: rule.hashField } : { field }
|
|
192
|
+
const rows = await em.execute<Array<{ organization_id: string }>>(
|
|
193
|
+
`
|
|
194
|
+
update "encryption_maps"
|
|
195
|
+
set "fields_json" = coalesce("fields_json", '[]'::jsonb) || ?::jsonb, "updated_at" = now()
|
|
196
|
+
where "entity_id" = ?
|
|
197
|
+
and "tenant_id" = ?
|
|
198
|
+
and "organization_id" is not null
|
|
199
|
+
and "deleted_at" is null
|
|
200
|
+
and jsonb_typeof(coalesce("fields_json", '[]'::jsonb)) = 'array'
|
|
201
|
+
and not exists (
|
|
202
|
+
select 1
|
|
203
|
+
from jsonb_array_elements(case when jsonb_typeof("fields_json") = 'array' then "fields_json" else '[]'::jsonb end) as existing_rule
|
|
204
|
+
where existing_rule->>'field' = ?
|
|
205
|
+
)
|
|
206
|
+
returning "organization_id"
|
|
207
|
+
`,
|
|
208
|
+
[JSON.stringify([appended]), input.entityId, input.tenantId, field],
|
|
209
|
+
)
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
if (row?.organization_id) updatedOrganizationIds.add(String(row.organization_id))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (updatedOrganizationIds.size) forgetEncryptionPolicyMemo(em)
|
|
215
|
+
return Array.from(updatedOrganizationIds, (organizationId) => ({
|
|
216
|
+
entityId: input.entityId,
|
|
217
|
+
tenantId: input.tenantId,
|
|
218
|
+
organizationId,
|
|
219
|
+
}))
|
|
220
|
+
}
|
|
221
|
+
|
|
168
222
|
// Idempotently upsert a specific set of encryption-map specs for one (tenant, org) scope. Exported so
|
|
169
223
|
// upgrade actions can backfill a newly-added encrypted entity for pre-existing tenants (whose maps were
|
|
170
224
|
// seeded once at tenant creation and never re-run) without depending on the full CLI module registry.
|
|
@@ -193,6 +247,17 @@ export async function upsertEncryptionMapSpecs(
|
|
|
193
247
|
materializedScopes.push(materializedScope)
|
|
194
248
|
await onMaterializedScope?.(materializedScope)
|
|
195
249
|
logger(`🔒 Seeded encryption map for ${spec.entityId} ✨`)
|
|
250
|
+
if (organizationId !== null) continue
|
|
251
|
+
const organizationScopes = await appendMissingFieldsToOrganizationMaps(em, {
|
|
252
|
+
entityId: spec.entityId,
|
|
253
|
+
tenantId,
|
|
254
|
+
fields: spec.fields,
|
|
255
|
+
})
|
|
256
|
+
for (const organizationScope of organizationScopes) {
|
|
257
|
+
materializedScopes.push(organizationScope)
|
|
258
|
+
await onMaterializedScope?.(organizationScope)
|
|
259
|
+
logger(`🔒 Added missing fields to the ${spec.entityId} map of organization ${organizationScope.organizationId}`)
|
|
260
|
+
}
|
|
196
261
|
}
|
|
197
262
|
return materializedScopes
|
|
198
263
|
}
|