Skip to content

Commit ad047d2

Browse files
os-zhuangclaude
andauthored
feat(spec)!: 移除 app.areas[] 的两个 fail-open 访问闸门 —— visible / requiredPermissions (#4651) (#4718)
* feat(spec)!: retire the two fail-open app-area gates (#4651) `app.areas[].visible` and `app.areas[].requiredPermissions` leave the authorable contract. They were not ordinary dead keys: they were capability gates that FAILED OPEN. `filterAppForUser` (packages/rest/src/rest-server.ts) checks the app's `requiredPermissions`, then walks ONLY `item.navigation` — it returns early when that tree is absent and never reads `item.areas` at all — while the client renders every area in the switcher. An author writing `requiredPermissions: ['sales.admin']` on an area got a clean parse, a stored value, and an area visible to everyone. What made them read alive is that the SAME names are enforced one level up and one level down: app-level `requiredPermissions` drops the whole app server-side, and a navigation ITEM's `requiredPermissions` / `requiresService` are stripped server-side and re-checked in the shell, whose item-level `visible` is a real CEL gate. Three layers, of which the middle one was theatre — ADR-0078 false compliance, the `capabilities.readOnly` shape (#4583). Route B (remove) per the maintainer's ruling. Route A (enforce) is not wrong, it is unscoped: area filtering needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window cannot hold — and a retirement PR must not invent an authorization mechanism. Shipping the major with the gate still declared would have kept authors writing it for all of 17.x. `NavigationAreaSchema` is `.strict()`, so this is the strict-deletion route: the keys leave the shape and two `guidance` prescriptions carry the upgrade. The prescriptions name the layers that DO enforce rather than saying "removed", and carry the caveat that per-item gating INSIDE an area is shell-side only — trading one false belief for a weaker one would repeat the defect. The three alias spellings that used to RENAME onto these keys (`visibleWhen`, `visibleOn`, `permissions`) become prescriptions too: an alias pointing at a key that is itself gone answers an unknown key with a second unknown key (#4667 `sort` precedent). ADR-0087: a dedicated `app-area-fail-open-gates-removed` conversion rather than two more keys on `app-dead-authoring-keys-removed`. That entry's summary is a list of inert authoring keys; this is a security finding, and the summary string is what spec-changes.json, the upgrade guide and the `spec_changes` MCP tool serve — folded in, "a gate that never gated has been removed" would arrive buried in a sentence about `version` and `mobileNavigation`. Wired into the protocol-17 D3 chain step with its rationale. Ledgers, per route: the liveness rows are DELETED (strict removal — the keys left the walked shape, and retained rows report ORPHAN), and the CLI advisory lint falls silent by itself, its test flipped from warn-pin to silence-pin with an anti-vacuity guard. The strictness ledger needs no change and was not touched: `check:strictness-ledger` counts `z.object(` SITES per file, and this removes keys from an existing site — the C3 lesson applies to deleting a `.strict()` shape, which this is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 * docs(spec): sync the liveness README row + add the major changeset (#4651) The `app` row's counts come from `check-liveness.mts --json byStatus`, the number CI enforces — regenerated rather than hand-edited: dead 11 → 9 after the two area-gate rows were deleted. (Its Notes cell said "Dead 12" against a column reading 11, so that was already stale; corrected in the same pass.) The changeset is deliberately NOT the "zero metadata migration" shape the same-window renames (#4684 C9, #4535 C12) could claim. Those kept every key. These two are authorable keys on a `.strict()` schema, so existing `app` metadata declaring either one now fails to parse and `authorable-surface.json` is net −2. It carries the FROM → TO table, the one-line fix, the `os migrate meta --from 16` command, and the caveat that per-item gating inside an area is shell-side only — a breaking changeset ships as CHANGELOG.md in the npm package and is what an upgrading agent greps after the rejection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2823d82 commit ad047d2

14 files changed

Lines changed: 596 additions & 62 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@objectstack/spec': major
3+
---
4+
5+
feat(spec)!: retire the two fail-open app-area gates — `app.areas[].visible` and `app.areas[].requiredPermissions` (#4651)
6+
7+
These were **not** inert authoring keys. They were capability gates that **failed
8+
open**: an author wrote `requiredPermissions: ['sales.admin']` on a navigation
9+
area, got a clean parse and a stored value, and the area — with everything under
10+
it — was served and rendered to **every user**.
11+
12+
**This is a breaking change with a real migration.** Both keys are authorable
13+
metadata keys on a `.strict()` schema, so existing `app` metadata that declares
14+
either one now **fails to parse** with the prescription below. `authorable-surface.json`
15+
is net **−2 keys**. This is not the "zero metadata migration" shape of the
16+
same-window renames (#4661 C8, #4684 C9) — those kept every key.
17+
18+
**The retirement kit:**
19+
20+
| FROM | TO | Fix |
21+
|---|---|---|
22+
| `app.areas[].requiredPermissions` | *(removed)* | Delete the key. Gate each of the area's `navigation` items with `requiredPermissions` / `requiresService`, or gate the whole app with `requiredPermissions` on the AppSchema. |
23+
| `app.areas[].visible` | *(removed)* | Delete the key. Move the same CEL expression onto the area's `navigation` items — a navigation **item**'s `visible` is evaluated per item by the shell. |
24+
25+
The retired alias spellings `visibleWhen` / `visibleOn` / `permissions` carry the
26+
same prescriptions rather than renaming onto keys that are themselves gone.
27+
28+
Run `os migrate meta --from 16` to rewrite existing sources automatically
29+
(ADR-0087 D2 conversion `app-area-fail-open-gates-removed`, wired into the
30+
protocol-17 D3 chain step).
31+
32+
**Why they read alive — and why that made them worse than dead.** The *same key
33+
names* are genuinely enforced one level up and one level down:
34+
35+
- **app-level** `requiredPermissions` — server-side: an app whose required
36+
permissions the caller lacks is dropped from `/meta` entirely;
37+
- **item-level** `requiredPermissions` / `requiresService` — stripped server-side
38+
from the app's top-level `navigation` tree, and re-checked in the shell;
39+
item-level `visible` is a real CEL gate in the shell.
40+
41+
Three layers, of which the middle one was theatre — `filterAppForUser` reads the
42+
app's `requiredPermissions` and then walks **only** `item.navigation`; it never
43+
touches `item.areas`, and the client renders every area in the switcher. ADR-0078
44+
false compliance, the same shape as `capabilities.readOnly` (#4583).
45+
46+
**Removed rather than enforced (ADR-0049), deliberately.** Enforcing area gates
47+
is not wrong, it is unscoped: it needs semantics settled first — when an area is
48+
filtered out, do its items disappear everywhere, or still participate in other
49+
areas? does the server bind `user` for area-level CEL? — and a retirement must
50+
not invent an authorization mechanism. Removing a gate that never gated is
51+
strictly safer than shipping a major with it still declared, which would have
52+
kept authors writing it for all of 17.x.
53+
54+
**One caveat the prescription carries rather than hides:** per-item gating
55+
*inside* an area is enforced by the shell only, because the server does not walk
56+
`areas`. Anything that must never reach the browser belongs in the app's
57+
top-level `navigation` tree, or in its own app. Trading one false belief for a
58+
weaker one would have repeated the defect this removal exists to end.

content/docs/references/ui/app.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,6 @@ const result = ActionNavItemSchema.parse(data);
211211
| **label** | `string` || Area display label |
212212
| **icon** | `string` | optional | Area icon name |
213213
| **description** | `string` | optional | Area description |
214-
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) for this area. |
215-
| **requiredPermissions** | `string[]` | optional | Permissions required to access this area |
216214
| **navigation** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[]` || Navigation items within this area |
217215

218216

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ Separately, `object.managedBy: 'system'` is retired in favour of `'system-data'`
168168

169169
Finally, five keys retire because the advisory lint could never have warned about them (#4509): mapping `extractQuery` / `errorPolicy` / `batchSize`, and app `contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, and a default materialises at parse time — so the liveness lint cannot tell a value the author wrote from one the schema supplied, and marking them would have warned on every mapping and every selector in existence. For a key in that state removal is not the escalation after a warning; it is the only channel that ever reaches the author, which is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What they claimed: `extractQuery` promised an export path no exporter implements (exports go through the ordinary query API); `errorPolicy` offered skip/abort/retry where error handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth reading twice — it was not unread but deliberately DISOBEYED, because context selectors are mandatory-scope and an "All" row would clear the scope: on Studio's package selector that means listing the platform's own system/cloud kernel packages to a developer who scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ignored it. The mapping prescription for `batchSize` deliberately offers no rename: bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all live, but each is a different key sizing its own path — the same trap `datasource.retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.
170170

171+
The sharpest removal in this step is two keys wide: `app.areas[].visible` and `app.areas[].requiredPermissions` (#4651). Read the class before the count — these were not inert authoring keys but FAIL-OPEN access gates. The server-side authority (`filterAppForUser`) checks the app's `requiredPermissions` and then walks ONLY the top-level `navigation` tree; it never reads `item.areas` at all, and the client renders every area in the switcher. So an author writing `requiredPermissions: ['sales.admin']` on an area got a clean parse, a stored value, and an area visible to everybody — and had every reason to believe otherwise, because the SAME key names are genuinely enforced one level up and one level down: app-level `requiredPermissions` drops the whole app server-side, and a navigation ITEM's `requiredPermissions` / `requiresService` are stripped server-side and re-checked in the shell, whose item-level `visible` is a real CEL gate. Three layers, of which the middle one was theatre. Enforcing instead was weighed and deliberately not taken here: it needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), and a retirement must not invent an authorization mechanism — while shipping a major with the gate still declared would have kept authors writing it for all of 17.x. The rewrite is lossless in outcome (the keys changed nothing), so what an upgrading author has to re-decide is only where the gate really goes: onto the items inside the area, or onto the app. One honest caveat the prescription carries rather than hides — per-item gating INSIDE an area is enforced by the shell only, since the server does not walk `areas`, so anything that must never reach the browser belongs in the top-level tree or in its own app.
172+
171173
The same window converges the retry policy (#4661). `@objectstack/spec/automation` and `@objectstack/spec/system` each exported a `RetryPolicy`/`RetryPolicySchema` resolving to a DIFFERENT declaration, so which shape a consumer got depended only on the import path (#4411) — yet both computed `delay = base * multiplier^(retry-1)` and both executors implemented that same formula. One declaration now serves both entries with the union of their capabilities, so `job.retryPolicy` gains the `maxRetryDelayMs` ceiling and `jitter` (both enforced in `runWithPolicy`, not merely declared — jitter is what stops a fleet of jobs that failed on one outage from retrying in lockstep). The single authorable casualty is the automation spelling of the base delay: `retryDelayMs``backoffMs`, a pure rename that replays losslessly and is what the already-enforced retry policies (`job.retryPolicy`, `hook.retryPolicy`) call it.
172174

173175
The subtle half is the defaults, and it is worth stating because no gate can see it: `job.retryPolicy` defaulted `maxRetries: 3` / `backoffMultiplier: 2` while the automation shape defaulted 0 / 1, and the authorable-surface gate compares KEY SETS — a changed default is invisible to it, to the tombstone mechanism and to `spec_changes` alike. The merged declaration takes 0 / 1 (retry replays side effects, so it is opt-in — the same reading already recorded in `flow-retry-max-retries-required`), and the conversion writes the pre-17 numbers into every existing `job.retryPolicy` that omitted them. Deployed stacks therefore keep their exact behaviour; what changes is only what a NEWLY authored omission means.
@@ -192,6 +194,7 @@ The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` dr
192194
| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only |
193195
| `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only |
194196
| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas) | retired — `migrate meta` only |
197+
| `app-area-fail-open-gates-removed` | `app.areas.visible / app.areas.requiredPermissions` | navigation-area keys 'visible'/'requiredPermissions' removed (#4651, ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app) | retired — `migrate meta` only |
195198
| `field-required-notnull-explicit` | `object.fields.*.required / object.fields.*.storage.notNull` | required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract) | retired — `migrate meta` only |
196199
| `action-inert-keys-removed` | `action.shortcut / action.bulkEnabled` | action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | retired — `migrate meta` only |
197200
| `flow-inert-keys-removed` | `flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId` | flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle) | retired — `migrate meta` only |

packages/cli/src/utils/lint-liveness-properties.test.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -258,15 +258,19 @@ describe('lintLivenessProperties', () => {
258258
// ── #4488 — the nine remaining types, governed. Pins run against the REAL
259259
// ledgers, one per finding class the audit surfaced.
260260

261-
// The app ledger's most important entries: area-level gating keys that FAIL
262-
// OPEN (nothing evaluates them, so a "hidden"/"gated" area shows for
263-
// everyone), on the surface whose item-level siblings ARE enforced.
264-
// `homePageId` and `areas.order` used to be asserted here too. Both were
265-
// RETIRED in 17.0.0 (#4667) — the schema owns them now (a tombstone and a
266-
// strict rejection respectively), so this advisory lint correctly says
267-
// nothing about them. The two that remain are the ones #4651 still has to
268-
// decide, and they are the reason this test exists.
269-
it('warns on the fail-open area gates (#4488, tracked as #4651)', () => {
261+
// The app ledger's most important entries were the area-level gating keys
262+
// that FAILED OPEN — nothing evaluated them, so a "hidden"/"gated" area
263+
// showed for everyone, on the surface whose item-level siblings ARE enforced.
264+
// This test used to assert the WARNING. #4651 removed the keys (route B),
265+
// so the advisory lint must now say nothing about them: `NavigationAreaSchema`
266+
// is strict and rejects them at parse with the prescription, which reaches an
267+
// author harder and earlier than an advisory line, and warning about a key
268+
// that no longer parses is noise. Same disposition `homePageId` and
269+
// `areas.order` reached in #4667.
270+
//
271+
// Kept as a SILENCE pin rather than deleted: a half-reverted retirement
272+
// (ledger rows restored without the schema, or vice versa) shows up here.
273+
it('is silent on the fail-open area gates — retired in 17.0.0 (#4651)', () => {
270274
const findings = lintLivenessProperties({
271275
apps: [{
272276
name: 'crm',
@@ -281,12 +285,36 @@ describe('lintLivenessProperties', () => {
281285
}],
282286
});
283287
const msgs = paths(findings);
284-
expect(msgs.some((m) => m.includes('areas.visible'))).toBe(true);
285-
expect(msgs.some((m) => m.includes('areas.requiredPermissions'))).toBe(true);
286-
// The gating hints must point at the enforced alternative (per-item gates),
287-
// or the warning just relocates the author's confusion.
288-
const perms = findings.find((f) => f.message.includes('areas.requiredPermissions'));
289-
expect(perms!.hint).toMatch(/per item|Per-item/i);
288+
expect(msgs.some((m) => m.includes('areas.visible'))).toBe(false);
289+
expect(msgs.some((m) => m.includes('areas.requiredPermissions'))).toBe(false);
290+
expect(findings).toEqual([]);
291+
});
292+
293+
// Anti-vacuity guard for the pin above. `lintLivenessProperties` resolves the
294+
// shipped ledgers off `@objectstack/spec/package.json` and returns [] when it
295+
// cannot find them — so "no findings" is also what a BROKEN lint returns, and
296+
// the silence pin alone would pass on a lint that had stopped reading ledgers
297+
// entirely. This asserts it still warns on a property that is still marked
298+
// `authorWarn` (`object.externalSharingModel`, the last one in tree), in the
299+
// same call that authors the retired area gates: same process, same ledger
300+
// load, one warning and not three.
301+
it('the area-gate silence is a real verdict, not a lint that stopped loading ledgers', () => {
302+
const findings = lintLivenessProperties({
303+
objects: [{ name: 'widget', externalSharingModel: 'read' }],
304+
apps: [{
305+
name: 'crm',
306+
label: 'CRM',
307+
areas: [{
308+
id: 'area_sales',
309+
label: 'Sales',
310+
visible: "'sales' in current_user.positions",
311+
requiredPermissions: ['crm.access'],
312+
navigation: [],
313+
}],
314+
}],
315+
});
316+
expect(paths(findings).some((m) => m.includes('externalSharingModel'))).toBe(true);
317+
expect(paths(findings).some((m) => m.includes('areas.'))).toBe(false);
290318
});
291319

292320
// email_template used to carry a per-artifact warn on `name`: the WHOLE

packages/lint/src/validate-capability-references.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,13 @@ export function validateCapabilityReferences(stack: AnyRec): CapabilityRefFindin
166166
}
167167
}
168168

169-
// ── Apps: requiredPermissions can appear at the app, area/tab, and nav-item
170-
// (recursively through groups) levels. Walk each app subtree. ──
169+
// ── Apps: requiredPermissions can appear at the app and nav-item
170+
// (recursively through groups) levels. Walk each app subtree. `areas` is
171+
// still traversed, but only to REACH the nav items nested inside it: the
172+
// area itself stopped carrying `requiredPermissions` in 17.0.0 (#4651 — it
173+
// was a fail-open gate nothing enforced), so the generic check below no
174+
// longer fires on an area node. Dropping the traversal would strand every
175+
// area-nested item. ──
171176
const apps = asArray(stack.apps);
172177
for (let i = 0; i < apps.length; i++) {
173178
const app = apps[i];

0 commit comments

Comments
 (0)