diff --git a/.changeset/api-methods-derivation-contract.md b/.changeset/api-methods-derivation-contract.md index 5234c32bde..3f0ed9ca09 100644 --- a/.changeset/api-methods-derivation-contract.md +++ b/.changeset/api-methods-derivation-contract.md @@ -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, diff --git a/.changeset/apimethod-enum-shrink.md b/.changeset/apimethod-enum-shrink.md new file mode 100644 index 0000000000..847e351045 --- /dev/null +++ b/.changeset/apimethod-enum-shrink.md @@ -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`. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index c52c53c3a3..a9f967645c 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -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 | diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index de9fe2d69d..fe6ba9e3ad 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -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) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index dc93561ab4..4d4ca73fb7 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -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); @@ -27,6 +27,20 @@ const result = ApiMethod.parse(data); ### Allowed Values +* `get` +* `list` +* `create` +* `update` +* `delete` +* `bulk` + + +--- + +## ApiOperation + +### Allowed Values + * `get` * `list` * `create` @@ -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; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>` | 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 <= 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) | @@ -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 | diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index ce29b62973..7ea130d284 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -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. | --- diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 17bef33436..b61956eae4 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -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; @@ -848,14 +848,14 @@ 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 }, ); @@ -863,12 +863,25 @@ describe('warnDeprecatedExplicitApiMethods (#3391)', () => { 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 }, ); @@ -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); }); }); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 05f4401d1e..df6436a90f 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -397,8 +397,10 @@ export function applySystemFields( * `@objectstack/spec/data` `API_METHOD_DERIVATION` / `resolveEffectiveApiMethods` * (#3391). The identical-shaped `WRITE_OP_AFFORDANCE` in plugin-security * `system-write-guard.ts` is the runtime enforcement of this same UI-intent - * axis. Unifying the three is deferred to the enum-shrink (P2 of #3391); until - * then keep the axes separate — see ADR-0103. + * axis. The enum shrink (#3543) DELIBERATELY kept the three tables separate — + * merging would blur a UX-affordance concern into a security concern (ADR-0103). + * The `upsert`/`purge` keys survive the shrink: raw (un-parsed) whitelists may + * still carry legacy verbs, and stripping here must keep covering them. */ const MANAGED_WRITE_VERB_AFFORDANCE: Record = { create: 'create', @@ -473,21 +475,27 @@ export function reconcileManagedApiMethods( }; } -/** Objects already warned about explicit legacy `apiMethods` (once per object). */ -const warnedDeprecatedApiMethods = new Set(); +/** Objects already warned about stripped legacy `apiMethods` (once per object). */ +const warnedLegacyApiMethods = new Set(); /** - * Registration-time deprecation warning for standalone LEGACY `apiMethods` - * values (#3391). The 8 legacy verbs (`upsert`/`aggregate`/`history`/`search`/ - * `restore`/`purge`/`import`/`export`) are DERIVED from the six primitives; an - * object that declares one explicitly is honored verbatim for one release - * ("explicit wins"), then the value is removed in the enum-shrink (P2 of #3391). + * Registration-time diagnostic for retired LEGACY `apiMethods` values (#3543, + * P2 of #3391). Since the enum shrink, the 8 legacy verbs (`upsert`/ + * `aggregate`/`history`/`search`/`restore`/`purge`/`import`/`export`) are no + * longer authorable: a declared legacy value is IGNORED — the effective API + * surface derives from the six primitives alone. The Zod parse path already + * strips-and-warns (`stripLegacyApiMethods` in `@objectstack/spec/data`), but + * carries no object name; this registration-time diagnostic adds the per-object + * view for schemas that reach the registry without passing through Zod (raw + * `ServiceObject` literals, out-of-band metadata). Real metadata does not + * upgrade in lockstep with the spec, so this is a PERMANENT compatibility + * diagnostic, not a transition. * - * Emitted once per object (not per request — the hot path stays free), mirroring - * the {@link reconcileManagedApiMethods} warning format and pointing at P2. Pure - * observation: it never mutates the schema. + * Emitted once per object (not per request — the hot path stays free). Pure + * observation: it never mutates the schema (the derivation resolver ignores + * legacy values on its own). */ -export function warnDeprecatedExplicitApiMethods( +export function warnStrippedLegacyApiMethods( schema: ServiceObject, opts?: { warn?: (msg: string) => void }, ): void { @@ -496,17 +504,22 @@ export function warnDeprecatedExplicitApiMethods( const legacy = methods.filter((m: string) => (LEGACY_API_METHODS as readonly string[]).includes(m)); if (legacy.length === 0) return; const name = String((schema as any).name ?? ''); - if (warnedDeprecatedApiMethods.has(name)) return; - warnedDeprecatedApiMethods.add(name); + if (warnedLegacyApiMethods.has(name)) return; + warnedLegacyApiMethods.add(name); + const remaining = methods.filter((m: string) => !(LEGACY_API_METHODS as readonly string[]).includes(m)); const warn = opts?.warn ?? ((msg: string) => console.warn(msg)); warn( - `[Registry] Object "${name}" declares derived legacy apiMethods value(s) ` + - `[${legacy.join(', ')}] in enable.apiMethods — these derive from the six ` + - `primitives (get/list/create/update/delete/bulk) and are honored verbatim ` + - `for now, but standalone declaration is deprecated and will be removed in ` + - `the enum-shrink (P2 of #3391). Declare the underlying primitives instead ` + - `(e.g. ['create','update'] grants upsert/import; ['list'] grants ` + - `aggregate/export/search).`, + `[Registry] Object "${name}" declares retired legacy apiMethods value(s) ` + + `[${legacy.join(', ')}] in enable.apiMethods — since the enum shrink ` + + `(#3543) these are IGNORED: the effective API surface derives from the ` + + `six primitives (get/list/create/update/delete/bulk) alone ` + + `(['create','update'] ⇒ upsert/import; ['list'] ⇒ aggregate/search/export; ` + + `['get'] + trackHistory ⇒ history).` + + (remaining.length === 0 + ? ` ⚠ Ignoring them leaves NO primitives — this object's API resolves ` + + `to deny-all (fully closed).` + : '') + + ` Remove the value(s); codemod: node scripts/codemod/apimethods-legacy-to-primitives.mjs.`, ); } @@ -761,10 +774,11 @@ export class SchemaRegistry { // every non-`better-auth` object. schema = reconcileManagedApiMethods(schema); - // [#3391] One-shot deprecation warning for standalone LEGACY apiMethods - // values (the derived 8) — honored this release, removed in the enum-shrink. - // Runs after reconcile so we warn about what actually ships. - warnDeprecatedExplicitApiMethods(schema); + // [#3543] One-shot per-object diagnostic for retired LEGACY apiMethods + // values (the derived 8) — ignored by the derivation resolver; this adds + // the object-name context the parse-time strip warning lacks. Runs after + // reconcile so we diagnose what actually ships. + warnStrippedLegacyApiMethods(schema); // [ADR-0079] Object-materialization seam — DESIGNATE-ONLY primary-title // provisioning. Runs AFTER `applySystemFields` (so any designated field diff --git a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts index 4bdb19f0d5..abae0c3cc8 100644 --- a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -101,16 +101,10 @@ export const SysBusinessUnitMember = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - // import/export complete the HRIS org-tree sync scenario: the units - // (sys_business_unit, #3025/#3392) and their memberships are imported - // together as one bulk operation. Import reuses the already-granted - // create/update affordances; export is a bulk read. Transitional — #3391 - // P2 derives these from create/update|list and reclaims the explicit - // entries. Reconcile-safe: import/export are not generic write verbs, so - // reconcileManagedApiMethods (managedBy:'platform') never strips them. - // `bulk` grants the createMany/updateMany/deleteMany + batch surfaces (bulk - // ∧ child after #3391) — memberships are imported as one bulk operation. - // `bulk` is a primitive — a permanent, legitimate declaration, not P2 debt. - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'], + // No `apiMethods` — default-open (#3543 audit). Memberships ride the same + // HRIS org-tree sync as sys_business_unit (#3025/#3392) and need every + // primitive; a whitelist naming all six is equivalent to no whitelist while + // NOT tracking future primitives. The P1 explicit `import`/`export` entries + // were reclaimed — both derive (import ⊆ create∨update, export ⊆ list). }, }); diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 43c2fd9ea9..8359592564 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -216,12 +216,10 @@ export const SysBusinessUnit = ObjectSchema.create({ trackHistory: true, searchable: true, apiEnabled: true, - // Data portability is expected for the org tree: fields like `external_ref` - // and `effective_from/to` are designed for HRIS batch sync (#3025). Import - // reuses the create/update affordances this object already grants. - // `bulk` grants the createMany/updateMany/deleteMany + batch surfaces, which - // after #3391 require the `bulk` primitive (bulk ∧ child). `bulk` is a - // primitive — a permanent, legitimate declaration, not P0/P2 debt. - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk', 'import', 'export'], + // No `apiMethods` — default-open (#3543 audit). The HRIS org-tree sync + // scenario (#3025) needs every primitive, and a whitelist naming all six is + // equivalent to no whitelist while NOT tracking future primitives; the P1 + // explicit `import`/`export` entries were reclaimed (both verbs derive: + // import ⊆ create∨update, export ⊆ list). }, }); diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index 70bfbe3413..302558b36f 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -115,9 +115,8 @@ export const SysUserPreference = ObjectSchema.create({ trackHistory: false, searchable: false, apiEnabled: true, - // `bulk` grants the createMany/updateMany/deleteMany + batch surfaces, which - // after #3391 require the `bulk` primitive (bulk ∧ child). `bulk` is a - // primitive — a permanent, legitimate declaration. - apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], + // No `apiMethods` — default-open (#3543 audit): the previous whitelist + // named all six primitives, which is equivalent to no whitelist while NOT + // tracking future primitives. }, }); diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index b2246d971e..712f6feb0a 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -38,6 +38,7 @@ import { import { SysSecret, SysSetting } from './system/index.js'; import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from './apps/index.js'; import { AppSchema } from '@objectstack/spec/ui'; +import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; const systemObjects = [ ['SysUser', SysUser, 'sys_user'], @@ -232,41 +233,31 @@ describe('@objectstack/platform-objects', () => { }); }); - describe('data portability whitelist (#3025)', () => { - it('sys_business_unit allows import/export so the org tree can be batch-synced', () => { - // The Business Units list exposes Import/Export buttons and the object's - // schema (external_ref, effective_from/to) is designed for HRIS batch - // sync. The REST data plane gates these routes on `enable.apiMethods` - // (ADR-0049), so both verbs must be present or the buttons 405 with - // OBJECT_API_METHOD_NOT_ALLOWED. Regression guard for #3025. - expect(SysBusinessUnit.enable?.apiMethods).toContain('import'); - expect(SysBusinessUnit.enable?.apiMethods).toContain('export'); - // The five CRUD verbs it already granted must remain — import writes - // reuse the create/update affordances. - for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) { - expect(SysBusinessUnit.enable?.apiMethods).toContain(verb); - } + describe('data portability — derived, not declared (#3025 / #3543)', () => { + it('sys_business_unit is default-open and the derivation grants import/export', () => { + // The Business Units list exposes Import/Export buttons for HRIS batch + // sync (#3025). Since the enum shrink (#3543) neither verb is declarable: + // the object carries NO whitelist (default-open) and the REST gate + // derives import (⊆ create∨update) and export (⊆ list) from the + // effective set. Regression guard: the whitelist must stay ABSENT — a + // whitelist naming all six primitives is equivalent but stops tracking + // future primitives, and any narrower whitelist would 405 the buttons. + expect(SysBusinessUnit.enable?.apiMethods).toBeUndefined(); + const eff = resolveEffectiveApiMethods(SysBusinessUnit.enable); + expect(eff.mode).toBe('unrestricted'); + expect(isApiOperationAllowed(eff, 'import')).toBe(true); + expect(isApiOperationAllowed(eff, 'export')).toBe(true); + expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: 'create' })).toBe(true); }); - it('sys_business_unit_member allows import/export and keeps CRUD (#3391 P0)', () => { + it('sys_business_unit_member is default-open and derives import/export (#3391 P0 pairing)', () => { // HRIS org-tree sync imports TWO tables together — the units (above) AND - // their memberships. The sibling membership table carries the same kind - // of restrictive whitelist, so it needs import/export too or the - // membership import path 405s (OBJECT_API_METHOD_NOT_ALLOWED). #3391's P0 - // checklist pairs both tables; this is the half #3392 did not cover. - const methods = SysBusinessUnitMember.enable?.apiMethods ?? []; - expect(methods).toContain('import'); - expect(methods).toContain('export'); - // CRUD must remain — import writes reuse create/update; the membership - // grid depends on the rest. - for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) { - expect(methods).toContain(verb); - } - // Transitional: #3391 P2 derives import/export (import ⊆ create/update, - // export ⊆ list) and reclaims both objects' explicit entries together. - // Reconcile-safe: import/export are not generic write verbs, so - // reconcileManagedApiMethods (managedBy:'platform') never strips them — - // this static whitelist IS what apiAccessDenialFromEnable enforces. + // their memberships. Both were reclaimed together in the #3543 audit. + expect(SysBusinessUnitMember.enable?.apiMethods).toBeUndefined(); + const eff = resolveEffectiveApiMethods(SysBusinessUnitMember.enable); + expect(eff.mode).toBe('unrestricted'); + expect(isApiOperationAllowed(eff, 'import')).toBe(true); + expect(isApiOperationAllowed(eff, 'export')).toBe(true); }); }); diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index 9ca02791f7..27ff164e9b 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -138,8 +138,9 @@ export const SysSecret = ObjectSchema.create({ trackHistory: false, // rotation events are recorded by sys_setting_audit // [ADR-0103] Engine-owned: secrets are minted/rotated only by the settings / // secret service (SYSTEM_CTX), never via the generic data API. Locked to - // reads (ciphertext only; decryption is a separate privileged path) — an - // empty apiMethods array would fail OPEN, so this list is explicit. + // reads (ciphertext only; decryption is a separate privileged path). Since + // #3391 an empty array fails CLOSED (deny-all) — this list stays explicit + // because reads ARE offered, not as protection against fail-open. apiMethods: ['get', 'list'], }, }); diff --git a/packages/rest/src/rest-api-derivation-gates.test.ts b/packages/rest/src/rest-api-derivation-gates.test.ts index 342f575ef1..9f5eead269 100644 --- a/packages/rest/src/rest-api-derivation-gates.test.ts +++ b/packages/rest/src/rest-api-derivation-gates.test.ts @@ -5,7 +5,7 @@ // `isApiOperationAllowed`). This suite pins the gate DECISION // (`apiAccessDenialFromEnable`) against the derivation contract: reverse-derived // import/export, writeMode-precise import, deny-all, bulk∧child, and the -// explicit-legacy compatibility path. Route-level plumbing (createMany/batch, +// legacy-strip semantics (#3543). Route-level plumbing (createMany/batch, // import two-stage, export projection) is covered by rest.test.ts, // rest-batch-endpoint.test.ts, and export-integration.test.ts. @@ -90,12 +90,16 @@ describe('REST gate — import writeMode precision (#3391)', () => { }); }); -describe('REST gate — explicit legacy wins (deprecated, #3391)', () => { - it("explicit ['import'] admits import in every writeMode", () => { +describe('REST gate — legacy values ignored, strip semantics (#3543)', () => { + it("a whitelist of ONLY ['import'] resolves to deny-all (legacy is not authorable)", () => { const e = { apiMethods: ['import'] }; - expect(allowed(e, 'import')).toBe(true); - expect(allowed(e, 'import', { writeMode: 'insert' })).toBe(true); - expect(allowed(e, 'import', { writeMode: 'update' })).toBe(true); + expect(allowed(e, 'import')).toBe(false); + expect(allowed(e, 'import', { writeMode: 'insert' })).toBe(false); + expect(allowed(e, 'import', { writeMode: 'update' })).toBe(false); + // the gate reports the closed surface, not the raw whitelist + const d = denial(e, 'import'); + expect(d?.status).toBe(405); + expect(d?.body.allowed).toEqual([]); }); }); diff --git a/packages/runtime/src/api-exposure.ts b/packages/runtime/src/api-exposure.ts index e5829296a8..ba04af7c8e 100644 --- a/packages/runtime/src/api-exposure.ts +++ b/packages/runtime/src/api-exposure.ts @@ -44,11 +44,10 @@ * read metadata (e.g. the REST `loadObjectItems`) LOG a thrown read so a * PERSISTENT outage is observable instead of a silent blanket-allow. * • Object resolvable but its `enable`/`apiMethods` is present-yet-unreadable - * (a non-array policy) → `resolveEffectiveApiMethods` currently treats it as - * `unrestricted` (silent widen). This path is unreachable through the - * Zod-validated registration flow (only a raw/out-of-band metadata write - * could produce it), so tightening it to fail-CLOSED is deferred to the - * exposure-semantics window (#3543) rather than changed here unilaterally. + * (a non-array policy) → `resolveEffectiveApiMethods` resolves it to + * `deny-all` (fails CLOSED) since the #3543 exposure-semantics window. The + * path is unreachable through the Zod-validated registration flow (only a + * raw/out-of-band metadata write can produce it). */ import { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 41d5eb04fb..170c8ccb5e 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -165,6 +165,7 @@ "ALL_OPERATORS (const)", "API_METHOD_DERIVATION (const)", "API_METHOD_ORDER (const)", + "API_OPERATION_ORDER (const)", "API_PRIMITIVES (const)", "Address (type)", "AddressSchema (const)", @@ -181,6 +182,8 @@ "AnalyticsQuerySchema (const)", "ApiMethod (type)", "ApiMethodsMode (type)", + "ApiOperation (type)", + "ApiOperationSchema (const)", "ApiPrimitive (type)", "AutonumberToken (type)", "BOOLEAN_VALUE_TYPES (const)", @@ -374,6 +377,7 @@ "JoinStrategy (const)", "JoinType (const)", "LEGACY_API_METHODS (const)", + "LEGACY_API_METHOD_GUIDANCE (const)", "LIFECYCLE_DURATION_REGEX (const)", "LOGICAL_OPERATORS (const)", "LegacyApiMethod (type)", @@ -578,6 +582,7 @@ "resolveEffectiveApiMethods (function)", "resolveRecordDisplayName (function)", "sequenceWidth (function)", + "stripLegacyApiMethods (function)", "suggestFieldType (function)", "valueSchemaFor (function)" ], diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index f23530a31b..f65e96d92e 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -667,6 +667,7 @@ "data/AggregationStage", "data/AnalyticsQuery", "data/ApiMethod", + "data/ApiOperation", "data/BaseEngineOptions", "data/CalendarDateValue", "data/ClockTimeValue", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index d4ebf053d4..6c550bd4cc 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -154,7 +154,7 @@ "apiMethods": { "status": "live", "evidence": "packages/spec/src/data/api-derivation.ts, packages/rest/src/rest-server.ts, packages/runtime/src/api-exposure.ts", - "note": "three-state whitelist over the six primitives (get/list/create/update/delete/bulk): undefined = unrestricted, [] = deny-all (fully closed), subset = the derived closure. resolveEffectiveApiMethods (the single derivation table) is consumed by the REST gate (405 outside the effective set) and the runtime dispatcher gate; the legacy 8 values (upsert/aggregate/history/search/restore/purge/import/export) are DERIVED, not declared standalone (#3391)." + "note": "three-state whitelist over the six primitives (get/list/create/update/delete/bulk): undefined = unrestricted, [] = deny-all (fully closed), subset = the derived closure; a present-but-non-array value fails CLOSED (#3545). resolveEffectiveApiMethods (the single derivation table) is consumed by the REST gate (405 outside the effective set) and the runtime dispatcher gate. Since #3543 the enum IS the six primitives; the legacy 8 values (upsert/aggregate/history/search/restore/purge/import/export) are DERIVED effective operations (ApiOperation vocabulary) — a stored legacy value is stripped at parse (stripLegacyApiMethods, canonicalize-and-warn, permanent tolerance)." }, "trackHistory": { "status": "live", diff --git a/packages/spec/src/data/api-derivation.test.ts b/packages/spec/src/data/api-derivation.test.ts index 40b0c6550e..0662242c4b 100644 --- a/packages/spec/src/data/api-derivation.test.ts +++ b/packages/spec/src/data/api-derivation.test.ts @@ -7,8 +7,8 @@ import { effectiveOperationsArray, DATA_ACTION_TO_API_OPERATION, API_PRIMITIVES, - LEGACY_API_METHODS, } from './api-derivation'; +import { ApiMethod, API_OPERATION_ORDER, LEGACY_API_METHODS } from './object.zod'; describe('api-derivation (#3391)', () => { describe('three-state mode', () => { @@ -130,17 +130,43 @@ describe('api-derivation (#3391)', () => { }); }); - describe('explicit legacy wins (deprecated compatibility)', () => { - it('honors a standalone explicit import even without create/update', () => { + describe('legacy values are ignored — strip semantics (#3543)', () => { + it('a whitelist of ONLY legacy values resolves to deny-all', () => { const eff = resolveEffectiveApiMethods({ apiMethods: ['import'] }); - expect(isApiOperationAllowed(eff, 'import')).toBe(true); - // even precise writeMode checks pass when explicitly declared - expect(isApiOperationAllowed(eff, 'import', { writeMode: 'update' })).toBe(true); + expect(eff.mode).toBe('deny-all'); + expect(isApiOperationAllowed(eff, 'import')).toBe(false); + expect(isApiOperationAllowed(eff, 'import', { writeMode: 'update' })).toBe(false); + expect(effectiveOperationsArray(eff)).toEqual([]); }); - it('honors a standalone explicit export even without list', () => { - const eff = resolveEffectiveApiMethods({ apiMethods: ['export'] }); - expect(isApiOperationAllowed(eff, 'export')).toBe(true); + it('legacy values mixed into a primitive whitelist change nothing (already derived)', () => { + const withLegacy = resolveEffectiveApiMethods({ apiMethods: ['list', 'export'] }); + const primitivesOnly = resolveEffectiveApiMethods({ apiMethods: ['list'] }); + expect(withLegacy.mode).toBe('restricted'); + expect(effectiveOperationsArray(withLegacy)).toEqual(effectiveOperationsArray(primitivesOnly)); + expect(isApiOperationAllowed(withLegacy, 'export')).toBe(true); // derived from list + }); + + it('a legacy value NOT derivable from the declared primitives stays denied', () => { + // pre-#3543 "explicit wins" honored this; now the derivation table is the + // only adjudicator: export needs list, and get does not grant it. + const eff = resolveEffectiveApiMethods({ apiMethods: ['get', 'export'] }); + expect(isApiOperationAllowed(eff, 'export')).toBe(false); + expect(isApiOperationAllowed(eff, 'get')).toBe(true); + }); + }); + + describe('present-but-unreadable policy fails CLOSED (#3545)', () => { + it('a non-array apiMethods resolves to deny-all, not unrestricted', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: 'get,list' as unknown as string[] }); + expect(eff.mode).toBe('deny-all'); + expect(isApiOperationAllowed(eff, 'get')).toBe(false); + expect(effectiveOperationsArray(eff)).toEqual([]); + }); + + it('null stays unrestricted (absent policy, not unreadable policy)', () => { + const eff = resolveEffectiveApiMethods({ apiMethods: null }); + expect(eff.mode).toBe('unrestricted'); }); }); @@ -212,8 +238,25 @@ describe('api-derivation (#3391)', () => { }); }); - it('legacy list and primitive list are disjoint and cover the enum', () => { + it('legacy list and primitive list are disjoint', () => { const overlap = LEGACY_API_METHODS.filter((m) => (API_PRIMITIVES as readonly string[]).includes(m)); expect(overlap).toEqual([]); }); + + describe('vocabulary split (#3543)', () => { + it('the authored enum is exactly the six primitives', () => { + expect(ApiMethod.options).toEqual([...API_PRIMITIVES]); + }); + + it('the effective vocabulary is primitives ∪ legacy in the stable wire order', () => { + expect([...API_OPERATION_ORDER].sort()).toEqual( + [...API_PRIMITIVES, ...LEGACY_API_METHODS].sort(), + ); + // wire order is the pre-#3543 enum declaration order, byte-stable + expect(API_OPERATION_ORDER).toEqual([ + 'get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', + 'aggregate', 'history', 'search', 'restore', 'purge', 'import', 'export', + ]); + }); + }); }); diff --git a/packages/spec/src/data/api-derivation.ts b/packages/spec/src/data/api-derivation.ts index d7912b8d9a..d392b96cff 100644 --- a/packages/spec/src/data/api-derivation.ts +++ b/packages/spec/src/data/api-derivation.ts @@ -3,7 +3,7 @@ /** * API-method derivation — the spec's ONE source of truth for turning an * object's `enable.apiMethods` whitelist into the effective set of operations - * the automatic API exposes (issue #3391, design in #3026). + * the automatic API exposes (issue #3391, design in #3026; enum shrink #3543). * * ## The contract (one sentence) * @@ -15,6 +15,18 @@ * only the effective result the server hands down; it never reads the raw * `apiMethods` nor derives anything itself. * + * ## Two vocabularies — authored vs effective (#3543) + * + * Since the enum shrink, the AUTHORED vocabulary ({@link ApiMethod}) is the six + * primitives only. The EFFECTIVE vocabulary ({@link ApiOperation}) is wider: it + * still carries the eight derived verbs, because the wire contract — the 405 + * `allowed` array, `/me/permissions` `apiOperations`, and the REST/dispatcher + * gate checks — speaks in operations (`export`, `search`, …), not in authored + * primitives. Authors write primitives; the server derives and serializes + * operations. Do not narrow {@link ApiOperation} to {@link ApiMethod}: the + * frontend gates affordances (e.g. the Export button) on derived verbs being + * present in the effective set. + * * ## Two orthogonal axes * * This module is the **API-tightening axis** (verb → *primitive*): what the @@ -33,35 +45,45 @@ * | `[]` | `deny-all` | no operation is exposed (fully closed) | * | `[..subset]` | `restricted` | only the derived closure of the subset | * - * ## Derived verbs are never declared standalone + * A PRESENT but non-array `apiMethods` (only producible by a raw/out-of-band + * metadata write — the Zod path rejects it) resolves to `deny-all`: a policy + * that exists but cannot be read fails CLOSED (#3545 residual-risk decision, + * tightened in the #3543 exposure-semantics window). + * + * ## Derived verbs are never declared * * The legacy 8 values (`upsert/aggregate/history/search/restore/purge/import/ * export`) are DERIVED from the primitives — an author who whitelists * `['create','update']` gets `upsert`/`import` for free; one who whitelists - * `['list']` gets `aggregate`/`export`/`search` for free. A standalone legacy - * value in a whitelist is honored verbatim for one release ("explicit wins", - * with a registration-time deprecation warning) but is slated for removal in - * the enum-shrink (P2 of #3391). + * `['list']` gets `aggregate`/`export`/`search` for free. Since #3543 they are + * no longer part of the authored enum: a stored legacy value is stripped at + * parse (`stripLegacyApiMethods` in `object.zod.ts`, canonicalize-and-warn) + * and IGNORED by this resolver on un-parsed inputs — both paths converge on + * the same effective set. */ -import { ApiMethod } from './object.zod'; +import { + ApiMethod, + API_OPERATION_ORDER, + LEGACY_API_METHODS, + type ApiOperation, + type LegacyApiMethod, +} from './object.zod'; /** * The six irreducible API primitives. Every effective operation is derived * from a subset of these — they are the only values an author needs to declare. + * Identical to the {@link ApiMethod} enum since the #3543 shrink. */ export const API_PRIMITIVES = ['get', 'list', 'create', 'update', 'delete', 'bulk'] as const; export type ApiPrimitive = (typeof API_PRIMITIVES)[number]; /** - * The eight legacy values that are DERIVED from primitives. Declaring one - * standalone is deprecated (still honored one release; see - * `warnDeprecatedExplicitApiMethods` in objectql `registry.ts`). + * @deprecated Renamed {@link API_OPERATION_ORDER} in the #3543 type split — + * the order is a property of the effective-operation vocabulary, not of the + * (now six-value) authored enum. Alias kept for import continuity. */ -export const LEGACY_API_METHODS = [ - 'upsert', 'aggregate', 'history', 'search', 'restore', 'purge', 'import', 'export', -] as const; -export type LegacyApiMethod = (typeof LEGACY_API_METHODS)[number]; +export const API_METHOD_ORDER: readonly ApiOperation[] = API_OPERATION_ORDER; const PRIMITIVE_SET: ReadonlySet = new Set(API_PRIMITIVES); const LEGACY_SET: ReadonlySet = new Set(LEGACY_API_METHODS); @@ -124,7 +146,7 @@ export const API_METHOD_DERIVATION: Record = { /** * Alias table normalizing the two runtime vocabularies onto the canonical - * {@link ApiMethod} operation names: + * {@link ApiOperation} names: * - runtime `callData` actions (`query`/`find`→`list`, `batch`→`bulk`); * - REST operation literals (already canonical, listed for completeness). * @@ -132,7 +154,7 @@ export const API_METHOD_DERIVATION: Record = { * the resolver, treated as ungated (custom actions were never gated by * `apiMethods`). */ -export const DATA_ACTION_TO_API_OPERATION: Record = { +export const DATA_ACTION_TO_API_OPERATION: Record = { // runtime callData actions get: 'get', query: 'list', @@ -143,7 +165,7 @@ export const DATA_ACTION_TO_API_OPERATION: Record = { delete: 'delete', batch: 'bulk', bulk: 'bulk', - // legacy / derived operation literals (identity) + // derived operation literals (identity) upsert: 'upsert', aggregate: 'aggregate', history: 'history', @@ -167,20 +189,17 @@ export interface ResolveApiOptions { export type ApiMethodsMode = 'unrestricted' | 'restricted' | 'deny-all'; /** - * The resolved, effective view of an object's API exposure. Carries both the - * granted primitives and the explicitly-declared legacy values so callers can - * distinguish "derived" from "author asked for it" (needed by import `writeMode` - * precision and bulk∧child), and a pre-computed `operations` closure for - * serialization to the 405 body / `/me/permissions`. + * The resolved, effective view of an object's API exposure. Carries the + * granted primitives and a pre-computed `operations` closure (primitives ∪ + * derived verbs) for gate checks and serialization to the 405 body / + * `/me/permissions`. */ export interface EffectiveApiMethods { mode: ApiMethodsMode; /** Granted primitives (subset of {@link API_PRIMITIVES}). */ primitives: ReadonlySet; - /** Legacy values the author declared explicitly (honored verbatim, deprecated). */ - explicitLegacy: ReadonlySet; - /** Full effective operation closure (primitives ∪ derived ∪ explicit legacy). */ - operations: ReadonlySet; + /** Full effective operation closure (primitives ∪ derived verbs). */ + operations: ReadonlySet; /** The user-level export slot captured at resolve time. */ userExportAllowed: boolean; } @@ -204,16 +223,13 @@ function isLegacyDerivable( function computeOperations( mode: ApiMethodsMode, primitives: ReadonlySet, - explicitLegacy: ReadonlySet, enable: EnableLike, userExportAllowed: boolean, -): Set { - const ops = new Set(); +): Set { + const ops = new Set(); if (mode === 'deny-all') return ops; for (const p of primitives) ops.add(p); - for (const l of explicitLegacy) ops.add(l); // explicit wins for (const legacy of LEGACY_API_METHODS) { - if (ops.has(legacy)) continue; if (isLegacyDerivable(legacy, primitives, enable, userExportAllowed)) ops.add(legacy); } return ops; @@ -221,8 +237,13 @@ function computeOperations( /** * Resolve an object's `enable` block into its effective API exposure. Pure and - * silent (the deprecation warning for explicit legacy values fires only at - * registration time — see objectql `registry.ts`). + * silent (the strip warning for stored legacy values fires at parse time — see + * `stripLegacyApiMethods` in `object.zod.ts` — and the per-object diagnostic + * at registration time in objectql `registry.ts`). + * + * Legacy/unknown strings in a raw (un-parsed) whitelist are IGNORED — the same + * strip semantics the Zod path applies — so a whitelist containing ONLY legacy + * values resolves to `deny-all` on either path. * * @param enable The object's `enable` capability block (or `undefined`). * @param opts Resolve-time options (e.g. the user-level export slot). @@ -235,37 +256,36 @@ export function resolveEffectiveApiMethods( const e: EnableLike = enable ?? {}; const raw = e.apiMethods; - // undefined / non-array → unrestricted (default-open, every operation). - if (raw == null || !Array.isArray(raw)) { + // undefined/null → unrestricted (default-open, every operation). + if (raw == null) { const primitives = new Set(API_PRIMITIVES); - const explicitLegacy = new Set(); - const operations = computeOperations('unrestricted', primitives, explicitLegacy, e, userExportAllowed); - return { mode: 'unrestricted', primitives, explicitLegacy, operations, userExportAllowed }; + const operations = computeOperations('unrestricted', primitives, e, userExportAllowed); + return { mode: 'unrestricted', primitives, operations, userExportAllowed }; } - // [] → deny-all (fully closed). This is the flipped semantics of #3391: an - // empty whitelist means "expose nothing", not "no restriction". - if (raw.length === 0) { + // Present but not an array → fails CLOSED (#3545 tightening, see module + // docs). Arrays keep only the granted primitives; legacy/unknown strings + // are ignored (strip semantics). + const declared = Array.isArray(raw) + ? new Set(raw.map((m) => String(m)).filter(isApiPrimitive)) + : new Set(); + + // No granted primitives → deny-all (fully closed) — whether authored `[]`, + // empty after legacy values were stripped/ignored, or a non-array policy. + // This is the flipped semantics of #3391: an empty whitelist means "expose + // nothing", not "no restriction". + if (declared.size === 0) { return { mode: 'deny-all', primitives: new Set(), - explicitLegacy: new Set(), - operations: new Set(), + operations: new Set(), userExportAllowed, }; } - // subset → restricted. Partition the declared values into granted primitives - // and explicitly-declared legacy values. - const primitives = new Set(); - const explicitLegacy = new Set(); - for (const m of raw) { - const v = String(m); - if (isApiPrimitive(v)) primitives.add(v); - else if (isLegacyApiMethod(v)) explicitLegacy.add(v); - } - const operations = computeOperations('restricted', primitives, explicitLegacy, e, userExportAllowed); - return { mode: 'restricted', primitives, explicitLegacy, operations, userExportAllowed }; + // subset → restricted: the derived closure of the granted primitives. + const operations = computeOperations('restricted', declared, e, userExportAllowed); + return { mode: 'restricted', primitives: declared, operations, userExportAllowed }; } /** Extra context for a single operation check. */ @@ -287,8 +307,8 @@ export interface OperationCheckOptions { * Decide whether a single operation is allowed for a resolved effective state. * * @param eff The resolved effective methods (from {@link resolveEffectiveApiMethods}). - * @param operation A canonical {@link ApiMethod} name (normalize runtime action - * names through {@link DATA_ACTION_TO_API_OPERATION} first). + * @param operation A canonical {@link ApiOperation} name (normalize runtime + * action names through {@link DATA_ACTION_TO_API_OPERATION} first). * @param opts `writeMode` (import precision) / `bulkChild` (bulk∧child). */ export function isApiOperationAllowed( @@ -311,8 +331,6 @@ export function isApiOperationAllowed( } if (isLegacyApiMethod(operation)) { - // Explicitly-declared legacy value → honored verbatim (deprecated). - if (eff.explicitLegacy.has(operation)) return true; // import: refine the coarse any-of into a writeMode-precise judgement. // (Unrestricted grants every primitive, so precision is moot → allowed.) if (operation === 'import' && opts?.writeMode && !unrestricted) { @@ -330,22 +348,16 @@ export function isApiOperationAllowed( } // Unknown/custom operation → not gated by apiMethods (matches prior behavior: - // actions with no ApiMethod mapping still respect `apiEnabled` only). + // actions with no ApiOperation mapping still respect `apiEnabled` only). return true; } /** - * The canonical serialization order for effective operations — the declaration - * order of the {@link ApiMethod} enum. Used for the deterministic 405 `allowed` - * array and the `/me/permissions` `apiOperations` field. - */ -export const API_METHOD_ORDER: readonly ApiMethod[] = ApiMethod.options; - -/** - * Serialize an effective state's operation closure into a stable, enum-ordered - * array — the single "effective set" the server hands down (405 body, - * `/me/permissions`). The frontend consumes this, never the raw whitelist. + * Serialize an effective state's operation closure into a stable array in + * {@link API_OPERATION_ORDER} — the single "effective set" the server hands + * down (405 body, `/me/permissions`). The frontend consumes this, never the + * raw whitelist. */ -export function effectiveOperationsArray(eff: EffectiveApiMethods): ApiMethod[] { - return API_METHOD_ORDER.filter((m) => eff.operations.has(m)); +export function effectiveOperationsArray(eff: EffectiveApiMethods): ApiOperation[] { + return API_OPERATION_ORDER.filter((m) => eff.operations.has(m)); } diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 641ffe05f2..6d977f5de6 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1521,3 +1521,65 @@ describe('ObjectSchema.create() password-field author warning (ADR-0100)', () => expect(msg).toContain('ackPlaintextMasking'); }); }); + +// --------------------------------------------------------------------------- +// #3543 — ApiMethod enum shrink: stripLegacyApiMethods parse-time compat layer +// Legacy values in stored metadata are stripped (canonicalize-and-warn), never +// a hard parse failure — real metadata does not upgrade in lockstep with the +// spec, so this tolerance is permanent. Unknown values (typos) still fail. +// NOTE: the strip warning dedups per distinct legacy combination for the +// process lifetime, so each test below uses a distinct combination. +// --------------------------------------------------------------------------- +describe('#3543 apiMethods legacy-value strip (ObjectCapabilities)', () => { + afterEach(() => vi.restoreAllMocks()); + + it('strips a legacy value and keeps the declared primitives', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = ObjectCapabilities.parse({ apiMethods: ['get', 'list', 'export'] }); + expect(result.apiMethods).toEqual(['get', 'list']); + const msg = warn.mock.calls.map((c) => c[0]).join('\n'); + expect(msg).toContain('export'); + expect(msg).toContain('#3543'); + expect(msg).toContain("declare ['list']"); // FROM → TO prescription + }); + + it('warns LOUDLY when stripping empties the whitelist (deny-all cliff)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = ObjectCapabilities.parse({ apiMethods: ['upsert'] }); + expect(result.apiMethods).toEqual([]); + const msg = warn.mock.calls.map((c) => c[0]).join('\n'); + expect(msg).toContain('DENY-ALL'); + expect(msg).toContain("declare ['create','update']"); + }); + + it('warns once per distinct legacy combination (parse is hot)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + ObjectCapabilities.parse({ apiMethods: ['list', 'aggregate'] }); + ObjectCapabilities.parse({ apiMethods: ['list', 'aggregate'] }); + const hits = warn.mock.calls.filter((c) => String(c[0]).includes('aggregate')); + expect(hits.length).toBe(1); + }); + + it('still hard-rejects unknown (non-legacy) values — typos stay loud', () => { + const result = ObjectCapabilities.safeParse({ apiMethods: ['get', 'lst'] }); + expect(result.success).toBe(false); + }); + + it('restore/purge strip carries the retired-trash guidance', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = ObjectCapabilities.parse({ apiMethods: ['delete', 'restore', 'purge'] }); + expect(result.apiMethods).toEqual(['delete']); + const msg = warn.mock.calls.map((c) => c[0]).join('\n'); + expect(msg).toContain('#2377'); + }); + + it('the authored enum itself no longer admits legacy values', () => { + const result = ObjectCapabilities.safeParse({ apiMethods: ['get'] }); + expect(result.success).toBe(true); + // sanity: primitives round-trip untouched, no warning + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const clean = ObjectCapabilities.parse({ apiMethods: ['get', 'list', 'bulk'] }); + expect(clean.apiMethods).toEqual(['get', 'list', 'bulk']); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index e1654af952..b162b937e2 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -14,18 +14,114 @@ import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { ProtectionSchema } from '../shared/protection.zod'; export const ApiMethod = z.enum([ - 'get', 'list', // Read + 'get', 'list', // Read 'create', 'update', 'delete', // Write - 'upsert', // Idempotent Write - 'bulk', // Batch operations - 'aggregate', // Analytics (count, sum) - 'history', // Audit access - 'search', // Search access - 'restore', 'purge', // Trash management - 'import', 'export', // Data portability + 'bulk', // Batch operations ]); export type ApiMethod = z.infer; +/** + * The eight RETIRED legacy `apiMethods` values (#3543, P2 of #3391). Each is + * DERIVED from the six primitives by the spec's single derivation table + * (`API_METHOD_DERIVATION` in `api-derivation.ts`) — an author never declares + * them. A stored/authored legacy value is stripped at parse by + * {@link stripLegacyApiMethods} (canonicalize-and-warn, never a hard parse + * failure): real metadata does not upgrade in lockstep with the spec, so this + * tolerance is a PERMANENT compatibility layer, not a one-release transition. + */ +export const LEGACY_API_METHODS = [ + 'upsert', 'aggregate', 'history', 'search', 'restore', 'purge', 'import', 'export', +] as const; +export type LegacyApiMethod = (typeof LEGACY_API_METHODS)[number]; + +/** + * Tombstones for the retired legacy values — same doctrine as + * `CAPABILITIES_RETIRED_KEY_GUIDANCE` below: the strip warning must carry the + * FROM → TO prescription, because it is the one channel a consumer whose + * metadata still declares a legacy value is guaranteed to hit. + */ +export const LEGACY_API_METHOD_GUIDANCE: Record = { + upsert: "declare ['create','update'] — `upsert` derives from create ∧ update", + aggregate: "declare ['list'] — `aggregate` derives from list", + history: "declare ['get'] with `enable.trackHistory: true` — `history` derives from get ∧ trackHistory", + search: "declare ['list'] (with `searchable` not false) — `search` derives from list ∧ searchable", + restore: "delete the value — `restore` never derives (`enable.trash` retired, #2377); it returns only with a real recycle bin (#1893)", + purge: "delete the value — `purge` never derives (`enable.trash` retired, #2377)", + import: "declare ['create'] and/or ['update'] — `import` derives from create ∨ update (writeMode-precise at the gate)", + export: "declare ['list'] — `export` derives from list", +}; + +/** + * The canonical serialization order of the EFFECTIVE operation vocabulary — + * the declaration order of the pre-#3543 fourteen-value enum, preserved + * verbatim so the wire contract (405 `allowed` array, `/me/permissions` + * `apiOperations`) is byte-stable across the shrink. + */ +export const API_OPERATION_ORDER = [ + 'get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', + 'aggregate', 'history', 'search', 'restore', 'purge', 'import', 'export', +] as const; + +/** + * An effective API operation — the vocabulary of gates and wire serialization: + * the six authored primitives plus the eight derived verbs. Authors declare + * {@link ApiMethod}; servers derive and speak THIS (see `api-derivation.ts`). + */ +export type ApiOperation = (typeof API_OPERATION_ORDER)[number]; + +/** + * Zod schema for {@link ApiOperation} — response-side surfaces that carry an + * effective operation set (e.g. `EffectiveObjectPermissionSchema.apiOperations`) + * validate against THIS, never against the authored {@link ApiMethod} enum. + */ +export const ApiOperationSchema = z.enum(API_OPERATION_ORDER); + +const LEGACY_API_METHOD_SET: ReadonlySet = new Set(LEGACY_API_METHODS); + +/** Distinct legacy combinations already warned about (bounded; parse is hot). */ +const warnedLegacyApiMethods = new Set(); + +/** + * Strip retired legacy values from an `apiMethods` whitelist before enum + * validation (#3543). Non-arrays pass through untouched (the schema reports + * them). Emits a single warning per distinct legacy combination — parse runs + * on hot paths and carries no object-name context; the registration-time + * diagnostic in objectql `registry.ts` adds the per-object view. + * + * The one behavioral cliff is called out loudly: a whitelist that becomes + * EMPTY after stripping is `[]` = deny-all under the three-state contract, + * so a pure-legacy whitelist (e.g. `['upsert']`) now closes the object's API + * entirely instead of widening it. + */ +export function stripLegacyApiMethods( + raw: unknown, + opts?: { warn?: (msg: string) => void }, +): unknown { + if (!Array.isArray(raw)) return raw; + const legacy = [...new Set(raw.filter( + (v): v is LegacyApiMethod => typeof v === 'string' && LEGACY_API_METHOD_SET.has(v), + ))]; + if (legacy.length === 0) return raw; + const kept = raw.filter((v) => !(typeof v === 'string' && LEGACY_API_METHOD_SET.has(v))); + const key = `${[...legacy].sort().join(',')}${kept.length === 0 ? '|deny-all' : ''}`; + if (!warnedLegacyApiMethods.has(key)) { + warnedLegacyApiMethods.add(key); + const warn = opts?.warn ?? ((msg: string) => console.warn(msg)); + warn( + `[spec] enable.apiMethods declares retired legacy value(s) [${legacy.join(', ')}] — ` + + `the ApiMethod enum is the six primitives get/list/create/update/delete/bulk (#3543). ` + + `Legacy values are stripped at parse; their semantics are DERIVED from the primitives:\n` + + legacy.map((v) => ` • \`${v}\`: ${LEGACY_API_METHOD_GUIDANCE[v]}`).join('\n') + + (kept.length === 0 + ? `\n ⚠ After stripping, this whitelist is EMPTY — \`[]\` means DENY-ALL (fully closed ` + + `API). Declare the underlying primitives if the object should stay reachable.` + : '') + + `\nCodemod: node scripts/codemod/apimethods-legacy-to-primitives.mjs`, + ); + } + return kept; +} + /** * Tombstones for RETIRED capability flags — same doctrine as the tenancy * block and the top-level `UNKNOWN_KEY_GUIDANCE` map below: a retired @@ -115,9 +211,17 @@ export const ObjectCapabilities = z.object({ /** * API Supported Operations - * Granular control over API exposure. + * Granular control over API exposure — a whitelist over the SIX PRIMITIVES + * (#3391/#3543): `undefined` = unrestricted, `[]` = deny-all, a subset = the + * derived closure (see `api-derivation.ts`). Retired legacy values are + * stripped at parse by {@link stripLegacyApiMethods} (permanent tolerance + * for stored metadata); the cast below keeps the AUTHORING type at the six + * primitives so TS authors get the compile-time migration signal instead of + * the `unknown` input a raw `z.preprocess` would infer. */ - apiMethods: z.array(ApiMethod).optional().describe('Whitelist of allowed API operations'), + apiMethods: (z.preprocess((raw) => stripLegacyApiMethods(raw), z.array(ApiMethod)) + .optional() as unknown as z.ZodOptional>) + .describe('Whitelist of allowed API operations (six primitives; undefined = all, [] = none)'), /** * Generic Attachments panel (Salesforce "Notes & Attachments" parity) — diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index ac17ddac0c..dcb31760c3 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { RowLevelSecurityPolicySchema } from './rls.zod'; -import { ApiMethod } from '../data/object.zod'; +import { ApiOperationSchema } from '../data/object.zod'; /** * Entity (Object) Level Permissions @@ -123,8 +123,8 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({ */ export const EffectiveObjectPermissionSchema = lazySchema(() => (ObjectPermissionSchema as unknown as z.ZodObject).extend({ - apiOperations: z.array(ApiMethod).optional().describe( - '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: z.array(ApiOperationSchema).optional().describe( + '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.', ), }), ); diff --git a/scripts/codemod/apimethods-legacy-to-primitives.mjs b/scripts/codemod/apimethods-legacy-to-primitives.mjs new file mode 100644 index 0000000000..3815add594 --- /dev/null +++ b/scripts/codemod/apimethods-legacy-to-primitives.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Codemod / migration reporter: legacy `apiMethods` values → six primitives + * ======================================================================== + * + * Part of #3543 (P2 of #3391): the `ApiMethod` enum shrank to the six + * primitives (`get/list/create/update/delete/bulk`). The eight legacy values + * (`upsert/aggregate/history/search/restore/purge/import/export`) are DERIVED + * from the primitives by the server's single derivation table and can no + * longer be authored: + * + * enable: { apiMethods: ['get', 'list', 'export'] } // legacy + * enable: { apiMethods: ['get', 'list'] } // → export derives from list + * + * At runtime a stored legacy value is STRIPPED at parse (canonicalize-and-warn, + * never a hard failure) — stripping only ever NARROWS exposure. Where the strip + * would lose intent (a legacy value not derivable from the primitives you + * declared), the fix is to declare the underlying primitives — which WIDENS the + * authored whitelist. That widening must be an explicit, reviewable edit, which + * is exactly what this tool reports. + * + * WHY THIS IS A REPORTER, NOT AN AUTO-REWRITER + * -------------------------------------------- + * Same doctrine as `view-to-viewitem.mjs`: a regex/AST rewrite of arbitrary + * user code (spread arrays, computed values, imported fragments) is unsafe for + * marginal value — and a tool that silently WIDENS an API-exposure whitelist is + * a security hazard. The runtime already tolerates legacy values (strip + + * warn), so migration is not time-critical. This tool SCANS for `apiMethods` + * whitelists containing legacy values and REPORTS, per site, the exact + * replacement whitelist, so a human applies and reviews the edit. + * + * Replacement rules (per legacy value, closure-preserving): + * upsert → create, update (upsert ⊆ create ∧ update) + * import → create, update (import ⊆ create ∨ update; both cover every writeMode) + * export → list (export ⊆ list) + * aggregate → list (aggregate ⊆ list) + * search → list (search ⊆ list ∧ searchable) + * history → get (history ⊆ get ∧ trackHistory) + * restore → (drop) (never derives — `enable.trash` retired, #2377) + * purge → (drop) (never derives — `enable.trash` retired, #2377) + * + * If the replacement whitelist ends up naming all six primitives, the report + * suggests deleting the `apiMethods` key entirely (equivalent to default-open, + * and it keeps tracking future primitives). + * + * Usage: + * node scripts/codemod/apimethods-legacy-to-primitives.mjs [dir ...] # human report + * node scripts/codemod/apimethods-legacy-to-primitives.mjs --json [dir …] # machine output + * + * Default scan roots: examples/ and packages/ . Scans *.ts, *.js, *.json, + * *.yml, *.yaml. Zero third-party deps. + */ + +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { dirname, join, resolve, relative, extname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); + +const argv = process.argv.slice(2); +const jsonMode = argv.includes('--json'); +const roots = argv.filter((a) => !a.startsWith('--')); +const scanRoots = (roots.length ? roots : ['examples', 'packages']).map((r) => + resolve(repoRoot, r), +); + +const PRIMITIVES = ['get', 'list', 'create', 'update', 'delete', 'bulk']; +const LEGACY_TO_PRIMITIVES = { + upsert: ['create', 'update'], + import: ['create', 'update'], + export: ['list'], + aggregate: ['list'], + search: ['list'], + history: ['get'], + restore: [], + purge: [], +}; +const SCAN_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.json', '.yml', '.yaml']); + +/** Recursively collect scannable files, skipping node_modules/dist/.cache. */ +function collectFiles(dir, out = []) { + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist' || entry === '.cache' || entry.startsWith('.git')) continue; + const p = join(dir, entry); + const st = statSync(p); + if (st.isDirectory()) collectFiles(p, out); + else if (SCAN_EXTS.has(extname(entry))) out.push(p); + } + return out; +} + +/** + * Find `apiMethods` whitelists in a source text. Matches the common authored + * shapes — TS/JS (`apiMethods: [ … ]`), JSON (`"apiMethods": [ … ]`), and + * YAML flow arrays (`apiMethods: [ … ]`). Block-style YAML lists and computed + * arrays are not matched; the runtime strip still covers them. + */ +function findWhitelists(text) { + const results = []; + const re = /(["']?)apiMethods\1\s*:\s*\[([^\]]*)\]/g; + let m; + while ((m = re.exec(text)) !== null) { + const values = [...m[2].matchAll(/["']([a-zA-Z_]+)["']/g)].map((v) => v[1]); + const legacy = values.filter((v) => v in LEGACY_TO_PRIMITIVES); + if (legacy.length === 0) continue; + const line = text.slice(0, m.index).split('\n').length; + const replacement = []; + for (const v of values) { + for (const r of v in LEGACY_TO_PRIMITIVES ? LEGACY_TO_PRIMITIVES[v] : [v]) { + if (!replacement.includes(r)) replacement.push(r); + } + } + const ordered = PRIMITIVES.filter((p) => replacement.includes(p)); + results.push({ + line, + current: values, + legacy, + replacement: ordered, + widens: ordered.some((p) => !values.includes(p)), + suggestDelete: ordered.length === PRIMITIVES.length, + becomesDenyAll: ordered.length === 0, + }); + } + return results; +} + +const findings = []; +for (const root of scanRoots) { + for (const file of collectFiles(root)) { + let text; + try { + text = readFileSync(file, 'utf8'); + } catch { + continue; + } + if (!text.includes('apiMethods')) continue; + for (const hit of findWhitelists(text)) { + findings.push({ file: relative(repoRoot, file), ...hit }); + } + } +} + +if (jsonMode) { + console.log(JSON.stringify(findings, null, 2)); + process.exit(findings.length ? 1 : 0); +} + +if (findings.length === 0) { + console.log('✅ No legacy apiMethods values found — nothing to migrate.'); + process.exit(0); +} + +console.log(`Found ${findings.length} apiMethods whitelist(s) with legacy value(s) (#3543):\n`); +for (const f of findings) { + console.log(` ${f.file}:${f.line}`); + console.log(` current: [${f.current.join(', ')}] (legacy: ${f.legacy.join(', ')})`); + if (f.becomesDenyAll) { + console.log( + ' ⚠ replacement is EMPTY — [] means DENY-ALL (fully closed API). If the object', + ); + console.log( + ' should stay reachable, declare the primitives it needs; if it is engine-owned,', + ); + console.log(' [] (with apiEnabled: false) is likely what you want. Review required.'); + } else if (f.suggestDelete) { + console.log( + ` suggest: delete the apiMethods key — [${f.replacement.join(', ')}] names all six`, + ); + console.log(' primitives, which is default-open and tracks future primitives.'); + } else { + console.log(` replace: [${f.replacement.join(', ')}]`); + } + if (f.widens) { + console.log( + ' ⚠ WIDENS the authored whitelist (a legacy verb needed primitives you had not', + ); + console.log(' declared). Review that the added primitives are intended to be exposed.'); + } + console.log(''); +} +console.log( + 'Legacy verbs are DERIVED at runtime (upsert ⊆ create∧update; import ⊆ create∨update;\n' + + 'export/aggregate/search ⊆ list; history ⊆ get∧trackHistory; restore/purge never — #2377).\n' + + 'Stored metadata with legacy values keeps parsing (stripped + warned), so migrate at your\n' + + 'own pace — but the strip only NARROWS; apply the replacements above to preserve intent.', +); +process.exit(1); diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index 76f7122e87..45ab2312bb 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -62,8 +62,13 @@ Data CRUD lives under the `/data` prefix. There is no `/bulk` route and no aggregation goes through `POST /api/v1/data/{object}/query` with `groupBy`/`aggregations` in the body. -> **Key rule:** If your object defines `apiMethods`, only those operations are -> exposed. For example, `apiMethods: ['get', 'list']` creates a read-only API. +> **Key rule:** If your object defines `apiMethods`, only those operations (and +> what derives from them) are exposed. For example, `apiMethods: ['get', 'list']` +> creates a read-only API. The authorable values are the SIX PRIMITIVES +> (`get/list/create/update/delete/bulk`, #3543); everything else (`export`, +> `search`, `upsert`, …) is DERIVED from them by the server — `['list']` grants +> aggregate/search/export for free, `['create','update']` grants upsert/import. +> An empty array `[]` means deny-all (fully closed). ### Metadata API (`/meta`) @@ -167,9 +172,12 @@ only the scoped routes are registered; with `optional`/`auto` the bare ## API Methods (Operations) -The full set of operations an object can expose (the `ApiMethod` enum, 14 -values). Not every enum value has its own generated route — some only gate -access: +The authorable `ApiMethod` enum is the SIX PRIMITIVES (#3543). The wider +EFFECTIVE operation vocabulary (`ApiOperation`, 14 values) is what gates and +responses speak — the eight extra verbs are DERIVED from the primitives, never +declared in `apiMethods`: + +**Authorable primitives:** | Method | HTTP surface today | Purpose | |:-------|:-------------------|:--------| @@ -178,15 +186,20 @@ access: | `create` | `POST /data/{object}` | Create a new record | | `update` | `PATCH /data/{object}/:id` | Update an existing record | | `delete` | `DELETE /data/{object}/:id` | Delete a record | -| `upsert` | Enum value gating access — no dedicated generated route in `@objectstack/rest` today | Create or update by external ID | -| `bulk` | `POST /data/{object}/batch` | Batch create/update/delete | -| `aggregate` | No dedicated route — use `POST /data/{object}/query` with `groupBy`/`aggregations` | Count, sum, avg, min, max | -| `history` | Enum value gating access — no dedicated generated route today | Audit trail access | -| `search` | Global `GET /api/v1/search` (cross-object), not per-object | Full-text search | -| `restore` | Enum value gating access — no dedicated generated route today | Restore a soft-deleted record (reserved — platform deletes are hard today) | -| `purge` | Enum value gating access — no dedicated generated route today | Permanent deletion | -| `import` | `POST /data/{object}/import` | Bulk data import | -| `export` | `GET /data/{object}/export` | Data export | +| `bulk` | `POST /data/{object}/batch` | Batch create/update/delete (bulk ∧ child op) | + +**Derived operations (granted automatically, never authored):** + +| Operation | Derives from | HTTP surface today | Purpose | +|:----------|:-------------|:-------------------|:--------| +| `upsert` | `create` ∧ `update` | No dedicated generated route in `@objectstack/rest` today | Create or update by external ID | +| `aggregate` | `list` | No dedicated route — use `POST /data/{object}/query` with `groupBy`/`aggregations` | Count, sum, avg, min, max | +| `history` | `get` ∧ `trackHistory` | Gating only — no dedicated generated route today | Audit trail access | +| `search` | `list` ∧ `searchable` | Global `GET /api/v1/search` (cross-object), not per-object | Full-text search | +| `restore` | never (trash retired, #2377) | Gating only | Restore a soft-deleted record (reserved — platform deletes are hard today) | +| `purge` | never (trash retired, #2377) | Gating only | Permanent deletion | +| `import` | `create` ∨ `update` (writeMode-precise) | `POST /data/{object}/import` | Bulk data import | +| `export` | `list` | `GET /data/{object}/export` | Data export | --- diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index bafed90b18..fb7165221c 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -92,7 +92,7 @@ Toggle system behaviours per object: | `trackHistory` | `false` | Field-level audit trail | | `searchable` | `true` | Index records for global search | | `apiEnabled` | `true` | Expose via automatic REST / GraphQL APIs | -| `apiMethods` | all | Whitelist specific operations (`get`, `list`, `create`, …) | +| `apiMethods` | all | Whitelist over the six primitives (`get`, `list`, `create`, `update`, `delete`, `bulk`); derived verbs (search/export/upsert/…) follow automatically | | `files` | `false` | Attachments & document management | | `feeds` | `true` | Social feed, comments, mentions — **opt-out**: explicit `false` hides the feed UI and rejects new comments | | `activities` | `true` | Activity timeline (`sys_activity` mirror of CRUD) — **opt-out**: explicit `false` stops mirroring and hides the timeline |