diff --git a/.changeset/managed-apimethods-affordance-gate.md b/.changeset/managed-apimethods-affordance-gate.md new file mode 100644 index 0000000000..ba4395e7bc --- /dev/null +++ b/.changeset/managed-apimethods-affordance-gate.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +"@objectstack/objectql": patch +--- + +feat(spec,lint): gate `enable.apiMethods` ⊆ affordances at authoring time, not only in the boot log (#7521) + +A `managedBy` object that advertises a generic write verb in `enable.apiMethods` +while its own resolved affordances refuse that write is internally +contradictory — ADR-0049's `declared != enforced` class, stated entirely within +one object's declaration. `reconcileManagedApiMethods` (objectql's registry) has +always caught it at registration and **stripped** the verb, so nothing was ever +exposed. What it could not do is tell anyone: the only signal was a +`console.warn`. + +`sys_environment` and `sys_package` declared +`apiMethods: ['get','list','create','update']` against `userActions` that refused +all three writes. The strip and its warning fired on **every control-plane boot +for the life of the divergence and nobody noticed** — the split was eventually +found by hand-driving the HTTP seam while writing something unrelated, not by +any gate. A boot log is not an authoring surface: it is read after an incident, +by an operator, in a repo whose author has long since moved on. + +**New rule — `object/managed-api-method-unaffordable` (`error`).** `os lint`, +`os validate` and `os build` now report the contradiction where the author is +standing, naming the refused verbs, the `userActions` flags that would be needed +and both ways out. It runs pre-parse, so the finding survives an unrelated schema +error elsewhere in the stack. + +**One predicate, two consumers.** The judgement moved to +`checkManagedApiMethodAffordances` in `@objectstack/spec/data` — beside +`resolveCrudAffordances`, the affordance authority both sides already read — and +the registry's strip is now a pure reaction to it. That is the point rather than +a tidy-up: a second copy of this table at either consumer would *be* the +declared≠enforced drift the rule exists to detect. Same shape, and same reason, +as `checkFieldCompleteness` under ADR-0078. + +**Boot behaviour is deliberately unchanged.** `reconcileManagedApiMethods` still +warns and strips; it does not throw. Failing registration closed would let one +metadata typo kill a control-plane boot, which is too harsh for ops — the +author-time gate is where this blocks. The boot warning now cites the lint rule +id, so an operator who greps a stripped verb out of a log lands on the gate. + +Also exported: `validateManagedApiMethods` and `MANAGED_API_METHOD_UNAFFORDABLE` +from `@objectstack/lint`. A repo whose object definitions live in **code** — which +`os lint` never walks — can run the same rule over its own registry instead of +hand-rolling the affordance table, which is what every such repo has had to do +until now. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index e32266e04d..db2849ed0d 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -100,6 +100,7 @@ import { validateStackExpressions } from './validate-expressions.js'; import { validateListViewMode } from './validate-list-view-mode.js'; import { validateFunctionalCompleteness } from './validate-functional-completeness.js'; +import { validateManagedApiMethods } from './validate-managed-api-methods.js'; import { validateViewContainers } from './validate-view-containers.js'; import { validateWidgetBindings } from './validate-widget-bindings.js'; import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js'; @@ -432,6 +433,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_OBJECT_WRITES_P2, run: (stack) => validateFunctionalCompleteness(stack), }, + // [#7521, via cloud#1225] A managed object advertising a generic write verb + // in `enable.apiMethods` that its own resolved affordances refuse. Every key + // is one we know and each is individually valid, so #4001's unknown-key + // rejection and the Zod parse both pass it; the contradiction is only visible + // when the two keys are read TOGETHER, which nothing did at authoring time. + // + // `gating` because the declaration is already false when it ships: objectql's + // registry strips the verb at registration, so the metadata advertises an API + // the product does not serve. That strip has been correct and silent — a + // `console.warn` on every control-plane boot that went unread for the life of + // a real divergence (`sys_environment`/`sys_package`). This entry is the + // ruling's "close it where the author is"; boot stays warn-and-strip. + // + // Pre-parse: the predicate reads only authored keys, and the finding must + // survive an unrelated schema error elsewhere in the stack. + { + name: 'validateManagedApiMethods', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-managed-api-methods.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_OBJECT_WRITES_P2, + run: (stack) => validateManagedApiMethods(stack), + }, // A view container in `views: []` that registers zero views: nothing appears // in the Console, and the schema step cannot tell it from an intentionally // empty one. The FLAT-list-view arm no longer needs this tier — `ViewSchema` diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d3d7adb922..a9780107d3 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -75,6 +75,17 @@ export type { FunctionalCompletenessFinding, FunctionalCompletenessSeverity, } from './validate-functional-completeness.js'; +// [#7521] The managed-object `apiMethods` ⊆ affordances gate. All judgement +// lives in the shared predicate in `@objectstack/spec/data`, which objectql's +// `reconcileManagedApiMethods` reads too — so the boot-time strip and this +// author-time gate cannot reach different verdicts. Exported so a repo that +// ships object definitions in CODE (which `os lint` never walks) can run the +// same rule over its own registry, instead of hand-rolling the table. +export { + validateManagedApiMethods, + MANAGED_API_METHOD_UNAFFORDABLE, +} from './validate-managed-api-methods.js'; +export type { ManagedApiMethodFinding } from './validate-managed-api-methods.js'; export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js'; export { validateFlowTriggerReadiness, diff --git a/packages/lint/src/validate-managed-api-methods.test.ts b/packages/lint/src/validate-managed-api-methods.test.ts new file mode 100644 index 0000000000..5d9e179ee0 --- /dev/null +++ b/packages/lint/src/validate-managed-api-methods.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7521 — the authoring-time gate for `enable.apiMethods` ⊆ affordances. +// +// The predicate's own verdict table is tested in `@objectstack/spec` +// (`managed-api-affordance.test.ts`); what is tested HERE is the walk — which +// stack shapes are reached, and whether a conflict becomes a finding an author +// can act on. + +import { describe, expect, it } from 'vitest'; + +import { + MANAGED_API_METHOD_UNAFFORDABLE, + validateManagedApiMethods, +} from './validate-managed-api-methods'; + +/** The #7521 shape: `platform` bucket, `userActions` closing every write. */ +const sysEnvironment = { + name: 'sys_environment', + managedBy: 'platform', + userActions: { create: false, edit: false, delete: false }, + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update'] }, +}; + +describe('validateManagedApiMethods — the finding', () => { + it('flags the declaration #7521 was filed for', () => { + const findings = validateManagedApiMethods({ objects: [sysEnvironment] }); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.severity).toBe('error'); + expect(f.rule).toBe(MANAGED_API_METHOD_UNAFFORDABLE); + expect(f.where).toBe('object "sys_environment"'); + expect(f.path).toBe('objects[0].enable.apiMethods'); + expect(f.message).toContain('create, update'); + expect(f.message).toContain("managedBy: 'platform'"); + }); + + it('offers both ways out, and names the affordances that would be needed', () => { + const [f] = validateManagedApiMethods({ objects: [sysEnvironment] }); + expect(f.hint).toContain('userActions: { create: true, edit: true }'); + expect(f.hint).toContain('remove'); + // ADR-0092 D4 — an author must not open the affordance to silence a lint. + expect(f.hint).toContain('ADR-0092 D4'); + }); + + it('emits ONE finding per object, not one per offending verb', () => { + // Three refused verbs, one authoring mistake, one edit to fix it. + const findings = validateManagedApiMethods({ + objects: [ + { + name: 'sys_thing', + managedBy: 'better-auth', + enable: { apiMethods: ['get', 'create', 'update', 'delete'] }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('create, update, delete'); + }); +}); + +describe('validateManagedApiMethods — the walk', () => { + it('reads objects declared as a name-keyed map as well as an array', () => { + const findings = validateManagedApiMethods({ objects: { sys_environment: sysEnvironment } }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toBe('object "sys_environment"'); + }); + + it('reports the index of the offending object, not of the finding', () => { + const clean = { name: 'crm_lead', enable: { apiMethods: ['get', 'create'] } }; + const findings = validateManagedApiMethods({ objects: [clean, clean, sysEnvironment] }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[2].enable.apiMethods'); + }); + + it('stays silent on a coherent stack', () => { + const findings = validateManagedApiMethods({ + objects: [ + // Unmanaged — no bucket default to contradict. + { name: 'crm_lead', enable: { apiMethods: ['get', 'list', 'create', 'update', 'delete'] } }, + // Managed, and the affordance is declared (the `sys_api_key` shape). + { + name: 'sys_api_key', + managedBy: 'better-auth', + userActions: { edit: true }, + enable: { apiMethods: ['get', 'list', 'update'] }, + }, + // Managed and read-only — reads are never affordance-gated. + { name: 'sys_email', managedBy: 'append-only', enable: { apiMethods: ['get', 'list'] } }, + ], + }); + expect(findings).toEqual([]); + }); + + it('survives a stack that is missing, junk, or has no objects at all', () => { + expect(validateManagedApiMethods(undefined)).toEqual([]); + expect(validateManagedApiMethods('nope')).toEqual([]); + expect(validateManagedApiMethods({})).toEqual([]); + expect(validateManagedApiMethods({ objects: [null, 42] })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-managed-api-methods.ts b/packages/lint/src/validate-managed-api-methods.ts new file mode 100644 index 0000000000..914d232c9e --- /dev/null +++ b/packages/lint/src/validate-managed-api-methods.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#7521, found via cloud#1225] The authoring-time half of +// `reconcileManagedApiMethods` — a managed object may not advertise a generic +// write verb in `enable.apiMethods` that its own resolved affordances refuse. +// +// A pure `(stack) => Finding[]` rule (ADR-0019). All judgement lives in the +// SHARED predicate — `@objectstack/spec/data`'s +// `checkManagedApiMethodAffordances`, the same call objectql's registry makes +// when it strips the verb at registration — so this file is only the walk: +// where objects live in a stack, and how a predicate conflict becomes a lint +// finding with a location. If a verdict seems wrong, fix the predicate, never +// this walk. A second affordance table here would BE the declared≠enforced +// drift the rule exists to catch. +// +// ## Why an authoring-time rule when the registry already fixes it +// +// The registry's fix is real and fail-closed — it strips the verb, so nothing +// is ever exposed. What it cannot do is TELL anyone. `sys_environment` and +// `sys_package` declared `apiMethods: ['get','list','create','update']` against +// `userActions` that refused all three writes; the strip and its `console.warn` +// fired on every control-plane boot for the life of the divergence and nobody +// noticed. The split was found by hand-driving the HTTP seam while writing +// something else. A boot log is not an authoring surface: it is read after an +// incident, by an operator, in a repo whose author has long since moved on. +// +// This rule puts the same verdict where the author is standing, which is the +// #7521 ruling in one sentence. Boot behaviour is deliberately unchanged — +// still warn-and-strip, never fail-closed, so a metadata typo cannot kill a +// control-plane boot. +// +// Runs on the NORMALIZED (pre-parse) stack, like validate-functional- +// completeness: the finding must reach the author even when an unrelated schema +// error would stop the parse, and the predicate reads only authored keys +// (`managedBy`, `userActions`, `enable.apiMethods`) — no parse-time defaults. + +import { + checkManagedApiMethodAffordances, + describeManagedApiMethodConflicts, +} from '@objectstack/spec/data'; + +/** + * Stable diagnostic id. Named for the CONTRADICTION rather than for the strip, + * because at authoring time nothing has been stripped yet — the declaration is + * simply advertising something the object cannot honour. + */ +export const MANAGED_API_METHOD_UNAFFORDABLE = 'object/managed-api-method-unaffordable'; + +export interface ManagedApiMethodFinding { + /** + * Always `error`. The contradiction is decidable from the object's own + * declaration — no call graph, no runtime state — and shipping it means the + * metadata claims an API surface the registry will silently take away. + */ + severity: 'error'; + rule: typeof MANAGED_API_METHOD_UNAFFORDABLE; + /** Human-readable location, e.g. `object "sys_environment"`. */ + where: string; + /** Config path, e.g. `objects[2].enable.apiMethods`. */ + path: string; + message: string; + hint: string; +} + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +/** Array-or-name-keyed-map collection → entries with a name and an index label. */ +function entriesOf(v: unknown): Array<{ name: string; def: AnyRec; key: string }> { + if (Array.isArray(v)) { + return v.flatMap((def, i) => + isRec(def) ? [{ name: String(def.name ?? i), def, key: `[${i}]` }] : [], + ); + } + if (isRec(v)) { + return Object.entries(v).flatMap(([name, def]) => + isRec(def) ? [{ name, def: { name, ...def }, key: `.${name}` }] : [], + ); + } + return []; +} + +/** + * Walk every object declaration in the stack through the shared + * managed-affordance predicate. + * + * One finding per object, not per verb: an object declaring both `create` and + * `update` against an all-locked bucket has ONE authoring mistake, and the fix + * — open the affordances or drop the verbs — is a single edit. + */ +export function validateManagedApiMethods(stack: unknown): ManagedApiMethodFinding[] { + const out: ManagedApiMethodFinding[] = []; + if (!isRec(stack)) return out; + + for (const [oi, obj] of entriesOf(stack.objects).entries()) { + const conflicts = checkManagedApiMethodAffordances(obj.def); + if (conflicts.length === 0) continue; + + const verbs = conflicts.map((c) => c.verb).join(', '); + const flags = [...new Set(conflicts.map((c) => c.needs))]; + out.push({ + severity: 'error', + rule: MANAGED_API_METHOD_UNAFFORDABLE, + where: `object "${obj.name}"`, + path: `objects[${oi}].enable.apiMethods`, + message: + `\`managedBy: '${String(obj.def.managedBy)}'\` object "${obj.name}" ` + + describeManagedApiMethodConflicts(conflicts) + + ` The registry STRIPS [${verbs}] at registration, so this declaration and the API you ` + + `actually get already disagree — today the only trace is a line in the boot log.`, + hint: + `Either add \`userActions: { ${flags.map((f) => `${f}: true`).join(', ')} }\` to the object ` + + `— only if the write is genuinely one a user context may perform, and only once the guard ` + + `enforcing it exists (ADR-0092 D4: affordance never ships ahead of the guard) — or remove ` + + `[${verbs}] from \`enable.apiMethods\`, which is what the runtime does for you today.`, + }); + } + + return out; +} diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 709eb853d0..3fb2849265 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { SchemaRegistry, applySystemFields, reconcileManagedApiMethods, warnStrippedLegacyApiMethods, warnFunctionalCompleteness, computeFQN, parseFQN } from './registry'; -import { AUDIT_PROVENANCE_FIELDS, type ServiceObject } from '@objectstack/spec/data'; +import { AUDIT_PROVENANCE_FIELDS, checkManagedApiMethodAffordances, type ServiceObject } from '@objectstack/spec/data'; describe('SchemaRegistry', () => { let registry: SchemaRegistry; @@ -995,6 +995,69 @@ describe('reconcileManagedApiMethods', () => { const stored = (reg as any).objectContributors.get('sys_thing')[0].definition; expect(stored.enable.apiMethods).toEqual(['get', 'list']); }); + + // ── #7521: the strip is a REACTION to the shared predicate ────────── + // + // The judgement moved to `checkManagedApiMethodAffordances` + // (`@objectstack/spec/data`) so that `@objectstack/lint`'s author-time gate + // reaches the identical verdict from the identical table. These pin the + // join: if someone reintroduces a local table here, the two can disagree + // again — which is the declared≠enforced drift the card is about. + describe('shares ONE predicate with the author-time gate (#7521)', () => { + const apiMethodsOf = (schema: ServiceObject): string[] => + (schema as { enable: { apiMethods: string[] } }).enable.apiMethods; + + it('strips exactly the verbs the shared predicate names, and only those', () => { + const schema = { + name: 'sys_environment', + managedBy: 'platform', + userActions: { create: false, edit: false, delete: false }, + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update'] }, + } as ServiceObject; + + const conflicts = checkManagedApiMethodAffordances(schema); + expect(conflicts.map((c) => c.verb)).toEqual(['create', 'update']); + + const warn = vi.fn<(msg: string) => void>(); + const kept = apiMethodsOf(reconcileManagedApiMethods(schema, { warn })); + expect(kept).toEqual(['get', 'list']); + // The declared set minus exactly the predicate's verdict. + expect(kept).toEqual( + ['get', 'list', 'create', 'update'].filter( + (_v, i) => !conflicts.some((c) => c.index === i), + ), + ); + }); + + it('a duplicated offender does not take a legitimate verb down with it', () => { + const warn = vi.fn<(msg: string) => void>(); + const out = reconcileManagedApiMethods( + { + name: 'sys_thing', + managedBy: 'better-auth', + enable: { apiEnabled: true, apiMethods: ['create', 'get', 'create', 'list'] }, + } as ServiceObject, + { warn }, + ); + expect(apiMethodsOf(out)).toEqual(['get', 'list']); + }); + + it('points the operator at the lint rule id, so a boot log leads to the gate', () => { + const warn = vi.fn<(msg: string) => void>(); + reconcileManagedApiMethods(managed() as ServiceObject, { warn }); + expect(warn.mock.calls[0]?.[0]).toContain('object/managed-api-method-unaffordable'); + }); + + it('still WARNS rather than throwing — boot must survive a metadata typo', () => { + // The #7521 ruling: fail-closed at registration would let one typo + // kill a control-plane boot. The gate is where this blocks. + const warn = vi.fn<(msg: string) => void>(); + expect(() => + reconcileManagedApiMethods(managed() as ServiceObject, { warn }), + ).not.toThrow(); + expect(warn).toHaveBeenCalledTimes(1); + }); + }); }); // ========================================== diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index e8e6996ec4..625fcee68f 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, resolveInjectedSystemColumns, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; // [#4513] The audit-family governance table, and [#6562] the injected-column // DEFINITION tables it governs — see the re-exports below for why both live in a // package `objectql` and `metadata-protocol` both depend on. @@ -615,31 +615,6 @@ function declaresTenantIndex(schema: ServiceObject): boolean { ); } -/** - * Generic-write `apiMethods` verbs mapped to the {@link resolveCrudAffordances} - * flag each one needs. Read verbs (`get`/`list`/`search`/`history`/…) are - * always permitted, so they are absent here and never stripped. - * - * ⚠️ Two orthogonal axes — do NOT merge this with the API-tightening table. - * This table (verb → *affordance*) is the UI-intent axis: it strips write verbs - * a managed bucket does not *offer* from the whitelist. The verb → *primitive* - * derivation that decides what the automatic API *admits* lives in - * `@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. 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', - update: 'edit', - upsert: 'edit', - delete: 'delete', - purge: 'delete', -}; - /** * Reconcile a managed object's `enable.apiMethods` against the generic-write * affordances it actually grants (ADR-0092 / ADR-0103). @@ -653,9 +628,22 @@ const MANAGED_WRITE_VERB_AFFORDANCE: Record void }, ): ServiceObject { - if ((schema as any).managedBy == null) return schema; - - const methods = (schema as any).enable?.apiMethods; - if (!Array.isArray(methods) || methods.length === 0) return schema; - - const affordances = resolveCrudAffordances(schema); - const stripped: string[] = []; - const kept = methods.filter((m: string) => { - const need = MANAGED_WRITE_VERB_AFFORDANCE[m]; - if (need && !affordances[need]) { - stripped.push(m); - return false; - } - return true; - }); + const conflicts = checkManagedApiMethodAffordances(schema); + if (conflicts.length === 0) return schema; - if (stripped.length === 0) return schema; + const methods = (schema as any).enable.apiMethods as unknown[]; + const strippedIndexes = new Set(conflicts.map((c) => c.index)); + const kept = methods.filter((_m, i) => !strippedIndexes.has(i)); + const stripped = conflicts.map((c) => c.verb); const warn = opts?.warn ?? ((msg: string) => console.warn(msg)); warn( `[Registry] Object "${schema.name}" is managedBy:'${(schema as any).managedBy}' but advertised ` + `generic write verb(s) [${stripped.join(', ')}] in enable.apiMethods its resolved affordances ` + `do not permit — stripping them (ADR-0092/ADR-0103). Declare userActions to open a verb the ` + - `object legitimately takes from a user context. Kept: [${kept.join(', ')}].`, + `object legitimately takes from a user context. Kept: [${kept.join(', ')}]. ` + + `\`os lint\` reports the same contradiction as \`object/managed-api-method-unaffordable\` ` + + `with full authoring context.`, ); return { diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 5db2c195e1..1125c41335 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -337,10 +337,12 @@ "LocationValue (type)", "LocationValueSchema (const)", "LogicalOperatorKey (type)", + "MANAGED_WRITE_VERB_AFFORDANCE (const)", "MAX_BULK_PER_ROW_HOOK_ROWS (const)", "MEASURE_FIELD_TYPES (const)", "MULTI_CAPABLE_TYPES (const)", "MULTI_OPTION_TYPES (const)", + "ManagedApiMethodConflict (interface)", "Mapping (type)", "MappingParsed (type)", "MappingSchema (const)", @@ -599,6 +601,7 @@ "canonicalAstOperator (function)", "canonicalizeSqlType (function)", "checkLiteralDefaultValue (function)", + "checkManagedApiMethodAffordances (function)", "classifyFilterToken (function)", "countAuthorableFields (function)", "defaultAggregateFor (function)", @@ -612,6 +615,7 @@ "deriveFieldGroupLayout (function)", "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", + "describeManagedApiMethodConflicts (function)", "discriminateDefaultValueShape (function)", "driverConfigJsonSchema (function)", "driverHasLocalDefault (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 96501936ad..712ef167aa 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -337,10 +337,12 @@ "LocationValue": "src/data/field-value.zod.ts#LocationValue (type)", "LocationValueSchema": "src/data/field-value.zod.ts#LocationValueSchema (const)", "LogicalOperatorKey": "src/data/filter.zod.ts#LogicalOperatorKey (type)", + "MANAGED_WRITE_VERB_AFFORDANCE": "src/data/managed-api-affordance.ts#MANAGED_WRITE_VERB_AFFORDANCE (const)", "MAX_BULK_PER_ROW_HOOK_ROWS": "src/data/bulk-write-hook-conformance.ts#MAX_BULK_PER_ROW_HOOK_ROWS (const)", "MEASURE_FIELD_TYPES": "src/data/aggregation-policy.ts#MEASURE_FIELD_TYPES (const)", "MULTI_CAPABLE_TYPES": "src/data/field-value.zod.ts#MULTI_CAPABLE_TYPES (const)", "MULTI_OPTION_TYPES": "src/data/field-value.zod.ts#MULTI_OPTION_TYPES (const)", + "ManagedApiMethodConflict": "src/data/managed-api-affordance.ts#ManagedApiMethodConflict (interface)", "Mapping": "src/data/mapping.zod.ts#Mapping (type)", "MappingParsed": "src/data/mapping.zod.ts#MappingParsed (type)", "MappingSchema": "src/data/mapping.zod.ts#MappingSchema (const)", @@ -599,6 +601,7 @@ "canonicalAstOperator": "src/data/filter.zod.ts#canonicalAstOperator (function)", "canonicalizeSqlType": "src/data/type-compat.ts#canonicalizeSqlType (function)", "checkLiteralDefaultValue": "src/data/default-value-shape.ts#checkLiteralDefaultValue (function)", + "checkManagedApiMethodAffordances": "src/data/managed-api-affordance.ts#checkManagedApiMethodAffordances (function)", "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)", "countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)", "defaultAggregateFor": "src/data/aggregation-policy.ts#defaultAggregateFor (function)", @@ -612,6 +615,7 @@ "deriveFieldGroupLayout": "src/data/field-group-layout.ts#deriveFieldGroupLayout (function)", "deriveRecordFlowSurface": "src/data/record-surface.ts#deriveRecordFlowSurface (function)", "deriveRecordSurface": "src/data/record-surface.ts#deriveRecordSurface (function)", + "describeManagedApiMethodConflicts": "src/data/managed-api-affordance.ts#describeManagedApiMethodConflicts (function)", "discriminateDefaultValueShape": "src/data/default-value-shape.ts#discriminateDefaultValueShape (function)", "driverConfigJsonSchema": "src/data/driver/common.zod.ts#driverConfigJsonSchema (function)", "driverHasLocalDefault": "src/data/driver/config-registry.zod.ts#driverHasLocalDefault (function)", diff --git a/packages/spec/src/data/api-derivation.ts b/packages/spec/src/data/api-derivation.ts index 6bee23c122..b5dc39bd7a 100644 --- a/packages/spec/src/data/api-derivation.ts +++ b/packages/spec/src/data/api-derivation.ts @@ -32,7 +32,7 @@ * This module is the **API-tightening axis** (verb → *primitive*): what the * automatic API will admit. It is deliberately NOT the same as the UI-intent * axis (verb → *affordance*) that `resolveCrudAffordances` / - * `MANAGED_WRITE_VERB_AFFORDANCE` (objectql `registry.ts`) and + * `MANAGED_WRITE_VERB_AFFORDANCE` (`./managed-api-affordance`, #7521) and * `WRITE_OP_AFFORDANCE` (plugin-security `system-write-guard.ts`) implement. * Merging the two would blur an authoring-intent (UX affordance) concern into a * security (API exposure) concern; they stay separate tables. See ADR-0103. diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index c9f6ff3975..b677027040 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -53,6 +53,10 @@ export * from './object.zod'; // API-method derivation — the single source of truth turning an object's // `enable.apiMethods` whitelist into its effective operation set (#3391). export * from './api-derivation'; +// The managed-object `apiMethods` ⊆ affordances predicate — one table read by +// objectql's registration-time strip AND @objectstack/lint's authoring gate, +// so the two can never drift apart (#7521). +export * from './managed-api-affordance'; export * from './field.zod'; // The credential read mask (ADR-0100) — the ONE string a masked read serves, in // place of the two byte-identical literals objectql and service-settings each diff --git a/packages/spec/src/data/managed-api-affordance.test.ts b/packages/spec/src/data/managed-api-affordance.test.ts new file mode 100644 index 0000000000..69e875cc4f --- /dev/null +++ b/packages/spec/src/data/managed-api-affordance.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The shared managed-`apiMethods` predicate (#7521). These cases mirror +// `reconcileManagedApiMethods`'s own suite in `@objectstack/objectql` +// one-for-one: that function is now a pure REACTION to this predicate, so any +// divergence between the two suites means the strip changed shape. + +import { describe, expect, it } from 'vitest'; + +import { + MANAGED_WRITE_VERB_AFFORDANCE, + checkManagedApiMethodAffordances, + describeManagedApiMethodConflicts, + type ManagedApiMethodConflict, +} from './managed-api-affordance'; + +/** The fixture the registry suite uses: all-locked `better-auth`, full CRUD declared. */ +const managed = (extra: Record = {}): Record => ({ + name: 'sys_thing', + managedBy: 'better-auth', + enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] }, + ...extra, +}); + +const verbs = (conflicts: readonly ManagedApiMethodConflict[]): string[] => + conflicts.map((c) => c.verb); + +describe('checkManagedApiMethodAffordances — the contradiction it names', () => { + it('reports every write verb an all-locked managed bucket refuses', () => { + const conflicts = checkManagedApiMethodAffordances(managed()); + expect(verbs(conflicts)).toEqual(['create', 'update', 'delete']); + expect(conflicts.map((c) => c.needs)).toEqual(['create', 'edit', 'delete']); + }); + + it('never reports read verbs — only writes are affordance-gated', () => { + const conflicts = checkManagedApiMethodAffordances( + managed({ enable: { apiMethods: ['get', 'list', 'search', 'history'] } }), + ); + expect(conflicts).toEqual([]); + }); + + it('keeps `update` once `userActions.edit` opens the affordance (the sys_user case)', () => { + const conflicts = checkManagedApiMethodAffordances(managed({ userActions: { edit: true } })); + expect(verbs(conflicts)).toEqual(['create', 'delete']); + }); + + it('covers the legacy `upsert`/`purge` verbs a raw whitelist may still carry (#3543)', () => { + const conflicts = checkManagedApiMethodAffordances( + managed({ enable: { apiMethods: ['upsert', 'purge'] } }), + ); + expect(conflicts).toEqual([ + { verb: 'upsert', needs: 'edit', index: 0 }, + { verb: 'purge', needs: 'delete', index: 1 }, + ]); + }); + + it('is the exact shape #7521 was filed for (sys_environment / sys_package)', () => { + // `managedBy: 'platform'` grants CRUD by default, so the contradiction here + // comes from `userActions` CLOSING the writes while `apiMethods` still + // advertises them. This declaration booted the control plane for months. + const conflicts = checkManagedApiMethodAffordances({ + name: 'sys_environment', + managedBy: 'platform', + userActions: { create: false, edit: false, delete: false }, + enable: { apiMethods: ['get', 'list', 'create', 'update'] }, + }); + expect(verbs(conflicts)).toEqual(['create', 'update']); + }); +}); + +describe('checkManagedApiMethodAffordances — what it deliberately does not judge', () => { + it('unmanaged objects: no bucket default, nothing to contradict', () => { + expect( + checkManagedApiMethodAffordances({ + name: 'crm_lead', + enable: { apiMethods: ['get', 'create', 'update', 'delete'] }, + }), + ).toEqual([]); + }); + + it('a bucket that grants the writes reports nothing (`platform`, `system-data`)', () => { + const full = ['get', 'list', 'create', 'update', 'delete']; + expect( + checkManagedApiMethodAffordances({ managedBy: 'platform', enable: { apiMethods: full } }), + ).toEqual([]); + expect( + checkManagedApiMethodAffordances({ managedBy: 'system-data', enable: { apiMethods: full } }), + ).toEqual([]); + }); + + it('`undefined` (unrestricted) and `[]` (deny-all) advertise nothing', () => { + expect(checkManagedApiMethodAffordances(managed({ enable: {} }))).toEqual([]); + expect(checkManagedApiMethodAffordances(managed({ enable: { apiMethods: [] } }))).toEqual([]); + }); + + it('tolerates junk the Zod path would have rejected, rather than throwing', () => { + // A raw / out-of-band metadata write can reach the registry unparsed; the + // predicate must return a verdict, never blow up mid-registration. + expect(checkManagedApiMethodAffordances(null)).toEqual([]); + expect(checkManagedApiMethodAffordances('nope')).toEqual([]); + expect(checkManagedApiMethodAffordances(managed({ enable: { apiMethods: 'create' } }))).toEqual( + [], + ); + expect( + verbs(checkManagedApiMethodAffordances(managed({ enable: { apiMethods: [7, null, 'create'] } }))), + ).toEqual(['create']); + }); +}); + +describe('checkManagedApiMethodAffordances — the index contract', () => { + it('indexes point at the declared array, so a caller can filter without collapsing duplicates', () => { + const schema = managed({ enable: { apiMethods: ['get', 'create', 'list', 'create'] } }); + const conflicts = checkManagedApiMethodAffordances(schema); + expect(conflicts.map((c) => c.index)).toEqual([1, 3]); + + // The rebuild `reconcileManagedApiMethods` performs, done here to pin that + // a duplicated offender does not take a legitimate verb down with it. + const declared = ['get', 'create', 'list', 'create']; + const strip = new Set(conflicts.map((c) => c.index)); + expect(declared.filter((_v, i) => !strip.has(i))).toEqual(['get', 'list']); + }); +}); + +describe('the verb → affordance table', () => { + it('maps exactly the five generic write verbs, and is frozen', () => { + expect(MANAGED_WRITE_VERB_AFFORDANCE).toEqual({ + create: 'create', + update: 'edit', + upsert: 'edit', + delete: 'delete', + purge: 'delete', + }); + expect(Object.isFrozen(MANAGED_WRITE_VERB_AFFORDANCE)).toBe(true); + }); +}); + +describe('describeManagedApiMethodConflicts', () => { + it('names the verbs and the affordance they would need', () => { + const msg = describeManagedApiMethodConflicts(checkManagedApiMethodAffordances(managed())); + expect(msg).toContain('create, update, delete'); + expect(msg).toContain('userActions create/edit/delete'); + expect(msg).toContain('enable.apiMethods'); + expect(msg).toContain('ADR-0092'); + }); + + it('states the contradiction only — the remedy belongs to each consumer', () => { + const msg = describeManagedApiMethodConflicts(checkManagedApiMethodAffordances(managed())); + expect(msg).not.toMatch(/\b(drop|remove|Either)\b/); + }); + + it('does not repeat an affordance flag shared by two verbs', () => { + const msg = describeManagedApiMethodConflicts( + checkManagedApiMethodAffordances(managed({ enable: { apiMethods: ['update', 'upsert'] } })), + ); + expect(msg).toContain('needs userActions edit)'); + }); + + it('reads correctly for a single conflict', () => { + const msg = describeManagedApiMethodConflicts( + checkManagedApiMethodAffordances(managed({ enable: { apiMethods: ['get', 'delete'] } })), + ); + expect(msg).toContain('[delete]'); + expect(msg).toContain('needs userActions delete)'); + }); +}); diff --git a/packages/spec/src/data/managed-api-affordance.ts b/packages/spec/src/data/managed-api-affordance.ts new file mode 100644 index 0000000000..9e2543eb92 --- /dev/null +++ b/packages/spec/src/data/managed-api-affordance.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The managed-object `apiMethods` ⊆ affordances predicate — ONE table, read by + * the boot-time strip and by the authoring-time gate (#7521). + * + * ## What it decides + * + * A `managedBy` object that advertises a generic **write** verb in + * `enable.apiMethods` while its resolved affordances refuse that write is + * internally contradictory: the metadata says the API offers a verb the object + * itself does not permit. That is ADR-0049's `declared != enforced` class, + * stated entirely within one object's own declaration — no call graph, no + * runtime state, so it is fully decidable at authoring time. + * + * ## Why the predicate lives here rather than at either consumer + * + * It has exactly two consumers, and they sit on opposite sides of a package + * boundary that cannot be crossed in either direction: + * + * - `reconcileManagedApiMethods` (`@objectstack/objectql`'s `registry.ts`) — + * the registration-time backstop that STRIPS the offending verbs and warns + * (ADR-0092 / ADR-0103). Deliberately warn-and-strip, never throw: a + * metadata typo must not kill a control-plane boot. + * - `validateManagedApiMethods` (`@objectstack/lint`) — the authoring-time + * gate that reports the same contradiction as a finding, at the layer where + * the author is present. + * + * `@objectstack/lint` depends on `@objectstack/spec` and, by its own stated + * contract, **never on a runtime** — so it cannot import objectql's table; and + * objectql must not depend on the lint package. Housing the judgement in spec — + * beside {@link resolveCrudAffordances}, the affordance authority both sides + * already read — is the only arrangement in which the rule exists once. It is + * the same shape, and the same reason, as `checkFieldCompleteness` + * (`@objectstack/spec/kernel`), which serves the registry's functional- + * completeness warning and `@objectstack/lint`'s `validate-functional- + * completeness` gate from one predicate (ADR-0078). + * + * If a verdict here seems wrong, fix it HERE. A second copy at either consumer + * reintroduces precisely the declared≠enforced drift this predicate detects. + * + * ## The window it closes (#7521, found via cloud#1225) + * + * `sys_environment` and `sys_package` (both `managedBy: 'platform'` with + * `userActions` create/edit/delete all `false`) declared + * `apiMethods: ['get','list','create','update']`. The registry stripped + * `create`/`update` correctly on every control-plane boot — and the + * `console.warn` announcing it went unread for the life of the divergence. The + * strip itself is fail-closed, so nothing was ever exposed; what was lost was + * the SIGNAL. A boot log is not an authoring surface, and the split was + * eventually found by hand-driving the HTTP seam, not by any gate. + */ + +import { resolveCrudAffordances, type ServiceObject } from './object.zod'; + +/** + * Generic-write `apiMethods` verbs mapped to the {@link resolveCrudAffordances} + * flag each one needs. Read verbs (`get`/`list`/`search`/`history`/…) are + * always permitted, so they are absent here and are never reported. + * + * ⚠️ Two orthogonal axes — do NOT merge this with the API-tightening table. + * This table (verb → *affordance*) is the UI-intent axis: what a managed bucket + * *offers*. The verb → *primitive* derivation that decides what the automatic + * API *admits* lives in `./api-derivation` (`API_METHOD_DERIVATION` / + * `resolveEffectiveApiMethods`, #3391). The identical-shaped + * `WRITE_OP_AFFORDANCE` in plugin-security's `system-write-guard.ts` is the + * runtime enforcement of this same UI-intent 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 this predicate must keep covering them. + */ +export const MANAGED_WRITE_VERB_AFFORDANCE: Readonly> = + Object.freeze({ + create: 'create', + update: 'edit', + upsert: 'edit', + delete: 'delete', + purge: 'delete', + }); + +/** One declared write verb an object's own resolved affordances refuse. */ +export interface ManagedApiMethodConflict { + /** The offending verb, exactly as declared in `enable.apiMethods`. */ + verb: string; + /** The {@link CrudAffordances} flag that verb needs and does not have. */ + needs: 'create' | 'edit' | 'delete'; + /** Index of the offending entry within the declared `enable.apiMethods` array. */ + index: number; +} + +/** + * Every generic write verb `enable.apiMethods` declares that this object's + * resolved affordances — bucket default plus `userActions` overrides, exactly + * as {@link resolveCrudAffordances} computes them for the UI — do not grant. + * + * An empty result means the declaration is coherent. Reads are never reported. + * + * Scope, matching the registration-time backstop one-for-one so the two can + * never disagree: + * + * - **unmanaged objects are out of scope** — no `managedBy` means no bucket + * default to contradict, so nothing is judged; + * - a missing, non-array or empty `apiMethods` is out of scope — `undefined` + * means "unrestricted" and `[]` means "deny-all" (see `./api-derivation`); + * neither advertises anything to contradict. + * + * Conflicts are returned in declaration order, **one entry per offending + * occurrence** rather than per distinct verb, so a caller rebuilding the + * whitelist can filter by index without collapsing a duplicated entry. + */ +export function checkManagedApiMethodAffordances(schema: unknown): ManagedApiMethodConflict[] { + if (!schema || typeof schema !== 'object') return []; + const obj = schema as { managedBy?: unknown; enable?: { apiMethods?: unknown } }; + if (obj.managedBy == null) return []; + + const methods = obj.enable?.apiMethods; + if (!Array.isArray(methods) || methods.length === 0) return []; + + const affordances = resolveCrudAffordances(schema as ServiceObject); + const out: ManagedApiMethodConflict[] = []; + for (const [index, verb] of methods.entries()) { + if (typeof verb !== 'string') continue; + const needs = MANAGED_WRITE_VERB_AFFORDANCE[verb]; + if (needs && !affordances[needs]) out.push({ verb, needs, index }); + } + return out; +} + +/** + * The one sentence both consumers say about a conflict, so an operator who + * greps a stripped verb out of a boot log and an author reading the lint + * finding are told the same thing in the same words. + * + * States the CONTRADICTION only. Each consumer appends its own remedy, because + * the honest remedy differs by where you are standing: at registration the verb + * has already been taken away and the operator's next move is to find the + * author, while at authoring time both doors — open the affordance, or drop the + * verb — are still open. + */ +export function describeManagedApiMethodConflicts( + conflicts: readonly ManagedApiMethodConflict[], +): string { + const verbs = conflicts.map((c) => c.verb).join(', '); + const flags = [...new Set(conflicts.map((c) => c.needs))].join('/'); + return ( + `advertises generic write verb(s) [${verbs}] in enable.apiMethods that its resolved ` + + `affordances do not permit (needs userActions ${flags}) — see ADR-0092 / ADR-0103.` + ); +}