Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/26723-save-door-stores-action-parse.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions packages/metadata-protocol/src/protocol-stored-action-parse.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>), actions };
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] = [];
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<string, unknown>) {
nextId += 1;
if (table === 'sys_metadata') rows.push(data);
return { id: `r_${nextId}` };
},
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
assertEngineUpdateDispatch(data, opts);
return { id: null };
},
async delete(_t: string, opts?: Record<string, unknown>) {
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<string, unknown>[], 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' }));
},
);
});
5 changes: 5 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading