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
17 changes: 17 additions & 0 deletions .changeset/21046-discovery-auth-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
'@objectstack/runtime': patch
'@objectstack/metadata-protocol': patch
---

feat(spec): discovery reports which optional `/auth` route families are mounted, starting with the better-auth admin family (`authFamilies.admin`) (#21046)

Clause-②: yes

**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)`.

**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`.

**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".

**What did not change.** No existing key, route or status moved. The unmounted admin routes still answer a plain `404`.
26 changes: 23 additions & 3 deletions content/docs/references/api/discovery.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Discovery schema — API Protocol reference
navTitle: Discovery
description: "Discovery schemas of the ObjectStack API Protocol: ApiRoutes, CapabilityDescriptor and 9 more — each property with its type, default and a TypeScript example."
description: "Discovery schemas of the ObjectStack API Protocol: ApiRoutes, AuthFamilies and 10 more — each property with its type, default and a TypeScript example."
---

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

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

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


---

## AuthFamilies

Which optional better-auth route families are mounted under routes.auth

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |


---

## CapabilityDescriptor
Expand Down Expand Up @@ -77,6 +90,7 @@ const result = ApiRoutesSchema.parse(data);
| **capabilities** | `{ comments: object; automation: object; cron: object; search: object; … }` | ✅ | Hierarchical capability descriptors — the full WellKnownCapabilities vocabulary, every key present |
| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration |
| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint |
| **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 |
| **metadata** | `Record<string, any>` | optional | Custom metadata key-value pairs for extensibility |

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

### Nested Shape: `Discovery.authFamilies`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |


---

Expand Down
7 changes: 7 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ Enable package response
| **capabilities** | `{ comments: object; automation: object; cron: object; search: object; … }` | optional | Hierarchical capability descriptors — the full WellKnownCapabilities vocabulary, every key present |
| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration |
| **scoping** | `{ enabled: boolean; resolution: Enum<'required' \| 'optional' \| 'auto'>; scoped: boolean; environmentId?: string }` | optional | Environment-scoping posture, added by the REST discovery endpoint |
| **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 |
| **metadata** | `Record<string, any>` | optional | Custom metadata key-value pairs for extensibility |
| **apiName** | `string` | optional | API name (deprecated — use `name`; removed in protocol 18) |

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

### Nested Shape: `GetDiscoveryResponse.authFamilies`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |


---

Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules
---

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

---

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

## API Protocol

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

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

Expand All @@ -75,7 +75,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery.
| [`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` |
| [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` |
| [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` |
| [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` |
| [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `AuthFamilies`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` |
| [`dispatcher.zod.ts`](/docs/references/api/dispatcher) | `DispatcherConfig`, `DispatcherErrorCode`, `DispatcherErrorResponse`, `DispatcherRoute` |
| [`documentation.zod.ts`](/docs/references/api/documentation) | `ApiChangelogEntry`, `ApiDocumentationConfig`, `ApiTestCollection`, `ApiTestRequest`, `ApiTestingUiConfig`, `ApiTestingUiType`, `CodeGenerationTemplate`, `GeneratedApiDocumentation`, `OpenApiSecurityScheme`, `OpenApiServer`, `OpenApiSpec` |
| [`endpoint.zod.ts`](/docs/references/api/endpoint) | `ApiEndpoint`, `ApiMapping` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 432 |
| `api/` | 433 |
73 changes: 73 additions & 0 deletions packages/metadata-protocol/src/discovery-auth-families.pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#21046] Discovery reports which optional `/auth` route families are
* mounted — the `getDiscovery()` producer's half. This is the builder
* `@objectstack/rest`'s `GET /api/v1/discovery` composes over and passes
* `authFamilies` through from, i.e. the document an SDK connects through.
*
* Same contract as the dispatcher's sibling pin
* (`packages/runtime/src/discovery-auth-families.pin.test.ts`): the answer is
* the registered auth service's own public config — what `GET /auth/config`
* serves — read through `readAuthFamilies`, with no second derivation of "is
* the admin plugin on". Pinned from the service's answer outward, both values,
* so a hard-coded `false` (today's stock answer) or `true` cannot pass.
*/

import { describe, it, expect, vi } from 'vitest';
import { DiscoverySchema, readAuthFamilies } from '@objectstack/spec/api';
import { ObjectStackProtocolImplementation } from './index.js';

/** Same minimal engine `discovery-schema-conformance.test.ts` uses. */
function makeImpl(services: Map<string, any>) {
const engine = {
registry: { getObject: (_n: string) => undefined, getRegisteredTypes: () => [] },
};
return new ObjectStackProtocolImplementation(engine as any, () => services);
}

/** An auth service whose public config — the `/auth/config` body — says `admin`. */
function authAnswering(admin: boolean) {
return {
handleRequest: async () => new Response(null, { status: 404 }),
verify: async () => ({ success: false }),
getPublicConfig: vi.fn(() => ({
emailPassword: { enabled: true },
socialProviders: [],
features: { admin, organization: true, twoFactor: false },
})),
};
}

describe('[#21046] getDiscovery() reports the auth families the auth service says it mounts', () => {
for (const admin of [false, true]) {
it(`reports authFamilies.admin: ${admin} when /auth/config's source says ${admin}`, async () => {
const auth = authAnswering(admin);
const discovery: any = await makeImpl(new Map([['auth', auth]])).getDiscovery();

expect(discovery.authFamilies).toEqual({ admin });
expect(auth.getPublicConfig).toHaveBeenCalled();
expect(discovery.authFamilies).toEqual(readAuthFamilies(auth));
expect(discovery.routes.auth).toBe('/api/v1/auth');

const parsed = DiscoverySchema.safeParse(discovery);
expect(parsed.success ? [] : parsed.error.issues).toEqual([]);
expect(parsed.success && parsed.data.authFamilies).toEqual({ admin });
});
}

it('emits no `authFamilies` when no auth service is registered (no auth surface either)', async () => {
const discovery: any = await makeImpl(new Map()).getDiscovery();

expect(Object.prototype.hasOwnProperty.call(discovery, 'authFamilies')).toBe(false);
expect(discovery.routes.auth).toBeUndefined();
});

it('emits no `authFamilies` when the auth service publishes no public config — it cannot say, so it does not guess', async () => {
const { getPublicConfig: _omitted, ...withoutConfig } = authAnswering(true);
const discovery: any = await makeImpl(new Map([['auth', withoutConfig]])).getDiscovery();

expect(Object.prototype.hasOwnProperty.call(discovery, 'authFamilies')).toBe(false);
expect(discovery.routes.auth).toBe('/api/v1/auth');
});
});
13 changes: 12 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ import type {
} from '@objectstack/spec/api';
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api';
import type { ApiError, BatchOperationResult } from '@objectstack/spec/api';
import { readServiceSelfInfo, readChannelRoute, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api';
import {
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
Expand Down Expand Up @@ -6807,6 +6807,16 @@ export class ObjectStackProtocolImplementation implements
// consumer had a key that worked against both.
const name = 'ObjectStack API';

// [#21046] Which optional `/auth` route families are mounted — the
// registered auth service's OWN public-config answer, the object
// `GET /auth/config` serves, through the one reader the runtime
// dispatcher's `getDiscoveryInfo()` calls too (`readAuthFamilies`,
// `@objectstack/spec/api`). This builder never re-derives "is the admin
// plugin on" itself. `undefined` — no `auth` service in this kernel
// (then no `routes.auth` either), or one that publishes no public
// config — emits no key; the REST `/discovery` passes the value through.
const authFamilies = readAuthFamilies(registeredServices.get('auth'));

return {
// [#11235] The serving system's identity, DERIVED — an injected
// `OS_RUNTIME_VERSION` stamp, falling back to this package's own
Expand Down Expand Up @@ -6840,6 +6850,7 @@ export class ObjectStackProtocolImplementation implements
locale,
services,
capabilities,
...(authFamilies ? { authFamilies } : {}),
};
}

Expand Down
Loading
Loading