|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | /** |
4 | | - * Object-level API exposure gate (ADR-0049, #1889). |
| 4 | + * Object-level API exposure gate (ADR-0049, #1889, #3391). |
5 | 5 | * |
6 | 6 | * 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. |
11 | 10 | * |
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). |
18 | 30 | */ |
19 | 31 |
|
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). */ |
21 | 41 | export interface ObjectApiDef { |
22 | 42 | apiEnabled?: boolean; |
23 | 43 | apiMethods?: string[] | null; |
| 44 | + /** Real `getObject()` shape — the exposure flags live under `enable`. */ |
| 45 | + enable?: EnableLike | null; |
| 46 | + [key: string]: unknown; |
24 | 47 | } |
25 | 48 |
|
26 | 49 | export interface ApiExposureDecision { |
27 | 50 | allowed: boolean; |
28 | 51 | /** HTTP status to return when denied (404 hides, 405 = method not allowed). */ |
29 | 52 | status?: number; |
30 | 53 | reason?: string; |
| 54 | + /** The effective operation set (enum-ordered) — surfaced on a 405 denial. */ |
| 55 | + allowedOperations?: string[]; |
31 | 56 | } |
32 | 57 |
|
33 | 58 | /** |
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. |
37 | 62 | */ |
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 | +} |
50 | 67 |
|
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 { |
52 | 81 | // Unresolvable definition → fall open to the schema defaults. |
53 | 82 | if (!def) return { allowed: true }; |
54 | 83 |
|
| 84 | + const enable = resolveEnableBlock(def); |
| 85 | + |
55 | 86 | // `apiEnabled: false` hides the object from the API entirely → 404. |
56 | | - if (def.apiEnabled === false) { |
| 87 | + if (enable.apiEnabled === false) { |
57 | 88 | return { allowed: false, status: 404, reason: 'object is not exposed via the API' }; |
58 | 89 | } |
59 | 90 |
|
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 }; |
73 | 101 |
|
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 | + }; |
75 | 108 | } |
0 commit comments