Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/auth-mount-ledger-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
"@objectstack/plugin-auth": patch
---

A `rawApp` mount under the auth `basePath` with no ledger row now fails a gate (#10534 follow-up 4)

`auth-plugin.ts` mounts routes **directly on the raw Hono app, ahead of the
better-auth catch-all**. The catch-all never sees them, so the vendor's own
route table cannot account for them, and `auth.api`'s enumeration — which is
what `auth-route-ledger.conformance.test.ts` drives — cannot see them either. A
mount with no row in either half of the ledger was therefore invisible to every
check in this tree. That is not hypothetical: it is what produced #9941
(`organization/add-member` mounted, ledgered nowhere) and #10050 (the same route
mounted and undocumented), and the #10534 census found it was not a one-off —
**nine of the seventeen mounts were in neither half**.

The pin the conformance suite already carries asserts the `source: 'objectstack'`
set exactly, so it fails when a row **disappears**. What it structurally cannot
do is fail when a **mount appears** with no row, because *both* of its sides are
hand-written: the mount list in that assertion is a copy of the truth, not a
reading of it, and nobody who adds route 18 has to touch it.

`pnpm check:auth-mount-ledger` (`scripts/check-auth-mount-ledger.mjs`) supplies
the missing half. It enumerates the mounts from `auth-plugin.ts` **source** and
diffs them against both halves of the ledger. No runtime surface is added and no
route's mounting, behaviour or accept/reject set changes.

Four properties it is built to, each measured rather than assumed:

- **The match carries a right boundary.** Accounting is exact string equality on
`METHOD /full/wire/path`. #10534's own census read "5 undocumented" when the
truth was 6, because it matched by substring and `/admin/sso/register` is a
strict prefix of `/admin/sso/register-saml` — the shorter route was scored
accounted-for on the strength of its longer sibling's URL. When an unaccounted
mount does stand in a prefix relation to a ledgered route, the failure text
**says so**, so the property is observable in the output rather than implicit
in a comparison operator.
- **`rawApp.all` and `rawApp.use` are excluded.** The better-auth catch-all is an
`.all()` and the IP gate is a `.use()`; both would otherwise read as unledgered
mounts. They are the lanes routes arrive through, not routes.
- **A row alone does not satisfy it.** A disposition cannot be inferred
mechanically, and a gate that accepts a pasted row teaches the next author to
paste a row. An `source: 'objectstack'` row must carry the evidence its
disposition claims: `client:` for `sdk`, a substantive `note:` for
`server-only`/`disabled`/`public`. The failure text names the peer-group
discriminator and the `set-initial-password` precedent, and offers
`PENDING_DISPOSITION` — with an issue number — as the honest answer for
"undecided", instead of the nearest allowed word.
- **A partial read is never reported as a complete one.** Any `rawApp` mounting
form the census cannot read per-route (an unrecognised verb such as
`rawApp.on(...)`, a non-literal path, an unresolved interpolation) is a
finding. A missing file, a moved anchor, an underivable `basePath` or a parse
that yields zero of anything exits **2** and prints `NOT MEASURED`.

On `bbe643c08` the gate is green: 17 mounts, 12 accounted for by a reviewed
ledger row, 4 shadowing a vendor-declared path, and one —
`POST /api/v1/auth/set-initial-password` — declared in `PENDING_DISPOSITION`
against #10975, printed on every clean run so the open question stays visible.
That list is a shrink-only ratchet; #10975 landing deletes the entry, and the
gate then fails if it is still there.
21 changes: 21 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -1205,6 +1205,27 @@ jobs:
- name: Dispatcher error-code vocabulary guard
run: pnpm check:dispatcher-error-vocabulary

# #10534 follow-up 4: a `rawApp` mount under the auth basePath with no ledger
# row. `auth-plugin.ts` mounts routes DIRECTLY on the raw Hono app, ahead of
# the better-auth catch-all, so the vendor's route table cannot account for
# them and `auth.api`'s enumeration -- which the conformance suite drives --
# cannot see them either. That state produced #9941 and #10050, and the
# #10534 census found NINE of seventeen mounts in neither half of the ledger.
# The pin the suite already carries asserts the objectstack-sourced set
# exactly, so it fails when a row DISAPPEARS; it structurally cannot fail
# when a MOUNT APPEARS, because both of its sides are hand-written. This
# gate supplies that half by enumerating the mounts from SOURCE. Accounting
# is exact on `METHOD path`: matching by substring silently credits a prefix
# route to its longer sibling, which is the artifact that made #10534's own
# census read 5 when the truth was 6. A row alone does not satisfy it -- an
# objectstack row must carry the evidence its disposition claims -- because
# a gate that accepts a pasted row teaches the next author to paste one.
# No `paths:` filter, deliberately: either input can move it, and both live
# in one package, so a filter would only ever blind it. Runs its own
# --self-test first.
- name: Auth mount-vs-ledger guard
run: pnpm check:auth-mount-ledger

# Optional-`error` sink contract (#9754, wired by #10608). A sink TYPE that
# declares `error` as OPTIONAL must also declare `warn` as NON-optional, so
# every value of that type has somewhere to put a durability report —
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@
"check:verify-stand-in": "node scripts/check-verify-stand-in-erasure.mjs --self-test && node scripts/check-verify-stand-in-erasure.mjs",
"check:service-providers": "node scripts/check-service-providers.mjs",
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
"check:auth-mount-ledger": "node scripts/check-auth-mount-ledger.mjs --self-test && node scripts/check-auth-mount-ledger.mjs",
"check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs",
"check:error-status-conformance": "node scripts/check-error-status-conformance.mjs --self-test && node scripts/check-error-status-conformance.mjs",
"check:dispatcher-error-vocabulary": "node scripts/check-dispatcher-error-vocabulary.mjs --self-test && node scripts/check-dispatcher-error-vocabulary.mjs",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,11 +180,18 @@ describe('auth route ledger hygiene', () => {
// mounts, of which nine were in NEITHER half of the ledger; eight are
// ledgered now. This pin is the thing that makes the enlarged set
// reviewable: an ObjectStack mount added or removed without a matching
// row fails HERE, naming the route, which is the closest mechanical check
// that exists today for the "mounted with no ledger row" state. It is not
// a substitute for the mount-vs-ledger gate #10534 proposes — this list
// is still hand-written, so it catches a row that disappears, not a mount
// that never got one. The ninth mount,
// row fails HERE, naming the route. It is not a substitute for the
// mount-vs-ledger gate, because this list is still hand-written: it
// catches a row that disappears, not a mount that never got one.
//
// That other half now EXISTS — `scripts/check-auth-mount-ledger.mjs`
// (`pnpm check:auth-mount-ledger`, #10534 follow-up 4). It enumerates the
// `rawApp` mounts from `auth-plugin.ts` SOURCE and diffs them against both
// halves of the ledger, so a mount added with no row fails THERE. The two
// are complements rather than duplicates, and both are worth keeping: this
// pin is a reviewed, hand-written statement of what the objectstack-sourced
// set IS, and the gate is a reading of what the plugin actually serves.
// The ninth mount,
// `POST /api/v1/auth/set-initial-password`, is deliberately absent: its
// disposition is escalated on #10534 rather than guessed (see the ledger
// comment above these rows).
Expand Down
Loading
Loading