Repository navigation
Commit 2767af8
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
- .changeset
- packages/plugins/plugin-hono-server/src
Lines changed: 16 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
Lines changed: 28 additions & 7 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
457 | 457 | | |
458 | 458 | | |
459 | 459 | | |
460 | | - | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
461 | 471 | | |
462 | 472 | | |
463 | 473 | | |
464 | 474 | | |
465 | 475 | | |
466 | 476 | | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
467 | 482 | | |
468 | 483 | | |
469 | 484 | | |
470 | | - | |
471 | | - | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
472 | 489 | | |
473 | 490 | | |
474 | 491 | | |
| |||
479 | 496 | | |
480 | 497 | | |
481 | 498 | | |
482 | | - | |
483 | | - | |
484 | | - | |
485 | | - | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
486 | 507 | | |
487 | 508 | | |
488 | 509 | | |
| |||
Lines changed: 70 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
173 | 173 | | |
174 | 174 | | |
175 | 175 | | |
176 | | - | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
177 | 184 | | |
178 | 185 | | |
179 | 186 | | |
180 | | - | |
| 187 | + | |
| 188 | + | |
181 | 189 | | |
182 | 190 | | |
183 | 191 | | |
| |||
218 | 226 | | |
219 | 227 | | |
220 | 228 | | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
221 | 289 | | |
0 commit comments