Repository navigation
fix(hono-server): seed /me/permissions for a viewAll-only wildcard principal too - #19242
Conversation
…incipal too `seedSuperUserRestrictedObjects` was guarded to `modifyAllRecords`, so a principal reaching an object only via a wildcard `viewAllRecords` grant got no entry at all: the client fell back to default-allow and rendered write and Export affordances the server answers 403. Admit both super-user classes through one shared reading of the wildcard READ bypass (`wildcardGrantsSuperRead`), the same question `foldWildcardSuperUser` already asks and the same one `PermissionEvaluator` applies server-side. The seeded entry starts all-false, the fold pulls `allowRead` true, the write bits stay false, and `annotateEffectiveApiOperations` attaches `apiOperations` through the predicate already shared with the modify-all class. Ruling: batch #159 item 1, letter A. Two `toBeUndefined` pins on the viewAll-only class are inverted deliberately under it. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude <noreply@anthropic.com>
…ewall-only-permissions-seed
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b398e25dd69706af7ddab657a874b4bebb9387c0 && git checkout b398e25dd69706af7ddab657a874b4bebb9387c0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 847e5773a494aa0b495aee9c72528490cff1cdcf 0625da76a140de07fcedb2e4d77a2d1133c81ba1 && git checkout -B drift-repro 847e5773a494aa0b495aee9c72528490cff1cdcf && git merge --no-ff 0625da76a140de07fcedb2e4d77a2d1133c81ba1
node scripts/docs-audit/affected-docs.mjs --json 847e5773a494aa0b495aee9c72528490cff1cdcf |
|
Landed by another seat under a direct maintainer instruction — audit record. Acted by the The three things this kind of act owes: whose instruction, its words, and where
What was foundThis PR was finished and green five hours before the instruction, and nothing had moved it:
⇒ two finished PRs (this one and objectstack#19241) were left parked when that seat went quiet. ⛔ This is ⛔ not a reclamation of the seat — the inactivity rule is a day, and it is five hours. Only these two PRs were touched, because only they were named. The content review this seat did before flipping it — ⛔ not a rubber stampThe change admits both super-user bypass bits to the seeding pass ( The load-bearing claim was checked against the server's own evaluator rather than taken from the PR body:
What this seat did, and did not
Generated by Claude Code |
…gineCanRollBack, not the engine-only probe (objectstack-ai#18997) (objectstack-ai#19286) Fixes objectstack-ai#18997 Clause-②: no `getDiscovery()` derived `capabilities.transactionalBatch` from the ENGINE alone — `typeof this.engine?.transaction === 'function'` — while `runAtomicBatch` refuses `batchData({ atomic: true })` with `501 NOT_IMPLEMENTED` on `engineCanRollBack(this.engine)`, which asks the DEFAULT DRIVER as well. `engine.transaction` is a function on every real engine, so `/discovery` answered `true` for exactly the compositions that then 501 — and the 501's own remedy text sends the caller to that bit ("probe `capabilities.transactionalBatch` on /discovery first"). The prescribed remedy routed the caller to a signal that was wrong in the only case the remedy exists for. One line at the read site: the advertisement now asks the predicate the refusal already trusts. ## What a consumer that sees `true` today will see after this lands `/discovery` is a published surface, so this is stated rather than assumed. Two compositions stop advertising `true` and begin advertising `false`. Both already answered `501 NOT_IMPLEMENTED` to an atomic batch before this change, so **nothing that was accepted becomes refused** — only a lying value becomes honest: - **(a) a default driver with no `beginTransaction` at all** — pre-existing on `main`, not introduced by objectstack-ai#18063; - **(b) a default driver that inherits `beginTransaction` and declares `supports.transactionsUnsupported`** — the population objectstack-ai#18063 added. The shipped example is `TursoDriver` on its remote transport (`packages/drivers/driver-turso/src/turso-driver.ts`, `transactionsUnsupported: this.transportMode === 'remote'`). A client that read `true` and proceeded was taking the 501; it now reads `false` and takes its non-atomic fallback ahead of the failure, which is what probing the capability was for. A client that hard-asserts `transactionalBatch === true` at startup against such a composition now fails at startup instead of at its first atomic batch. The REST producer's own AND with `api.enableBatch` (`packages/rest/src/rest-server.ts`) is unchanged and inherits the corrected input. Everything else is byte-identical: the key, its type, its `description` and its place in the closed capability vocabulary do not move. ## Clause-② — measured, not argued The claim comment grades this `no` on the ground that it narrows. The falsifier was to be measured, not assumed: if any composition advertised `true` where `origin/main` advertises `false`, that would be a widening. Enumerated over all 16 compositions of the four inputs the two predicates can read (engine has `transaction`; driver registry inspectable; driver has `beginTransaction`; driver declares `transactionsUnsupported`), comparing `main`'s derivation with this one: ``` compositions: 16 | widened (false -> true): 0 | narrowed (true -> false): 3 VERDICT: NO WIDENING — accept set untouched, advertisement narrows only ``` The 3 narrowed rows are population (a) x2 (driver has no `beginTransaction`, with and without the declaration) and population (b) x1. `Clause-②: no` holds. ## Both directions, or the card is not delivered "A fix that makes the flag honest by making it always `false` is worse than the bug." So the TRUE arm is pinned beside the FALSE arm, and each composition is driven through `batchData({ atomic: true })` in the same test — the advertisement and the refusal have to agree composition by composition. `packages/metadata-protocol/src/protocol.discovery-transactional-batch-honesty.test.ts` (6 cases): - a default driver that CAN roll back: still `true`, and the atomic batch commits; - a host whose driver registry is not inspectable (test doubles, metadata-only hosts): still `true` — the engine-level probe is all there is, which is what `engineCanRollBack` documents; - population (a): `false`, and the 501 arrives with the remedy sentence this card is about, having attempted no write; - population (b): `false`, same refusal; - no `transaction()` at all: `false` (unchanged); - and the rule itself — the advertised bit equals `engineCanRollBack()` for every composition, so a future composition that satisfies one end and not the other reds here even if nobody adds a case for it. **Against the shipped driver**, measured on the built `dist/` after the fix, not inferred: ``` real MemoryDriver: typeof beginTransaction = function | supports.transactionsUnsupported = undefined | driverSupportsTransactions = true /discovery capabilities.transactionalBatch = {"enabled":true} ``` The default dev composition keeps its `true`. ## Evidence - **Pre-fix reproduction.** The pin file was written first and run unmodified against unmodified `protocol.ts`: `Tests 3 failed | 3 passed (6)` — the two population cases and the predicate-agreement case red, the TRUE-arm controls already green. Post-fix: `Test Files 1 passed (1) · Tests 6 passed (6)`. - **Ablation** through `scripts/ablation-replace.mjs`, from the committed state, with on-disk proof: anchor `1 -> 0`, blob `0b04d1ff5d3a -> 7c63e7631f05`, then `3 failed | 3 passed`; restore verified `blob == HEAD (0b04d1f)` and `git diff HEAD` empty. No `dist` preflight is owed: the pin imports `./protocol.js`, so the mutation is on the resolution path directly. - **Package suite** `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 181 passed | 3 skipped (184) · Tests 2591 passed | 19 skipped (2610)`. `typecheck` exit 0, and `tsc --noEmit --listFiles` names the new pin file among the 750 files in the program, so the type check really covers it. - **Cross-package consumer** `packages/objectql/src/protocol-discovery.test.ts` against the rebuilt `metadata-protocol` dist: `32 passed (32)` — including the real-`ObjectQL`-engine pin that must keep advertising `true`. - **Gates**, derived from the actual diff with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` and reconciled with `--ran`: 59 derived, **56 run, all exit 0**, 3 NOT MEASURED. The 3 exited `3` (PREREQUISITE NOT MET, nothing measured) because they read a whole-workspace `dist/` this checkout does not carry — `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt`; CI's `Build Core` supplies the prerequisite they refuse without. `check:plugin-teardown-shape --self-test` also exited 3 at first (its fixture commit is unreachable in a shallow clone) and exits 0 after fetching that commit. - **`pnpm lint`** (repo-wide `eslint . --no-inline-config`): exit 0. All readings are from `objectstack-ai/objectstack` at this branch's head commit. ## Acceptance notes - `publishPackageDrafts` in the same file keeps an engine-only probe (`typeof this.engine?.transaction === 'function'`) for its own `inTxn` helper. Noted, not filed, and deliberately not changed here: it advertises nothing to a caller, and both of its branches deliver the same guarantee today — `engine.transaction()` takes its declared non-transactional path (ADR-0119 D1) for exactly the drivers the probe would have excluded, so swapping the predicate there is a no-op in behaviour. It would become a real question only for whoever makes draft-publish atomicity a promise rather than a best effort. - The runtime dispatcher's hardcoded `transactionalBatch: { enabled: false }` (`packages/runtime/src/http-dispatcher.ts`) is correct and untouched: that face mounts no `/batch` route. - Scope held to `packages/metadata-protocol/src/` plus the changeset. `packages/spec` is not touched — the spec half of objectstack-ai#18063 is landed and correct. - `origin/main` moved two commits while this was in flight (`objectstack-ai#19162` checklist-status workflow, `objectstack-ai#19242` hono-server). Neither touches this package; the `dispatch-gates.mjs` change in that range is to its own self-test roster and derives the same 59 families for this diff. Not merged down; the merge queue rebuilds on current `main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18990
Clause-②: no
/auth/me/permissionsstayed completely silent about every object a principal could reach when that principal's only grant was a wildcard carryingviewAllRecords.seedSuperUserRestrictedObjectsreturned early unless the wildcard carriedmodifyAllRecords, so the object got no entry, the client fell back to its default-allow path, and it rendered write and Export affordances the server answers403 EXPORT_NOT_PERMITTED. Same silence and same consequence as the class #18931 closed, for a different principal class.Implements the ruling on this card — batch #159 item 1, letter A, maintainer 「同意」 2026-09-18T11:42Z, recorded at comment
5729478681.What changed
One predicate admits both principal classes. The seed now asks the wildcard READ bypass rather than the modify bit alone. That question already existed twice in this file's neighbourhood —
foldWildcardSuperUserasks it to decide whoseallowReadit pulls true, andPermissionEvaluator.checkObjectPermissionapplies the same bypass server-side (viewAllRecords || modifyAllRecordsshort-circuits read; only the modify bit reaches the write axis). It is now a single module-local reading that the fold and the seed share, so the seed structurally cannot materialise an entry for a principal the fold leaves entirely false.The export axis needed no new branch. This was the premise worth falsifying, since adding a second predicate for one question is the exact defect #18931 was about. It holds: the seed resolves
resolveEffectiveApiMethodswithuserExportAllowedread off the wildcard'sallowExport, annotate resolves it withacc.allowExport ?? wildExport, and a seeded entry carries noallowExportof its own while the fold adds none — so the two resolve to the same bit, and both skip on the sameunrestricted && userExportAllowed. The ruling's carve-out ("skip only an unrestricted object whose export stays allowed") is therefore reached through the predicate that was already there, not a copy of it.A plain wildcard grant carrying neither bypass bit is still not seeded, and that is what makes the admission the read bypass rather than "any wildcard": the fold pulls nothing true for such a principal, so a seeded entry would be an all-false claim with no server behaviour behind it. Pinned as its own case.
⭐ Two
toBeUndefinedpins are INVERTED on purposeA reviewer should not have to guess whether a flipped pin was intentional. Both flips are the ruling being carried out, not collateral:
does not reach a viewAll-only principal (the seed guard is unchanged)— the pin PR fix(plugin-hono-server): /auth/me/permissions speaks for an unrestricted object when the export axis withholds export #18984 added, and the one the ruling names by its text. That PR pinned this boundary deliberately while saying in the same breath that the pin was not a ruling that the silence was correct. The ruling on this card is what now says otherwise.does not seed for a viewAll-only wildcard (avoids flipping check() to explicit deny)— the older 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391-era pin on the same principal class. The ruling names only the first; this one asserts the identical thing about the identical class, so it moves with it. Flagging it explicitly because the dispatch order named one pin and the diff flips two.The behaviour change they pinned is real and deliberate: for a viewAll-only principal, a client that read "no entry" as default-allow now reads an explicit
allowEdit: false. That is the truth about it — the seed only ever touches objects with no explicit entry (if (objects[name]) continue), and on those a viewAll-only principal genuinely can only read, while the silence it replaces was not true about anything.Verification
Reverse verification, run from the committed fix and restored byte-exact (
git hash-objectequal to the HEAD blob,git diff HEADempty). Expected direction chosen before the run: the two new POSITIVE pins go red; the two negative/control pins cannot distinguish and stay green. That is what happened — replacing the shared predicate with the oldmodifyAllRecords-only guard:Tests 2 failed | 22 passed, failing exactlyseeds for a viewAll-only wildcard tooandreaches a viewAll-only principal: read folded true, writes explicitly false. The two absence-asserting cases stayed green under the old guard, correctly: absence held there too, so they are controls on the predicate's other half and not evidence for the fix.pnpm --filter @objectstack/plugin-hono-server test:: exit 0 — 26 files, 311 passed, 1 todopnpm --filter @objectstack/plugin-hono-server typecheck:: exit 0 — 0 errors, 0 pinned signaturespnpm lint(the full repo union,eslint . --no-inline-config) :: exit 0, at0625da76adispatch-gates --commands --repo objectstack-ai/objectstack, reconciled with--rancarrying every exit code: 56 run green, 3 NOT MEASURED, 0 UNRUNThe three NOT MEASURED are
check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt. Each exits 3 (PREREQUISITE NOT MET, which is neither a pass nor a failure) because it reads built output across the whole workspace and this tree has only theplugin-hono-serverdependency closure built. CI builds the workspace before them. A declared narrowing, not a skipped gate.check-widening-tells --declaration noexits 0 but reports all 3 files NOT MEASURED — no declared surface coverspackages/plugins/**— so theClause-②: noabove is a structural measurement, not that gate's clearance: the diff adds no exported symbol (wildcardGrantsSuperReadis module-local), no key on the published payload (theobjectsmap gains entries, not keys), no registration and no error code.EXPORT_NOT_PERMITTEDappears in this diff only as prose citing the existing code.Acceptance notes
patchon@objectstack/plugin-hono-server. A behaviour fix in a released package. The body carries the deliberate-behaviour-change warning for whoever compiles the release notes, since a consumer reading "no entry" as default-allow sees a different answer after this.packages/spec—resolveEffectiveApiMethodsis imported, not edited. The declared file surfacepackages/plugins/plugin-hono-server/src/was not breached; the only file outside it is the changeset this package owes.plugin-hono-server, confirmed again at this branch's base.Generated by Claude Code