@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": "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;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;AAAA,EAC1D;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;",
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": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7714.1.9dbb8200d8",
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.7714.1.9dbb8200d8",
269
- "@open-mercato/shared": "0.10.1-develop.7714.1.9dbb8200d8",
270
- "@open-mercato/ui": "0.10.1-develop.7714.1.9dbb8200d8",
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.7714.1.9dbb8200d8",
276
- "@open-mercato/shared": "0.10.1-develop.7714.1.9dbb8200d8",
277
- "@open-mercato/ui": "0.10.1-develop.7714.1.9dbb8200d8",
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
  }