Skip to content

Commit 2767af8

Browse files
fix(plugin-hono-server): /auth/me/permissions speaks for an unrestricted object when the export axis withholds export (#18984)
Fixes #18931 Clause-②: no `/auth/me/permissions` now reports an unrestricted object's effective operation set whenever the export axis withholds `export`, so the Console stops rendering an Export button the server answers `403 EXPORT_NOT_PERMITTED`. This is the **stop-the-silence half only**, which is the half triage said needs no ruling: 「⭐『停止静默』那一半今天就能做且不需要任何裁决 —— 通道对这一类主体不出声,本身就是可以先修的那一半。」 It deliberately does **not** decide the other half — 「『服务端补种』与『客户端在读不到时不渲染』是两条不同的收法,⛔ 分诊不替执行席选」 — and it does not have to: after this change the client **can** read the permission, so no policy about a client that cannot read one is needed or implied. No Console file is touched. ## The defect, re-derived on today's `origin/main` The card pinned `@objectstack/*` 17.4.0 at `objectstack main@02bdeaaf`. That anchor has moved; the behaviour has not. Re-derived at `89c6ec52b5` by driving the endpoint's own four passes — seed, fold, clamp, annotate — with the card's principal verbatim (`'*'` carrying the super-user bits and no `allowExport`): ``` BEFORE crm_account: entry=present apiOperations=["get","list","create","update","delete","upsert","aggregate","search","import"] clientShowsExport=false crm_lead: entry=ABSENT apiOperations=undefined clientShowsExport=YES (default-allow) AFTER crm_account: entry=present apiOperations=["get","list","create","update","delete","upsert","aggregate","search","import"] clientShowsExport=false crm_lead: entry=present apiOperations=["get","list","create","update","delete","upsert","bulk","aggregate","search","import"] clientShowsExport=false ``` The 29-class row is byte-identical before and after; only the silent class moves. **Read from the symbols, not the card's line numbers.** `seedSuperUserRestrictedObjects` resolved each schema without the export slot and skipped every `unrestricted` one: ```ts const eff = resolveEffectiveApiMethods(schema.enable ?? undefined); if (eff.mode === 'unrestricted') continue; // only restricting objects ``` `annotateEffectiveApiOperations` resolves **with** the slot and iterates existing entries only — `for (const [obj, acc] of Object.entries(objects))` — so its own export-axis branch cannot fire for an object that was never seeded. Two predicates for one question, disagreeing on exactly the population #8681 created. ## Premises tested rather than adopted - **The census — re-derived with a stated predicate, neither number inherited.** Predicate: an object whose source declares no `apiMethods` key. The card's 29/9 is HotCRM's, and HotCRM is not in this repo. In this repo's own corpus: **26 of 26** `examples/**/*.object.ts` are unrestricted, and `apiMethods` is a platform convention (44 of 48 `packages/platform-objects` objects declare one). So the affected population is not a 24% minority here — it is **every application-authored object**. That strengthens the card rather than weakening it. - **The card's CRUD-axis claim — tested, and it holds.** Measured across the `enable` shapes an unrestricted object can carry: withholding `export` subtracts `export` and nothing else, and `mode` stays `unrestricted` in both directions. So the fix is **not** larger than the card thinks: the CRUD axis needed no annotation and still gets none. It also explains why a `mode`-only guard could never tell the two cases apart — the discriminator is `userExportAllowed`, not `mode`. - **Does seeding newly interact with the managed-write clamp?** Measured: the guarded buckets (`better-auth`, `engine-owned`, `append-only`) hold 60 objects in this tree and **all 60 already declare `apiMethods`**, so the "guarded and unrestricted" population is empty and this change introduces no new clamp interaction. Control leg: the same scan lists all 60, so the empty result is a reading and not a broken scan. Emptiness is a property of today's corpus, not of the construction — an app object could adopt `managedBy` without `apiMethods` tomorrow. ## The change The seed now applies annotate's own predicate: ```ts const userExportAllowed = objects['*']?.allowExport === true; ... const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); if (eff.mode === 'unrestricted' && userExportAllowed) continue; ``` A seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser` does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to the same wildcard bit the seed read. The two passes now share one predicate and cannot diverge again. **What it costs.** For a modify-all principal holding no export grant, the response gains one entry per unrestricted object — in this repo's corpus, one per application object. Each entry's CRUD bits are folded `true`: the same answer the client already computed by falling back to `'*'`, now stated explicitly rather than inherited. Denial is untouched (`enforceExportPermission` → `security.canExport` still answers 403); no request that was refused is now accepted. **The `modifyAllRecords` guard is unchanged.** #18931 widens *which schemas* are considered, never *which principals* — a viewAll-only caller still gets nothing, because materialising a `false` entry for a caller the fold does not pull true would flip the client from default-allow to explicit deny. ## The regression pin triage asked for 「它正是被减掉的那一类」 — an object with no `apiMethods` declaration. Pinned in `packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts`, asserting **pedigree and not a count**: the entry exists, its `apiOperations` omits `export`, and it equals the full unrestricted closure minus `export` — so neither the wrong entry nor an over-narrow one can satisfy it. Two controls beside it: the same object stays unseeded once the principal really may export, and the viewAll-only guard still seeds nothing. **Which tier it lands in, measured in both directions before choosing the file** (the #18848 shape — a pin only a nightly cron can collect has no reader at the moment it matters): - Tier selection is by **filename** and nothing else (`*.e2e.test.*`, `*.live.test.*`). `node scripts/nightly-tiers.mjs --packages` reports **one** package owns nightly-tier files — `@objectstack/cli`, 68 of them. `plugin-hono-server` owns **zero**, and the negative direction is mechanical too: grepping that output for `plugin-hono-server` returns 0. - `plugin-hono-server`'s `vitest.config.ts` declares a **single unnamed project** — there is no `--project` narrowing in this package at all, so `pnpm test` is the whole suite, exactly as CI runs it. - Its package is reached by `Test Core`'s `turbo ls --affected` set because this diff edits its `src/`. ⇒ collected **per-PR and in the merge queue**, never nightly-only. **One existing fixture was re-judged, not bulk-renamed.** `for a modify-all super-user, seeds false-init entries for restricting objects only` asserted `objects.open_obj` undefined with a wildcard carrying no `allowExport` — so after #3544 it was reading the export axis while naming the `apiMethods` derivation. It now grants `allowExport: true`, for the reason the annotate block already states at its head. Coverage is not deleted: the withheld-export case is the new pin. ## Verification **Owning package's whole suite, the way CI runs it** — no `--project`, no hand-picked files: ``` pnpm --filter @objectstack/plugin-hono-server test VERDICT command-exit 0 Test Files 26 passed (26) Tests 309 passed | 1 todo (310) ``` **Ablation — the pin can fail, and it fails on this defect.** The source predicate was reverted to its pre-fix form, the mutation proven on disk before the run and the restore proven by hash afterwards: ``` HEAD blob = ba5ceba on-disk mutated = 4b23955604f4caf3dead588aaf710c2d474fc017 (differs -> mutation landed) anchor counts = fixed spelling 2 -> 1, reverted spelling 0 -> 1 ABLATION exit = 1 x is seeded, and its annotation withholds export while keeping everything else Test Files 1 failed | 25 passed (26) Tests 1 failed | 308 passed (310) on-disk restored = ba5ceba == HEAD blob git diff HEAD = empty; git status --porcelain = empty ``` Direction observed: **transitions to red**, on exactly the new assertion. The two control tests in the same block stayed green under the mutation, which is what makes the pin targeted rather than a blanket. Exit codes were captured before any pipe throughout. **Changeset by measurement, controls both ways.** The package ships `files: ["dist", …]`, so the question is whether a shipped artifact moves: ``` baseline dist/index.js (clean source) = 94080c02612de7650c7995f078e871c08d4ac261 arm (a) revert the CODE predicate, rebuild = 7e128fe76d58ca9971d75df38c48e44fea7b6be2 -> MOVES arm (b) revert ONLY comment text, rebuild = 94080c02612de7650c7995f078e871c08d4ac261 -> UNCHANGED (control proven on disk: marker present 1, replaced text 0, source hash moved) restored: source == HEAD blob, dist == baseline ``` Arm (b) is the answer to "only comments changed is not nothing published moves": comments are stripped from the shipped artifact, so arm (a)'s movement is attributable to code. A released package's published output moves ⇒ **`patch` changeset**, and `skip-changeset` would have been wrong. **Gate families**, derived mechanically from the real change set with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived from a tree **at** `origin/main` after merging (the first derivation warned STALE TREE and was discarded): **59 commands, 56 exit 0**. Three report `PREREQUISITE NOT MET` and exit **3** — `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt` — each of which needs a built workspace closure. Exit 3 is recorded as **NOT MEASURED**, neither a pass nor a red, in the words the gates themselves print. `check-widening-tells` was run against the real diff and reports **NOT MEASURED**: no declared surface covers either file, so the mechanical half of the clause-② reading abstains and the declaration below rests on the diff reading stated next. ## Clause-② reading `Clause-②: no`, decided from the diff: - **Accepted set — unchanged.** `enforceExportPermission` and `security.canExport` are untouched; nothing refused before is accepted now. The diff touches no request-acceptance path. - **Public surface — no new member.** No endpoint, no response field, no exported symbol, no type, and no `packages/spec/src/**` path. `apiOperations` is an existing published field on an existing map entry (#3391). What changes is which objects carry an entry, for one principal class. - **Direction — restrictive at the consumer.** The client goes from "no information, default-allow, renders Export" to "explicit array without `export`, hides Export". This change can only cause a button to be hidden, never shown. A widening reading is available — a channel now reports something it did not report — and it is flagged for the seat rather than taken silently; it would force at least a `minor`, which is the wrong size for a bug fix in a released package. ## Lane boundary `SKILL.md:250` lists `plugin-hono-server` verbatim under `domain:cli`, and the fix landed **entirely** in `packages/plugins/plugin-hono-server/src/`. `plugin-security` and `plugin-auth` were **not** required and are not touched, so 〈多仓协调〉's no-seat-change clause did not need to be invoked. Recording the reading as asked; no label was changed. ## Acceptance notes Observed while working, deliberately **not** fixed here: - **The viewAll-only population has the same silence** and sits outside the `modifyAllRecords` guard — the card's own Options §2, which it defers ("Follow-up; not required"). This PR closes #18931, so that half would otherwise lose its home; handed to the seat to file rather than filed here. - **`ResolveApiOptions.userExportAllowed`'s docstring in `packages/spec/src/data/api-derivation.ts` contradicts shipped behaviour** — it reads "Always `true` this phase (there is no user-level export permission bit yet)", which #3544 falsified when it wired the bit in. An author trusting it would conclude the export axis is inert and could simplify the parameter away, which is the shape of this very incident. It is in `packages/spec` (a different lane) and outside this card's surface; handed to the seat. - **Payload growth** is the intended cost of the fix, named above rather than filed. Two things could **not** be measured here and are reported as such, not as passes: - **The client leg.** The card cites `objectui packages/permissions/src/MePermissionsProvider.tsx` `getObjectApiOperations` ("Absent → undefined so consumers fall back to their current (default-allow) path"). `objectstack-ai/objectui` is not configured for this session, so that leg was not re-verified today. It bounds the claim, not the fix: the server change can only ever remove an affordance. - **The three exit-3 gates** named above. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- _Generated by [Claude Code](https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f347c79 commit 2767af8

3 files changed

Lines changed: 114 additions & 9 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/plugin-hono-server": patch
3+
---
4+
5+
`/auth/me/permissions` now reports an unrestricted object's effective operation set whenever the export axis withholds `export`, so the Console stops rendering an Export button the server answers `403 EXPORT_NOT_PERMITTED` (#18931).
6+
7+
`Clause-②: no`
8+
9+
The endpoint builds its per-object map in four passes — seed, fold, clamp, annotate. `seedSuperUserRestrictedObjects` resolved each registered schema **without** the export slot and skipped every `unrestricted` one; `annotateEffectiveApiOperations` resolves **with** it and iterates existing entries only. Two predicates for one question, and they disagreed on exactly one population: a principal whose only grant is a `'*'` wildcard carrying `modifyAllRecords` and no `allowExport` — which, since #8681 removed the wildcard export grant from the built-in admin sets, is every platform administrator holding no app-authored set.
10+
11+
For that principal an unrestricted object got no entry, so annotate never saw it and the response said nothing about it at all. The client reads `apiOperations: undefined`, takes the default-allow path #3391 gave it, renders **Export**, and the click is refused. A sibling object declaring `apiMethods` got an entry, an `apiOperations` without `export`, and no button — the same principal, the same session, two answers.
12+
13+
- **The seed now applies annotate's own predicate**: resolve with the export slot annotate will read for the entry being seeded, and skip only an object that is unrestricted **and** keeps `export`. A seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser` does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to the same wildcard bit the seed read — the two passes cannot diverge again.
14+
- **The export axis is the only axis this reaches.** Measured across the `enable` shapes an unrestricted object can carry: withholding `export` subtracts `export` and nothing else, and `mode` stays `unrestricted` either way — which is why the old `mode`-only guard could not tell the two cases apart. The CRUD axis needed no annotation and still gets none.
15+
- **What the response gains**: for such a principal, one entry per unrestricted object, each the full closure minus `export`. Its CRUD bits are folded `true` — the same answer the client already computed by falling back to `'*'`, now stated explicitly rather than inherited.
16+
- **Denial is unchanged.** `enforceExportPermission` → `security.canExport` still answers `403 EXPORT_NOT_PERMITTED`, and no request that was refused is now accepted. This is the affordance half: the channel that is supposed to tell the client now does.

‎packages/plugins/plugin-hono-server/src/current-user-endpoints.ts‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -457,18 +457,35 @@ export interface ApiExposureSchemaLike {
457457
* materializing a `false` entry would flip the client's `check('edit')` from
458458
* "undefined → default-allow" to "explicit false → deny" — a scope-exceeding
459459
* behavior change. A modify-all caller is folded to `true` anyway, so seeding is
460-
* harmless there. Unrestricted objects are skipped (they carry no annotation).
460+
* harmless there.
461+
*
462+
* [#18931] A schema is skipped only when it needs NO annotation, which is the
463+
* predicate {@link annotateEffectiveApiOperations} itself applies: unrestricted
464+
* AND the export axis leaves `export` in place. Resolving WITHOUT the export
465+
* slot made this pass disagree with annotate's — an unrestricted object whose
466+
* `export` the axis withholds got no entry, annotate (which iterates existing
467+
* entries only) never saw it, and `/me/permissions` stayed silent for exactly
468+
* the population #8681 created: a wildcard-only admin holding no `allowExport`.
469+
* The client's `apiOperations` is then `undefined`, its default-allow path
470+
* renders an Export button, and the click is refused `403 EXPORT_NOT_PERMITTED`.
461471
*/
462472
export function seedSuperUserRestrictedObjects(
463473
objects: Record<string, any>,
464474
allSchemas: readonly ApiExposureSchemaLike[],
465475
): void {
466476
if (objects?.['*']?.modifyAllRecords !== true) return;
477+
// [#18931] The export slot annotate will read for an entry seeded here. A
478+
// seeded entry carries no `allowExport` of its own and `foldWildcardSuperUser`
479+
// does not add one, so annotate's `acc.allowExport ?? wildExport` resolves to
480+
// exactly this wildcard bit — the two passes cannot diverge again.
481+
const userExportAllowed = objects['*']?.allowExport === true;
467482
for (const schema of allSchemas) {
468483
const name = schema?.name;
469484
if (!name || name === '*' || objects[name]) continue;
470-
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined);
471-
if (eff.mode === 'unrestricted') continue; // only restricting objects
485+
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed });
486+
// Same skip predicate as annotate: there is nothing to say about an
487+
// unrestricted object that keeps its full operation closure.
488+
if (eff.mode === 'unrestricted' && userExportAllowed) continue;
472489
objects[name] = { allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false };
473490
}
474491
}
@@ -479,10 +496,14 @@ export function seedSuperUserRestrictedObjects(
479496
*
480497
* This is the single "effective" channel the frontend consumes — it renders the
481498
* operations the server hands down here, never the raw `apiMethods` whitelist.
482-
* Only objects whose whitelist actually tightens exposure are annotated (a
483-
* `deny-all` object gets an empty array; an unrestricted object gets nothing, so
484-
* the client keeps its default-allow behavior). Runs AFTER fold + clamp so the
485-
* annotation sits alongside the final CRUD affordances.
499+
* An object is annotated whenever its effective set is narrower than the
500+
* client's default-allow assumption (a `deny-all` object gets an empty array;
501+
* [#3544] an unrestricted object gets one too whenever the export axis withholds
502+
* `export`). Only an unrestricted object that keeps its full closure gets
503+
* nothing, because for it default-allow is already the right answer. Runs AFTER
504+
* fold + clamp so the annotation sits alongside the final CRUD affordances, and
505+
* {@link seedSuperUserRestrictedObjects} applies the same predicate so a
506+
* wildcard-only principal has an entry here to annotate ([#18931]).
486507
*/
487508
export function annotateEffectiveApiOperations(
488509
objects: Record<string, any>,

‎packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,19 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => {
173173
];
174174

175175
it('for a modify-all super-user, seeds false-init entries for restricting objects only', () => {
176-
const objects: Record<string, any> = { '*': { modifyAllRecords: true, viewAllRecords: true } };
176+
// [#18931] `allowExport: true` is what keeps this case about the `apiMethods`
177+
// DERIVATION — the same reason the annotate block above grants it. Without
178+
// it the export axis also withholds `export`, which is its own reason to
179+
// seed `open_obj`, and this assertion would be reading that instead. The
180+
// withheld-export case is pinned separately below.
181+
const objects: Record<string, any> = {
182+
'*': { modifyAllRecords: true, viewAllRecords: true, allowExport: true },
183+
};
177184
seedSuperUserRestrictedObjects(objects, schemas);
178185
expect(objects.widget).toEqual({ allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false });
179186
expect(objects.locked).toBeDefined();
180-
// unrestricted objects are NOT seeded (no annotation to attach)
187+
// an unrestricted object that keeps its FULL closure is NOT seeded — for it
188+
// the client's default-allow path is already the right answer
181189
expect(objects.open_obj).toBeUndefined();
182190
});
183191

@@ -218,4 +226,64 @@ describe('seedSuperUserRestrictedObjects (#3391)', () => {
218226
annotateEffectiveApiOperations(objects, (name) => schemas.find((s) => s.name === name));
219227
expect(objects.widget.apiOperations).toEqual(['get', 'list', 'aggregate', 'search', 'export']);
220228
});
229+
230+
// [#18931] The class this pass used to SUBTRACT: an object with NO `apiMethods`
231+
// declaration, for a principal whose only grant is a wildcard carrying no
232+
// `allowExport` — every built-in admin since #8681. It got no entry, so
233+
// `annotateEffectiveApiOperations` (which iterates existing entries only)
234+
// never saw it and `/me/permissions` said nothing about it at all; the client
235+
// read `apiOperations: undefined`, took its default-allow path, rendered
236+
// Export, and the click came back `403 EXPORT_NOT_PERMITTED`.
237+
describe('the unrestricted object a wildcard-only principal cannot be told about (#18931)', () => {
238+
// Exactly the card's shape: `{ apiEnabled: true }` and nothing else.
239+
const unrestricted: ApiExposureSchemaLike[] = [{ name: 'crm_lead', enable: { apiEnabled: true } }];
240+
// The card's principal: org owner holding no app-authored set. Its wildcard
241+
// carries the super-user bits and NO `allowExport` (#8681, by design).
242+
const wildcardOnlyAdmin = () => ({
243+
'*': {
244+
allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true,
245+
viewAllRecords: true, modifyAllRecords: true, allowTransfer: false,
246+
},
247+
});
248+
249+
it('is seeded, and its annotation withholds export while keeping everything else', () => {
250+
const objects: Record<string, any> = wildcardOnlyAdmin();
251+
seedSuperUserRestrictedObjects(objects, unrestricted);
252+
annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name));
253+
254+
// PEDIGREE, not a count — a bare "an entry exists" is satisfied by the
255+
// wrong entry, and a bare length is satisfied by the wrong set.
256+
expect(objects.crm_lead).toBeDefined();
257+
expect(objects.crm_lead.apiOperations).toBeDefined();
258+
// The one operation the server refuses for this principal is the one the
259+
// client must not offer.
260+
expect(objects.crm_lead.apiOperations).not.toContain('export');
261+
// …and withholding it costs the object nothing else: this is the full
262+
// unrestricted closure minus `export`, so the fix cannot be satisfied by
263+
// an over-narrow entry that hides unrelated affordances too.
264+
expect(objects.crm_lead.apiOperations).toEqual(
265+
['get', 'list', 'create', 'update', 'delete', 'upsert', 'bulk', 'aggregate', 'search', 'import'],
266+
);
267+
});
268+
269+
it('stays silent for the same object once the principal really may export', () => {
270+
// The control: same schema, same super-user bits, `allowExport` granted.
271+
// Nothing is withheld, so there is nothing to say and the client's
272+
// default-allow path is correct — no entry, exactly as before #18931.
273+
const objects: Record<string, any> = wildcardOnlyAdmin();
274+
objects['*'].allowExport = true;
275+
seedSuperUserRestrictedObjects(objects, unrestricted);
276+
annotateEffectiveApiOperations(objects, (name) => unrestricted.find((s) => s.name === name));
277+
expect(objects.crm_lead).toBeUndefined();
278+
});
279+
280+
it('does not reach a viewAll-only principal (the seed guard is unchanged)', () => {
281+
// Materialising a `false` entry for a caller the fold does NOT pull true
282+
// would flip the client's `check()` from default-allow to explicit deny.
283+
// #18931 widens WHICH schemas are considered, never WHICH principals.
284+
const objects: Record<string, any> = { '*': { viewAllRecords: true } };
285+
seedSuperUserRestrictedObjects(objects, unrestricted);
286+
expect(objects.crm_lead).toBeUndefined();
287+
});
288+
});
221289
});

0 commit comments

Comments
 (0)