Skip to content

Commit 70dae53

Browse files
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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
feat(spec): discovery reports which optional `/auth` route families are mounted, starting with the better-auth admin family (`authFamilies.admin`) (#21046)
8+
9+
Clause-②: yes
10+
11+
**New key.** `DiscoverySchema` declares an optional `authFamilies` block, `{ admin: boolean }`. `admin` says whether the better-auth admin family (`{routes.auth}/admin/*`: `list-users`, `set-role`, `update-user`, `ban-user`, …) is mounted on this deployment. On a deployment that does not enable the admin plugin those routes answer a plain `404`, the same as a mistyped path, so a caller checks `authFamilies.admin` before building a URL into the family. `@objectstack/spec/api` also exports the block's schema (`AuthFamiliesSchema`, type `AuthFamilies`) and its reader, `readAuthFamilies(authService)`.
12+
13+
**Same answer as `/auth/config`.** The value is the auth service's own `getPublicConfig().features.admin`, the object `GET /api/v1/auth/config` serves. Both discovery producers read it through `readAuthFamilies`: `getDiscovery()` in `@objectstack/metadata-protocol` (served by `@objectstack/rest` at `GET /api/v1/discovery`) and `getDiscoveryInfo()` in `@objectstack/runtime` (served at `GET /.well-known/objectstack`). Neither re-derives whether the admin plugin is on, so on one boot the two documents and `/auth/config` agree. On a stock boot `authFamilies.admin` is `false`. With the admin plugin on (`plugins.admin: true`, or SCIM, which forces it on) it is `true`.
14+
15+
**When the key is absent.** A producer that cannot read the answer emits no `authFamilies`, rather than a guessed `false`. That happens when no `auth` service is registered (then `routes.auth` is absent too), when the registered service has no `getPublicConfig()`, or when that call throws (`/auth/config` answers `500 AUTH_CONFIG_ERROR` in that state). Treat an absent block as "not known to be mounted".
16+
17+
**What did not change.** No existing key, route or status moved. The unmounted admin routes still answer a plain `404`.

‎content/docs/references/api/discovery.mdx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Discovery schema — API Protocol reference
33
navTitle: Discovery
4-
description: "Discovery schemas of the ObjectStack API Protocol: ApiRoutes, CapabilityDescriptor and 9 more — each property with its type, default and a TypeScript example."
4+
description: "Discovery schemas of the ObjectStack API Protocol: ApiRoutes, AuthFamilies and 10 more — each property with its type, default and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -13,8 +13,8 @@ description: "Discovery schemas of the ObjectStack API Protocol: ApiRoutes, Capa
1313
## TypeScript Usage
1414

1515
```typescript
16-
import { ApiRoutesSchema, CapabilityDescriptorSchema, DiscoverySchema, DiscoveryEnvironmentSchema, EnvironmentTypeSchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, ServiceStatus, WellKnownCapabilitiesSchema } from '@objectstack/spec/api';
17-
import type { ApiRoutes, CapabilityDescriptor, DiscoveryEnvironment, EnvironmentType, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, ServiceStatus, WellKnownCapabilities } from '@objectstack/spec/api';
16+
import { ApiRoutesSchema, AuthFamiliesSchema, CapabilityDescriptorSchema, DiscoverySchema, DiscoveryEnvironmentSchema, EnvironmentTypeSchema, RouteHealthEntrySchema, RouteHealthReportSchema, ServiceInfoSchema, ServiceSelfInfoSchema, ServiceStatus, WellKnownCapabilitiesSchema } from '@objectstack/spec/api';
17+
import type { ApiRoutes, AuthFamilies, CapabilityDescriptor, DiscoveryEnvironment, EnvironmentType, RouteHealthEntry, RouteHealthReport, ServiceInfo, ServiceSelfInfo, ServiceStatus, WellKnownCapabilities } from '@objectstack/spec/api';
1818

1919
// Validate data
2020
const result = ApiRoutesSchema.parse(data);
@@ -47,6 +47,19 @@ const result = ApiRoutesSchema.parse(data);
4747
| **mcp** | `string` | optional | e.g. /api/v1/mcp — always the unscoped base; absent when MCP is disabled or unserveable |
4848

4949

50+
---
51+
52+
## AuthFamilies
53+
54+
Which optional better-auth route families are mounted under routes.auth
55+
56+
### Properties
57+
58+
| Property | Type | Required | Description |
59+
| :--- | :--- | :--- | :--- |
60+
| **admin** | `boolean` | ✅ | Whether the better-auth admin family (`{routes.auth}`/admin/*: list-users, set-role, update-user, ban-user, …) is mounted. Same value as features.admin on GET `{routes.auth}`/config, read from the same source; false means those routes answer a plain 404 on this deployment. |
61+
62+
5063
---
5164

5265
## CapabilityDescriptor
@@ -77,6 +90,7 @@ const result = ApiRoutesSchema.parse(data);
7790
| **capabilities** | `{ comments: object; automation: object; cron: object; search: object; … }` | ✅ | Hierarchical capability descriptors — the full WellKnownCapabilities vocabulary, every key present |
7891
| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration |
7992
| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint |
93+
| **authFamilies** | `{ admin: boolean }` | optional | Which optional better-auth route families are mounted under routes.auth — the same source as GET `{routes.auth}`/config features; absent when no auth service answers |
8094
| **metadata** | `Record<string, any>` | optional | Custom metadata key-value pairs for extensibility |
8195

8296
### Nested Shape: `Discovery.routes`
@@ -148,6 +162,12 @@ const result = ApiRoutesSchema.parse(data);
148162
| **scoped** | `boolean` | ✅ | Whether THIS response was served from the environment-scoped mount |
149163
| **environmentId** | `string` | optional | The resolved environment id — present only on a scoped mount |
150164

165+
### Nested Shape: `Discovery.authFamilies`
166+
167+
| Property | Type | Required | Description |
168+
| :--- | :--- | :--- | :--- |
169+
| **admin** | `boolean` | ✅ | Whether the better-auth admin family (`{routes.auth}`/admin/*: list-users, set-role, update-user, ban-user, …) is mounted. Same value as features.admin on GET `{routes.auth}`/config, read from the same source; false means those routes answer a plain 404 on this deployment. |
170+
151171

152172
---
153173

‎content/docs/references/api/protocol.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,7 @@ Enable package response
999999
| **capabilities** | `{ comments: object; automation: object; cron: object; search: object; … }` | optional | Hierarchical capability descriptors — the full WellKnownCapabilities vocabulary, every key present |
10001000
| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration |
10011001
| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint |
1002+
| **authFamilies** | `{ admin: boolean }` | optional | Which optional better-auth route families are mounted under routes.auth — the same source as GET `{routes.auth}`/config features; absent when no auth service answers |
10021003
| **metadata** | `Record<string, any>` | optional | Custom metadata key-value pairs for extensibility |
10031004
| **apiName** | `string` | optional | API name (deprecated — use `name`; removed in protocol 18) |
10041005

@@ -1071,6 +1072,12 @@ Enable package response
10711072
| **scoped** | `boolean` | ✅ | Whether THIS response was served from the environment-scoped mount |
10721073
| **environmentId** | `string` | optional | The resolved environment id — present only on a scoped mount |
10731074

1075+
### Nested Shape: `GetDiscoveryResponse.authFamilies`
1076+
1077+
| Property | Type | Required | Description |
1078+
| :--- | :--- | :--- | :--- |
1079+
| **admin** | `boolean` | ✅ | Whether the better-auth admin family (`{routes.auth}`/admin/*: list-users, set-role, update-user, ban-user, …) is mounted. Same value as features.admin on GET `{routes.auth}`/config, read from the same source; false means those routes answer a plain 404 on this deployment. |
1080+
10741081

10751082
---
10761083

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with
2121
| Module | Pages | Schemas | Description |
2222
| :--- | ---: | ---: | :--- |
2323
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
24-
| [API Protocol](/docs/references/api) | 32 | 429 | REST contracts, endpoints, routing, realtime, batch, discovery. |
24+
| [API Protocol](/docs/references/api) | 32 | 430 | REST contracts, endpoints, routing, realtime, batch, discovery. |
2525
| [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
2626
| [Data Protocol](/docs/references/data) | 30 | 178 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
@@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **197** | **1521** | 14 protocol modules |
37+
| **Total** | **197** | **1522** | 14 protocol modules |
3838

3939
---
4040

@@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations.
6363

6464
## API Protocol
6565

66-
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 429 schemas**
66+
**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 430 schemas**
6767

6868
REST contracts, endpoints, routing, realtime, batch, discovery.
6969

@@ -75,7 +75,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
7575
| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` |
7676
| [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` |
7777
| [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` |
78-
| [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` |
78+
| [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `AuthFamilies`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` |
7979
| [`dispatcher.zod.ts`](/docs/references/api/dispatcher) | `DispatcherConfig`, `DispatcherErrorCode`, `DispatcherErrorResponse`, `DispatcherRoute` |
8080
| [`documentation.zod.ts`](/docs/references/api/documentation) | `ApiChangelogEntry`, `ApiDocumentationConfig`, `ApiTestCollection`, `ApiTestRequest`, `ApiTestingUiConfig`, `ApiTestingUiType`, `CodeGenerationTemplate`, `GeneratedApiDocumentation`, `OpenApiSecurityScheme`, `OpenApiServer`, `OpenApiSpec` |
8181
| [`endpoint.zod.ts`](/docs/references/api/endpoint) | `ApiEndpoint`, `ApiMapping` |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 432 |
22+
| `api/` | 433 |
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21046] Discovery reports which optional `/auth` route families are
5+
* mounted — the `getDiscovery()` producer's half. This is the builder
6+
* `@objectstack/rest`'s `GET /api/v1/discovery` composes over and passes
7+
* `authFamilies` through from, i.e. the document an SDK connects through.
8+
*
9+
* Same contract as the dispatcher's sibling pin
10+
* (`packages/runtime/src/discovery-auth-families.pin.test.ts`): the answer is
11+
* the registered auth service's own public config — what `GET /auth/config`
12+
* serves — read through `readAuthFamilies`, with no second derivation of "is
13+
* the admin plugin on". Pinned from the service's answer outward, both values,
14+
* so a hard-coded `false` (today's stock answer) or `true` cannot pass.
15+
*/
16+
17+
import { describe, it, expect, vi } from 'vitest';
18+
import { DiscoverySchema, readAuthFamilies } from '@objectstack/spec/api';
19+
import { ObjectStackProtocolImplementation } from './index.js';
20+
21+
/** Same minimal engine `discovery-schema-conformance.test.ts` uses. */
22+
function makeImpl(services: Map<string, any>) {
23+
const engine = {
24+
registry: { getObject: (_n: string) => undefined, getRegisteredTypes: () => [] },
25+
};
26+
return new ObjectStackProtocolImplementation(engine as any, () => services);
27+
}
28+
29+
/** An auth service whose public config — the `/auth/config` body — says `admin`. */
30+
function authAnswering(admin: boolean) {
31+
return {
32+
handleRequest: async () => new Response(null, { status: 404 }),
33+
verify: async () => ({ success: false }),
34+
getPublicConfig: vi.fn(() => ({
35+
emailPassword: { enabled: true },
36+
socialProviders: [],
37+
features: { admin, organization: true, twoFactor: false },
38+
})),
39+
};
40+
}
41+
42+
describe('[#21046] getDiscovery() reports the auth families the auth service says it mounts', () => {
43+
for (const admin of [false, true]) {
44+
it(`reports authFamilies.admin: ${admin} when /auth/config's source says ${admin}`, async () => {
45+
const auth = authAnswering(admin);
46+
const discovery: any = await makeImpl(new Map([['auth', auth]])).getDiscovery();
47+
48+
expect(discovery.authFamilies).toEqual({ admin });
49+
expect(auth.getPublicConfig).toHaveBeenCalled();
50+
expect(discovery.authFamilies).toEqual(readAuthFamilies(auth));
51+
expect(discovery.routes.auth).toBe('/api/v1/auth');
52+
53+
const parsed = DiscoverySchema.safeParse(discovery);
54+
expect(parsed.success ? [] : parsed.error.issues).toEqual([]);
55+
expect(parsed.success && parsed.data.authFamilies).toEqual({ admin });
56+
});
57+
}
58+
59+
it('emits no `authFamilies` when no auth service is registered (no auth surface either)', async () => {
60+
const discovery: any = await makeImpl(new Map()).getDiscovery();
61+
62+
expect(Object.prototype.hasOwnProperty.call(discovery, 'authFamilies')).toBe(false);
63+
expect(discovery.routes.auth).toBeUndefined();
64+
});
65+
66+
it('emits no `authFamilies` when the auth service publishes no public config — it cannot say, so it does not guess', async () => {
67+
const { getPublicConfig: _omitted, ...withoutConfig } = authAnswering(true);
68+
const discovery: any = await makeImpl(new Map([['auth', withoutConfig]])).getDiscovery();
69+
70+
expect(Object.prototype.hasOwnProperty.call(discovery, 'authFamilies')).toBe(false);
71+
expect(discovery.routes.auth).toBe('/api/v1/auth');
72+
});
73+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ import type {
9999
} from '@objectstack/spec/api';
100100
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api';
101101
import type { ApiError, BatchOperationResult } from '@objectstack/spec/api';
102-
import { readServiceSelfInfo, readChannelRoute, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
102+
import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
103103
import {
104104
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
105105
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
@@ -6821,6 +6821,16 @@ export class ObjectStackProtocolImplementation implements
68216821
// consumer had a key that worked against both.
68226822
const name = 'ObjectStack API';
68236823

6824+
// [#21046] Which optional `/auth` route families are mounted — the
6825+
// registered auth service's OWN public-config answer, the object
6826+
// `GET /auth/config` serves, through the one reader the runtime
6827+
// dispatcher's `getDiscoveryInfo()` calls too (`readAuthFamilies`,
6828+
// `@objectstack/spec/api`). This builder never re-derives "is the admin
6829+
// plugin on" itself. `undefined` — no `auth` service in this kernel
6830+
// (then no `routes.auth` either), or one that publishes no public
6831+
// config — emits no key; the REST `/discovery` passes the value through.
6832+
const authFamilies = readAuthFamilies(registeredServices.get('auth'));
6833+
68246834
return {
68256835
// [#11235] The serving system's identity, DERIVED — an injected
68266836
// `OS_RUNTIME_VERSION` stamp, falling back to this package's own
@@ -6854,6 +6864,7 @@ export class ObjectStackProtocolImplementation implements
68546864
locale,
68556865
services,
68566866
capabilities,
6867+
...(authFamilies ? { authFamilies } : {}),
68576868
};
68586869
}
68596870

0 commit comments

Comments
 (0)