Skip to content

Commit d3ae192

Browse files
committed
feat(spec,runtime): single-source API-method derivation table (#3391 PR-1)
Introduce the spec's one source of truth for turning an object's `enable.apiMethods` whitelist into its effective operation set, and make the runtime dispatcher gate consume it. - spec: new `@objectstack/spec/data` `api-derivation` module — `resolveEffectiveApiMethods` / `isApiOperationAllowed` / `effectiveOperationsArray` / `API_METHOD_DERIVATION` / `DATA_ACTION_TO_API_OPERATION`. Three-state semantics (undefined = unrestricted, [] = deny-all, subset = derived closure); the legacy 8 verbs derive from the six primitives (get/list/create/update/delete/bulk). restore/purge never derive (enable.trash retired, #2377). liveness note updated. - runtime: rewrite `api-exposure.ts` to consume the shared table. Fixes the silent dead dispatcher/MCP gate — `getObject()` returns the flags nested under `.enable`, which the flat-only reader ignored (nested-first, flat- compatible). Flips `[]` from fail-open to deny-all. - objectql: registration-time deprecation warning for standalone legacy apiMethods values (`warnDeprecatedExplicitApiMethods`); cross-reference comments distinguishing the verb→affordance (UI-intent) axis from the verb→primitive (API-tightening) axis. - plugin-security: cross-reference comment on the parallel WRITE_OP_AFFORDANCE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH
1 parent 69f1dfd commit d3ae192

9 files changed

Lines changed: 814 additions & 49 deletions

File tree

packages/objectql/src/registry.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
2-
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, computeFQN, parseFQN } from './registry';
2+
import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnDeprecatedExplicitApiMethods, computeFQN, parseFQN } from './registry';
33

44
describe('SchemaRegistry', () => {
55
let registry: SchemaRegistry;
@@ -846,3 +846,47 @@ describe('reconcileManagedApiMethods', () => {
846846
expect(stored.enable.apiMethods).toEqual(['get', 'list']);
847847
});
848848
});
849+
850+
// ==========================================
851+
// warnDeprecatedExplicitApiMethods — #3391
852+
// One-shot deprecation warning for standalone LEGACY apiMethods values (the
853+
// derived 8). Pure observation — never mutates the schema.
854+
// ==========================================
855+
describe('warnDeprecatedExplicitApiMethods (#3391)', () => {
856+
it('warns when a whitelist declares a derived legacy value (import/export)', () => {
857+
const warn = vi.fn();
858+
warnDeprecatedExplicitApiMethods(
859+
{ name: 'crm_deal_a', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'import', 'export'] } } as any,
860+
{ warn },
861+
);
862+
expect(warn).toHaveBeenCalledTimes(1);
863+
expect(warn.mock.calls[0][0]).toContain('crm_deal_a');
864+
expect(warn.mock.calls[0][0]).toContain('import');
865+
expect(warn.mock.calls[0][0]).toContain('export');
866+
expect(warn.mock.calls[0][0]).toContain('#3391');
867+
});
868+
869+
it('stays silent for a pure-primitive whitelist', () => {
870+
const warn = vi.fn();
871+
warnDeprecatedExplicitApiMethods(
872+
{ name: 'crm_deal_b', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'] } } as any,
873+
{ warn },
874+
);
875+
expect(warn).not.toHaveBeenCalled();
876+
});
877+
878+
it('stays silent for an undefined / empty whitelist', () => {
879+
const warn = vi.fn();
880+
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_c', enable: {} } as any, { warn });
881+
warnDeprecatedExplicitApiMethods({ name: 'crm_deal_d', enable: { apiMethods: [] } } as any, { warn });
882+
expect(warn).not.toHaveBeenCalled();
883+
});
884+
885+
it('warns only once per object name (hot path stays free)', () => {
886+
const warn = vi.fn();
887+
const schema: any = { name: 'crm_deal_once', enable: { apiMethods: ['list', 'aggregate'] } };
888+
warnDeprecatedExplicitApiMethods(schema, { warn });
889+
warnDeprecatedExplicitApiMethods(schema, { warn });
890+
expect(warn).toHaveBeenCalledTimes(1);
891+
});
892+
});

packages/objectql/src/registry.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled } from '@objectstack/spec/data';
3+
import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS } from '@objectstack/spec/data';
44
import { resolveMultiOrgEnabled, resolveSearchPinyinEnabled } from '@objectstack/types';
55
import { provisionSearchCompanion } from './search-companion.js';
66
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema } from '@objectstack/spec/kernel';
@@ -389,6 +389,16 @@ export function applySystemFields(
389389
* Generic-write `apiMethods` verbs mapped to the {@link resolveCrudAffordances}
390390
* flag each one needs. Read verbs (`get`/`list`/`search`/`history`/…) are
391391
* always permitted, so they are absent here and never stripped.
392+
*
393+
* ⚠️ Two orthogonal axes — do NOT merge this with the API-tightening table.
394+
* This table (verb → *affordance*) is the UI-intent axis: it strips write verbs
395+
* a managed bucket does not *offer* from the whitelist. The verb → *primitive*
396+
* derivation that decides what the automatic API *admits* lives in
397+
* `@objectstack/spec/data` `API_METHOD_DERIVATION` / `resolveEffectiveApiMethods`
398+
* (#3391). The identical-shaped `WRITE_OP_AFFORDANCE` in plugin-security
399+
* `system-write-guard.ts` is the runtime enforcement of this same UI-intent
400+
* axis. Unifying the three is deferred to the enum-shrink (P2 of #3391); until
401+
* then keep the axes separate — see ADR-0103.
392402
*/
393403
const MANAGED_WRITE_VERB_AFFORDANCE: Record<string, 'create' | 'edit' | 'delete'> = {
394404
create: 'create',
@@ -463,6 +473,43 @@ export function reconcileManagedApiMethods(
463473
};
464474
}
465475

476+
/** Objects already warned about explicit legacy `apiMethods` (once per object). */
477+
const warnedDeprecatedApiMethods = new Set<string>();
478+
479+
/**
480+
* Registration-time deprecation warning for standalone LEGACY `apiMethods`
481+
* values (#3391). The 8 legacy verbs (`upsert`/`aggregate`/`history`/`search`/
482+
* `restore`/`purge`/`import`/`export`) are DERIVED from the six primitives; an
483+
* object that declares one explicitly is honored verbatim for one release
484+
* ("explicit wins"), then the value is removed in the enum-shrink (P2 of #3391).
485+
*
486+
* Emitted once per object (not per request — the hot path stays free), mirroring
487+
* the {@link reconcileManagedApiMethods} warning format and pointing at P2. Pure
488+
* observation: it never mutates the schema.
489+
*/
490+
export function warnDeprecatedExplicitApiMethods(
491+
schema: ServiceObject,
492+
opts?: { warn?: (msg: string) => void },
493+
): void {
494+
const methods = (schema as any).enable?.apiMethods;
495+
if (!Array.isArray(methods) || methods.length === 0) return;
496+
const legacy = methods.filter((m: string) => (LEGACY_API_METHODS as readonly string[]).includes(m));
497+
if (legacy.length === 0) return;
498+
const name = String((schema as any).name ?? '');
499+
if (warnedDeprecatedApiMethods.has(name)) return;
500+
warnedDeprecatedApiMethods.add(name);
501+
const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
502+
warn(
503+
`[Registry] Object "${name}" declares derived legacy apiMethods value(s) ` +
504+
`[${legacy.join(', ')}] in enable.apiMethods — these derive from the six ` +
505+
`primitives (get/list/create/update/delete/bulk) and are honored verbatim ` +
506+
`for now, but standalone declaration is deprecated and will be removed in ` +
507+
`the enum-shrink (P2 of #3391). Declare the underlying primitives instead ` +
508+
`(e.g. ['create','update'] grants upsert/import; ['list'] grants ` +
509+
`aggregate/export/search).`,
510+
);
511+
}
512+
466513
/**
467514
* Platform namespaces that multiple packages may legitimately share, so the
468515
* install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on
@@ -714,6 +761,11 @@ export class SchemaRegistry {
714761
// every non-`better-auth` object.
715762
schema = reconcileManagedApiMethods(schema);
716763

764+
// [#3391] One-shot deprecation warning for standalone LEGACY apiMethods
765+
// values (the derived 8) — honored this release, removed in the enum-shrink.
766+
// Runs after reconcile so we warn about what actually ships.
767+
warnDeprecatedExplicitApiMethods(schema);
768+
717769
// [ADR-0079] Object-materialization seam — DESIGNATE-ONLY primary-title
718770
// provisioning. Runs AFTER `applySystemFields` (so any designated field
719771
// co-exists with the injected system columns) and ONLY for owned objects

packages/plugins/plugin-security/src/system-write-guard.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export const ENGINE_OWNED_BUCKETS: ReadonlySet<string> = new Set(['system', 'eng
5252
* Read ops (`find`/`findOne`/`count`/`aggregate`/…) are absent and always pass.
5353
* Aligned with the `DelegatedAdminGate` governed-operation set and the registry's
5454
* `MANAGED_WRITE_VERB_AFFORDANCE`.
55+
*
56+
* ⚠️ This is the UI-intent axis (verb → *affordance*), NOT the API-tightening
57+
* axis. What the automatic API *admits* is decided by the verb → *primitive*
58+
* derivation in `@objectstack/spec/data` (`resolveEffectiveApiMethods`, #3391) —
59+
* a separate table on a separate axis (ADR-0103). Merging the two is deferred to
60+
* the enum-shrink (P2 of #3391); keep them distinct until then.
5561
*/
5662
const WRITE_OP_AFFORDANCE: Record<string, 'create' | 'edit' | 'delete'> = {
5763
insert: 'create',

packages/runtime/src/api-exposure.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,69 @@ describe('checkApiExposure (#1889)', () => {
5050
expect(d.status).toBe(405);
5151
});
5252

53-
it('an empty whitelist is treated as no restriction', () => {
54-
expect(checkApiExposure({ apiMethods: [] }, 'create').allowed).toBe(true);
53+
it('an empty whitelist is deny-all (405) — flipped by #3391', () => {
54+
// Previously treated as "no restriction" (fail-open); the documented
55+
// three-state contract makes `[]` mean "expose nothing".
56+
const d = checkApiExposure({ apiMethods: [] }, 'create');
57+
expect(d.allowed).toBe(false);
58+
expect(d.status).toBe(405);
59+
// every operation is closed
60+
expect(checkApiExposure({ apiMethods: [] }, 'query').allowed).toBe(false);
61+
expect(checkApiExposure({ apiMethods: [] }, 'get').allowed).toBe(false);
5562
});
5663

5764
it('does not gate actions with no ApiMethod mapping', () => {
5865
expect(checkApiExposure({ apiMethods: ['list'] }, 'somethingCustom').allowed).toBe(true);
5966
});
67+
68+
it('surfaces the effective operation set on a 405 denial', () => {
69+
const d = checkApiExposure({ apiMethods: ['get', 'list'] }, 'create');
70+
expect(d.allowed).toBe(false);
71+
expect(d.allowedOperations).toContain('get');
72+
expect(d.allowedOperations).toContain('list');
73+
// list-class derived reads are exposed too
74+
expect(d.allowedOperations).toContain('aggregate');
75+
expect(d.allowedOperations).toContain('export');
76+
expect(d.allowedOperations).not.toContain('create');
77+
});
78+
});
79+
80+
describe('nested enable shape (getObject) — regression for #3391', () => {
81+
// meta.getObject() returns the whole schema with the exposure flags under
82+
// `.enable`. The previous flat-only reader ignored them → a dead gate.
83+
it('reads apiEnabled from the nested enable block (404)', () => {
84+
const d = checkApiExposure({ name: 'x', enable: { apiEnabled: false } } as any, 'get');
85+
expect(d.allowed).toBe(false);
86+
expect(d.status).toBe(404);
87+
});
88+
89+
it('reads apiMethods from the nested enable block (405)', () => {
90+
const def = { name: 'x', enable: { apiMethods: ['list', 'get'] } } as any;
91+
expect(checkApiExposure(def, 'query').allowed).toBe(true);
92+
const d = checkApiExposure(def, 'create');
93+
expect(d.allowed).toBe(false);
94+
expect(d.status).toBe(405);
95+
});
96+
97+
it('nested empty whitelist is deny-all', () => {
98+
const def = { name: 'x', enable: { apiMethods: [], apiEnabled: true } } as any;
99+
expect(checkApiExposure(def, 'get').allowed).toBe(false);
100+
expect(checkApiExposure(def, 'query').allowed).toBe(false);
101+
});
102+
});
103+
104+
describe('derivation-backed operations (#3391)', () => {
105+
it('import derives from create/update (writeMode-precise)', () => {
106+
const createOnly = { apiMethods: ['create'] };
107+
expect(checkApiExposure(createOnly, 'import', { writeMode: 'insert' }).allowed).toBe(true);
108+
expect(checkApiExposure(createOnly, 'import', { writeMode: 'update' }).allowed).toBe(false);
109+
});
110+
111+
it('bulk requires the bulk primitive AND the child op', () => {
112+
const bulkCreate = { apiMethods: ['create', 'bulk'] };
113+
expect(checkApiExposure(bulkCreate, 'batch', { bulkChild: 'create' }).allowed).toBe(true);
114+
const createOnly = { apiMethods: ['create'] };
115+
expect(checkApiExposure(createOnly, 'batch', { bulkChild: 'create' }).allowed).toBe(false);
116+
});
60117
});
61118
});
Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,108 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* Object-level API exposure gate (ADR-0049, #1889).
4+
* Object-level API exposure gate (ADR-0049, #1889, #3391).
55
*
66
* Objects declare `apiEnabled` (default true) and an optional `apiMethods`
7-
* whitelist, but the HTTP/MCP data dispatch previously ignored both — an object
8-
* could not actually be hidden from the API, nor could its allowed operations
9-
* be restricted. This module decides, for a given data action, whether the
10-
* object's declared exposure permits it.
7+
* whitelist. The HTTP/MCP data dispatch decides, for a given data action,
8+
* whether the object's declared exposure permits it. Both fields live in the
9+
* object's `enable` capability block.
1110
*
12-
* Both fields are *additive restrictions* over a default-allow surface
13-
* (`apiEnabled` defaults true; absent `apiMethods` means "all operations").
14-
* Therefore an unresolvable object definition fails OPEN here — that matches
15-
* the schema defaults and avoids breaking traffic when metadata is briefly
16-
* unavailable. The gate is a no-op for system/internal contexts (callers pass
17-
* `isSystem` and skip this check entirely).
11+
* ## What #3391 changed here
12+
*
13+
* 1. **Nested-first shape.** `meta.getObject()` returns the full object schema
14+
* with `apiEnabled`/`apiMethods` under `.enable`. The previous version read
15+
* them off the *flat* top level, so the dispatcher/MCP whitelist never fired
16+
* (a silent dead gate). We now read `def.enable` first, falling back to the
17+
* flat shape for legacy/test doubles.
18+
* 2. **Shared derivation.** The action→operation mapping and the three-state
19+
* whitelist semantics are no longer duplicated here — they come from the
20+
* spec's single source of truth (`@objectstack/spec/data`
21+
* `resolveEffectiveApiMethods` / `isApiOperationAllowed`).
22+
* 3. **`[]` = deny-all.** An empty whitelist now means "expose nothing" (405),
23+
* matching the documented three-state contract, instead of the prior
24+
* fail-open "no restriction".
25+
*
26+
* An unresolvable object definition still fails OPEN — that matches the schema
27+
* defaults (`apiEnabled` true, no `apiMethods`) and avoids breaking traffic when
28+
* metadata is briefly unavailable. The gate is a no-op for system/internal
29+
* contexts (callers pass `isSystem` and skip this check entirely).
1830
*/
1931

20-
/** The exposure-relevant slice of an object definition. */
32+
import {
33+
resolveEffectiveApiMethods,
34+
isApiOperationAllowed,
35+
effectiveOperationsArray,
36+
DATA_ACTION_TO_API_OPERATION,
37+
type EnableLike,
38+
} from '@objectstack/spec/data';
39+
40+
/** The exposure-relevant slice of an object definition (flat or nested). */
2141
export interface ObjectApiDef {
2242
apiEnabled?: boolean;
2343
apiMethods?: string[] | null;
44+
/** Real `getObject()` shape — the exposure flags live under `enable`. */
45+
enable?: EnableLike | null;
46+
[key: string]: unknown;
2447
}
2548

2649
export interface ApiExposureDecision {
2750
allowed: boolean;
2851
/** HTTP status to return when denied (404 hides, 405 = method not allowed). */
2952
status?: number;
3053
reason?: string;
54+
/** The effective operation set (enum-ordered) — surfaced on a 405 denial. */
55+
allowedOperations?: string[];
3156
}
3257

3358
/**
34-
* Map an internal `callData` action onto the spec `ApiMethod` vocabulary
35-
* (`object.zod.ts` → `ApiMethod`). Actions with no mapping are not gated by
36-
* `apiMethods` (they still respect `apiEnabled`).
59+
* Resolve the `enable` capability block from either the nested `getObject()`
60+
* shape (`{ enable: {...} }`) or a flat legacy/test shape (`{ apiEnabled, ... }`).
61+
* Nested wins when present.
3762
*/
38-
const ACTION_TO_API_METHOD: Record<string, string> = {
39-
create: 'create',
40-
get: 'get',
41-
update: 'update',
42-
delete: 'delete',
43-
query: 'list',
44-
find: 'list',
45-
// Aggregation is a list-class read: an object whose whitelist excludes
46-
// `list` must not leak row statistics through GROUP BY either.
47-
aggregate: 'list',
48-
batch: 'bulk',
49-
};
63+
function resolveEnableBlock(def: ObjectApiDef): EnableLike {
64+
if (def.enable && typeof def.enable === 'object') return def.enable;
65+
return def as EnableLike;
66+
}
5067

51-
export function checkApiExposure(def: ObjectApiDef | null | undefined, action: string): ApiExposureDecision {
68+
/**
69+
* Decide whether a data `action` is permitted for `def`'s declared exposure.
70+
*
71+
* @param def Object definition (nested `getObject` shape or flat).
72+
* @param action Runtime data action (`create`/`get`/`query`/`find`/`aggregate`/
73+
* `batch`/…); normalized to a canonical operation internally.
74+
* @param opts Optional `writeMode` (import precision) / `bulkChild` (bulk∧child).
75+
*/
76+
export function checkApiExposure(
77+
def: ObjectApiDef | null | undefined,
78+
action: string,
79+
opts?: { writeMode?: string; bulkChild?: string },
80+
): ApiExposureDecision {
5281
// Unresolvable definition → fall open to the schema defaults.
5382
if (!def) return { allowed: true };
5483

84+
const enable = resolveEnableBlock(def);
85+
5586
// `apiEnabled: false` hides the object from the API entirely → 404.
56-
if (def.apiEnabled === false) {
87+
if (enable.apiEnabled === false) {
5788
return { allowed: false, status: 404, reason: 'object is not exposed via the API' };
5889
}
5990

60-
// `apiMethods` whitelist (when present and non-empty) restricts operations.
61-
const whitelist = def.apiMethods;
62-
if (Array.isArray(whitelist) && whitelist.length > 0) {
63-
const method = ACTION_TO_API_METHOD[action];
64-
// Only gate actions that map to a known ApiMethod; unmapped actions pass.
65-
if (method && !whitelist.includes(method)) {
66-
return {
67-
allowed: false,
68-
status: 405,
69-
reason: `API operation '${method}' is not allowed for this object`,
70-
};
71-
}
72-
}
91+
const eff = resolveEffectiveApiMethods(enable);
92+
// Unrestricted (no whitelist) → allow everything, exactly as before.
93+
if (eff.mode === 'unrestricted') return { allowed: true };
94+
95+
// Normalize the runtime action onto a canonical ApiMethod operation. An
96+
// action with no mapping is not gated by `apiMethods` (respects `apiEnabled`
97+
// only), preserving the prior behavior for custom actions.
98+
const operation = DATA_ACTION_TO_API_OPERATION[action] ?? action;
99+
100+
if (isApiOperationAllowed(eff, operation, opts)) return { allowed: true };
73101

74-
return { allowed: true };
102+
return {
103+
allowed: false,
104+
status: 405,
105+
reason: `API operation '${operation}' is not allowed for this object`,
106+
allowedOperations: effectiveOperationsArray(eff),
107+
};
75108
}

packages/spec/liveness/object.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,8 @@
153153
},
154154
"apiMethods": {
155155
"status": "live",
156-
"evidence": "packages/rest/src/rest-server.ts",
157-
"note": "enforced by enforceApiAccess — operations outside the whitelist → 405."
156+
"evidence": "packages/spec/src/data/api-derivation.ts, packages/rest/src/rest-server.ts, packages/runtime/src/api-exposure.ts",
157+
"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)."
158158
},
159159
"trackHistory": {
160160
"status": "live",

0 commit comments

Comments
 (0)