diff --git a/.changeset/26723-save-door-stores-action-parse.md b/.changeset/26723-save-door-stores-action-parse.md new file mode 100644 index 00000000000..2e51b4090cc --- /dev/null +++ b/.changeset/26723-save-door-stores-action-parse.md @@ -0,0 +1,20 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): the metadata save door stores an action as its `ActionSchema` parse, so a declared `requiresFeature` / `requiresMembershipReach` is lowered into `visible` + +Clause-②: no — the save door refuses nothing it accepted before. It already judged the body by the same parse; what changes is only what it stores for an action that passed. + +Metadata-protocol: none + +`PUT /api/v1/meta/object/NAME` (draft and active) and a standalone `action` save judged an action by `ActionSchema`, then stored the request body as written. `ActionSchema`'s parse lowers `requiresMembershipReach` and `requiresFeature` into `visible`, and the console reads `visible` alone, so the stored action kept both gate keys raw, had no `visible`, and its button was offered to every user. + +Now the door stores the parse it already ran: + +- an object's own `actions` array is stored as the parsed array; every other key of the object body stays as written; +- a standalone `action` item is stored as the parsed action. + +A stored action therefore carries the lowered `visible` (and its schema defaults, and a CEL shorthand wrapped in `{ dialect, source }`), the same shape a code package's action has been stored in since #25981. The parse is idempotent over its own output, so a GET then PUT round trip stores the same bytes. + +Rows already saved before this fix are not rewritten. Saving the object (or action) again stores it lowered. diff --git a/packages/metadata-protocol/src/protocol-stored-action-parse.ts b/packages/metadata-protocol/src/protocol-stored-action-parse.ts new file mode 100644 index 00000000000..7cce0bb1f81 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-stored-action-parse.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26723] The actions a metadata save stores, as their `ActionSchema` parse. + * + * The save door judges a body by its type's spec schema and then keeps the + * request body as written, with a few normalizations grafted back + * (`graftFoldedFormSections`, `graftNormalizedOperators`). For an action that + * dropped the one transform a reader depends on: `ActionSchema` lowers + * `requiresMembershipReach` and `requiresFeature` into `visible`, the console + * reads `visible` alone, and the stored body carried the two gate keys raw with + * no `visible`. The declared gate did nothing: the button was offered to every + * caller. + * + * So an action is stored as the parse the door already ran, the same parse the + * code-package registration door stores since #25981 (`parsePackageAction` in + * `@objectstack/objectql`'s registry). ⛔ No second copy of the lowering: the + * values here are the parse's own output, read off the `parsed` tree, never + * re-derived. The parse is idempotent over its own output, so a GET → PUT + * round trip of a stored body stores the same bytes. + * + * - `object`: its own `actions` array is replaced by the parsed array + * (`ObjectSchema`'s `actions` is `ActionSchema[]`); every other key of the + * body stays as written. + * - `action`: the body is the parsed action. + * - Any other type, or an object with no `actions` array, is returned as is. + */ +export function withParsedStoredActions(singularType: string, authored: unknown, parsed: unknown): unknown { + if (singularType === 'action') return parsed; + if (singularType !== 'object') return authored; + if (!authored || typeof authored !== 'object' || Array.isArray(authored)) return authored; + const actions = (parsed as { actions?: unknown } | null | undefined)?.actions; + if (!Array.isArray((authored as { actions?: unknown }).actions) || !Array.isArray(actions)) return authored; + return { ...(authored as Record), actions }; +} diff --git a/packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts b/packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts new file mode 100644 index 00000000000..7ad738fb125 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26723] An action the metadata save door stores is stored as its + * `ActionSchema` parse, so a declared `requiresFeature` / + * `requiresMembershipReach` is lowered into `visible` — on an object's own + * `actions` and on a standalone `action` item, in draft and in active saves. + * + * The door already judged the body by that parse (`ObjectSchema`'s `actions` + * is `ActionSchema[]`), then stored the request body as written: the gate keys + * rode along raw and no `visible` was stored, so the console — which reads + * `visible` alone — offered the button to every caller. The code-package + * registration door has stored the parse since #25981; this is the save-door + * sibling of that fix. + */ +import { describe, expect, it } from 'vitest'; +import { ObjectSchema } from '@objectstack/spec/data'; +import { ActionSchema } from '@objectstack/spec/ui'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** The engine surface the repository write path touches (as the external-binding harness). */ +function makeProtocol() { + const rows: Record[] = []; + let nextId = 0; + const engine: any = { + async findOne(object: string, query?: EngineFindOneQueryInput) { + assertEngineFindOnePredicate(object, query); return null; }, + async find() { return []; }, + async insert(table: string, data: Record) { + nextId += 1; + if (table === 'sys_metadata') rows.push(data); + return { id: `r_${nextId}` }; + }, + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + registry: { registerItem: () => {}, registerObject: () => {}, getItem: () => undefined }, + }; + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); + return { protocol, rows }; +} + +/** The body of the one `sys_metadata` row a save wrote, as stored. */ +function storedBody(rows: Record[], type: string, name: string): any { + const hits = rows.filter((r) => r.type === type && r.name === name); + expect(hits).toHaveLength(1); + const metadata = hits[0].metadata; + return typeof metadata === 'string' ? JSON.parse(metadata) : metadata; +} + +const SSO_ONLY = { + name: 'sso_only', label: 'SSO only', type: 'url' as const, target: 'https://example.com/sso', + locations: ['record_header' as const], requiresFeature: 'ssoEnforced' as const, +}; +const OWNER_ONLY = { + name: 'owner_only', label: 'Owner only', type: 'url' as const, target: 'https://example.com/owner', + locations: ['record_header' as const], visible: "record.name != ''", + requiresMembershipReach: 'delete_organization' as const, +}; +const PLAIN = { + name: 'plain', label: 'Plain', type: 'url' as const, target: 'https://example.com/plain', + locations: ['record_header' as const], +}; + +const order = (actions: unknown[]) => ({ + name: 'gap_order', + label: 'Order', + sharingModel: 'private', + fields: { name: { name: 'name', label: 'Name', type: 'text' } }, + actions, +}); + +/** What the spec's own parse makes of the same declaration. */ +const parsedAction = (action: unknown) => ActionSchema.parse(action) as any; + +describe('#26723 the save door stores an action as its ActionSchema parse', () => { + it.each(['draft', 'publish'] as const)( + 'an object saved in %s mode stores its gated actions lowered into `visible`', + async (mode) => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ + type: 'object', name: 'gap_order', mode, + item: order([structuredClone(SSO_ONLY), structuredClone(OWNER_ONLY)]), + }); + expect(result.success).toBe(true); + + const stored = storedBody(rows, 'object', 'gap_order'); + const [sso, owner] = stored.actions; + expect(sso).not.toHaveProperty('requiresFeature'); + // `ssoEnforced` is an opt-in flag, so its gate reads `== true`. + expect(sso.visible).toEqual({ dialect: 'cel', source: 'features.ssoEnforced == true' }); + expect(owner).not.toHaveProperty('requiresMembershipReach'); + expect(owner.visible).toEqual(parsedAction(OWNER_ONLY).visible); + // The same actions `ObjectSchema.create` gives the same declaration. + const authored: any = ObjectSchema.create(order([SSO_ONLY, OWNER_ONLY]) as any); + expect(stored.actions).toEqual(authored.actions); + }, + ); + + it('control: an action without the gate keys is stored as its parse, and the rest of the body as written', async () => { + const { protocol, rows } = makeProtocol(); + const item = order([structuredClone(PLAIN)]); + await protocol.saveMetaItem({ type: 'object', name: 'gap_order', item }); + + const stored = storedBody(rows, 'object', 'gap_order'); + expect(stored.actions).toEqual([parsedAction(PLAIN)]); + expect(stored.actions[0]).not.toHaveProperty('visible'); + expect(stored.fields).toEqual(item.fields); + }); + + it('re-saving a stored body is a no-op: the parse is idempotent over its own output', async () => { + const first = makeProtocol(); + await first.protocol.saveMetaItem({ type: 'object', name: 'gap_order', item: order([structuredClone(SSO_ONLY)]) }); + const once = storedBody(first.rows, 'object', 'gap_order'); + + const second = makeProtocol(); + await second.protocol.saveMetaItem({ type: 'object', name: 'gap_order', item: structuredClone(once) }); + expect(storedBody(second.rows, 'object', 'gap_order').actions).toEqual(once.actions); + }); + + it('an object with no `actions` key is stored without one', async () => { + const { protocol, rows } = makeProtocol(); + const { actions: _none, ...bare } = order([]); + await protocol.saveMetaItem({ type: 'object', name: 'gap_order', item: bare }); + expect(storedBody(rows, 'object', 'gap_order')).not.toHaveProperty('actions'); + }); + + it.each(['draft', 'publish'] as const)( + 'a standalone action saved in %s mode is stored lowered', + async (mode) => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ + type: 'action', name: 'sso_only', mode, + item: { ...structuredClone(SSO_ONLY), objectName: 'gap_order' }, + }); + expect(result.success).toBe(true); + + const stored = storedBody(rows, 'action', 'sso_only'); + expect(stored).not.toHaveProperty('requiresFeature'); + expect(stored.visible).toEqual({ dialect: 'cel', source: 'features.ssoEnforced == true' }); + expect(stored).toEqual(parsedAction({ ...SSO_ONLY, objectName: 'gap_order' })); + }, + ); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 4ccf8acedb4..92f1deab22f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -414,6 +414,7 @@ import { reportSealedOverlayRows } from './protocol-sealed-overlay-report.js'; import { expandUnderOwnName, resolveRowlessExpandedView } from './protocol-view-expansion-names.js'; // [#25467 cut 23] The read-side conversion of a stored item: the ADR-0087 chain with its notices, and the warned-once read with the page `type` default. import { convertStoredItem, convertStoredItemDetailed } from './protocol-stored-item-conversion.js'; +import { withParsedStoredActions } from './protocol-stored-action-parse.js'; // [#25467 cut 24] The live metadata-type listing: the SchemaRegistry's types and the `metadata` service's, de-duplicated. import { listLiveMetadataTypes } from './protocol-live-metadata-types.js'; // [#25467 cut 24] The save door's 409 for a `ConflictError`, with its decision-audit row. @@ -20946,6 +20947,10 @@ export class ObjectStackProtocolImplementation implements graftFoldedFormSections(request.item, parsed.data), parsed.data, ); + // [#26723] …and an action is stored as its parse, so its + // `requiresFeature` / `requiresMembershipReach` is lowered + // into `visible`. See {@link withParsedStoredActions}. + request.item = withParsedStoredActions(singularType, request.item, parsed.data); } // [#20101] …and a page's declared `type` default, the one // default that a reader of the served body selects on. It is diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d13a5b82bc4..5ce0c505d3b 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1691,6 +1691,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.stored-conversions.test.ts", "verb": "delete",