Repository navigation
Commit 70dae53
feat(spec): discovery reports which auth route families are mounted (authFamilies.admin) (#21145)
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.
- **Spec (`@objectstack/spec`, minor, widening).** `DiscoverySchema`
gains an optional, closed `authFamilies` block, `{ admin: boolean }`. It
comes with `AuthFamiliesSchema` / `AuthFamilies` and the one reader both
producers call, `readAuthFamilies(authService)`. The reader returns
`getPublicConfig().features.admin`, which is the object `GET
/api/v1/auth/config` serves. It returns `undefined` (and the producer
emits no key) when there is no auth service, when the service has no
`getPublicConfig()`, when that call throws, or when the flag is not a
boolean.
- **Producers (patch).** `getDiscovery()` in
`@objectstack/metadata-protocol` and `getDiscoveryInfo()` in
`@objectstack/runtime` each emit `authFamilies` from `readAuthFamilies`.
Neither one re-derives whether the admin plugin is on. The
`@objectstack/rest` `GET /api/v1/discovery` composes over
`getDiscovery()` and passes the key through unchanged.
- **Why only `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. For `sso`, the `/config` route
narrows the flag to "usable" after `getPublicConfig()` returns, so the
raw flag answers a different question.
## Repro: before and after
Measured with `@objectstack/verify` `bootStack(showcase)`. The admin
plugin is switched on with `OS_SCIM_ENABLED=true`, which forces it on
(ADR-0134). Each column is one boot.
| reading | stock, base `9c8b65aa23` | admin on, base `9c8b65aa23` |
stock, this branch | admin on, this branch |
|---|---|---|---|---|
| `GET /api/v1/discovery` `authFamilies` | absent | absent | `{ admin:
false }` | `{ admin: true }` |
| `GET /.well-known/objectstack` `data.authFamilies` | absent | absent |
`{ admin: false }` | `{ admin: true }` |
| `GET /api/v1/auth/config` `data.features.admin` | `false` | `true` |
`false` | `true` |
| anonymous `GET /api/v1/auth/admin/list-users` | `404`, empty body |
`401 UNAUTHENTICATED` | `404`, empty body | `401 UNAUTHENTICATED` |
On base, both discovery documents had the same key set in both boots,
and `routes.auth` was the only auth fact. With this branch, both
documents agree with `/auth/config` and with what the wire does.
## Pins
- `packages/spec/src/api/discovery-auth-families.pin.test.ts`: the key
is declared and kept by both `DiscoverySchema` and the consumer parse
`GetDiscoveryResponseSchema`. The block is optional and closed
(`['admin']`). The reader reads `getPublicConfig()` on the service
itself. It answers `undefined`, never a guessed `false`, for each of the
unreadable cases.
- `packages/runtime/src/discovery-auth-families.pin.test.ts` and
`packages/metadata-protocol/src/discovery-auth-families.pin.test.ts`:
each producer reports `{ admin }` for both values of the service's
public config. Each asserts that `getPublicConfig` was called and that
the result equals `readAuthFamilies(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 the `isolated` project): two real boots, stock and
admin-on. Each boot asserts four things agree: REST `/discovery`, the
dispatcher's `/.well-known/objectstack`, `/auth/config`
`features.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 in
`rest-route-ledger.ts`.
- `packages/spec/src/type-alias-convention.pin.test.ts`:
`AuthFamiliesSchema` is pinned as ADR-0122 isomorphic, 778 to 779.
`check:spec-parsed-alias` named the new alias. The schema is a closed
object of booleans, so the pin is the ADR's remedy rather than an
`AuthFamiliesParsed` synonym.
## 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 through `scripts/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 empty `git diff HEAD`.
- **Runtime unit leg:** 5 of 5 red, then 5 of 5 green after restore.
Before running, I predicted red for the `true` case and the two
absent-key cases only. The `false` case went red too, on
`expect(getPublicConfig).toHaveBeenCalled()`. So the pin also rejects a
constant that equals today's answer.
- **Metadata-protocol unit leg:** 4 of 4 red, then 4 of 4 green after
restore.
- **Door leg (metadata-protocol, the REST `/discovery` producer):** 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-preflight` reported the marker present in 2 built files.
Dogfood result: **1 red, 7 green**. The red is `GET /api/v1/discovery
reports authFamilies.admin: true` in the admin-on boot. The stock boot
stayed green (`false` matches) and so did the dispatcher's `.well-known`
(not mutated). After restore and rebuild, `ablation-dist-preflight
--absent` reported the marker gone from all 24 built files and the tree
clean. Dogfood was then 8 of 8 green.
## Local verification (final head `155c7268ff`)
- Tests at `6b88aab411`: spec `--project local` 591 files / 17367 tests
passed. spec + runtime `test:repo` 47 / 832 and 3 / 751. runtime
`--project local` 298 / 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. At
`155c7268ff` I re-ran that file together with the new spec pin and
`discovery.test.ts`: 3 files, 102 tests, all passed (`still declares all
779 isomorphic pins`).
- Typecheck at `155c7268ff`: spec (tsc, `check:scripts-typecheck`,
`check:test-typecheck`), runtime (tsc, `check:test-typecheck`),
metadata-protocol and dogfood all passed.
- Spec artifacts were regenerated from a built dist, and `pnpm --filter
@objectstack/spec check:generated` reports all 15 up to date.
- Gate families: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 110 commands at
`155c7268ff`, 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`, and `check: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. `--ran` reconciliation:
`110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED`, all
exit 0. Along the way, `check:spec-parsed-alias` caught a real miss, the
ADR-0122 alias, which is fixed in `155c7268ff`.
- Lint at `155c7268ff`, as a proven narrowing rather than a full `pnpm
lint`. (1) Population: the 8 changed `.ts` files all match
`eslint.config.mjs`'s `files` globs (`packages/**/*.{ts,…}` and
`**/*.{ts,…}`). (2) `eslint --no-inline-config --format json` over them
linted 8 files with 0 errors and 0 warnings. (3) Invariance: that config
enables no type-aware linting (no `parserOptions.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
- **Not merged with `origin/main` before opening.** The branch is based
on `9c8b65aa23`. A local `merge-tree` probe against `origin/main`
`39ab2940e2` (19 commits ahead) is clean. Upstream touched
`packages/metadata-protocol/src/protocol.ts` (imports, a different
region) but no file this diff generates. CI and the merge queue verify
the merge ref.
- **Observation, not filed: `IAuthService`
(`packages/spec/src/contracts/auth-service.ts`) does not declare
`getPublicConfig`.** Discovery now reads it structurally, as
`packages/adapters/hono` already does for `/auth/config`. That is the
"called, declared by nobody" shape the contract's own [#4127] notes
describe. It was out of this card's file surface. Carrier: none.
- **Observation, not filed: `GetAuthConfigResponseSchema.features`
(`AuthFeaturesConfigSchema`) does not declare `admin`, nor several other
flags `getPublicConfig()` serves.** A consumer that parses
`/auth/config` through the spec strips `features.admin`. That makes this
discovery key the only spec-declared carrier of the answer. The SDK's
`auth.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](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent f20f669 commit 70dae53
18 files changed
Lines changed: 630 additions & 14 deletions
File tree
- .changeset
- content/docs/references
- api
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- metadata-protocol/src
- qa/dogfood/test
- runtime/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
| 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 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
17 | | - | |
| 16 | + | |
| 17 | + | |
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
50 | 63 | | |
51 | 64 | | |
52 | 65 | | |
| |||
77 | 90 | | |
78 | 91 | | |
79 | 92 | | |
| 93 | + | |
80 | 94 | | |
81 | 95 | | |
82 | 96 | | |
| |||
148 | 162 | | |
149 | 163 | | |
150 | 164 | | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
151 | 171 | | |
152 | 172 | | |
153 | 173 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
999 | 999 | | |
1000 | 1000 | | |
1001 | 1001 | | |
| 1002 | + | |
1002 | 1003 | | |
1003 | 1004 | | |
1004 | 1005 | | |
| |||
1071 | 1072 | | |
1072 | 1073 | | |
1073 | 1074 | | |
| 1075 | + | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
| 1080 | + | |
1074 | 1081 | | |
1075 | 1082 | | |
1076 | 1083 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
63 | 63 | | |
64 | 64 | | |
65 | 65 | | |
66 | | - | |
| 66 | + | |
67 | 67 | | |
68 | 68 | | |
69 | 69 | | |
| |||
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
78 | | - | |
| 78 | + | |
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
| 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 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
| 102 | + | |
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
| |||
6821 | 6821 | | |
6822 | 6822 | | |
6823 | 6823 | | |
| 6824 | + | |
| 6825 | + | |
| 6826 | + | |
| 6827 | + | |
| 6828 | + | |
| 6829 | + | |
| 6830 | + | |
| 6831 | + | |
| 6832 | + | |
| 6833 | + | |
6824 | 6834 | | |
6825 | 6835 | | |
6826 | 6836 | | |
| |||
6854 | 6864 | | |
6855 | 6865 | | |
6856 | 6866 | | |
| 6867 | + | |
6857 | 6868 | | |
6858 | 6869 | | |
6859 | 6870 | | |
| |||
0 commit comments