From a55b8f4bb1cc805194c07d001b869304f07e568a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:57:15 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): the save door stores an action as its ActionSchema parse, so requiresFeature / requiresMembershipReach are lowered into visible An object's own actions and a standalone action item were judged by ActionSchema and then stored as written: the gate keys rode along raw and no visible was stored, so the console offered the button to everyone. The door now stores the parse it already ran (withParsedStoredActions), the shape the code-package registration door stores since #25981. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kBygwShyUWpFansVP --- .../26723-save-door-stores-action-parse.md | 20 +++ .../src/protocol-stored-action-parse.ts | 35 ++++ ...otocol.stored-action-gate-lowering.test.ts | 150 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 5 + 4 files changed, 210 insertions(+) create mode 100644 .changeset/26723-save-door-stores-action-parse.md create mode 100644 packages/metadata-protocol/src/protocol-stored-action-parse.ts create mode 100644 packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts 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 From 91803f6ff4b06680284c5fa8b4bc88c5e27fb507 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:15:36 +0000 Subject: [PATCH 2/2] test(metadata-protocol): record the new save-door test's engine double in the pinned ledger Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5kBygwShyUWpFansVP --- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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",