Repository navigation
feat(spec): discovery reports which auth route families are mounted (authFamilies.admin) - #21145
Conversation
…authFamilies.admin) DiscoverySchema gains an optional, closed `authFamilies` block. `admin` says whether the better-auth admin family is mounted, read through the new `readAuthFamilies` from the auth service's own getPublicConfig() -- the object GET /auth/config serves -- so neither discovery producer re-derives whether the admin plugin is on. Both producers (metadata-protocol getDiscovery, runtime getDiscoveryInfo) emit it; REST /discovery passes it through. Pins: spec reader/schema, both producer unit pins, and a dogfood door pin that boots showcase stock and with the admin plugin on and checks the two discovery documents against /auth/config and the wire. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
api-surface, export-origins, declaration-map, authorable-surface, json-schema manifest, reference docs and the strictness-ledger count, from a dist built at the previous commit (check:generated green). Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
check:spec-parsed-alias named the new `AuthFamilies` alias: a closed object of booleans, so z.input === z.infer and the ADR's remedy is the isomorphism pin, not an `AuthFamiliesParsed` synonym. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 142 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 53b5c7083bf45d6e6189ad1a072bddf710fe29b9 && git checkout 53b5c7083bf45d6e6189ad1a072bddf710fe29b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3917d9090b3401c545757a034e163b1cff9c0a1 155c7268ff5e91c4f1a87bfe389c68130cc7ec00 && git checkout -B drift-repro b3917d9090b3401c545757a034e163b1cff9c0a1 && git merge --no-ff 155c7268ff5e91c4f1a87bfe389c68130cc7ec00
node scripts/docs-audit/affected-docs.mjs --json b3917d9090b3401c545757a034e163b1cff9c0a1
|
Contract reviewServed-tier: Inputs read: card #21046 (body, triage grade ① Derived judgments(a) One source, no second derivation — holds.
(b) The contract shape — accepted as shipped, with two product-contract flags recorded in ③.
(c) Pins — they hold the triage pin (off by default, on with admin, equal to
(d) Generated artifacts and the ADR-0122 registration — consistent, nothing hand-edited, nothing extra.
② Semver level
③ Boundary flagsDeviations (each read against the head):
Out-of-scope findings:
Closing and surfaces:
Escalation flags (product-contract choices beyond the card's direction, recorded, not decided here): the key name Check-runs on the head (converged, 35 names collapsed latest-per-name): 33 Implemented-by: VERDICT: PASS |
Fixes #21046
Clause-②: yes
What this does
The #15920 ruling (maintainer 「同意」, ruling record
5564370232) keeps the plain 404 that an unmounted better-auth admin route answers. It names discovery as the place an SDK caller asks "does this deployment mount the admin family" before building those URLs. Discovery could not answer that. This PR adds the answer.@objectstack/spec, minor, widening).DiscoverySchemagains an optional, closedauthFamiliesblock,{ admin: boolean }. It comes withAuthFamiliesSchema/AuthFamiliesand the one reader both producers call,readAuthFamilies(authService). The reader returnsgetPublicConfig().features.admin, which is the objectGET /api/v1/auth/configserves. It returnsundefined(and the producer emits no key) when there is no auth service, when the service has nogetPublicConfig(), when that call throws, or when the flag is not a boolean.getDiscovery()in@objectstack/metadata-protocolandgetDiscoveryInfo()in@objectstack/runtimeeach emitauthFamiliesfromreadAuthFamilies. Neither one re-derives whether the admin plugin is on. The@objectstack/restGET /api/v1/discoverycomposes overgetDiscovery()and passes the key through unchanged.admin. It is the family the ruling discusses. The block is closed, so adding a family is a contract change, and the schema's docblock says what to check first. Forsso, the/configroute narrows the flag to "usable" aftergetPublicConfig()returns, so the raw flag answers a different question.Repro: before and after
Measured with
@objectstack/verifybootStack(showcase). The admin plugin is switched on withOS_SCIM_ENABLED=true, which forces it on (ADR-0134). Each column is one boot.9c8b65aa239c8b65aa23GET /api/v1/discoveryauthFamilies{ admin: false }{ admin: true }GET /.well-known/objectstackdata.authFamilies{ admin: false }{ admin: true }GET /api/v1/auth/configdata.features.adminfalsetruefalsetrueGET /api/v1/auth/admin/list-users404, empty body401 UNAUTHENTICATED404, empty body401 UNAUTHENTICATEDOn base, both discovery documents had the same key set in both boots, and
routes.authwas the only auth fact. With this branch, both documents agree with/auth/configand with what the wire does.Pins
packages/spec/src/api/discovery-auth-families.pin.test.ts: the key is declared and kept by bothDiscoverySchemaand the consumer parseGetDiscoveryResponseSchema. The block is optional and closed (['admin']). The reader readsgetPublicConfig()on the service itself. It answersundefined, never a guessedfalse, for each of the unreadable cases.packages/runtime/src/discovery-auth-families.pin.test.tsandpackages/metadata-protocol/src/discovery-auth-families.pin.test.ts: each producer reports{ admin }for both values of the service's public config. Each asserts thatgetPublicConfigwas called and that the result equalsreadAuthFamilies(service). Each emits no key without an auth service, and none for a service with no public config. The runtime pin also covers a throwing config.packages/qa/dogfood/test/discovery-auth-families.dogfood.test.ts(door pin, runs in theisolatedproject): two real boots, stock and admin-on. Each boot asserts four things agree: REST/discovery, the dispatcher's/.well-known/objectstack,/auth/configfeatures.admin, and the wire (404 against non-404 on an admin-family route). The frame of each body is asserted, not tolerated. REST answers bare and the dispatcher answers enveloped, as recorded inrest-route-ledger.ts.packages/spec/src/type-alias-convention.pin.test.ts:AuthFamiliesSchemais pinned as ADR-0122 isomorphic, 778 to 779.check:spec-parsed-aliasnamed the new alias. The schema is a closed object of booleans, so the pin is the ADR's remedy rather than anAuthFamiliesParsedsynonym.Ablation (the fix committed first; every leg restored and proven)
Each producer was mutated to hard-code today's stock answer:
const authFamilies = ['ablation-21046'].length ? { admin: false } : undefined;. The mutation went throughscripts/ablation-replace.mjs. In each leg the anchor hit 1 to 0 and the blob changed. Restore was proven as blob equal to HEAD with an emptygit diff HEAD.truecase and the two absent-key cases only. Thefalsecase went red too, onexpect(getPublicConfig).toHaveBeenCalled(). So the pin also rejects a constant that equals today's answer./discoveryproducer): I rebuilt from the mutated source. The JS emitted; the DTS step failed on the now-unused import (TS6133), which is expected under this mutation.ablation-dist-preflightreported the marker present in 2 built files. Dogfood result: 1 red, 7 green. The red isGET /api/v1/discovery reports authFamilies.admin: truein the admin-on boot. The stock boot stayed green (falsematches) and so did the dispatcher's.well-known(not mutated). After restore and rebuild,ablation-dist-preflight --absentreported the marker gone from all 24 built files and the tree clean. Dogfood was then 8 of 8 green.Local verification (final head
155c7268ff)6b88aab411: spec--project local591 files / 17367 tests passed. spec + runtimetest:repo47 / 832 and 3 / 751. runtime--project local298 / 4254. metadata-protocol 197 files (3 skipped) / 2935. The dogfood door pin passed 8 of 8. The only commit after that (155c7268ff) edits one spec test file, the ADR-0122 pin. At155c7268ffI re-ran that file together with the new spec pin anddiscovery.test.ts: 3 files, 102 tests, all passed (still declares all 779 isomorphic pins).155c7268ff: spec (tsc,check:scripts-typecheck,check:test-typecheck), runtime (tsc,check:test-typecheck), metadata-protocol and dogfood all passed.pnpm --filter @objectstack/spec check:generatedreports all 15 up to date.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 110 commands at155c7268ff, and I ran each one with its exit code captured before any pipe. Six spec gates first refused with exit 3 (check:api-surface,check:dual-source-exports,check:entry-nameability,check:exported-any,check:skill-examples, andcheck:generated's api-surface leg). The cause was a stale spec dist: the last commit edits a spec test file, which moves the dist input hash. I rebuilt spec at the same head and re-ran all six; each exited 0.--ranreconciliation:110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED, all exit 0. Along the way,check:spec-parsed-aliascaught a real miss, the ADR-0122 alias, which is fixed in155c7268ff.155c7268ff, as a proven narrowing rather than a fullpnpm lint. (1) Population: the 8 changed.tsfiles all matcheslint.config.mjs'sfilesglobs (packages/**/*.{ts,…}and**/*.{ts,…}). (2)eslint --no-inline-config --format jsonover them linted 8 files with 0 errors and 0 warnings. (3) Invariance: that config enables no type-aware linting (noparserOptions.project, no typed rules), and its local plugins read nothing but config-time baselines. This diff changes neither the config nor a baseline, so it cannot move the verdict on any file it does not touch.Acceptance notes
origin/mainbefore opening. The branch is based on9c8b65aa23. A localmerge-treeprobe againstorigin/main39ab2940e2(19 commits ahead) is clean. Upstream touchedpackages/metadata-protocol/src/protocol.ts(imports, a different region) but no file this diff generates. CI and the merge queue verify the merge ref.IAuthService(packages/spec/src/contracts/auth-service.ts) does not declaregetPublicConfig. Discovery now reads it structurally, aspackages/adapters/honoalready does for/auth/config. That is the "called, declared by nobody" shape the contract's own [dispatcher 多个 domain 调用契约里没有的方法 —— #4087 的同类,只是方向相反(契约缺声明,不是调用点乱编) #4127] notes describe. It was out of this card's file surface. Carrier: none.GetAuthConfigResponseSchema.features(AuthFeaturesConfigSchema) does not declareadmin, nor several other flagsgetPublicConfig()serves. A consumer that parses/auth/configthrough the spec stripsfeatures.admin. That makes this discovery key the only spec-declared carrier of the answer. The SDK'sauth.getConfig()returns the body raw, so no shipped reader loses it today. Carrier: none.#15920 is not reopened here: the 404 on unmounted admin routes is unchanged, as that ruling decided.Generated by Claude Code