From bddb2635564123973f5a5aab13a2bb8c8a01097b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:28:16 +0000 Subject: [PATCH 1/3] fix(metadata-protocol): duplicatePackage re-points a copied permission set's fields keys at the renamed objects Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 70 +++++ ...cate-package-permission-field-keys.test.ts | 287 ++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fc4e72a6095..776cba82b01 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -26299,6 +26299,10 @@ export class ObjectStackProtocolImplementation implements * when its target name is already claimed (the source also holds * `iojn_ticket`) the object is refused in `failed[]`, before anything is * written for it, with the source rename that clears it. + * + * A copied permission set's field rules follow too: in `fields`, keyed + * `.`, a key's object part takes the renamed object's name + * and its field part is left as stored. */ async duplicatePackage(request: { sourcePackageId: string; @@ -26542,6 +26546,59 @@ export class ObjectStackProtocolImplementation implements } return v; }; + // ⭐ A COPIED PERMISSION SET'S FIELD RULES FOLLOW THE RENAMED OBJECTS. + // `PermissionSetSchema.fields` is a record keyed `.` + // (`packages/spec/src/security/permission.zod.ts`), and the security + // plugin reads a set's rules for an object by the key prefix + // `.` (`PermissionEvaluator.getFieldPermissions`). Left as + // stored, the copy's rules govern the SOURCE's object and the + // duplicate's object gets none: a field the source set hides is + // readable on the copy for whoever holds the copied set. + // + // `deepRewrite` cannot carry it: the derived site index reads + // `permission.fields` as a site naming `field` items (its property + // spells that type), never an object, and has no arm for a composite + // key — and widening the index would also change the "Used by" answer + // it feeds (`findReferencesToMeta`). So this step is keyed on the + // declared shape — the `fields` record of a copied `permission` item — + // never on a property name, which would also catch an object's own + // `fields` (keyed by field name). + // + // The split: object and field names are both `^[a-z_][a-z0-9_]*$`, so + // a key's object part is everything before its FIRST `.`. It is + // rewritten only when it equals a renamed object name exactly, and the + // field part is never read — `dupsrc_customer.dupsrc_customer` becomes + // `dupdst_customer.dupsrc_customer`. A key naming an object outside the + // package keeps its name. + // + // ⛔ REFUSED, NEVER MERGED: when a renamed key equals a key the set + // already holds (the source also governs an object, outside the + // package, named like the copy), keeping either rule would silently + // drop the other, so the set is reported in `failed[]` with both keys. + // `Object.fromEntries` defines own properties, so no key can land on + // the prototype. + const rewriteFieldRuleKeys = (fields: Record): { fields: Record } | { collision: string } => { + const out = new Map(); + const from = new Map(); + for (const [key, rule] of Object.entries(fields)) { + const dot = key.indexOf('.'); + const renamed = dot > 0 ? renameMap.get(key.slice(0, dot)) : undefined; + const next = renamed === undefined ? key : `${renamed}${key.slice(dot)}`; + const other = from.get(next); + if (other !== undefined) { + const moved = next === key ? other : key; + return { + collision: `its field rule '${moved}' follows object '${moved.slice(0, moved.indexOf('.'))}' ` + + `to '${next}', a key the set already holds for object ` + + `'${next.slice(0, next.indexOf('.'))}', which this copy does not rename. ` + + `Remove '${moved}' or '${next}' on the source, then duplicate again.`, + }; + } + out.set(next, rule); + from.set(next, key); + } + return { fields: Object.fromEntries(out) }; + }; if (srcPkg?.manifest && typeof registry?.installPackage === 'function') { try { @@ -26671,6 +26728,19 @@ export class ObjectStackProtocolImplementation implements if (singular === 'permission' && rewritten.packageId === request.sourcePackageId) { rewritten.packageId = request.targetPackageId; } + if (singular === 'permission' && rewritten.fields + && typeof rewritten.fields === 'object' && !Array.isArray(rewritten.fields)) { + const result = rewriteFieldRuleKeys(rewritten.fields); + if ('collision' in result) { + failed.push({ + type: row.type, + name: row.name, + error: `Permission set '${row.name}' is not copied: ${result.collision}`, + }); + continue; + } + rewritten.fields = result.fields; + } } try { await this.saveMetaItem({ diff --git a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts new file mode 100644 index 00000000000..30c46a900c2 --- /dev/null +++ b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0070 D4 — `duplicatePackage` re-points a copied permission set's + * field-level rules at the copied objects: in `PermissionSetSchema.fields`, + * keyed `.`, a key's OBJECT part follows the renamed + * object, and nothing else about the key moves. + * + * ## The defect + * + * The copy's `objects` map followed the renamed objects, but its `fields` map + * kept the source's keys (`dupsrc_customer.full_name`). The security plugin + * reads a set's field rules for an object by the key prefix `.` + * (`PermissionEvaluator.getFieldPermissions`), so the copy's rules applied to + * the SOURCE's object and the duplicate's object got none: a field the source + * set hid was readable on the duplicate for whoever held the copied set. + * + * ## The split rule + * + * Object names and field names are both `^[a-z_][a-z0-9_]*$`, so a key's + * object part is everything before its FIRST `.`. That part is rewritten only + * when it equals a renamed object name exactly; the field part is never read. + * A renamed key that meets a key the set already holds is refused in + * `failed[]`, because keeping either rule would silently drop the other. + * + * ## Why a real kernel + * + * `ObjectQLPlugin` binds the real protocol to the real engine and registry, so + * every pin reads the stored `sys_metadata` rows out of the driver, never a + * payload. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; + +type Stores = Map>>; + +/** In-memory driver, copied from `protocol-duplicate-package-unprefixed-object.test.ts`; equality-only WHERE. */ +function makeMemoryDriver(stores: Stores) { + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + const id = (data.id as string) ?? `r_${randomUUID()}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return driver; +} + +/** Source package; `installPackage` derives its namespace `dupsrc`. */ +const P = 'com.qa.dupsrc'; +/** Target package; namespace `dupdst`, derived the same way. */ +const Q = 'com.qa.dupdst'; +/** A third package, whose object the source's set also governs. */ +const R = 'com.qa.dupext'; + +const HIDDEN = { readable: false, editable: false }; +const LOCKED = { readable: true, editable: false }; +const OPEN = { readable: true, editable: true }; + +/** A prefixed object with a field that spells the object's own name. */ +const customerBody = { + name: 'dupsrc_customer', label: 'Customer', sharingModel: 'private', + fields: { + full_name: { name: 'full_name', type: 'text', label: 'Full name' }, + dupsrc_customer: { name: 'dupsrc_customer', type: 'text', label: 'Customer code' }, + }, +}; +/** An object whose name lacks the `dupsrc_` prefix, likewise. */ +const ticketBody = { + name: 'ticket', label: 'Ticket', sharingModel: 'private', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + ticket: { name: 'ticket', type: 'text', label: 'Ticket number' }, + }, +}; +/** An object of another package: not copied, so not renamed. */ +const accountBody = { + name: 'dupext_account', label: 'Account', sharingModel: 'private', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, +}; +const flsBody = { + name: 'dupsrc_fls', label: 'Field rules', + objects: { + dupsrc_customer: { allowRead: true, allowEdit: true }, + ticket: { allowRead: true, allowEdit: true }, + dupext_account: { allowRead: true }, + }, + fields: { + 'dupsrc_customer.full_name': HIDDEN, + 'dupsrc_customer.dupsrc_customer': LOCKED, + 'ticket.title': HIDDEN, + 'ticket.ticket': OPEN, + 'dupext_account.name': LOCKED, + }, +}; + +describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s `fields` keys', () => { + let kernel: ObjectKernel; + let stores: Stores; + let engine: ObjectQL; + let protocol: any; + + beforeEach(async () => { + // `logger.level` is the real config key, and gracefulShutdown:false keeps + // process signal handlers out of vitest's fork workers (#4250). + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + stores = new Map(); + engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver(stores), true); + await kernel.use(new ObjectQLPlugin({ ql: engine })); + await kernel.bootstrap(); + protocol = kernel.getService('protocol'); + }); + afterEach(async () => { + if (kernel.getState() === 'running') await kernel.shutdown(); + }); + + /** The stored `sys_metadata` rows, as the driver holds them. */ + const storedRows = () => Array.from(stores.get('sys_metadata')?.values() ?? []); + const storedBody = (type: string, name: string, packageId: string): any => { + const row = storedRows().find((r) => r.type === type && r.name === name && r.package_id === packageId); + if (!row) return undefined; + return typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata; + }; + + async function save(packageId: string, type: string, item: { name: string }): Promise { + const res = await protocol.saveMetaItem({ type, name: item.name, packageId, item }); + expect(res.success).toBe(true); + } + + /** Seeds the source and the third package, duplicates, and returns the copy's `fields`. */ + async function duplicate(): Promise> { + await protocol.installPackage({ manifest: { id: R, name: 'Other', type: 'application' } }); + await save(R, 'object', accountBody); + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + expect(engine.registry.getPackage(P)?.manifest?.namespace).toBe('dupsrc'); + await save(P, 'object', customerBody); + await save(P, 'object', ticketBody); + await save(P, 'permission', flsBody); + + const res = await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); + + expect(res.failed).toEqual([]); + expect(res.copied).toEqual(expect.arrayContaining([ + { type: 'object', name: 'dupdst_customer' }, + { type: 'object', name: 'dupdst_ticket' }, + { type: 'permission', name: 'dupdst_fls' }, + ])); + const copy = storedBody('permission', 'dupdst_fls', Q); + expect(copy).toBeDefined(); + return copy.fields; + } + + it('a prefixed object\'s key follows its renamed object, with its rule', async () => { + const fields = await duplicate(); + + expect(fields['dupdst_customer.full_name']).toEqual(HIDDEN); + // NEGATIVE CONTROL: no key of the copy names the source's object. + expect(Object.keys(fields).filter((key) => key.startsWith('dupsrc_customer.'))).toEqual([]); + }); + + it('a bare object\'s key follows the target-namespace name the copy took', async () => { + const fields = await duplicate(); + + expect(fields['dupdst_ticket.title']).toEqual(HIDDEN); + // NEGATIVE CONTROL: no key of the copy names the source's `ticket`. + expect(Object.keys(fields).filter((key) => key.startsWith('ticket.'))).toEqual([]); + }); + + it('only the object part moves: a field part that spells the object\'s name stays', async () => { + const fields = await duplicate(); + + expect(fields['dupdst_customer.dupsrc_customer']).toEqual(LOCKED); + expect(fields['dupdst_ticket.ticket']).toEqual(OPEN); + // NEGATIVE CONTROL: the field part was not renamed with the object. + expect(fields).not.toHaveProperty(['dupdst_customer.dupdst_customer']); + expect(fields).not.toHaveProperty(['dupdst_ticket.dupdst_ticket']); + }); + + it('a key naming an object outside the package keeps its name, and the source set is untouched', async () => { + const fields = await duplicate(); + + // NEGATIVE CONTROL: an object the package does not hold is not renamed. + expect(fields['dupext_account.name']).toEqual(LOCKED); + expect(Object.keys(fields).sort()).toEqual([ + 'dupdst_customer.dupsrc_customer', + 'dupdst_customer.full_name', + 'dupdst_ticket.ticket', + 'dupdst_ticket.title', + 'dupext_account.name', + ]); + // The source set keeps every key it had, with its rule. + expect(storedBody('permission', 'dupsrc_fls', P).fields).toEqual(flsBody.fields); + }); + + it('a renamed key that meets a key the set already holds is refused, never merged', async () => { + await protocol.installPackage({ manifest: { id: P, name: 'Source', type: 'application' } }); + await save(P, 'object', customerBody); + // `dupdst_customer.full_name` names no object of the package, so it + // keeps its name, and `dupsrc_customer.full_name` follows onto it. + await save(P, 'permission', { + name: 'dupsrc_clash', label: 'Clash', + objects: { dupsrc_customer: { allowRead: true } }, + fields: { 'dupsrc_customer.full_name': HIDDEN, 'dupdst_customer.full_name': OPEN }, + } as { name: string }); + + const res = await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); + + expect(res.failed).toHaveLength(1); + const [refusal] = res.failed; + expect(refusal).toMatchObject({ type: 'permission', name: 'dupsrc_clash' }); + // Located: the set, the key that moved, the key it met, and the remedy. + expect(refusal.error).toContain("Permission set 'dupsrc_clash' is not copied"); + expect(refusal.error).toContain("'dupsrc_customer.full_name'"); + expect(refusal.error).toContain("'dupdst_customer.full_name'"); + expect(refusal.error).toContain('on the source, then duplicate again'); + // NEGATIVE CONTROL: nothing of the set was written to the target, and + // the object still copied. + expect(storedBody('permission', 'dupdst_clash', Q)).toBeUndefined(); + expect(res.copied).toEqual([{ type: 'object', name: 'dupdst_customer' }]); + }); +}); From 900af320617a4f1f2df54ab4c957abdcd8a5cc0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:35:59 +0000 Subject: [PATCH 2/3] test(objectql): pin the copied permission set's field keys on fields the object copy leaves named as stored Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- ...cate-package-permission-field-keys.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts index 30c46a900c2..e5112f69ea1 100644 --- a/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts @@ -122,15 +122,23 @@ const HIDDEN = { readable: false, editable: false }; const LOCKED = { readable: true, editable: false }; const OPEN = { readable: true, editable: true }; -/** A prefixed object with a field that spells the object's own name. */ +/** + * A prefixed object with a field that spells the renamed bare object's name. + * + * ⛔ No field here spells a PREFIXED object's name. The object copy's + * free-text rewrite renames such a field's `name` but not its `fields` key, so + * the save door's FLS lint (which reads the `name`) refuses the copied set while + * the data plane still addresses the column by its key — a separate defect, + * not this rule's. + */ const customerBody = { name: 'dupsrc_customer', label: 'Customer', sharingModel: 'private', fields: { full_name: { name: 'full_name', type: 'text', label: 'Full name' }, - dupsrc_customer: { name: 'dupsrc_customer', type: 'text', label: 'Customer code' }, + ticket: { name: 'ticket', type: 'text', label: 'Ticket reference' }, }, }; -/** An object whose name lacks the `dupsrc_` prefix, likewise. */ +/** An object whose name lacks the `dupsrc_` prefix, with a field that spells its own name. */ const ticketBody = { name: 'ticket', label: 'Ticket', sharingModel: 'private', fields: { @@ -152,7 +160,7 @@ const flsBody = { }, fields: { 'dupsrc_customer.full_name': HIDDEN, - 'dupsrc_customer.dupsrc_customer': LOCKED, + 'dupsrc_customer.ticket': LOCKED, 'ticket.title': HIDDEN, 'ticket.ticket': OPEN, 'dupext_account.name': LOCKED, @@ -235,11 +243,12 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s it('only the object part moves: a field part that spells the object\'s name stays', async () => { const fields = await duplicate(); - expect(fields['dupdst_customer.dupsrc_customer']).toEqual(LOCKED); + // The field part spells the key's own object, and another renamed object. expect(fields['dupdst_ticket.ticket']).toEqual(OPEN); + expect(fields['dupdst_customer.ticket']).toEqual(LOCKED); // NEGATIVE CONTROL: the field part was not renamed with the object. - expect(fields).not.toHaveProperty(['dupdst_customer.dupdst_customer']); expect(fields).not.toHaveProperty(['dupdst_ticket.dupdst_ticket']); + expect(fields).not.toHaveProperty(['dupdst_customer.dupdst_ticket']); }); it('a key naming an object outside the package keeps its name, and the source set is untouched', async () => { @@ -248,8 +257,8 @@ describe('ADR-0070 D4 — duplicatePackage re-points a copied permission set\'s // NEGATIVE CONTROL: an object the package does not hold is not renamed. expect(fields['dupext_account.name']).toEqual(LOCKED); expect(Object.keys(fields).sort()).toEqual([ - 'dupdst_customer.dupsrc_customer', 'dupdst_customer.full_name', + 'dupdst_customer.ticket', 'dupdst_ticket.ticket', 'dupdst_ticket.title', 'dupext_account.name', From ee862ff9d0f4e5d3f23f75f918fcb3c026efed7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:38:59 +0000 Subject: [PATCH 3/3] chore(changeset): metadata-protocol patch for the copied permission set's field keys Claude-Session: https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy Co-authored-by: Claude --- .../23527-duplicate-permission-field-keys.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/23527-duplicate-permission-field-keys.md diff --git a/.changeset/23527-duplicate-permission-field-keys.md b/.changeset/23527-duplicate-permission-field-keys.md new file mode 100644 index 00000000000..45f69458707 --- /dev/null +++ b/.changeset/23527-duplicate-permission-field-keys.md @@ -0,0 +1,15 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): `duplicatePackage` points a copied permission set's field-level rules at the copied objects + +Clause-②: no + +`duplicatePackage` (ADR-0070 D4, the package duplicate route's method) renames every copied object into the target namespace and rewrites the references to it. A permission set's field-level rules are a map keyed `.` (`PermissionSetSchema.fields`), and the security plugin applies an entry to the object its key names. + +**FROM.** A copied permission set kept the source's `fields` keys: `crm_customer.full_name` stayed `crm_customer.full_name`, and `ticket.title` (an object whose name lacks the source prefix, copied as `crm2_ticket`) stayed `ticket.title`. The copy's `objects` map already named the copied objects, so the copied set granted access to the duplicate's objects while its field rules applied to the source's objects. A field the source set hides or locks was readable and editable on the duplicate's object for whoever held the copied set. + +**TO.** In a copied set's `fields`, a key's object part takes the copied object's name: `crm_customer.full_name` → `crm2_customer.full_name`, `ticket.title` → `crm2_ticket.title`. The object part is everything before the key's first `.`, and it is rewritten only when it is exactly the name of an object the copy renamed. The field part is left as stored, even when it spells an object's name (`ticket.ticket` → `crm2_ticket.ticket`). A key naming an object outside the package keeps its name. If a rewritten key equals a key the set already holds (the source set also has a rule for an object, outside the package, named like the copy), the set is listed in `failed[]` with both keys, and nothing is written for it, because keeping either rule would drop the other. + +**Unchanged.** The source set, the copied set's `objects` map, and every other copied item are unchanged.