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
6 changes: 4 additions & 2 deletions .changeset/api-methods-derivation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ the REST data surface, the runtime HTTP/MCP dispatcher, and the
`/me/permissions` annotation. The `apiMethods` whitelist is three-state —
`undefined` = unrestricted, `[]` = deny-all, a subset = the derived closure — and
the legacy 8 verbs (`upsert/aggregate/history/search/restore/purge/import/
export`) are DERIVED from the primitives, never declared standalone (a standalone
declaration is honored one release with a registration-time deprecation warning).
export`) are DERIVED from the primitives, never declared standalone. (This
release also ships the enum shrink — see the `#3543` changeset: the authored
enum IS the six primitives, and a stored legacy value is stripped at parse
with a warning rather than honored.)

**Derivation:** `import` ⊆ create∨update (writeMode-precise: insert→create,
update→update, upsert→create∧update); `export` ⊆ list (reserved user-export slot,
Expand Down
82 changes: 82 additions & 0 deletions .changeset/apimethod-enum-shrink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": patch
"@objectstack/platform-objects": patch
---

feat(spec)!: shrink the `ApiMethod` enum to the six primitives — legacy values are stripped at parse, never honored (#3543, P2 of #3391)

**BREAKING** (ships as `minor` per the lockstep launch-window policy — every
package versions together, so a `major` here would promote the entire
monorepo; the `!` marker and this changeset are the breaking-change record):
the authored `enable.apiMethods` enum is now exactly the six
primitives (`get`, `list`, `create`, `update`, `delete`, `bulk`). The eight
legacy values (`upsert`, `aggregate`, `history`, `search`, `restore`, `purge`,
`import`, `export`) are no longer authorable — they are DERIVED effective
operations, resolved by the server's single derivation table.

**Migration (FROM → TO).** Replace each legacy value with the primitives it
derives from, then de-duplicate; if the result names all six primitives, delete
the `apiMethods` key entirely (equivalent to default-open, and it tracks future
primitives):

| FROM (legacy) | TO (primitives) | why |
| ------------- | -------------------- | ------------------------------------------ |
| `upsert` | `create`, `update` | upsert ⊆ create ∧ update |
| `import` | `create`, `update` | import ⊆ create ∨ update (writeMode-precise) |
| `export` | `list` | export ⊆ list |
| `aggregate` | `list` | aggregate ⊆ list |
| `search` | `list` | search ⊆ list ∧ `searchable` |
| `history` | `get` | history ⊆ get ∧ `trackHistory` |
| `restore` | *(delete the value)* | never derives — `enable.trash` retired (#2377) |
| `purge` | *(delete the value)* | never derives — `enable.trash` retired (#2377) |

Reporter codemod: `node scripts/codemod/apimethods-legacy-to-primitives.mjs`
(scans, reports the exact replacement per site, and flags whitelists the
mapping would WIDEN so the edit stays reviewable).

**Stored metadata keeps parsing — permanent tolerance, narrowing only.** Real
metadata does not upgrade in lockstep with the spec, so a stored legacy value
is NOT a parse error: `stripLegacyApiMethods` (new export) strips it with a
FROM→TO warning (canonicalize-and-warn). Stripping only ever NARROWS exposure —
the derivation table still grants every legacy verb that derives from the
primitives you declared. Two cliffs to know:

1. A whitelist of ONLY legacy values (e.g. `['upsert']`) strips to `[]` =
**deny-all** — the object's API closes instead of widening. The strip
warning and the objectql registration diagnostic both call this out.
2. A legacy value NOT derivable from your declared primitives (e.g.
`['get', 'export']` — export needs `list`) was honored by the P1
"explicit wins" path and is now denied. Declare the underlying primitive.

**Type split — authored vs effective vocabulary.** `ApiMethod` (authored) is
now six values; the NEW `ApiOperation` type / `ApiOperationSchema` /
`API_OPERATION_ORDER` (fourteen values, byte-stable pre-shrink wire order)
carry the EFFECTIVE vocabulary. The wire contract is unchanged: the 405
`allowed` array and `/me/permissions` `apiOperations` still serialize derived
verbs (`export`, `search`, …), and `EffectiveObjectPermissionSchema.apiOperations`
now validates against `ApiOperationSchema`. `EffectiveApiMethods.explicitLegacy`
is removed (nothing is honored verbatim anymore); `API_METHOD_ORDER` remains as
a deprecated alias of `API_OPERATION_ORDER`.

**Fail-closed tightening (#3545):** a PRESENT but non-array `apiMethods` (only
producible by a raw/out-of-band metadata write) now resolves to `deny-all`
instead of unrestricted — a policy that exists but cannot be read fails CLOSED.

**Published JSON Schema diverges deliberately:** `data/ApiMethod.json` is the
strict six-value enum (a `z.preprocess` is not representable in JSON Schema),
so external JSON-Schema validators reject legacy values that the zod parse
would strip-and-warn. Treat the JSON Schema as the authored contract; the zod
tolerance exists for stored metadata.

**objectql:** the P1 "explicit wins" transition is reclaimed —
`warnDeprecatedExplicitApiMethods` is replaced by `warnStrippedLegacyApiMethods`
(a permanent per-object diagnostic for schemas that reach the registry without
passing through Zod; the parse-time strip warning carries no object name).

**platform-objects:** whitelist audit — `sys_business_unit`,
`sys_business_unit_member` (P1's explicit `import`/`export` reclaimed) and
`sys_user_preference` dropped their `apiMethods` entirely (each named all six
primitives = default-open). Read-only and deny-all whitelists are unchanged;
the seven `[]` declarations are deliberately KEPT as defense-in-depth alongside
`apiEnabled: false`.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ enable: {
| `trackHistory` | `false` | Field history tracking for audit compliance |
| `searchable` | `true` | Index records for global search |
| `apiEnabled` | `true` | Expose object via automatic APIs |
| `apiMethods` | all | Whitelist of allowed API operations |
| `apiMethods` | all | Whitelist over the six primitives (`get/list/create/update/delete/bulk`); derived verbs (search/export/upsert/…) follow automatically. `undefined` = all, `[]` = none |
| `files` | `false` | Enable file attachments |
| `feeds` | `true` | Enable social feed and comments |
| `activities` | `true` | Enable tasks and events tracking |
Expand Down
12 changes: 5 additions & 7 deletions content/docs/data-modeling/schema-design.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,12 @@ enable: {
trackHistory: true, // Track field changes over time
searchable: true, // Include in global search
apiEnabled: true, // Expose via REST/GraphQL
apiMethods: [ // Whitelist API operations
'get',
'list',
'create',
apiMethods: [ // Whitelist API operations — six primitives only;
'get', // search/export/… DERIVE from them (list grants
'list', // aggregate/search/export; create+update grants
'create', // upsert/import). undefined = all, [] = none.
'update',
'delete',
'search',
'export'
'delete'
],
files: true, // Allow file attachments
feeds: true, // Enable activity feed (Chatter-like)
Expand Down
22 changes: 18 additions & 4 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ API Operations Enum
## TypeScript Usage

```typescript
import { ApiMethod, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
import type { ApiMethod, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
import { ApiMethod, ApiOperation, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
import type { ApiMethod, ApiOperation, Index, Lifecycle, LifecycleClass, Object, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';

// Validate data
const result = ApiMethod.parse(data);
Expand All @@ -27,6 +27,20 @@ const result = ApiMethod.parse(data);

### Allowed Values

* `get`
* `list`
* `create`
* `update`
* `delete`
* `bulk`


---

## ApiOperation

### Allowed Values

* `get`
* `list`
* `create`
Expand Down Expand Up @@ -123,7 +137,7 @@ const result = ApiMethod.parse(data);
| **stageField** | `string \| 'false'` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
| **listViews** | `Record<string, { name?: string; label?: string; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]; … }` | optional | Enabled system features modules |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
| **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be &lt;= sharingModel in openness. |
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
Expand Down Expand Up @@ -160,7 +174,7 @@ const result = ApiMethod.parse(data);
| **trackHistory** | `boolean` | ✅ | Show the record History tab (audit-trail UI). Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance |
| **searchable** | `boolean` | ✅ | Index records for global search |
| **apiEnabled** | `boolean` | ✅ | Expose object via automatic APIs |
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Whitelist of allowed API operations |
| **apiMethods** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]` | optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |
| **files** | `boolean` | ✅ | Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments targeting this object; otherwise creation is rejected. Field.file/Field.image are independent |
| **feeds** | `boolean` | ✅ | Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects new comments for this object |
| **activities** | `boolean` | ✅ | Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const result = AdminScope.parse(data);
| **modifyAllRecords** | `boolean` | ✅ | Modify All Data (Bypass Sharing) |
| **readScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Read depth: own\|unit\|unit_and_below\|org |
| **writeScope** | `Enum<'own' \| 'own_and_reports' \| 'unit' \| 'unit_and_below' \| 'org'>` | optional | [ADR-0057 D1] Write depth: own\|unit\|unit_and_below\|org |
| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. |
| **apiOperations** | `Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'upsert' \| 'bulk' \| 'aggregate' \| 'history' \| 'search' \| 'restore' \| 'purge' \| 'import' \| 'export'>[]` | optional | Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs, #3543), not the authored six-value ApiMethod enum. |


---
Expand Down
37 changes: 25 additions & 12 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnDeprecatedExplicitApiMethods, computeFQN, parseFQN } from './registry';
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnStrippedLegacyApiMethods, computeFQN, parseFQN } from './registry';

describe('SchemaRegistry', () => {
let registry: SchemaRegistry;
Expand Down Expand Up @@ -848,27 +848,40 @@ describe('reconcileManagedApiMethods', () => {
});

// ==========================================
// warnDeprecatedExplicitApiMethods — #3391
// One-shot deprecation warning for standalone LEGACY apiMethods values (the
// warnStrippedLegacyApiMethods — #3543
// One-shot per-object diagnostic for retired LEGACY apiMethods values (the
// derived 8). Pure observation — never mutates the schema.
// ==========================================
describe('warnDeprecatedExplicitApiMethods (#3391)', () => {
it('warns when a whitelist declares a derived legacy value (import/export)', () => {
describe('warnStrippedLegacyApiMethods (#3543)', () => {
it('diagnoses a whitelist declaring a retired legacy value (import/export)', () => {
const warn = vi.fn();
warnDeprecatedExplicitApiMethods(
warnStrippedLegacyApiMethods(
{ name: 'crm_deal_a', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'] } } as any,
{ warn },
);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('crm_deal_a');
expect(warn.mock.calls[0][0]).toContain('import');
expect(warn.mock.calls[0][0]).toContain('export');
expect(warn.mock.calls[0][0]).toContain('#3391');
expect(warn.mock.calls[0][0]).toContain('IGNORED');
expect(warn.mock.calls[0][0]).toContain('#3543');
// primitives remain, so no deny-all escalation
expect(warn.mock.calls[0][0]).not.toContain('deny-all');
});

it('escalates when ignoring legacy values leaves NO primitives (deny-all cliff)', () => {
const warn = vi.fn();
warnStrippedLegacyApiMethods(
{ name: 'crm_deal_denyall', enable: { apiMethods: ['upsert'] } } as any,
{ warn },
);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('deny-all');
});

it('stays silent for a pure-primitive whitelist', () => {
const warn = vi.fn();
warnDeprecatedExplicitApiMethods(
warnStrippedLegacyApiMethods(
{ name: 'crm_deal_b', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] } } as any,
{ warn },
);
Expand All @@ -877,16 +890,16 @@ describe('warnDeprecatedExplicitApiMethods (#3391)', () => {

it('stays silent for an undefined / empty whitelist', () => {
const warn = vi.fn();
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_c', enable: {} } as any, { warn });
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_d', enable: { apiMethods: [] } } as any, { warn });
warnStrippedLegacyApiMethods({ name: 'crm_deal_c', enable: {} } as any, { warn });
warnStrippedLegacyApiMethods({ name: 'crm_deal_d', enable: { apiMethods: [] } } as any, { warn });
expect(warn).not.toHaveBeenCalled();
});

it('warns only once per object name (hot path stays free)', () => {
const warn = vi.fn();
const schema: any = { name: 'crm_deal_once', enable: { apiMethods: ['list', 'aggregate'] } };
warnDeprecatedExplicitApiMethods(schema, { warn });
warnDeprecatedExplicitApiMethods(schema, { warn });
warnStrippedLegacyApiMethods(schema, { warn });
warnStrippedLegacyApiMethods(schema, { warn });
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading