Skip to content

Commit 00f2f4c

Browse files
authored
feat: query-capability discovery via meta.schema + entity record response envelope (#3332)
* feat: wrap entity record responses in a data/meta envelope Every single-record entity endpoint (getOne/create/update/put/delete/ confirm across all 24 entity controllers) now returns the { data, meta } envelope (EntityRecordWrappedResponse) that the MFA enroll response pioneered, and the typed client exposes the envelope to callers. Services keep returning bare domain entities - the controller owns the wire shape. Groundwork for #1649: query-capability metadata (meta.schema) rides the record envelope and the collection meta in a follow-up. The OIDC userinfo endpoint splits onto a dedicated flat GET /userinfo route (advertised via discovery, client userinfoEndpoint updated), so GET /users/@me could adopt the envelope without touching the protocol surface. Protocol/bespoke shapes stay flat: token/introspect/revoke, authorize, jwks + openid-configuration, logout, register/activate/ password workflows, status, authenticator-challenge, permission/policy check, session deleteMany. BREAKING CHANGE: API consumers reading bare record bodies must unwrap data; old clients against a new server break (lockstep beta release). * feat(server-core): expose the queryable vocabulary under meta.schema Closes #1649. Every query-capable GET now carries the endpoint's rapiq schema description in the response meta: collections advertise the full vocabulary (fields/filters/sort/relations/pagination), record reads the fields+relations subset a single read processes. The description is the static allow-list upper bound, serialized once per schema via rapiq's new schema.describe() (tada5hi/rapiq#851, needs @rapiq/core 2.0.0-beta.11) and memoized in core/query/describe.ts. Relation capabilities are referenced by target schema name (relations.schemas) instead of being expanded inline - dotted vocabulary is discovered on the target entity's own endpoints. Actor-dependent gates stay deliberately unreflected. * docs: record envelope, meta.schema discovery and userinfo split Rewrites the core-http-kit client/testing SDK docs onto the { data, meta } record contract, adds the query-capability discovery section (meta.schema reading rules incl. the referenced-not-expanded relation vocabulary), and updates the agent guides (thin-controller conventions, core-http-kit row, mTLS userinfo example). * test: align meta.schema expectations and docs with normalized descriptions rapiq's describe() output is now shape-normalized (tada5hi/rapiq#851 follow-up): every described parameter carries every constraint key, with null = never declared and [] = explicitly nothing (realm's pinned empty relations allow-list now reads { allowed: [], schemas: {} }). * chore: bump @rapiq/* to ^2.0.0-beta.11 The released schema.describe() (tada5hi/rapiq#851) is what meta.schema serializes through - core-http-kit and server-core now declare the minimum that actually carries it. * test: adopt the converged include=client secret gating of rapiq beta.11 rapiq#847 (shipped in 2.0.0-beta.11 alongside the describe API) narrows an explicit include to its requested per-relation fieldset, so the select:false client secret is now genuinely selected under include=client + fields[client] and the schema gate governs it — the same self-visible / foreign-redacted contract as the auto-joined bare projection, which is exactly the convergence the #3322 gate design anticipated. The spec previously pinned the historical #831 fully-selected-join divergence (secret unshippable for anyone). * refactor: make EntityRecordResponse the envelope type directly EntityRecordResponse<R, M> IS the { data, meta } record envelope now - the transitional EntityRecordWrappedResponse name and the deprecated alias are gone; every signature, controller annotation and doc uses the one name. * fix: harden schema description sharing and audit fallout Review-audit follow-ups: the memoized meta.schema description is now deep-frozen (the shared-by-reference object's do-not-mutate contract is self-enforcing), the RECORD_QUERY_PARAMETERS doc no longer overclaims (only the user/client single reads decode fields/relations today - the record advertisement is the target vocabulary, convergence tracked in plan 076), and the two kit form specs stub their record routes with the real { data, meta } wire shape instead of bare entities.
1 parent 728dbb1 commit 00f2f4c

121 files changed

Lines changed: 1284 additions & 713 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/architecture.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -648,12 +648,13 @@ not by client-web:
648648

649649
### Thin Controller Pattern (HTTP Adapter)
650650

651-
Controllers are thin HTTP adapters. They extract input from the routup `IAppEvent`, build an `ActorContext`, delegate to the service, and format the HTTP response. Request body payload types come from `@authup/core-http-kit` (shared between the typed Client, the controller, and `@trapi/swagger` schema generation); response types are the domain entity from `@authup/core-kit` directly:
651+
Controllers are thin HTTP adapters. They extract input from the routup `IAppEvent`, build an `ActorContext`, delegate to the service, and format the HTTP response. Request body payload types come from `@authup/core-http-kit` (shared between the typed Client, the controller, and `@trapi/swagger` schema generation); every entity **record** response is the `{ data, meta }` envelope (`EntityRecordResponse<T>`the shape the MFA enroll response pioneered, uniform since issue #1649), with the domain entity from `@authup/core-kit` under `data`:
652652

653653
```typescript
654654
import type { Role } from '@authup/core-kit';
655655
import type {
656656
EntityCollectionResponse,
657+
EntityRecordResponse,
657658
RoleCreatePayload,
658659
} from '@authup/core-http-kit';
659660

@@ -677,25 +678,25 @@ export class RoleController {
677678
}
678679

679680
@DPost('')
680-
async add(@DBody() data: RoleCreatePayload, @DContext() event: IAppEvent): Promise<Role> {
681+
async add(@DBody() data: RoleCreatePayload, @DContext() event: IAppEvent): Promise<EntityRecordResponse<Role>> {
681682
const actor = buildActorContext(event);
682683
const entity = await this.service.create(data, actor);
683684
event.response.status = 201;
684-
return entity;
685+
return { data: entity, meta: {} };
685686
}
686687

687688
@DDelete('/:id')
688-
async drop(@DPath('id') id: string, @DContext() event: IAppEvent): Promise<Role> {
689+
async drop(@DPath('id') id: string, @DContext() event: IAppEvent): Promise<EntityRecordResponse<Role>> {
689690
const actor = buildActorContext(event);
690691
const entity = await this.service.delete(id, actor);
691692
event.response.status = 202;
692-
return entity;
693+
return { data: entity, meta: {} };
693694
}
694695
}
695696
```
696697

697698
Controller conventions:
698-
- Return type is the domain entity directly (`Promise<Role>`, `Promise<EntityCollectionResponse<Role>>`). This lets `@trapi/swagger` extract the response schema from the method signature.
699+
- Return type is a literal annotation (`Promise<EntityRecordResponse<Role>>`, `Promise<EntityCollectionResponse<Role>>`). This lets `@trapi/swagger` extract the response schema from the method signature. Services still return bare domain entities — the controller owns the envelope. Excluded from the envelope (protocol/bespoke shapes, stay flat): the OAuth2/OIDC surface (`/token*`, `/authorize`, jwks + openid-configuration, `/userinfo`, `/logout`), the register/activate/password workflows, `/`, the authenticator-challenge surface, permission/policy `check`, and session `deleteMany` (`{ count }`).
699700
- Body parameter type is the concrete payload type (`@DBody() data: RoleCreatePayload`) — sourced from `@authup/core-http-kit`. Naming convention: `<Entity>CreatePayload` for POST, `<Entity>UpdatePayload` for POST `/:id`, `<Entity>SavePayload` for PUT `/:id`. Response shapes that genuinely diverge from the domain entity (e.g. `PolicyResponse`, `RegisterResponse`, `PasswordForgotResponse`) keep a named alias; trivial passthrough aliases are not introduced.
700701
- **No business logic** — no permission checks, no validation, no entity manipulation
701702
- Read the routup event via `@DContext() event: IAppEvent`
@@ -710,6 +711,8 @@ Controller conventions:
710711
Exceptions where controllers retain some logic:
711712
- **Self-access resolution** (client, user): Resolve `@me`/`@self` tokens to actual IDs before delegating
712713

714+
The OIDC userinfo endpoint is a dedicated flat route `GET /userinfo` (`adapters/http/controllers/workflows/userinfo/`, advertised via discovery, `userinfoEndpoint` in the core-http-kit `Client` config) — it serves the authenticated user's record as a FLAT claims document and must never adopt the record envelope, which is why it is not an alias of `GET /users/@me` (that route carries the envelope like every other record read).
715+
713716
No controller (or service) reaches for global singletons — cross-cutting services (logger, domain-event publisher) are constructor-injected from the DI container by the factories in `app/modules/http/modules/controller.ts`.
714717

715718
### Wiring (Module Layer)
@@ -2380,7 +2383,7 @@ realtime), port `IUserAuthenticatorRepository` + `UserAuthenticatorService` in
23802383
entity) nulls both — the raw seed/URI/QR/codes appear exactly once, in the
23812384
enroll response (`{ data: <entity>, meta: { secret?, uri?, qr?, codes?,
23822385
webauthn? } }` — the entity under `data`, the shown-once provisioning
2383-
material under `meta`, the `EntityRecordWrappedResponse` envelope entity
2386+
material under `meta`, the `EntityRecordResponse` envelope entity
23842387
record responses converge on; QR is a server-rendered PNG data-URI via the
23852388
`qrcode` dep, TOTP via `otpauth`).
23862389
- The `findMany` adapter follows the plan-039 discipline

.agents/structure.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ It follows hexagonal architecture principles, separating core business logic, ad
2121
| [client-web-nuxt](../packages/client-web-nuxt) | Library | A package for the integration in a nuxt web application. |
2222
| [client-web-theme](../packages/client-web-theme)| Library | Authup app theme for vuecs components, built on `@vuecs/theme-tailwind` (extends `@authup/client-web-kit-theme`). CSS lives under `assets/css/` (`index.css` + `styles/**` partials) and ships raw (`files: ["assets", "dist"]`, `exports.style` + `./index.css``./assets/css/index.css`). Its entry imports the kit theme via the bare `@import "@authup/client-web-kit-theme";`, and the apps import this package via `@import "@authup/client-web-theme";` — Tailwind v4 resolves CSS imports through node resolution honoring the `style` exports condition (the apps' `@authup/* → src` Vite/Nuxt aliases do NOT apply to Tailwind's CSS import resolution — verified empirically; don't switch these to relative paths). |
2323
| [core-kit](../packages/core-kit) | Library | A package providing functions, interfaces and utilities for the core service. |
24-
| [core-http-kit](../packages/core-http-kit) | Library | A package providing a http client with different sub api clients for resources and workflows. Entity-type-string dispatch goes through the derived registry (`pickEntityAPI(client, type)`; `ClientEntityAPIKey = keyof IClient & keyof EntityTypeMap`, so a sub-API named after an `EntityTypeMap` key joins automatically) — the cast-free `ClientEntityAPIRegistry` assignment inside the helper is the compile-time proof that each entity-keyed sub-API serves its `EntityTypeMap` record type (record-type drift fails this package's build). The dispatch surface `EntityAPIDispatch<T>` is deliberately per-verb optional (sessions/events are read(+delete)-only, junctions carry no update), so dispatch callers — the kit's entity record/collection managers, `AEntityDelete`, `APermissionPolicyBindingButton` — guard per method instead of `as any`-indexing the client (#3087). |
24+
| [core-http-kit](../packages/core-http-kit) | Library | A package providing a http client with different sub api clients for resources and workflows. Entity **record** responses are the `{ data, meta }` envelope (`EntityRecordResponse`; `EntityRecordResponse` is a deprecated alias of it) and query-capable GETs carry the endpoint's queryable vocabulary under `meta.schema` (rapiq `SchemaDescription`, issue #1649). The OIDC userinfo endpoint is the dedicated flat `GET /userinfo` (`userinfoEndpoint` in the `Client` config). Entity-type-string dispatch goes through the derived registry (`pickEntityAPI(client, type)`; `ClientEntityAPIKey = keyof IClient & keyof EntityTypeMap`, so a sub-API named after an `EntityTypeMap` key joins automatically) — the cast-free `ClientEntityAPIRegistry` assignment inside the helper is the compile-time proof that each entity-keyed sub-API serves its `EntityTypeMap` record type (record-type drift fails this package's build). The dispatch surface `EntityAPIDispatch<T>` is deliberately per-verb optional (sessions/events are read(+delete)-only, junctions carry no update), so dispatch callers — the kit's entity record/collection managers, `AEntityDelete`, `APermissionPolicyBindingButton` — guard per method instead of `as any`-indexing the client (#3087). |
2525
| [core-realtime-kit](../packages/core-realtime-kit)| Library | A package for the core socket service. |
2626
| [errors](../packages/errors) | Library | `AuthupError` (extends `BaseError` from `@ebec/core`), error-code constants, built-in subclasses (`BadRequestError`, `EntityNotFoundError`, ...), code→HTTP-status mapping, and `Symbol.for(...)`-keyed duck guards. Error JSON carries the `@instanceof` marker chain as a string list (`BaseError.toJSON()` since `@ebec/core` 1.2.0, tada5hi/ebec#448; `AuthupError.toJSON()` re-stamps it after the `data` spread so a data key can't displace it), and every duck guard's fast path is `matchesInstanceof` (symbol **or** serialized-string chain, re-exported from `@ebec/core`) — so guards keep the inheritance match for JSON-rehydrated errors (#3042); new guard modules must use `matchesInstanceof`, never raw `hasInstanceof`. |
2727
| [i18n](../packages/i18n) | Library | Framework-agnostic translation catalogs + locale registry. `CATALOGS` is an ilingo `CatalogNode` (built via ilingo's `defineCatalog`/`defineLocale`/`defineNamespace`/`defineTranslations` helpers, locale → namespace → translations) consumed directly by `MemoryStore({ data: CATALOGS })`. Also exports namespace/key enums (`TranslatorTranslation*`), `LOCALES`/`LocaleCode`/`DEFAULT_LOCALE`/`isLocale`, the `NamespaceTranslations<K>` mapped type for compile-time key completeness, and the `authupError` namespace mapping `@authup/errors` `ErrorCode`s to localized messages (B1: validup-issue-shaped, `IssueDataByCode`-augmented). **Generic UI vocabulary is split across four namespaces** — `ENTITY` (`authupEntity`), `FIELD` (`authupField`), `ACTION` (`authupAction`), `COMMON` (`authupCommon`); every `TranslatorTranslationNamespace` value carries an `authup` prefix (e.g. `ENTITY = 'authupEntity'`, matching the pre-existing `ERROR = 'authupError'`) so a host app embedding `client-web-kit` can't collide with its own catalogs. Per-locale catalog modules mirror the split one-file-per-namespace (`catalogs/{en,de,fr,es}/{entity,field,action,common,client,app,error,vuecs}.ts`; the old `default.ts` is gone). All four locales in `LOCALES` (`en`, `de`, `fr`, `es`) are fully authored in `CATALOGS`; the locale-parity test enforces exact per-namespace key parity across every authored locale, and the `LanguageSwitcherDropdown` UI iterates `LOCALES` (rendering each `nativeName`) so adding a locale to the registry surfaces it in the switcher automatically. **Entity nouns are ilingo plural nodes** (`definePlural({ one, other })` under `authupEntity`): the call site selects the form via `count` (`count: 1` → singular, any other → plural) instead of a separate `*S` key. The `authupMail` namespace (`TranslatorTranslationMailKey`, `catalogs/{en,de,fr,es}/mail.ts`) holds transactional mail copy (subjects, intros, CTA labels, hints; ilingo `{{var}}` placeholders) consumed **server-side** by `apps/server-core`'s mail template renderer. Pure data, zero Vue; consumed by `client-web-kit`'s ilingo install and `server-core`'s mail renderer. |

apps/client-web/pages/clients/[id].vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ export default defineComponent({
8787
const translate = useTranslator();
8888
8989
try {
90-
entity.value = await injectHTTPClient()
90+
entity.value = (await injectHTTPClient()
9191
.client
92-
.getOne(route.params.id as string, { fields: ['+secret'] });
92+
.getOne(route.params.id as string, { fields: ['+secret'] })).data;
9393
} catch {
9494
await navigateTo({ path: '/clients' });
9595
throw createError({});

apps/client-web/pages/events/[id]/index.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ export default defineComponent({
104104
);
105105
106106
try {
107-
entity.value = await injectHTTPClient()
107+
entity.value = (await injectHTTPClient()
108108
.event
109-
.getOne(route.params.id as string);
109+
.getOne(route.params.id as string)).data;
110110
} catch {
111111
await navigateTo({ path: '/events' });
112112
throw createError({});

apps/client-web/pages/identity-providers/[id].vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ export default defineComponent({
7373
const translate = useTranslator();
7474
7575
try {
76-
entity.value = await injectHTTPClient()
76+
entity.value = (await injectHTTPClient()
7777
.identityProvider
78-
.getOne(route.params.id as string);
78+
.getOne(route.params.id as string)).data;
7979
} catch {
8080
await navigateTo({ path: '/identity-providers' });
8181
throw createError({});

apps/client-web/pages/keys/[id].vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ export default defineComponent({
5959
const translate = useTranslator();
6060
6161
try {
62-
entity.value = await injectHTTPClient()
62+
entity.value = (await injectHTTPClient()
6363
.key
64-
.getOne(route.params.id as string);
64+
.getOne(route.params.id as string)).data;
6565
} catch {
6666
await navigateTo({ path: '/keys' });
6767
throw createError({});

apps/client-web/pages/keys/index/index.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,7 @@ export default defineComponent({
169169
170170
try {
171171
const deleted = await httpClient.key.delete(row.id, { force: true });
172-
deleted.id = row.id;
173-
deletedCb(deleted as Key);
172+
deletedCb({ ...deleted.data, id: row.id });
174173
} catch (err) {
175174
emit('failed', err);
176175
}

apps/client-web/pages/permissions/[id].vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ export default defineComponent({
8181
const translate = useTranslator();
8282
8383
try {
84-
entity.value = await injectHTTPClient()
84+
entity.value = (await injectHTTPClient()
8585
.permission
86-
.getOne(route.params.id as string);
86+
.getOne(route.params.id as string)).data;
8787
} catch {
8888
await navigateTo({ path: '/permissions' });
8989
throw createError({});

apps/client-web/pages/policies/[id].vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ export default defineComponent({
5959
const translate = useTranslator();
6060
6161
try {
62-
entity.value = await injectHTTPClient()
62+
entity.value = (await injectHTTPClient()
6363
.policy
64-
.getOne(route.params.id as string);
64+
.getOne(route.params.id as string)).data;
6565
} catch {
6666
await navigateTo({ path: '/policies' });
6767
throw createError({});

apps/client-web/pages/realms/[id].vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ export default defineComponent({
6161
const translate = useTranslator();
6262
6363
try {
64-
entity.value = await injectHTTPClient()
64+
entity.value = (await injectHTTPClient()
6565
.realm
66-
.getOne(route.params.id as string);
66+
.getOne(route.params.id as string)).data;
6767
} catch {
6868
await navigateTo({ path: '/realms' });
6969
throw createError({});

0 commit comments

Comments
 (0)