diff --git a/Documentation/configuration/index.md b/Documentation/configuration/index.md
index a4a3c649..e4f2a4c2 100644
--- a/Documentation/configuration/index.md
+++ b/Documentation/configuration/index.md
@@ -1,9 +1,22 @@
---
title: Configuration
-description: Configure Arc through its grouped ArcOptions tree, appsettings.json, environment variables, or code.
+description: Configure Arc through its grouped ArcOptions tree, appsettings.json, environment variables, or code, add integrations through the builder, and see what each host reads.
---
-Arc reads its settings from one `ArcOptions` object. The TypeScript groups follow the same `Cratis:Arc` paths as [Arc on .NET](https://github.com/Cratis/Arc/blob/main/Documentation/backend/csharp/configuration/index.md): `CorrelationId`, `Tenancy`, `GeneratedApis`, `Query`, `Hosting`, and `ExposeExceptionDetails`. Node-specific transport limits and registration hooks live in those groups or alongside them as noted below.
+The same application runs on your laptop, in CI, and in production. The route prefix stays put, but the tenant source, the exception detail, and the listener address change between them. You want those differences in configuration, not in `if` statements around your startup code.
+
+Arc reads every setting from one `ArcOptions` object. You can fill it from `appsettings.json`, environment variables, and code, and code always has the last word. The groups follow the same `Cratis:Arc` paths as [Arc on .NET](https://github.com/Cratis/Arc/blob/main/Documentation/backend/csharp/configuration/index.md): `CorrelationId`, `Tenancy`, `GeneratedApis`, `Query`, `Hosting`, and `ExposeExceptionDetails`, so one `appsettings.json` shape serves both. Node-specific transport limits and registration hooks live in those groups or alongside them, as noted below.
+
+## What each entry point reads
+
+Where options come from depends on how you create the application. The host you mount it in does not matter: Express, Fastify, and Hono take an application that is already built. See the [hosting overview](../overview.md) for choosing a host.
+
+| Entry point | Reads `appsettings.json` and environment | Typical use |
+| --- | --- | --- |
+| `ArcApplication.createBuilder()` from `@cratis/arc.core` | Yes, unless you pass `configuration: false` | Node applications, with discovery and the standalone host |
+| `CratisApplication.createBuilder()` from `@cratis/cratis` | Yes, including `Cratis:Chronicle` | Arc and the experimental Chronicle integration in one call; see [Add event sourcing](../chronicle/add-event-sourcing.md) |
+| `ArcApplication.createBuilder()` from `@cratis/arc.core/fetch` | No, code options only | Fetch API runtimes without a filesystem; see [Fetch API runtimes](../hosts/fetch-runtimes.md) |
+| `new ArcServer(options)` | No, code options only | [Low-level definitions](../commands/low-level-definitions.md) and specs |
## Three ways to configure
@@ -37,6 +50,18 @@ Use `{ configuration: false }` to disable file and environment binding, or `{ co
`new ArcServer(options)` uses code options only. It never reads a file or environment overrides. On a Fetch-only runtime, its default for exception exposure is false because there is no Node environment.
+## Add features through the builder
+
+Storage and event sourcing are optional packages. Importing one adds its method to the builder, and the method registers everything the integration needs:
+
+| Package | Builder method | Configuration it binds |
+| --- | --- | --- |
+| `@cratis/arc.mongodb` | `builder.withMongoDB({ ... })`; see [MongoDB](../mongodb/getting-started.md) | `Cratis:MongoDB:{Server,Database}` |
+| `@cratis/arc.drizzle` | `builder.withDrizzle({ ... })`; see [SQL with Drizzle](../sql/getting-started.md) | None; pass the database in code |
+| `@cratis/arc.chronicle`, experimental | `builder.withChronicle({ ... })`; see [Chronicle](../chronicle/index.md) | `Cratis:Chronicle:{ConnectionString,EventStore}` |
+
+Each package also exports a function form, such as `withMongoDB(builder, options)`, which does the same. Calling a method whose package you did not import fails where you call it, so a missing integration never degrades into a silent no-op. Configuration covers only serializable values. Clients, connection pools, and model classes are passed in code.
+
## The ArcOptions tree
The paths below are relative to `Cratis:Arc` in configuration and camelCase in TypeScript. `.NET` settings with different value representations are called out explicitly. Unspecified options use the documented defaults.
@@ -44,6 +69,7 @@ The paths below are relative to `Cratis:Arc` in configuration and camelCase in T
| Configuration path / TypeScript path | Default | Effect |
| --- | --- | --- |
| `ExposeExceptionDetails` / `exposeExceptionDetails` | `true` only when the effective environment is Development | Include original exception messages and stack traces in serialized HTTP results; otherwise redact them. This does not enable development discovery. |
+| `Development` / `development` | `false` | Enable the development user and tenant discovery providers. TypeScript-only; it does not change exception exposure. |
| `CorrelationId:HttpHeader` / `correlationId.httpHeader` | `X-Correlation-ID` | Correlation ID request and response header. |
| `Tenancy:ResolverType` / `tenancy.resolverType` | `header` when `tenancy` is present | Single `header`, `query`, `claim`, `subdomain`, `development`, or `fixed` source. |
| `Tenancy:HttpHeader` / `tenancy.httpHeader` | `x-cratis-tenant-id` | Header source; also the fallback for `resolverType: 'subdomain'` or an ordered `['subdomain', 'header']` list. |
@@ -51,6 +77,8 @@ The paths below are relative to `Cratis:Arc` in configuration and camelCase in T
| `Tenancy:QueryParameter` / `tenancy.queryParameter` | `tenantId` | Query-string source. |
| `Tenancy:ClaimType` / `tenancy.claimType` | `tenant_id` | Claim source; only own string claims on authenticated principals count. |
| `Tenancy:FixedTenantId` / `tenancy.fixedTenantId` | `development` | Fixed or development source. The .NET `DevelopmentTenantId` configuration name also binds this value; do not supply both names. |
+| `Tenancy:Required` / `tenancy.required` | `false` | Answer 400 when no tenant is selected. TypeScript-only. |
+| `Tenancy:MembershipClaim` / `tenancy.membershipClaim` | None | Require the selected tenant in this comma-separated own claim of an authenticated principal, or answer 403. TypeScript-only. |
| `GeneratedApis:RoutePrefix` / `generatedApis.routePrefix` | `api` | Prefix for convention routes. |
| `GeneratedApis:SegmentsToSkipForRoute` / `generatedApis.segmentsToSkipForRoute` | `0` | Leading namespace segments removed from generated routes. |
| `GeneratedApis:IncludeCommandNameInRoute` / `generatedApis.includeCommandNameInRoute` | `true` | Append command names to convention routes. |
@@ -114,6 +142,20 @@ A body larger than `hosting.maxBodyBytes`, measured by `Content-Length` or while
| `identityDetails` | None | Registers `/.cratis/me`; see [Identity](../identity/index.md). |
| `developmentUsers`, `developmentTenants` | None | Code-only anonymous discovery providers; require `development: true`. |
+## A note on CORS
+
+CORS is not an Arc option. Arc sends no `Access-Control-*` headers, and it answers a preflight `OPTIONS` request to a command or query route with 405 and an `Allow` header. A browser on another origin therefore cannot call Arc until something in front of it handles CORS.
+
+Choose one of these:
+
+- **Serve the frontend from the same origin.** Proxy `/api` and `/.cratis` through your dev server, as the Library sample's Vite configuration does, or serve the built frontend with [static files](../core/static-files.md).
+- **Use your web framework's CORS middleware**, mounted before Arc: `cors` for Express, `@fastify/cors` for Fastify, or `hono/cors` for Hono. The middleware answers the preflight and adds the headers to Arc's responses.
+- **Handle CORS at your ingress** in front of the standalone host, which has no middleware of its own.
+
+Arc accepts HTTP `QUERY` for queries by default. If cross-origin clients use it, add `QUERY` to the allowed methods; it is not a simple method, so the browser always sends a preflight. A client that only uses GET does not need it.
+
+WebSocket upgrades for observable queries are not covered by CORS. Arc checks their `Origin` against `query.allowedOrigins` itself; see [WebSockets](../hosts/websockets.md#origin-checks).
+
## Errors and logging
The code-only `logger(error, correlationId)` receives the original error regardless of `exposeExceptionDetails`. A callback failure produces a 500 with `hasExceptions: true`; when exposure is off, HTTP callers receive `['An unexpected error occurred']` and no stack trace. Direct calls are neither redacted nor logged. If the logger throws or rejects, Arc attempts it once and returns a generic redacted 500. A handler failure and a scope cleanup failure arrive together as one `AggregateError`.
@@ -130,8 +172,27 @@ The code-only `logger(error, correlationId)` receives the original error regardl
- authorization combines anonymous with restricted access, names an unknown policy or scheme, or combines `nativePrincipal` with `authentication`;
- an input schema has case-insensitively duplicate property names or cannot become JSON Schema.
-The builder also rejects misplaced decorators, duplicate validator targets, missing service registrations, dependency cycles, and captive lifetimes.
+The builder also rejects misplaced decorators, duplicate validator targets, missing service registrations, dependency cycles, and captive lifetimes. A captive lifetime is a singleton that depends on a scoped service. It would keep the first request's instance forever, which in a multi-tenant application means the first tenant's data. Arc checks the declared graph without running any factory, so the check is safe in every environment.
## Low-level definition fields
`defineCommand` and `defineQuery` share `name` (required), `namespace`, `path`, `summary`, `schema` (required), `authorization`, `authorize`, `validate`, `filters`, `handlerDependencies`, `validatorDependencies`, and `clientOutput`. A command also takes `handle` (required), `provide`, and `scopes`. A query takes `perform` (required); an observable query takes `observe` (required). See [Low-level definitions](../commands/low-level-definitions.md).
+
+## Upgrading from v0.21
+
+v0.22 grouped the flat options under the .NET configuration paths and renamed the options type. Code that still uses the old names no longer compiles:
+
+| Before v0.22 | Now |
+| --- | --- |
+| `ArcServerOptions` | `ArcOptions` |
+| `correlationHeader` | `correlationId.httpHeader` |
+| `tenantHeader` | `tenancy.httpHeader` |
+| `resolveTenant` | `tenancy.resolve` |
+| `enableQueryMethod` | `generatedApis.enableQueryHttpMethod` |
+| `openApiVersion` | `generatedApis.openApiVersion` |
+| `maxBodyBytes` | `hosting.maxBodyBytes` |
+| `observableKeepAliveIntervalMs` | `query.keepAliveIntervalMs` |
+| `allowedOrigins` | `query.allowedOrigins` |
+| `maxObservable*`, `observableHandshakeTimeoutMs`, `observableShutdownTimeoutMs`, `enableObservableHealth`, `observableEmissionGuards` | The same names under `query` |
+
+In `appsettings.json`, use the grouped paths from [the ArcOptions tree](#the-arcoptions-tree). A flat key such as `Cratis:Arc:CorrelationHeader` does not bind; with a `logger` configured, Arc reports it as an unknown key.
diff --git a/Documentation/core/authorization.md b/Documentation/core/authorization.md
index fdbc5344..3659630c 100644
--- a/Documentation/core/authorization.md
+++ b/Documentation/core/authorization.md
@@ -1,9 +1,9 @@
---
title: Authorization policies and schemes
-description: Register named authorization policies as functions or classes, select a named authentication scheme per operation, and compare both with Arc on .NET.
+description: Register named authorization policies, read claims, make ownership decisions from your own data, understand authorization results, test them with CommandScenario, and select a named authentication scheme per operation.
---
-Roles answer "is this caller an editor?". Some rules need more: the caller's department, a subscription level, or a service lookup. A named policy puts that rule in one place, and every command or query that needs it names it. A policy checks who may run an operation; it never authenticates the caller.
+Roles answer "is this caller an editor?". Some rules need more: the caller's department, a subscription level, or whether they own the document they are about to archive. Scattering those checks through handlers makes them easy to forget on the next command. Arc gives each kind of rule one place: a named policy for rules about the caller, and `provide()` for rules about the data. Neither one authenticates the caller; that already happened in an [authentication handler](authentication.md).
## Register and use a policy
@@ -25,13 +25,13 @@ const app = await builder.build();
The snippet shows authorization only; configure [authentication](authentication.md) for real use. `@authorize('Finance')` is shorthand for the policy without roles, and `@authorize()` alone requires authentication.
-A function policy receives the selected principal and the execution context. Return `true` to allow and `false` to deny.
+A function policy receives the selected principal and the execution context. Return `true` to allow and `false` to deny. Every command and query that names `Finance` now runs the same check, before validation and before your code.
## Write a policy class
When a policy needs services, pass a class implementing `AuthorizationPolicy` to `addAuthorizationPolicy(name, PolicyClass)`. The builder registers it as scoped, resolves its constructor dependencies from the execution scope, and calls `authorize({ principal, target, resource })`. `target` is the command or query definition; `resource` holds the unvalidated `input` and the `execution` context.
-A thrown error fails the operation and is redacted on production HTTP routes. Unknown policies and unknown named schemes throw `InvalidAuthorizationConfiguration` **at build**, and a duplicate policy name is rejected.
+A thrown error fails the operation with a 500 and is redacted on production HTTP routes, so a broken policy never lets a caller through. Unknown policies and unknown named schemes throw `InvalidAuthorizationConfiguration` **at build**, and a duplicate policy name is rejected.
For a low-level `defineCommand` or `defineQuery`, write `authorization: { policy: 'Finance', authenticated: true }` and register the policy in the `authorizationPolicies` option.
@@ -39,6 +39,102 @@ For a low-level `defineCommand` or `defineQuery`, write `authorization: { policy
One declaration's roles are alternatives (OR). Stacked `@authorize` or `@roles` decorators are separate requirements that must all pass (AND). A declaration on a `@query()` method replaces the read-model class declaration. `@allowAnonymous()` cannot share a declaration with an authenticated requirement. [Authorizing commands and queries](../authorizing-commands-and-queries.md) covers the decorators and the order of checks.
+## Work with claims
+
+Arc's `Principal` has `id`, `roles`, `isAuthenticated`, and optional `name`, `scheme`, and `claims`. `claims` is typed `unknown`, because its shape comes from whoever authenticated the caller:
+
+| Authenticated by | `principal.claims` |
+| --- | --- |
+| [`jwtBearer()`](authentication.md#verify-jwt-bearer-tokens) | The verified token payload, such as `{ sub, department, roles }` |
+| [`microsoftIdentityPlatform()`](authentication.md#accept-easyauth-headers-behind-a-trusted-ingress) | A dictionary from claim type to value, including `sub` and the .NET name and name-identifier claim types |
+| A [native principal](../hosts/native-principal.md) or your own handler | Whatever your code put there |
+
+Narrow the value before you use it, as the `Finance` policy above does. Arc freezes the claim dictionary, so no step of the pipeline can change it.
+
+You can read the principal wherever you need it:
+
+- In a policy, from the `principal` argument.
+- In a command's `provide()` or `handle()`, by injecting `commandContext()` and reading `context.principal`.
+- In any service that runs during a command or query, with `currentContext()?.principal`. It uses Node's `AsyncLocalStorage`, so concurrent requests never see each other's principal, and it returns `undefined` outside an Arc execution.
+
+[Identity details](../identity/index.md) are display data for the frontend. They never add claims to this principal.
+
+## Decide from your own data
+
+"Only the owner may archive a document" is not a rule about the caller alone. You have to load the document first. Do that in `provide()` and return `denied(reason)` when the caller may not continue:
+
+```typescript title="Features/Documents/ArchiveDocument.ts"
+import { field } from '@cratis/fundamentals';
+import { command, commandContext, denied, inject, rejected, validation, type CommandContext } from '@cratis/arc.core';
+import { Documents, type StoredDocument } from './Documents.js';
+
+@command()
+export class ArchiveDocument {
+ @field(String) id!: string;
+
+ @inject(Documents, commandContext())
+ provide(documents: Documents, context: CommandContext) {
+ const document = documents.byId(this.id);
+ if (!document) return rejected(validation('The document does not exist', ['id'], 'notFound'));
+ return document.owner === context.principal?.id ? document : denied('Only the owner can archive a document');
+ }
+
+ handle(document: StoredDocument): void {
+ document.archived = true;
+ }
+}
+```
+
+`Documents` is your own service. `provide()` runs after authorization and validation, so the caller is already authenticated and the input is well-formed. `denied(...)` stops the command before `handle()` runs and answers 403 with the reason. A missing document is a different outcome: `rejected(...)` answers 400 with a validation result. See [Command outcomes](../commands/command-outcomes.md).
+
+For a low-level definition, the per-request `authorize(input, context)` callback makes the same decision before validation; see [Authorizing commands and queries](../authorizing-commands-and-queries.md#decide-per-request).
+
+:::caution[Keep permission checks out of validators]
+A validator result is a validation error, not a denial, and a trusted direct caller can lower the blocking severity. Put ownership and tenant checks in a policy, `provide()`, or `authorize`, where nothing a caller sends changes the outcome.
+:::
+
+## Authorization results
+
+When authorization fails, the command or query result has `isAuthorized: false`, and your code never ran:
+
+| Situation | HTTP status | Result |
+| --- | --- | --- |
+| Anonymous caller on a protected operation, with at least one authentication handler configured | 401 | `isAuthorized: false` |
+| The authentication handler returned `Failed` | 401 | `isAuthorized: false` |
+| Anonymous caller on a protected operation, with no authentication handler | 403 | `isAuthorized: false` |
+| Authenticated caller without a required role, or a policy returned `false` | 403 | `isAuthorized: false` |
+| `provide()` or `handle()` returned `denied(reason)` | 403 | `isAuthorized: false`, `authorizationFailureReason: reason` |
+| A policy threw | 500 | `hasExceptions: true`, redacted outside development |
+
+Arc on .NET answers 403 for an anonymous caller in the first row; this is a [deliberate difference](../reference/capabilities.md#deliberate-differences). A generated frontend proxy reads the same `isAuthorized` flag from the result, so the UI can tell "not allowed" apart from "invalid input".
+
+## Test authorization
+
+`CommandScenario` runs a command through the real pipeline, including authorization. Set the principal with `withContext` and assert the result:
+
+```typescript title="Features/Documents/for_ArchiveDocument/when_archiving/as_someone_else.ts"
+import { CommandScenario, type ScenarioCommandResult } from '@cratis/arc.testing';
+import { ArchiveDocument } from '../../ArchiveDocument.js';
+import { Documents } from '../../Documents.js';
+
+describe('when archiving a document as someone else', () => {
+ const scenario = CommandScenario.for(ArchiveDocument);
+ scenario.services.addSingleton(Documents, new Documents());
+ scenario.withContext({ principal: { id: 'bob', roles: [], isAuthenticated: true } });
+ let result: ScenarioCommandResult;
+
+ beforeAll(async () => { result = await scenario.execute({ id: 'doc-1' }); });
+ afterAll(async () => { await scenario.dispose(); });
+
+ it('should not be authorized', () => { result.shouldNotBeAuthorized(); });
+ it('should give the reason', () => { result.authorizationFailureReason.should.equal('Only the owner can archive a document'); });
+});
+```
+
+This assumes a `Documents` service in which `doc-1` belongs to `ada`. Cover at least three principals for each protected command: anonymous, authenticated without the role or ownership, and allowed. The [Library sample](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library/Features/Authors/Registration/for_RegisterAuthor/when_registering) does this for `@roles('Librarian')` with and without the role.
+
+A scenario calls the pipeline directly, so it never runs your authentication handlers. Test those, and any ingress that forwards identity, over HTTP. See [Testing commands](../testing/commands.md) for the full scenario API.
+
## Select an authentication scheme
Register named handlers with `authenticationSchemes: { Verified: handler }` and require one with `@authorize({ schemes: ['Verified'] })`. For that operation, Arc tries only the selected handlers in declaration order, strips any scheme the handler set, marks the verified principal with the selected `scheme`, and authorizes against it. The first recognized result wins; several named handlers do not merge identities.
@@ -52,10 +148,11 @@ Do not confuse a named policy with an authentication scheme: a policy decides, a
## Compared with Arc on .NET
-Arc on .NET 22.23.0 evaluates named policies through scoped `IAuthorizationPolicy` implementations; an unknown name throws `InvalidAuthorizationConfiguration`. Its policy context holds a principal, a reflected command type or query method (`Target`), and a command or query context (`Resource`). The TypeScript class form receives an operation definition and `{ input, execution }` instead, and a function form is also available. Node schemes select Arc handlers rather than ASP.NET Core authentication and challenge or forbid composition. The paired HTTP conformance fixture checks named-policy allow and deny for commands and queries against .NET 22.23.0; it does not compare policy context objects or scheme selection.
+Arc on .NET 22.23.0 evaluates named policies through scoped `IAuthorizationPolicy` implementations; an unknown name throws `InvalidAuthorizationConfiguration`. Its policy context holds a principal, a reflected command type or query method (`Target`), and a command or query context (`Resource`). The TypeScript class form receives an operation definition and `{ input, execution }` instead, and a function form is also available. Node schemes select Arc handlers, not ASP.NET Core authentication with its challenge and forbid composition.
## Related
- [Authentication](authentication.md)
- [Authorizing commands and queries](../authorizing-commands-and-queries.md)
+- [Tenancy](../tenancy/index.md), for tenant membership
- [Capability reference](../reference/capabilities.md#security-identity-tenancy-and-correlation)
diff --git a/Documentation/core/index.md b/Documentation/core/index.md
index 25f9ae86..cfe32110 100644
--- a/Documentation/core/index.md
+++ b/Documentation/core/index.md
@@ -3,7 +3,9 @@ title: Arc.Core and the standalone Node host
description: Run an Arc application on Node's own HTTP server, own the listener yourself, and shut it down without losing in-flight work.
---
-`@cratis/arc.core` is the whole Arc application model: the command and query pipelines, validation, authorization, services, and the result envelope. It also carries a small Node host, so an application can serve its routes, and a built frontend, without Express, Fastify, or Hono.
+Not every service needs a web framework. A worker that exposes a few commands, or a small application that serves its own frontend, would otherwise pull in Express only to listen on a port and shut down cleanly.
+
+`@cratis/arc.core` is the whole Arc application model: the command and query pipelines, validation, authorization, services, and the result envelope. It also carries a small Node host, so an application can serve its routes, and a built frontend, without Express, Fastify, or Hono. When you do use one of those frameworks, the same application mounts in it unchanged; see [Host adapters](../hosts/index.md).
:::note[Source preview]
`@cratis/arc.core` is not published to npm. Use it from a clone of this repository with the `workspace:^` protocol, as `Samples/Tasks/package.json` does.
diff --git a/Documentation/identity/development-users-and-tenants.md b/Documentation/identity/development-users-and-tenants.md
index 9230cc55..4cf724d7 100644
--- a/Documentation/identity/development-users-and-tenants.md
+++ b/Documentation/identity/development-users-and-tenants.md
@@ -3,7 +3,7 @@ title: Development users and tenants
description: Offer fixture users and tenants to local development tooling through /.cratis/users and /.cratis/tenants, and keep them out of production.
---
-Local development tools, such as a user or tenant picker, need something to pick from. Arc serves two anonymous discovery routes for that, which return nothing until you opt in with fixture data.
+Local development tools, such as a user or tenant picker, need something to pick from. Hard-coding that list in the tool means it drifts from your application. Arc serves two anonymous discovery routes instead, which return nothing until you opt in with fixture data from your own code.
## Opt in
@@ -38,7 +38,16 @@ Both routes are anonymous. Never return secrets, production user inventories, or
Tenant resolution is separate: listing a tenant here does not select or authorize it. See [Tenancy](../tenancy/index.md).
+:::caution[Tenancy rules apply to these routes too]
+The discovery routes resolve a tenant like every other request. With `tenancy.required`, an anonymous request without a tenant answers 400. With `tenancy.membershipClaim`, a request that names a tenant answers 403, because an anonymous caller has no membership claim. Leave `required` off in the local configuration that serves a picker.
+:::
+
+## What a picker does with the list
+
+A tool such as [Lens](/tools/lens/) reads both routes to fill its pickers, then sends identity and tenant headers with your application's requests. Arc only turns those identity headers into a principal when you registered `microsoftIdentityPlatform()`. [Simulate a signed-in user locally](local-development.md) shows that setup on a loopback host.
+
## Related
- [Identity](index.md)
+- [Simulate a signed-in user locally](local-development.md)
- [Tenant resolvers](../tenancy/resolvers.md)
diff --git a/Documentation/identity/frontend.md b/Documentation/identity/frontend.md
new file mode 100644
index 00000000..3d892e91
--- /dev/null
+++ b/Documentation/identity/frontend.md
@@ -0,0 +1,105 @@
+---
+title: Show identity in a React frontend
+description: Read the signed-in user and typed identity details with @cratis/arc.react, hide UI by role while the identity loads, and refresh or clear the cached identity.
+---
+
+Your backend now answers `/.cratis/me`. On the React side you want three things: the user's name in the header, controls hidden from people who cannot use them, and a way to pick up changes without a full reload. The published `@cratis/arc.react` client already does the fetching and caching. You connect it to the details class your backend declares.
+
+This page uses `@cratis/arc` and `@cratis/arc.react` 22.19.1, the client versions the [proxy generator](../proxy-generation/getting-started.md) targets.
+
+## Give the client your details type
+
+Generate proxies from the backend that holds your identity provider. The generator emits the provider's `detailsType` as a frontend class with the same `@field` declarations:
+
+```typescript title="src/generated/Identity/UserDetails.proxy.ts (generated excerpt)"
+import { field } from '@cratis/fundamentals';
+
+export class UserDetails {
+ @field(String)
+ greeting!: string;
+}
+```
+
+Pass it to the `Arc` component at the root of your application:
+
+```tsx title="src/App.tsx"
+import { Arc } from '@cratis/arc.react';
+import { UserDetails } from './generated/Identity/UserDetails.proxy';
+import { Header } from './Header';
+
+export function App() {
+ return
+
+ ;
+}
+```
+
+`Arc` already contains an identity provider. On mount it reads the `.cratis-identity` cookie. When the cookie is missing, it calls `/.cratis/me` with the same `httpHeadersCallback` headers your commands and queries send, and the response sets the cookie for next time.
+
+## Read the identity
+
+```tsx title="src/Header.tsx"
+import { useIdentity } from '@cratis/arc.react/identity';
+import { UserDetails } from './generated/Identity/UserDetails.proxy';
+
+export function Header() {
+ const identity = useIdentity(UserDetails);
+ if (identity.isLoading) return
Loading…
;
+ if (!identity.isSet) return
Please sign in.
;
+ return
{identity.details.greeting}
;
+}
+```
+
+`useIdentity(UserDetails)` returns the identity with `details` typed as `UserDetails`, plus `id`, `name`, `roles`, and `isInRole(role)`. Check `isLoading` first: before the first answer arrives, `isSet` is `false` for a signed-in user too. After loading, `isSet` is `false` when `/.cratis/me` answered anything other than 200, such as 401 for an anonymous caller or 403 from a provider that returned `undefined`.
+
+## Hide controls by role
+
+`RequireRole` renders its children only for a signed-in caller with one of the roles, and keeps the loading and denied states apart:
+
+```tsx title="src/ArchiveButton.tsx"
+import { RequireRole } from '@cratis/arc.react/identity';
+
+export function ArchiveButton() {
+ return Loading…} forbidden={
Read-only
}>
+
+ ;
+}
+```
+
+The roles come from the verified principal on the server, so they match what `@roles('Editor')` checks. The check in the browser still only hides UI. Anyone can edit the cookie and render the button; the command behind it answers 403 because the server authorizes against the real principal. Protect every command and query on the server, as described in [Authorization policies and schemes](../core/authorization.md).
+
+## Refresh after a change
+
+The client keeps using the cookie until something replaces it. After an action that changes what `/.cratis/me` would return, such as a role grant, a profile edit, or a switch to another tenant, call `refresh()`:
+
+```tsx
+import { useIdentity } from '@cratis/arc.react/identity';
+import { UserDetails } from './generated/Identity/UserDetails.proxy';
+
+export function SwitchTenant({ tenant }: { tenant: string }) {
+ const identity = useIdentity(UserDetails);
+ const select = async () => {
+ localStorage.setItem('tenant', tenant);
+ await identity.refresh();
+ };
+ return ;
+}
+```
+
+This example assumes the application's `httpHeadersCallback` reads the tenant from `localStorage` and sends it as the `x-cratis-tenant-id` header. `refresh()` clears the cookie, calls `/.cratis/me`, and re-renders every component that uses the identity. While it runs, `isLoading` is `true` again. When `/.cratis/me` answers with an error status, the identity becomes unset. When the request itself fails, for example on a network error, the promise rejects and the previous identity stays in place.
+
+When a user signs out, call `clearIdentity()` from `useIdentity()`. It removes the cookie and resets the identity to unset without calling the server.
+
+## Common mistakes
+
+- **Treating `isSet: false` as signed out during loading.** Show a loading state until `isLoading` is `false`, or signed-in users see the signed-out UI flash on every page load.
+- **Using only a type argument.** `useIdentity()` types the details but cannot deserialize them, because a type has no runtime field metadata. Pass the generated class, `useIdentity(UserDetails)`, so concepts, dates, and nested models arrive as their real types.
+- **Expecting the UI to notice server-side changes.** Nothing pushes identity changes to the browser. Call `refresh()` after the change, or after a fresh sign-in.
+
+## Recap
+
+- `Arc detailsType={...}` plus `useIdentity(Type)` gives every component typed details from one cached request.
+- `RequireRole` hides UI and never replaces server authorization.
+- `refresh()` re-reads `/.cratis/me` after a change; `clearIdentity()` forgets the user on sign-out.
+
+The shared [React identity](/arc/frontend/react/identity/) page covers the rest of the client API, including default details and the raw context. To see how services share one identity, read [Identity across services](topologies.md).
diff --git a/Documentation/identity/index.md b/Documentation/identity/index.md
index 4998b33f..dc14858f 100644
--- a/Documentation/identity/index.md
+++ b/Documentation/identity/index.md
@@ -1,18 +1,42 @@
---
title: Identity
-description: Give the frontend the current user's name, roles, and application details through /.cratis/me, and understand why the identity cookie is display data only.
+description: Give the frontend the signed-in user's name, roles, and application details through /.cratis/me, and keep that display data apart from authentication and authorization.
---
-A frontend wants to show "Hello, Ada" and hide buttons Ada cannot use, without calling a separate user service. Arc's identity endpoint returns the authenticated caller together with details your application adds, and sets a cookie the published `@cratis/arc` client reads.
+Ada signs in to your task application. The header should say "Hello, Ada", the Archive button should only appear for editors, and the page should know which customer she is working for. Without help you end up writing a `/me` route, repeating the role list in the frontend, and deciding by hand what the browser may cache.
+
+Arc gives you one endpoint for that job. You write a small provider that turns the authenticated caller into the details your UI needs. Arc serves the result at `GET /.cratis/me` and sets a cookie that the published `@cratis/arc` client reads, so every component can ask "who is this?" without another request.
+
+## Three jobs, three places
+
+Identity details sit next to two other concerns. Keep them apart, because each one trusts different evidence:
+
+| Job | Question | Where it lives |
+| --- | --- | --- |
+| Authentication | Who is calling? | [Authentication handlers](../core/authentication.md) or a [native principal](../hosts/native-principal.md) |
+| Authorization | May this caller run this command or query? | [Decorators and policies](../core/authorization.md) on each operation |
+| Identity details | What should the UI show about this caller? | An identity details provider, served at `/.cratis/me` |
+
+```mermaid
+flowchart LR
+ Request[Request with a credential] --> Authn[Authentication handler]
+ Authn --> Principal[Verified principal]
+ Principal --> Authz[Operation authorization]
+ Principal --> Provider[Identity details provider]
+ Provider --> Me["/.cratis/me JSON and cookie"]
+ Me --> UI[Frontend display]
+```
+
+The provider only ever sees a principal that authentication already verified. Nothing it returns flows back into authorization.
## Provide identity details
-Write a provider class and add or discover it with the builder:
+Write a provider class and let discovery find it, or add it with `builder.add(...)`:
-```typescript
+```typescript title="Features/Identity/GreetingDetails.ts"
import { field } from '@cratis/fundamentals';
import {
- ArcApplication, identityDetailsProvider,
+ identityDetailsProvider,
type ExecutionContext, type IdentityDetailsProvider, type Principal
} from '@cratis/arc.core';
@@ -30,42 +54,34 @@ export class GreetingDetails implements IdentityDetailsProvider {
}
```
-With a verified [authentication handler](../core/authentication.md) that recognizes Ada, `GET /.cratis/me` answers:
+`detailsType` tells Arc the shape of the details, so it can validate what `provide` returns, describe it at `/.cratis/identity-details/schema`, and let the [proxy generator](../proxy-generation/index.md) emit a matching frontend class.
+
+With an [authentication handler](../core/authentication.md) that recognizes Ada and a request for tenant `acme`, `GET /.cratis/me` answers:
```json
-{"id":"ada","name":"Ada","isAuthenticated":true,"isAuthorized":true,"roles":["reader"],"details":{"greeting":"Hello Ada (no tenant)"}}
+{"id":"ada","name":"Ada","isAuthenticated":true,"isAuthorized":true,"roles":["Editor"],"details":{"greeting":"Hello Ada (acme)"}}
```
-and sets `.cratis-identity=; Path=/; SameSite=Lax`, with `Secure` when the trusted transport is HTTPS.
+The same response sets `.cratis-identity=; Path=/; SameSite=Lax`. The `id`, `name`, and `roles` come from the verified principal. Only `details` comes from your provider, and Arc runs it again on every call to `/.cratis/me`.
-The provider declares its details shape either as `detailsType`, a class with `@field` declarations, or as a Zod `schema`. The details returned by `provide` must match it.
+:::danger[The identity cookie is not a credential]
+`.cratis-identity` is unsigned and readable by JavaScript. It exists so the frontend can display the user. Never use it to authenticate or authorize anything; Arc itself never reads it.
+:::
-## Choose how to register the provider
+## What you have so far
-- A class marked `@identityDetailsProvider()`, added with `builder.add(...)` or found by `builder.discover(...)`. Arc constructs one per request, so its constructor must take no arguments; unlike .NET, there is no constructor injection here.
-- An object in the `identityDetails` option: `{ schema, provide }` or `{ detailsType, provide }`. An explicit option wins, and cannot be combined with a discovered provider.
-- More than one discovered provider without an explicit option fails at build.
+- Authentication decides who the caller is. Your provider only adds display details.
+- `/.cratis/me` returns the principal plus those details, and caches them in a cookie for the frontend.
+- Commands and queries keep their own authorization. Hiding a button in the UI protects nothing.
-## The endpoint's answers
+## Go further
-| Situation | `GET /.cratis/me` |
+| Topic | What it covers |
| --- | --- |
-| No provider configured | Not mapped |
-| Anonymous caller | 401 |
-| `provide` returns `undefined` | 403 |
-| `provide` throws or rejects, or the encoded cookie would exceed 4096 bytes | Generic 500, no cookie |
-| Otherwise | 200 with the identity JSON and the cookie |
-
-Every answer carries `Cache-Control: no-store`. The provider runs in the current execution context with its own scoped services, even on denial or error. `GET /.cratis/identity-details/schema` returns the details JSON Schema; see [Introspection](../introspection/identity-details-schema.md).
-
-The JSON response keeps Unicode. The cookie escapes non-ASCII characters before Base64 encoding, so the client's `JSON.parse(atob(cookie))` recovers names and details, including emoji. Cookie bytes are not guaranteed to match .NET's JSON escaping.
-
-:::danger[The identity cookie is not a credential]
-`.cratis-identity` is unsigned and readable by JavaScript. It exists so the frontend can display the user. Never use it to authenticate or authorize anything; Arc itself never does.
-:::
-
-## Related
+| [How identity details are served](provider-flow.md) | Registration choices, every `/.cratis/me` answer, the cookie format, and how caching works |
+| [Show identity in a React frontend](frontend.md) | `useIdentity`, `RequireRole`, typed details, and refreshing after a change |
+| [Identity across services](topologies.md) | One service, several services behind a gateway, or a dedicated identity service |
+| [Simulate a signed-in user locally](local-development.md) | Try different users, roles, and tenants on a loopback development host |
+| [Development users and tenants](development-users-and-tenants.md) | Fixture lists for local user and tenant pickers |
-- [Development users and tenants](development-users-and-tenants.md)
-- [Authentication](../core/authentication.md)
-- [Frontend identity](/arc/frontend/) on the shared Arc pages
+Next, read [how identity details are served](provider-flow.md) to see what happens between the request and the cookie.
diff --git a/Documentation/identity/local-development.md b/Documentation/identity/local-development.md
new file mode 100644
index 00000000..a8e8439b
--- /dev/null
+++ b/Documentation/identity/local-development.md
@@ -0,0 +1,112 @@
+---
+title: Simulate a signed-in user locally
+description: Exercise different users, roles, and tenant memberships on a loopback development host with forwarded Microsoft identity headers, without mistaking Base64 assertions for verified tokens.
+---
+
+You want to see how the application behaves for an editor, for a reader, and for someone who belongs to another tenant, before production sign-in exists. This guide turns on Arc's Microsoft identity header handler on your development machine only, and sends it synthetic principals.
+
+This is identity **simulation**. Nothing here verifies a token.
+
+## Understand the trust boundary first
+
+`microsoftIdentityPlatform()` reads the `x-ms-client-principal`, `x-ms-client-principal-id`, and `x-ms-client-principal-name` headers that Azure EasyAuth or a trusted ingress forwards. The principal header is Base64-encoded JSON. It has no signature, so anyone who can reach the server can claim to be anyone.
+
+Enable the handler only on a host bound to `127.0.0.1`, and only when you run it locally. In production, either verify real tokens with [`jwtBearer()`](../core/authentication.md#verify-jwt-bearer-tokens), or accept these headers only behind an ingress that strips caller-supplied identity headers and blocks direct access to the backend.
+
+## Steps
+
+1. Register the handler only for local development. The standalone host binds `127.0.0.1` unless you pass another `host`:
+
+ ```typescript title="main.ts"
+ import { ArcApplication, microsoftIdentityPlatform } from '@cratis/arc.core';
+
+ const development = process.env.NODE_ENV === 'development';
+
+ const builder = ArcApplication.createBuilder({
+ development,
+ authentication: development ? [microsoftIdentityPlatform()] : [],
+ tenancy: { sources: ['header'], membershipClaim: 'tenants' }
+ });
+ await builder.discover(new URL('./Features/', import.meta.url));
+ const app = await builder.build();
+ await app.run({ port: 3000 });
+ ```
+
+ `membershipClaim: 'tenants'` makes Arc check the selected tenant against the principal's `tenants` claim, so you can test membership too. This guide assumes a command `ArchiveTask` in `Features/Tasks/` decorated with `@roles('Editor')`.
+
+2. Save a synthetic principal as `principal.json`. Use invented values, never a real user's:
+
+ ```json title="principal.json"
+ {
+ "identityProvider": "development",
+ "userId": "ada",
+ "userDetails": "Ada",
+ "userRoles": ["Editor"],
+ "claims": [{ "typ": "tenants", "val": "acme" }]
+ }
+ ```
+
+ `userRoles` become the principal's roles. Each claim becomes an own claim on the principal, so `tenants` is what `membershipClaim` reads. `userDetails` becomes the principal's `name`.
+
+3. Encode it on one line, locally. Do not paste identity payloads into an online encoder:
+
+ ```bash
+ PRINCIPAL=$(python3 -c 'import base64,pathlib; print(base64.b64encode(pathlib.Path("principal.json").read_bytes()).decode())')
+ ```
+
+4. Start the host with `NODE_ENV=development`, then send the three headers with a request:
+
+ ```bash
+ curl -X POST http://127.0.0.1:3000/api/tasks/archive-task \
+ -H 'content-type: application/json' \
+ -H 'x-cratis-tenant-id: acme' \
+ -H "x-ms-client-principal: $PRINCIPAL" \
+ -H 'x-ms-client-principal-id: ada' \
+ -H 'x-ms-client-principal-name: Ada' \
+ -d '{"id":"t1"}'
+ ```
+
+ The command succeeds with `"isSuccess":true` and HTTP 200.
+
+## Check each behavior
+
+Change one thing at a time and compare the status:
+
+| Request | Status | Why |
+| --- | --- | --- |
+| No identity headers | 401 | `ArchiveTask` requires a role and nobody is authenticated |
+| A principal header that is not valid Base64 JSON | 401 | The handler rejected the credential |
+| Ada, tenant `acme` | 200 | Ada has `Editor` and belongs to `acme` |
+| Ada, tenant `globex` | 403 | `globex` is not in Ada's `tenants` claim |
+| A principal without `Editor`, tenant `acme` | 403 | The role check failed |
+
+With an [identity details provider](provider-flow.md), `GET /.cratis/me` with the same headers returns Ada's identity and sets the display cookie.
+
+## Offer the users to a picker
+
+Tools that switch users for you, such as [Lens](/tools/lens/), read `/.cratis/users` and `/.cratis/tenants`. Their user entries use the same shape as `principal.json`, so one fixture serves both:
+
+```typescript
+const builder = ArcApplication.createBuilder({
+ development,
+ authentication: development ? [microsoftIdentityPlatform()] : [],
+ tenancy: { sources: ['header'], membershipClaim: 'tenants' },
+ developmentUsers: () => [{
+ microsoftIdentity: {
+ identityProvider: 'development', userId: 'ada', userDetails: 'Ada',
+ userRoles: ['Editor'], claims: [{ typ: 'tenants', val: 'acme' }]
+ }
+ }],
+ developmentTenants: () => [{ id: 'acme', name: 'Acme' }]
+});
+```
+
+A picker only lists these entries. Arc honors the identity headers a tool then sends because `microsoftIdentityPlatform()` is registered, and for no other reason. See [Development users and tenants](development-users-and-tenants.md) for the limits of these routes.
+
+:::caution[Use a narrow scope in browser header tools]
+If you add the headers with a browser extension instead of curl, restrict it to your local URLs, such as `http://127.0.0.1:5173/*`. A rule that applies to every site sends your synthetic identity everywhere you browse.
+:::
+
+## Clean up
+
+Remove the synthetic headers from any browser tool when you finish, and keep the `development` condition around `microsoftIdentityPlatform()`. These checks prove your roles, tenancy, and authorization configuration. They do not test production token verification or your ingress.
diff --git a/Documentation/identity/provider-flow.md b/Documentation/identity/provider-flow.md
new file mode 100644
index 00000000..588dbba9
--- /dev/null
+++ b/Documentation/identity/provider-flow.md
@@ -0,0 +1,104 @@
+---
+title: How identity details are served
+description: Register an identity details provider, reach your own services from it, and understand every /.cratis/me answer, the cookie Arc sets, and where identity is cached.
+---
+
+Once a provider exists, three questions follow quickly. How does Arc find it? What does the browser get back when something goes wrong? And if the cookie caches the identity, when does a change in your data reach the screen? This page follows one request through `/.cratis/me` and answers each.
+
+## Follow one request
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant Arc as Arc HTTP pipeline
+ participant Provider as Identity details provider
+ Browser->>Arc: GET /.cratis/me
+ Arc->>Arc: Authenticate the request
+ Arc->>Arc: Resolve the tenant
+ Arc->>Provider: provide(principal, context) in a new service scope
+ Provider-->>Arc: details, or undefined
+ Arc->>Arc: Validate details against the declared shape
+ Arc-->>Browser: 200 JSON and Set-Cookie .cratis-identity
+```
+
+Authentication and tenant resolution run exactly as they do for a command or query. An anonymous caller never reaches the provider. The provider runs inside the request's execution context, so `currentContext()` returns the same principal and tenant it receives as arguments.
+
+## Register the provider
+
+Choose one registration:
+
+- **A decorated class.** Mark it `@identityDetailsProvider()` and add it with `builder.add(...)` or let `builder.discover(...)` find it. Arc constructs a new instance for every request, so its constructor must take no arguments.
+- **An option object.** Pass `identityDetails: { detailsType, provide }` or `identityDetails: { schema, provide }` to `ArcApplication.createBuilder(...)`. The explicit option wins, and cannot be combined with a discovered provider.
+
+Two discovered providers without an explicit option fail at `build()`.
+
+The provider declares the shape of its details either as `detailsType`, a class with `@field` declarations, or as a Zod `schema`. Prefer `detailsType`: the [proxy generator](../proxy-generation/index.md) turns it into a frontend class you can pass to `useIdentity`.
+
+## Reach your own services
+
+Because the class has no constructor injection, resolve collaborators from the request's service scope inside `provide`:
+
+```typescript title="Features/Identity/DirectoryDetails.ts"
+import { field } from '@cratis/fundamentals';
+import { currentServices, identityDetailsProvider, type IdentityDetailsProvider, type Principal } from '@cratis/arc.core';
+import { Directory } from './Directory.js';
+
+export class ProfileDetails {
+ @field(String) displayName!: string;
+}
+
+@identityDetailsProvider()
+export class DirectoryDetails implements IdentityDetailsProvider {
+ readonly detailsType = ProfileDetails;
+
+ async provide(principal: Principal): Promise {
+ const directory = await currentServices().resolve(Directory);
+ return { displayName: directory.displayNameFor(principal) };
+ }
+}
+```
+
+`Directory` is your own service, registered with `builder.services.addScoped(Directory, ...)`. Arc disposes the scope after the provider finishes, also when it denies or fails.
+
+To keep someone out of the application, return `undefined`. Arc answers 403 for that caller. This decision controls `/.cratis/me` only: commands and queries still need their own [authorization](../core/authorization.md).
+
+## Every answer from /.cratis/me
+
+| Situation | `GET /.cratis/me` |
+| --- | --- |
+| No provider configured | Not mapped |
+| Anonymous caller | 401 |
+| The authentication handler rejected the credential | 401 |
+| Tenant resolution fails: missing with `tenancy.required`, or not a member under `tenancy.membershipClaim` | 400 or 403 |
+| `provide` returns `undefined` | 403 |
+| `provide` throws or rejects, the details do not match the declared shape, or the encoded cookie would exceed 4096 bytes | Generic 500, no cookie |
+| Otherwise | 200 with the identity JSON and the cookie |
+
+Every answer carries `Cache-Control: no-store`. `GET /.cratis/identity-details/schema` returns the JSON Schema of `details`; see [Identity details schema](../introspection/identity-details-schema.md).
+
+## The cookie Arc sets
+
+A 200 answer sets `.cratis-identity=; Path=/; SameSite=Lax`, adding `Secure` when the trusted transport is HTTPS. The value is the same JSON as the response body, Base64-encoded. It is not `HttpOnly`, because the frontend reads it.
+
+The JSON response keeps Unicode. The cookie escapes non-ASCII characters before encoding, so the client's `JSON.parse(atob(cookie))` recovers names and details, including emoji. Cookie bytes are not guaranteed to match .NET's JSON escaping.
+
+Keep details small. A cookie over 4096 bytes fails the request instead of being truncated, and anything you put in details is readable by any script on the page. Leave out tokens, secrets, and personal data the UI does not show.
+
+## Where identity is cached
+
+The server never reads `.cratis-identity`. Every call to `/.cratis/me` authenticates the request and runs your provider again.
+
+Caching happens in the browser. The published client's identity provider reads the cookie first and only calls `/.cratis/me` when the cookie is missing or when you ask it to refresh. That has two consequences:
+
+- **Details can be stale.** When roles or details change on the server, the UI keeps showing the cached values until the frontend refreshes. See [refresh after a change](frontend.md#refresh-after-a-change).
+- **A forged cookie changes only the display.** Anyone can edit the cookie in their own browser. Every command and query still authorizes against the principal authentication produced, so the edit can show a button but never run the operation behind it.
+
+Arc on .NET differs here. Its `/.cratis/me` endpoint accepts a nonempty identity cookie before it consults the provider, and it has an `IIdentityProvider` service with `ModifyDetails`. Arc for TypeScript has neither. To store a user preference, send a command and keep the value in your own storage; the next refresh returns it through the provider.
+
+## Recap
+
+- Register one provider, as a decorated class or as the `identityDetails` option.
+- Resolve services inside `provide`, and return `undefined` to answer 403.
+- Treat the cookie as a display cache owned by the browser. Arc never trusts it.
+
+Next, [show identity in a React frontend](frontend.md).
diff --git a/Documentation/identity/toc.yml b/Documentation/identity/toc.yml
index 2b967a2b..16faeb89 100644
--- a/Documentation/identity/toc.yml
+++ b/Documentation/identity/toc.yml
@@ -1,4 +1,12 @@
- name: Identity details
href: index.md
+- name: How identity details are served
+ href: provider-flow.md
+- name: Show identity in a React frontend
+ href: frontend.md
+- name: Identity across services
+ href: topologies.md
+- name: Simulate a signed-in user locally
+ href: local-development.md
- name: Development users and tenants
href: development-users-and-tenants.md
diff --git a/Documentation/identity/topologies.md b/Documentation/identity/topologies.md
new file mode 100644
index 00000000..8ad7f8e0
--- /dev/null
+++ b/Documentation/identity/topologies.md
@@ -0,0 +1,62 @@
+---
+title: Identity across services
+description: Decide where /.cratis/me lives when your system has one Arc service, several services behind a gateway, or a dedicated identity service, and keep authentication at every boundary.
+---
+
+A single Node service is simple: it authenticates the caller, serves `/.cratis/me`, and runs every command. As the system grows into an orders service, a billing service, and a gateway in front of them, the question changes. Which service answers "who is this?", and how does each service know the caller is real?
+
+Arc does not coordinate identity between services. Each Arc application authenticates its own requests and, if you give it a provider, serves its own `/.cratis/me`. The choice of topology is yours. The rules below keep it safe whichever one you pick.
+
+## The rules that hold in every topology
+
+- **Every service verifies the caller itself.** Configure authentication in each Arc application, for example [`jwtBearer()`](../core/authentication.md#verify-jwt-bearer-tokens) with the same issuer and audience everywhere, or a host-verified [native principal](../hosts/native-principal.md). A request that passed the gateway is not proof on its own.
+- **Every service authorizes its own operations.** Roles and policies belong on the commands and queries of the service that owns them.
+- **The identity cookie never crosses a trust boundary as evidence.** `.cratis-identity` is display data the browser can edit. Never forward it to another service to say who the caller is. Forward the real credential, such as the bearer token.
+
+## One service
+
+```mermaid
+flowchart LR
+ Browser --> Service["Arc service provider and operations"]
+```
+
+Put the identity details provider in the service. This is the default and needs nothing beyond [Identity](index.md).
+
+## Several services behind a gateway
+
+```mermaid
+flowchart LR
+ Browser --> Gateway
+ Gateway -->|"/.cratis/me"| Orders["Orders service provider and operations"]
+ Gateway -->|"/api/orders/..."| Orders
+ Gateway -->|"/api/billing/..."| Billing["Billing service operations only"]
+```
+
+The frontend talks to one origin, so it expects one `/.cratis/me`. Give exactly one service the provider and route `/.cratis/me` to it. The other services register no provider, so they do not map that route at all.
+
+Each service still maps its own `/.cratis/commands`, `/.cratis/queries`, and observable query routes under `/.cratis`. Route those per service, or keep them internal, instead of sending the whole `/.cratis` prefix to one backend.
+
+When the details need data owned by another service, fetch it inside `provide` and pass along the caller's real credential. Arc does not merge details from several services for you.
+
+## A dedicated identity service
+
+```mermaid
+flowchart LR
+ Browser --> Gateway
+ Gateway -->|"/.cratis/me"| Identity["Identity service provider only"]
+ Gateway -->|"/api/..."| Services["Domain services operations only"]
+```
+
+When several teams need a say in what the UI shows about a user, a small Arc application can own only the provider. It authenticates like the rest, answers `/.cratis/me`, and has no commands of its own. Keep its details small: the cookie it sets must stay under 4096 bytes, or `/.cratis/me` fails.
+
+## Which one to choose
+
+| Situation | Topology |
+| --- | --- |
+| One backend, or a frontend per backend | One service |
+| Several backends behind one origin, with details owned by one of them | Gateway, provider in the owning service |
+| Details assembled from several domains, or owned by a platform team | Dedicated identity service |
+
+Whatever you choose, test it end to end: an anonymous request, a forged `.cratis-identity` cookie, and a request that skips the gateway must all fail at the service that owns the operation.
+
+Next, [simulate a signed-in user locally](local-development.md) to exercise these rules without a real identity provider.
diff --git a/Documentation/introspection/commands.md b/Documentation/introspection/commands.md
index 5dcaf377..0219e2ca 100644
--- a/Documentation/introspection/commands.md
+++ b/Documentation/introspection/commands.md
@@ -3,7 +3,7 @@ title: Command introspection
description: The shape of GET /.cratis/commands, with one entry per registered command and its payload JSON Schema.
---
-`GET /.cratis/commands` returns a JSON array with one entry per registered command. For the Tasks sample:
+A tool that fills in a command form, or a test that checks nobody removed a command, needs the command's route and the exact shape of its body. `GET /.cratis/commands` returns a JSON array with one entry per registered command. For the Tasks sample, which registers its generated metadata:
```json
[{
@@ -11,7 +11,7 @@ description: The shape of GET /.cratis/commands, with one entry per registered c
"namespace": "Tasks.Registration",
"route": "/api/tasks/registration/register-task",
"type": "RegisterTask",
- "documentationSummary": "",
+ "documentationSummary": "Register a task.",
"payloadSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
@@ -30,10 +30,10 @@ description: The shape of GET /.cratis/commands, with one entry per registered c
| `namespace` | The namespace, or `""` |
| `route` | The execution route; the validation route is this plus `/validate` |
| `type` | The command name |
-| `documentationSummary` | A low-level definition's `summary`, or `""` |
+| `documentationSummary` | A low-level definition's `summary`, or the JSDoc summary of a model-bound command when [generated artifact metadata](../proxy-generation/generated-artifact-metadata.md) is registered; otherwise `""` |
| `payloadSchema` | JSON Schema 2020-12 of the request body |
-A concept field appears as its underlying scalar, here a UUID string. Optional, nullable, default, and enumeration metadata are reflected in `required`, types, and `enum`.
+A concept field appears as its underlying scalar, here a UUID string. Optional and defaulted fields are left out of `required`, a nullable field is `anyOf` its type and `null`, and an `@enumeration` field is `anyOf` one `const` per enum value. [How types appear in the document](../open-api/schemas.md) covers each case; the same schema is the command's OpenAPI request body.
## Related
diff --git a/Documentation/introspection/identity-details-schema.md b/Documentation/introspection/identity-details-schema.md
index 8a9faf50..b85e989d 100644
--- a/Documentation/introspection/identity-details-schema.md
+++ b/Documentation/introspection/identity-details-schema.md
@@ -3,7 +3,7 @@ title: Identity details schema
description: The shape of GET /.cratis/identity-details/schema, which describes the application details returned by /.cratis/me.
---
-`GET /.cratis/identity-details/schema` returns the JSON Schema of the `details` object that [`/.cratis/me`](../identity/index.md) returns. The schema comes from the identity details provider:
+A frontend or tool that shows identity details from another language cannot import your `detailsType` class. It can read its schema instead. `GET /.cratis/identity-details/schema` returns the JSON Schema of the `details` object that [`/.cratis/me`](../identity/index.md) returns. The schema comes from the identity details provider:
| Configuration | Response |
| --- | --- |
diff --git a/Documentation/introspection/index.md b/Documentation/introspection/index.md
index 3077a893..189015e4 100644
--- a/Documentation/introspection/index.md
+++ b/Documentation/introspection/index.md
@@ -1,24 +1,47 @@
---
title: Introspection
-description: List every command and query a running Arc application serves, with routes and input JSON Schema, from the anonymous /.cratis introspection endpoints.
+description: Ask a running Arc application which commands and queries it serves, with routes and input JSON Schema, from the anonymous /.cratis introspection endpoints, and know what they expose.
---
-When you need to know what a running backend exposes (for a developer tool, a test, or an AI assistant helping you), ask the backend. Arc serves its own metadata at three anonymous endpoints.
+You open a service you did not write, or one you wrote six months ago, and need to know what it accepts. Reading every feature folder takes a while. A developer tool, a contract test, or an AI assistant helping you has the same problem, and often cannot read the source at all.
+
+So ask the running application. Arc describes itself at three endpoints, built from the same metadata that binds requests, so the answer is never out of date.
| Endpoint | Returns |
| --- | --- |
-| [`GET /.cratis/commands`](commands.md) | Every command, with route and payload schema |
-| [`GET /.cratis/queries`](queries.md) | Every query, with route, full name, and arguments schema |
-| [`GET /.cratis/identity-details/schema`](identity-details-schema.md) | The JSON Schema of identity details |
+| [`GET /.cratis/commands`](commands.md) | Every command, with its route and payload schema |
+| [`GET /.cratis/queries`](queries.md) | Every query, with its route, full name, and arguments schema |
+| [`GET /.cratis/identity-details/schema`](identity-details-schema.md) | The JSON Schema of the identity details `/.cratis/me` returns |
+
+## Try it
+
+Start the Tasks sample and ask for its commands:
```bash
curl http://127.0.0.1:3000/.cratis/commands
-curl http://127.0.0.1:3000/.cratis/queries
```
-These endpoints accept only GET; other methods answer 405 with `Allow: GET`. They do not run authentication handlers, so treat what they reveal (names, routes, and input shapes) as public. They never include data.
+You get one entry for `RegisterTask`, with the route `/api/tasks/registration/register-task` and a JSON Schema that requires a UUID `id` and a string `title`. [Command introspection](commands.md) shows the full answer. The queries endpoint lists `allTasks`, `taskById`, and `observeAllTasks` the same way.
+
+Behind that answer there is no extra registry. Arc builds the list from the operations it compiled at startup, and the schemas from the `@field` declarations or Zod schemas that also validate incoming requests. Rename a field, restart, and the endpoint shows the new name.
+
+## What it exposes, and to whom
+
+The endpoints are anonymous. They do not run authentication handlers, and they list every operation whether or not a caller may run it. Treat names, routes, and input shapes as public information about your API. They never include data.
+
+They accept only GET; other methods answer 405 with `Allow: GET`. They are always mapped, in development and production alike. If the list of operations must stay private, block these paths at your ingress.
+
+## How it relates to OpenAPI and proxies
+
+Introspection, [`/openapi.json`](../open-api/index.md), and the [proxy generator](../proxy-generation/index.md) all see the same schemas. They serve different readers:
+
+| Use | For |
+| --- | --- |
+| Introspection | Arc-aware tools that need Arc's own names: the fully qualified query name for hub subscriptions, or the `/validate` route of a command |
+| OpenAPI | General HTTP tooling: API clients, gateways, and code generators for other languages |
+| Proxy generator | Your TypeScript frontend, generated from source at build time without a running server |
-The schemas come from the same `@field` metadata or Zod schemas that bind requests, and match what [`/openapi.json`](../open-api/index.md) and the [proxy generator](../proxy-generation/index.md) see.
+[How types appear in the document](../open-api/schemas.md) explains how concepts, enums, and optional fields are rendered, and applies to the introspection schemas too.
## Related
diff --git a/Documentation/introspection/queries.md b/Documentation/introspection/queries.md
index 2d9bfacc..324fb76e 100644
--- a/Documentation/introspection/queries.md
+++ b/Documentation/introspection/queries.md
@@ -3,7 +3,7 @@ title: Query introspection
description: The shape of GET /.cratis/queries, with one entry per registered query, its full name, and its arguments JSON Schema.
---
-`GET /.cratis/queries` returns a JSON array with one entry per registered query, observable queries included. For the Tasks sample's `taskById`:
+A developer tool that subscribes to an observable query needs its fully qualified name, and a client that calls a query needs its arguments. `GET /.cratis/queries` returns both, in a JSON array with one entry per registered query, observable queries included. For the Tasks sample's `taskById`:
```json
{
@@ -30,7 +30,7 @@ description: The shape of GET /.cratis/queries, with one entry per registered qu
| `namespace` | For a model-bound query, the discovery namespace plus the read-model class |
| `route` | The GET route |
| `type` | The query name |
-| `documentationSummary` | A low-level definition's `summary`, or `""` |
+| `documentationSummary` | A low-level definition's `summary`, or the JSDoc summary of a model-bound query method when generated artifact metadata is registered; otherwise `""`. The Tasks sample's query methods have no JSDoc |
| `fullyQualifiedName` | The identity used by direct calls and hub subscriptions, such as `Tasks.Listing.TaskItem.taskById` |
| `argumentsSchema` | JSON Schema 2020-12 of the arguments; `{ "properties": {} }` when there are none |
diff --git a/Documentation/open-api/index.md b/Documentation/open-api/index.md
index 27deaf94..dd2a50fb 100644
--- a/Documentation/open-api/index.md
+++ b/Documentation/open-api/index.md
@@ -1,31 +1,39 @@
---
title: OpenAPI
-description: Inspect command and query request and result envelopes at /openapi.json, and configure the advertised application version.
+description: Hand API consumers and tools an OpenAPI 3.1 description of every command and query at /openapi.json, with request schemas, result envelopes, and bearer security, without writing it by hand.
---
-Arc serves an OpenAPI 3.1 document at `GET /openapi.json`. Route paths follow [endpoint mapping](../core/endpoint-mapping.md). It describes registered commands as `POST` and queries as `GET`, including observable queries. Set `generatedApis.openApiVersion` when constructing the server or builder to advertise your application's version; the default remains `0.1.0`.
+A partner team wants to call your task API from Python. A QA engineer wants the endpoints in their API client. Your gateway wants a contract to validate against. Writing that description by hand means it is wrong the week after someone adds a field.
-```typescript
-const server = new ArcServer({ generatedApis: { openApiVersion: '2.3.0' } });
-```
+Arc writes it for you. Every running Arc application serves an OpenAPI 3.1 document at `GET /openapi.json`, built from the same `@field` declarations and Zod schemas that bind requests. When the code changes, the document changes with it.
-Fetch the document with `curl http://127.0.0.1:3000/openapi.json`. The document is public; this endpoint does not run authentication handlers. Host an OpenAPI UI separately if needed. Arc does not bundle a Swagger or Scalar UI.
+## Fetch the document
-## Operation descriptions
+```bash
+curl http://127.0.0.1:3000/openapi.json
+```
-For example, a registered `Tasks.Save` command on `/api/tasks/save` appears under `paths` with a `post` operation (excerpt):
+For the Tasks sample, with its generated metadata registered, the `RegisterTask` command appears under `paths` like this (excerpt, `responses` left out):
```json
-{
- "openapi": "3.1.0",
- "paths": {
- "/api/tasks/save": {
- "post": {
- "operationId": "Tasks.Save",
- "tags": ["Tasks"],
- "responses": {
- "200": { "description": "Result" },
- "400": { "description": "Invalid request" }
+"/api/tasks/registration/register-task": {
+ "post": {
+ "operationId": "Tasks.Registration.RegisterTask",
+ "tags": ["Tasks.Registration"],
+ "summary": "Register a task.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "id": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "format": "uuid" },
+ "title": { "type": "string" }
+ },
+ "required": ["id", "title"]
+ }
}
}
}
@@ -33,16 +41,53 @@ For example, a registered `Tasks.Save` command on `/api/tasks/save` appears unde
}
```
-The full document also includes `info`, request and result schemas, and the other responses; fetch it from your running server rather than using this excerpt as a complete OpenAPI document.
+The summary is the JSDoc comment on the `RegisterTask` class. `TaskId` and `TaskTitle` are concepts, so they appear as the UUID string and the string they wrap. Fetch the document from your running server to see the responses in full.
+
+Nothing about this needs setup: the route exists as soon as the application runs. The document is public, and the endpoint does not run authentication handlers. Arc does not bundle a Swagger or Scalar UI; point one you host at `/openapi.json` if you want a browsable page.
+
+## What each operation contains
+
+- **Identity.** A namespace-qualified `operationId`, such as `Tasks.Registration.RegisterTask`, a tag for the namespace, and a summary. Route paths follow [endpoint mapping](../core/endpoint-mapping.md).
+- **Input.** A command's JSON request body schema, or a query's arguments as GET parameters, with `required` taken from the input schema. Queries that can return a list, or whose result type is unknown, also accept `page`, `pageSize`, `sortBy`, and `sortDirection`. Observable queries add `waitForFirstResult` and `waitForFirstResultTimeout`.
+- **Responses.** The Arc `CommandResult` or `QueryResult` envelope for 200, 400, 403, and 500. Observable queries also describe 202, 408, and 503, and a `text/event-stream` response. A paged result carries `paging` with `page`, `size`, `totalItems`, and `totalPages`.
+- **Security.** HTTP bearer security, when the operation authenticates with a `jwtBearer()` handler.
+
+[How types appear in the document](schemas.md) shows how concepts, enums, optional fields, and result types are described.
+
+## Summaries and result types need generated metadata
+
+Arc reads your source only through the metadata it has at runtime. Two parts of the document depend on [generated artifact metadata](../proxy-generation/generated-artifact-metadata.md) registered with `useGeneratedMetadata()`:
+
+- **Summaries.** JSDoc on a command class or query method becomes the operation summary. Without metadata the summary is empty. A low-level definition sets `summary` directly.
+- **Result types.** The 200 envelope includes a typed `response` or `data` only when the metadata declares the return type. Without it, the document **omits** `response` or `data` rather than guess from the input schema or run the handler. The runtime result is the same; only its description is missing.
+
+The Tasks sample registers its metadata, so its `registerTask` response is described as a UUID string and `allTasks` as an array of `TaskItem`.
+
+## Set the advertised version
+
+`info.version` defaults to `0.1.0`. Set your application's version with `generatedApis.openApiVersion`, in code or as `Cratis:Arc:GeneratedApis:OpenApiVersion` in configuration:
+
+```typescript
+const server = new ArcServer({ generatedApis: { openApiVersion: '2.3.0' } });
+```
+
+## Bearer security
+
+An operation that requires authentication advertises HTTP bearer security when it authenticates with a default `jwtBearer()` handler, or explicitly selects a named JWT scheme. Anonymous operations advertise none. A named-only handler does not authenticate operations that use the default handlers. If a named scheme is called `bearer`, the default bearer component is called `arcBearer`.
-Each operation has a namespace-qualified `operationId`, a namespace tag, and a summary. Low-level definitions can provide `summary` directly. For model-bound artifacts, run the proxy generator with `--metadata` and register its output via `useGeneratedMetadata()` to supply JSDoc class and query-method summaries at runtime; otherwise the summary is empty. Command inputs use their JSON request schema; query arguments are GET parameters with required flags from the input schema. GET also accepts `page`, `pageSize`, `sortBy`, and `sortDirection` for many, paged, or unknown result cardinality (not known single-result queries); observable queries additionally accept `waitForFirstResult` and `waitForFirstResultTimeout` (fractional seconds greater than zero and at most 120). A bound argument with one of these names appears only once. Repeating an array argument sends multiple values under the same name.
+Arc cannot infer the protocol of a custom handler, so it never advertises one as bearer. A [native principal](../hosts/native-principal.md) is authenticated by the host and has no security scheme in this document.
-A 200 response has the Arc `CommandResult` or `QueryResult` envelope, with `response` or `data` when its source-declared result is available in registered generated artifact metadata. A paged query has an array-valued `data` and a `paging` object (`page`, `size`, `totalItems`, `totalPages`). Observable queries describe both the JSON snapshot and direct `text/event-stream` response, along with 202, 408, and 503. The 202, 408, and 503 responses also carry a JSON `QueryResult` envelope. The 400, 403, and 500 responses describe failure envelopes without typed `response` or `data`; actual error status can vary by request and authorization failure (including 401).
+## What the document leaves out
-When no generated return metadata exists, the document **omits `response` or `data`** rather than guessing from the input schema or executing the handler. The result remains valid at runtime; only its response payload type is unavailable to OpenAPI. Decorated model results are converted using the same wire schema as inputs: concepts use their primitive value, and registered derived types have discriminated variants. A low-level definition can provide a summary but does not have a generated result type.
+- **HTTP `QUERY`.** OpenAPI path items cannot represent it, so queries appear as GET only.
+- **Command `/validate` routes** and the `/.cratis` endpoints. [Introspection](../introspection/index.md) describes those.
+- **`pathBase`.** Paths are not rewritten for a standalone host `pathBase`.
+- **Every status the runtime can return.** A request can also answer 401, for example, which the operation does not list.
-Protected operations advertise HTTP bearer security only when the operation authenticates with a default `jwtBearer()` handler or explicitly selects a named JWT scheme; anonymous operations do not. A named-only handler does not authenticate operations using the default handlers. If a named scheme is called `bearer`, the default bearer component is called `arcBearer`. Arc cannot infer the protocol of a custom handler, so it does not advertise one as bearer. Native-principal authentication is supplied by the host and has no bearer scheme in this document.
+## Recap
-## Not included
+- `GET /openapi.json` is always on, public, and generated from the same schemas that bind requests.
+- Register generated metadata to get summaries and typed results.
+- Set `generatedApis.openApiVersion` to advertise your version.
-OpenAPI path items cannot represent the optional HTTP `QUERY` method. The document also omits command `/validate` routes, `/.cratis` endpoints, and standalone-host `pathBase` rewriting. For Arc-specific metadata endpoints, see [Introspection](../introspection/index.md).
+Next, see [how types appear in the document](schemas.md).
diff --git a/Documentation/open-api/schemas.md b/Documentation/open-api/schemas.md
new file mode 100644
index 00000000..d3398a79
--- /dev/null
+++ b/Documentation/open-api/schemas.md
@@ -0,0 +1,109 @@
+---
+title: How types appear in the document
+description: See how concepts, dates, enums, optional, nullable, and defaulted fields, and result types are described in /openapi.json and the introspection schemas, and why they match the wire format.
+---
+
+Your command declares `@field(TaskId) id`. A client generator in Go or Python must not see a `TaskId` object with a `value` inside it, because the JSON on the wire carries a plain UUID string. Arc builds every schema in the document from the same wire rules that bind and serialize requests, so what the document says is what the wire carries.
+
+The same schemas appear in the [introspection](../introspection/index.md) endpoints: a command's `payloadSchema` in `/.cratis/commands` is identical to its OpenAPI request body schema.
+
+## An example
+
+This command uses concepts, enums, and each field modifier:
+
+```typescript title="Features/Tasks/Planning.ts"
+import { field, ConceptAs, Guid } from '@cratis/fundamentals';
+import { command, defaultValue, enumeration, nullable, optional } from '@cratis/arc.core';
+
+export class TaskId extends ConceptAs { static readonly valueType = Guid; }
+export class Estimate extends ConceptAs { static readonly valueType = Number; }
+export enum Priority { Low, Normal, High }
+export enum Status { Open = 'open', Done = 'done' }
+
+@command({ namespace: 'Tasks' })
+export class PlanTask {
+ @field(TaskId) id!: TaskId;
+ @field(Estimate) estimate!: Estimate;
+ @field(Number) @enumeration(Priority) priority!: Priority;
+ @field(String) @enumeration(Status) status!: Status;
+ @field(String) @optional() note?: string;
+ @field(Date) @nullable() due!: Date | null;
+ @field(Boolean) @defaultValue(false) urgent!: boolean;
+ handle(): void {}
+}
+```
+
+Its request body schema in `/openapi.json`:
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "id": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "format": "uuid" },
+ "estimate": { "type": "number" },
+ "priority": { "anyOf": [{ "type": "number", "const": 0 }, { "type": "number", "const": 1 }, { "type": "number", "const": 2 }] },
+ "status": { "anyOf": [{ "type": "string", "const": "open" }, { "type": "string", "const": "done" }] },
+ "note": { "type": "string" },
+ "due": { "anyOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }] },
+ "urgent": { "default": false, "type": "boolean" }
+ },
+ "required": ["id", "estimate", "priority", "status", "due"]
+}
+```
+
+The sections below explain each property.
+
+## Concepts
+
+A concept is described as the value it wraps. `TaskId` is a `ConceptAs`, so `id` is a UUID string with a pattern; `Estimate` wraps a number, so `estimate` is a number. The concept's name does not appear anywhere in the document, because nothing on the wire carries it.
+
+| Declared type | Schema |
+| --- | --- |
+| `String`, or a concept over it | `{ "type": "string" }` |
+| `Number`, or a concept over it | `{ "type": "number" }` |
+| `Boolean`, or a concept over it | `{ "type": "boolean" }` |
+| `Guid`, or a concept over it | A string with `format: "uuid"` and a UUID pattern |
+| `Date` | A string with `format: "date-time"` |
+| `DateOnly` / `TimeOnly` | A string with `format: "date"` / `format: "time"` and a pattern |
+| `TimeSpan` | A string with a .NET-style `[-][d.]hh:mm:ss[.fffffff]` pattern |
+| A `@field` model class | An inline object schema of its fields |
+
+Concept validators do not become schema constraints. A rule such as "at most 100 characters" is enforced by Arc when the request arrives, and returned as a validation result, but the schema only says `string`.
+
+## Enums
+
+A TypeScript enum is not a runtime type that `@field` accepts, so you declare the scalar with `@field(Number)` or `@field(String)` and restrict it with `@enumeration(Enum)`. The schema lists the enum's **values**, one `const` per member:
+
+- A numeric enum such as `Priority` is described as the numbers `0`, `1`, and `2`. Arc ignores the reverse mappings TypeScript adds to numeric enums, so the names `Low`, `Normal`, and `High` do not appear.
+- A string enum such as `Status` is described as its string values, `open` and `done`.
+
+The wire carries the same values, so a client that follows the schema sends valid input. If your consumers need readable names, use a string enum: its values are the names they see in the document and on the wire. See [Wire format](../reference/wire-format.md) for how numeric enums and .NET compare.
+
+Arc on .NET differs here: the enum transformer in its ASP.NET Core OpenAPI integration lists the member names under an `integer` type, while the wire carries numbers. Arc for TypeScript describes the values it actually sends.
+
+## Optional, nullable, and default values
+
+| Field | Schema | Required? |
+| --- | --- | --- |
+| `@field(String) note` | `string` | Yes |
+| `@field(String) @optional() note` | `string` | No; the property may be left out |
+| `@field(Date) @nullable() due` | `anyOf` the type and `null` | Yes; the property must be present, and may be `null` |
+| `@field(Boolean) @defaultValue(false) urgent` | `boolean` with `default: false` | No; Arc fills in the default |
+
+## Derived types
+
+In input schemas, a field whose type has registered `@derivedType('id')` subclasses is described with `oneOf`, one variant per subclass, each carrying its `_derivedTypeId`. See [Wire format](../reference/wire-format.md) for the rules.
+
+## Result types
+
+Command responses and query data use the same rules, with two differences:
+
+- They appear only when [generated artifact metadata](../proxy-generation/generated-artifact-metadata.md) declares the return type. Otherwise the envelope has no `response` or `data` property.
+- They describe output, so a model's object schema sets `additionalProperties: false`. A query that may return nothing, such as the Tasks sample's `taskById` returning `TaskItem | undefined`, is described as `anyOf` the model and `null`.
+
+## Related
+
+- [OpenAPI](index.md)
+- [Concepts](../concepts.md)
+- [Command introspection](../introspection/commands.md)
diff --git a/Documentation/open-api/toc.yml b/Documentation/open-api/toc.yml
index 1c314f88..03cc4492 100644
--- a/Documentation/open-api/toc.yml
+++ b/Documentation/open-api/toc.yml
@@ -1,2 +1,4 @@
- name: OpenAPI
href: index.md
+- name: How types appear in the document
+ href: schemas.md
diff --git a/Documentation/proxy-generation/frontend-usage.md b/Documentation/proxy-generation/frontend-usage.md
new file mode 100644
index 00000000..7c733d40
--- /dev/null
+++ b/Documentation/proxy-generation/frontend-usage.md
@@ -0,0 +1,105 @@
+---
+title: Use generated proxies in React
+description: Execute generated commands, show snapshot and live queries, page and sort results, and pass query arguments from React components, following the Library sample.
+---
+
+The Library sample's frontend registers authors, lists them live, pages through them five at a time, and shows each author's books. None of it contains a URL, a `fetch` call, or a hand-written response type. Every component talks to the backend through a proxy from [Set up proxy generation](getting-started.md).
+
+This page walks through those components. The excerpts come from [`Samples/Library/Web/src`](https://github.com/Cratis/Arc.TypeScript/tree/main/Samples/Library/Web/src) with the license header removed, and use `@cratis/arc` and `@cratis/arc.react` 22.19.1.
+
+## Wrap the application in Arc
+
+```tsx title="Web/src/App.tsx"
+import { Arc } from '@cratis/arc.react';
+import { RegisterAuthorForm } from './Features/Authors/Registration/RegisterAuthorForm';
+import { AuthorCatalog } from './Features/Authors/Listing/AuthorCatalog';
+
+export function App() {
+ return
+ CRATIS · ARC FOR TYPESCRIPT
The Library
+
Make room for a new story. Register an author, then fill their shelf.
+
Register an author
+
Authors & books
+ ;
+}
+```
+
+`Arc` configures the client once: where requests go, which headers they carry, and how observable queries connect. The generated hooks read that configuration, so a proxy never needs a base URL. The Library frontend runs on the Vite dev server, which proxies `/api` and `/.cratis` to the backend, so every request is same-origin.
+
+## Execute a command
+
+```tsx title="Web/src/Features/Authors/Registration/RegisterAuthorForm.tsx"
+import { useState, type FormEvent } from 'react';
+import { Guid } from '@cratis/fundamentals';
+import { RegisterAuthor } from '../../../generated/Authors/Registration/RegisterAuthor.proxy';
+
+export function RegisterAuthorForm() {
+ const [command, setValues] = RegisterAuthor.use();
+ const [name, setName] = useState('');
+ const [message, setMessage] = useState('');
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ setValues({ id: Guid.create(), name: name.trim() });
+ // Setters update the command instance before execution; use the instance itself for the result.
+ const result = await command.execute();
+ if (result.isSuccess) { setName(''); setMessage('Author registered.'); }
+ else setMessage(result.validationResults.map(item => item.message).join(' ') || 'Registration failed.');
+ };
+ return ;
+}
+```
+
+`RegisterAuthor.use()` returns the command instance and a setter. `setValues` writes the properties onto that instance, and `execute()` sends it to `/api/authors/registration/register-author`.
+
+Before any request leaves the browser, `execute()` runs the client-side rules the generator copied from the backend's validators. An empty name fails right here with the backend's own message, "An author name is required". Only valid input reaches the server, which runs authorization, validation, and `handle()` again. Either way you get one `CommandResult`: `isSuccess`, `validationResults`, `isAuthorized`, and, for a command that returns a value, `response`.
+
+## Show a live list
+
+```tsx title="Web/src/Features/Authors/Listing/AuthorCatalog.tsx (excerpt)"
+const [live] = AllAuthors.use();
+```
+
+`allAuthors` returns an RxJS `Observable` on the server, so its proxy is an observable query. `AllAuthors.use()` subscribes when the component mounts and unsubscribes when it unmounts. `live.data` is always an array of `Author`, starting from the generated `defaultValue` of `[]`, and every new author registered anywhere appears without a reload.
+
+The client keeps one connection to the server's observable query hub and multiplexes every live query over it. [Observable queries](../queries/observable-queries.md) covers the server side, and the shared [React observable queries](/arc/frontend/react/queries/observable-queries/) page covers transport options.
+
+## Page and sort a snapshot
+
+```tsx title="Web/src/Features/Authors/Listing/AuthorCatalog.tsx (excerpt)"
+const [page, performPage, , setPage] = AuthorsPage.useWithPaging(5, AuthorsPage.sortBy.name.ascending);
+useEffect(() => { void performPage(); }, [live.data.length]);
+```
+
+`authorsPage` returns a plain array, so its proxy is a snapshot query. `useWithPaging(5, ...)` asks the server for five authors at a time, sorted by name. The generator emits a `sortBy` helper with one entry per model field, so a misspelled sort field is a compile error.
+
+The tuple is `[result, perform, setSorting, setPage, setPageSize]`; this component skips `setSorting`. `page.paging` carries `page`, `size`, `totalItems`, and `totalPages`, which drive the Previous and Next buttons:
+
+```tsx
+
+```
+
+A snapshot does not update by itself. The effect re-runs `performPage` whenever the live list changes length, so the paged view follows new registrations.
+
+## Pass query arguments
+
+```tsx title="Web/src/Features/Books/Listing/AuthorBooks.tsx (excerpt)"
+export function AuthorBooks({ authorId }: { authorId: Guid }) {
+ const [books] = BooksForAuthor.use({ authorId });
+```
+
+`booksForAuthor(authorId: AuthorId, ...)` takes an argument, so the generator emits a `BooksForAuthorParameters` interface and `use(args)` takes it first. The server's `AuthorId` concept arrives in the frontend as its underlying `Guid`. When `authorId` changes, the hook subscribes with the new argument.
+
+## Follow individual changes
+
+An observable query that returns a list also gets `useChangeStream(args?, getKey?, sorting?)`. It returns a `ChangeSet` with the items added, replaced, and removed since the last emission, instead of the whole list. Use it when a component animates or reconciles rows. See the shared [change stream](/arc/frontend/react/queries/change-stream/) page.
+
+## Recap
+
+- `Command.use()` gives an instance you fill and `execute()`; client rules run before the request.
+- `Query.use()` returns `[result, ...]`: a snapshot for plain returns, a live subscription for observables.
+- `useWithPaging(pageSize, sorting)` and the generated `sortBy` helpers page and sort on the server.
+- Arguments go in a generated parameters object, typed from the server's method signature.
+
+For every hook signature, read [What the generator writes](generated-code.md). The shared React pages go further into [commands](/arc/frontend/react/commands/react-usage/), [queries](/arc/frontend/react/queries/usage/), and [paging](/arc/frontend/react/queries/paging/).
diff --git a/Documentation/proxy-generation/generated-code.md b/Documentation/proxy-generation/generated-code.md
new file mode 100644
index 00000000..a2beb705
--- /dev/null
+++ b/Documentation/proxy-generation/generated-code.md
@@ -0,0 +1,124 @@
+---
+title: What the generator writes
+description: The structure of generated command classes, query classes, parameters interfaces, models, and barrels, and the exact React hook signatures each query shape receives.
+---
+
+This page describes the generated files so you can read them, import from them, and predict what changes when the backend changes. Never edit them: the next run replaces any file that carries the `@generated by Cratis` header. The excerpts come from the Library and Tasks samples.
+
+## Files and folders
+
+| Backend artifact | Generated file |
+| --- | --- |
+| `@command()` class `RegisterAuthor` in namespace `Authors.Registration` | `Authors/Registration/RegisterAuthor.proxy.ts` |
+| `@readModel()` class `Author` in `Authors.Listing` | `Authors/Listing/Author.proxy.ts`, the model |
+| `@query()` method `allAuthors` on `Author` | `Authors/Listing/AllAuthors.proxy.ts`, named after the method in PascalCase |
+| A `@field` model used by a command, query, or identity provider | A model file in its own namespace folder |
+| Every folder | `index.ts`, exporting the folder's files |
+
+The `.proxy.ts` suffix appears with `--use-proxy-file-suffix`; without it the files end in `.ts`. Namespaces come from the discovery folder, or from an explicit `namespace` option, prefixed with `--root-namespace` when you set one.
+
+## Commands
+
+For each command the file holds an interface, a validator when the command has client-safe rules, and the command class:
+
+```typescript title="Authors/Registration/RegisterAuthor.proxy.ts (excerpt)"
+export interface IRegisterAuthor {
+ id?: Guid;
+ name?: string;
+}
+
+export class RegisterAuthorValidator extends CommandValidator {
+ constructor() {
+ super();
+ this.ruleFor(c => c.name).notEmpty().withMessage('An author name is required');
+ this.ruleFor(c => c.name).maxLength(100).withMessage('An author name cannot exceed 100 characters');
+ }
+}
+
+export class RegisterAuthor extends Command implements IRegisterAuthor {
+ readonly route: string = '/api/authors/registration/register-author';
+ readonly validation: CommandValidator = new RegisterAuthorValidator();
+ readonly treatWarningsAsErrors: boolean = false;
+ readonly roles: string[] = ['Librarian'];
+```
+
+| Member | Comes from |
+| --- | --- |
+| `I` interface | Every `@field` property, optional in the interface so you can fill it gradually |
+| `Validator` | The literal, unconditional rules of the command's validators and of concept validators such as `AuthorNameValidator`; see [Validation rules](validation.md) |
+| Base class | `Command` when `handle()` returns nothing for the client; `Command` when it returns a value, here the Tasks sample's `TaskId` |
+| `route` | The server's convention route, using the [route alignment](configuration.md#route-alignment) options |
+| `roles` | `@roles(...)` on the command, for display decisions only |
+| `treatWarningsAsErrors` | `@command({ treatWarningsAsErrors: true })` |
+| Properties | A getter and setter per field, with change tracking |
+| `static use(initialValues?)` | Returns `[command, setValues, clearValues]` for React |
+
+Values that `handle()` returns for the server, such as Chronicle events or [command operations](../commands/operations/index.md), are not part of the client response. [Type mapping](type-mapping.md#return-types) lists each return shape.
+
+## Queries
+
+Each query method becomes a class. A query with arguments also gets a parameters interface, named `Parameters` with no `I` prefix:
+
+```typescript title="Books/Listing/BooksForAuthor.proxy.ts (excerpt)"
+export interface BooksForAuthorParameters {
+ authorId: Guid;
+}
+
+export class BooksForAuthor extends ObservableQueryFor {
+ readonly route: string = '/api/books/listing/books-for-author';
+ readonly queryName: string = 'Books.Listing.Book.booksForAuthor';
+ readonly treatWarningsAsErrors: boolean = false;
+ readonly roles: string[] = [];
+ readonly defaultValue: Book[] = [];
+```
+
+| Member | Meaning |
+| --- | --- |
+| Base class | `QueryFor` for a snapshot, `ObservableQueryFor` when the method returns an observable source |
+| `queryName` | The fully qualified name the server uses for hub subscriptions |
+| `defaultValue` | What `result.data` holds before the first answer: `[]` for a list, `{} as TModel` for a single model |
+| `sortBy` | For list results, one sort helper per model field, as a static and an instance property |
+| `parameterDescriptors`, `requiredRequestParameters` | The arguments, so the client can wait until required ones are set |
+| `validation` | A `QueryValidator` when the query's arguments have client-safe rules |
+
+A query without arguments has no parameters interface, and its hooks take `sorting` as the first argument.
+
+## Hook signatures
+
+`result` is a `QueryResultWithState` in every row. `args` appears only for queries with arguments.
+
+| Query shape | Hook | Returns |
+| --- | --- | --- |
+| Snapshot, single result | `use(args?)`, `useSuspense(args?)` | `[result, perform, setSorting]` |
+| Snapshot, list | `use(args?, sorting?)`, `useSuspense(args?, sorting?)` | `[result, perform, setSorting]` |
+| Snapshot, list | `useWithPaging(pageSize, args?, sorting?)`, `useSuspenseWithPaging(...)` | `[result, perform, setSorting, setPage, setPageSize]` |
+| Observable, single result | `use(args?)`, `useSuspense(args?)` | `[result]` |
+| Observable, list | `use(args?, sorting?)`, `useSuspense(args?, sorting?)` | `[result, setSorting]` |
+| Observable, list | `useWithPaging(pageSize, args?, sorting?)`, `useSuspenseWithPaging(...)` | `[result, setSorting, setPage, setPageSize]` |
+| Observable, list | `useChangeStream(args?, getKey?, sorting?)` | `ChangeSet` with `added`, `replaced`, and `removed` |
+| Any | `when(condition)` | A builder with the same hooks, enabled only while `condition` is true |
+
+Only list results get paging, sorting helpers, and change streams. That is a client API rule; the server decides how it pages. See [Paging](../queries/model-bound/paging.md).
+
+## Models
+
+A read model or nested `@field` class becomes a class with the same `@field` declarations, so the client deserializes JSON into real types:
+
+```typescript title="Authors/Listing/Author.proxy.ts (excerpt)"
+export class Author {
+ @field(Guid)
+ id!: Guid;
+
+ @field(String)
+ name!: string;
+}
+```
+
+The server declares `@field(AuthorId) id` and `@field(AuthorName) name`. Concepts arrive as their underlying types, here `Guid` and `string`, because the wire carries only the value. With `--emit-interfaces` the generator writes interfaces instead, which carry no runtime metadata. The identity provider's `detailsType` is generated the same way, for [`useIdentity`](../identity/frontend.md).
+
+## Related
+
+- [Use generated proxies in React](frontend-usage.md)
+- [Type mapping](type-mapping.md)
+- [Validation rules](validation.md)
+- [File index tracking](file-index-tracking.md)
diff --git a/Documentation/proxy-generation/getting-started.md b/Documentation/proxy-generation/getting-started.md
new file mode 100644
index 00000000..dbacedd7
--- /dev/null
+++ b/Documentation/proxy-generation/getting-started.md
@@ -0,0 +1,106 @@
+---
+title: Set up proxy generation
+description: Point arc-proxygenerator at an existing Arc backend, write the proxies into a dedicated frontend folder, install the client packages, and keep generation repeatable.
+---
+
+Start with an Arc backend that already has [commands](../commands/index.md) or [queries](../queries/index.md), and a React frontend beside it. By the end of this guide the frontend has a `src/generated` folder with typed proxies, and the frontend compiler checks every call against the backend's declarations.
+
+The steps follow the [Library sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/README.md): a backend in `Samples/Library/Features` and a Vite frontend in `Samples/Library/Web`.
+
+## Build the generator
+
+`@cratis/arc.proxygenerator` is not published to npm. Build it from a clone of this repository:
+
+```sh
+yarn install --immutable
+yarn build
+```
+
+The CLI is then `Source/Tools/ProxyGenerator/dist/cli.js`. Run it with `node`.
+
+## Choose a dedicated output folder
+
+Give the generator a folder it owns, such as `Web/src/generated`, instead of the frontend's `src` root. On every run it replaces files it wrote, removes files for artifacts that no longer exist, and leaves hand-written files alone. A folder that only holds generated code makes that easy to review. [File index tracking](file-index-tracking.md) explains how it tells the two apart.
+
+The output folder must exist before the first run.
+
+## Run the generator from a script
+
+A small script keeps the options in one place. This is the Library sample's [`generate-proxies.mjs`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Library/generate-proxies.mjs):
+
+```javascript title="generate-proxies.mjs"
+import { execFileSync } from 'node:child_process';
+import { mkdirSync } from 'node:fs';
+import process from 'node:process';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = dirname(fileURLToPath(import.meta.url));
+mkdirSync(join(root, 'Web/src/generated'), { recursive: true });
+execFileSync(process.execPath, [join(root, '../../Source/Tools/ProxyGenerator/dist/cli.js'),
+ '--project', join(root, 'tsconfig.json'), '--artifacts', join(root, 'Features'),
+ '--output', join(root, 'Web/src/generated'), '--metadata', join(root, 'Features/generatedMetadata.ts'),
+ '--use-proxy-file-suffix'], { stdio: 'inherit' });
+```
+
+| Option | Why it is here |
+| --- | --- |
+| `--project` | The backend's `tsconfig.json`, so the compiler API resolves your imports and decorators |
+| `--artifacts` | The same folder the backend passes to `builder.discover(...)` |
+| `--output` | The generated-only frontend folder |
+| `--metadata` | Also writes the server metadata module that `main.ts` registers with `useGeneratedMetadata`. Use `--use-generated-metadata` instead when you only want client output |
+| `--use-proxy-file-suffix` | Names files `*.proxy.ts`, so generated files stand out in imports and reviews |
+
+Run it with `yarn workspace @cratis/arc.sample.library generate-proxies`. Each run reports how many files it changed. A run with nothing to change prints `Generated 0 changed file(s)` and leaves every file untouched, timestamps included.
+
+If your server changes route options, such as `generatedApis.routePrefix` or a `rootNamespace` for discovery, pass the matching [route alignment](configuration.md#route-alignment) options too. Otherwise the proxies call URLs the server does not serve.
+
+## Look at the result
+
+The output mirrors the backend's namespaces, one file per artifact and a barrel per folder:
+
+```text
+Web/src/generated/Authors/Listing/AllAuthors.proxy.ts
+Web/src/generated/Authors/Listing/Author.proxy.ts
+Web/src/generated/Authors/Listing/AuthorsPage.proxy.ts
+Web/src/generated/Authors/Listing/index.ts
+Web/src/generated/Authors/Registration/RegisterAuthor.proxy.ts
+Web/src/generated/Authors/Registration/index.ts
+Web/src/generated/Books/Listing/Book.proxy.ts
+Web/src/generated/Books/Listing/BooksForAuthor.proxy.ts
+Web/src/generated/Books/Listing/index.ts
+Web/src/generated/Books/Registration/AddBook.proxy.ts
+Web/src/generated/Books/Registration/index.ts
+```
+
+`AllAuthors` and `AuthorsPage` are static query methods on the `Author` read model. Each query method gets its own file, named after the method, in the read model's folder.
+
+## Install the frontend packages
+
+The generated files import the published client packages directly. In the frontend project, install:
+
+```sh
+npm install @cratis/arc@22.19.1 @cratis/arc.react@22.19.1 @cratis/fundamentals react react-dom reflect-metadata
+```
+
+Generated commands and queries import both `@cratis/arc` and the React hooks from `@cratis/arc.react`, even when you only use the classes. Models use `@field` from `@cratis/fundamentals`. `@cratis/arc.react` accepts React 18 or 19.
+
+Import `reflect-metadata` once, before anything else, in the frontend's entry point, as the Library sample's `main.tsx` does. Compile the frontend in `Bundler` module resolution with `experimentalDecorators: true`, as both samples do.
+
+## Check it compiles
+
+Compile the frontend with its own type check. For the Library sample:
+
+```sh
+yarn workspace @cratis/arc.sample.library.web build
+```
+
+This second checkpoint catches a missing package, a decorator setting, or an import path that does not match the generated folders. A type error that points into a generated file almost always means a package version or compiler setting differs from the ones above, since generated files are never edited by hand.
+
+## Keep generation repeatable
+
+- **Commit or regenerate, and pick one.** The Library sample commits `Web/src/generated`, so the frontend builds without running the backend's tooling. Unchanged runs keep files byte for byte, so committed output only changes when the source does.
+- **Regenerate with the backend.** Run the script whenever a command, query, model, or validator changes. During development, add `--watch` to the same options in a separate terminal.
+- **Gate stale server metadata.** When you use `--metadata`, run the generator with `--check-metadata` in CI; it fails when the committed module no longer matches the source.
+
+Next, [use the proxies in React](frontend-usage.md).
diff --git a/Documentation/proxy-generation/index.md b/Documentation/proxy-generation/index.md
index a06d7326..48488567 100644
--- a/Documentation/proxy-generation/index.md
+++ b/Documentation/proxy-generation/index.md
@@ -3,75 +3,58 @@ title: Proxy generation
description: Generate typed @cratis/arc frontend proxies, React hooks, and client validation from your decorated TypeScript commands and read models.
---
-A frontend that calls your commands and queries through hand-written `fetch` calls drifts from the server the first time someone renames a field. `arc-proxygenerator` reads your TypeScript project and writes typed proxies for the published `@cratis/arc` client: command and query classes, React hooks, models, and the client-safe part of your validators, with the same routes the server serves.
+A frontend that calls your commands and queries through hand-written `fetch` calls drifts from the server the first time someone renames a field. The route changes, a required property becomes optional, and nothing fails until a user clicks the button.
-It reads source through the TypeScript compiler API. It never imports or runs your application, and it never talks to a running server.
+`arc-proxygenerator` removes that drift. It reads your TypeScript project and writes typed proxies for the published `@cratis/arc` client: command and query classes, React hooks, models, and the client-safe part of your validators, all with the routes the server serves. Rename a field on the server, regenerate, and the frontend compiler shows you every place that needs to change.
:::note[Source preview]
`@cratis/arc.proxygenerator` is not published to npm. Run its CLI from a clone of this repository after `yarn build`.
:::
-## Generate proxies for the Tasks sample
-
-```sh
-yarn install --immutable
-yarn build
-mkdir -p my-app/frontend/src/generated
-node Source/Tools/ProxyGenerator/dist/cli.js \
- --project "$PWD/Samples/Tasks/tsconfig.json" \
- --artifacts "$PWD/Samples/Tasks/Features" \
- --output "$PWD/my-app/frontend/src/generated" \
- --use-generated-metadata \
- --use-proxy-file-suffix
-```
-
-The output folder must exist. You get one file per artifact, in folders that follow the namespace, plus a barrel per folder:
+## How it works
-```text
-Tasks/Listing/AllTasks.proxy.ts
-Tasks/Listing/ObserveAllTasks.proxy.ts
-Tasks/Listing/TaskById.proxy.ts
-Tasks/Listing/TaskItem.proxy.ts
-Tasks/Listing/index.ts
-Tasks/Registration/RegisterTask.proxy.ts
-Tasks/Registration/index.ts
+```mermaid
+flowchart LR
+ Source["Decorated TypeScript @command, @readModel, validators"] --> Analyzer["arc-proxygenerator TypeScript compiler API"]
+ Analyzer --> Proxies["Proxies, models, hooks, client validators"]
+ Proxies --> Frontend["Frontend build @cratis/arc, @cratis/arc.react"]
+ Analyzer -. optional .-> Metadata["Generated artifact metadata for the server"]
```
-The Tasks sample's `generate-proxies` script (`yarn workspace @cratis/arc.core.sample.tasks generate-proxies`) writes the same files to its ignored `dist/proxies` and compiles them.
+The generator reads source through the TypeScript compiler API. It never imports or runs your application, and it never talks to a running server. That makes it safe to run in CI and in watch mode, and it means everything it knows comes from your declarations: decorators, `@field` types, return types, and validator rules.
-## What a proxy looks like
+It walks the artifacts folder with the same rules as `builder.discover()`, so `for_*` and `given` folders and `index.ts` files are skipped there too. Route options such as `--api-prefix` and `--segments-to-skip` must match the server's [endpoint mapping](../core/endpoint-mapping.md), because the generator cannot ask the server which routes it chose.
-An excerpt of the generated `RegisterTask.proxy.ts`:
+## What you receive
-```typescript
-export class RegisterTask extends Command implements IRegisterTask {
- readonly route: string = '/api/tasks/registration/register-task';
- readonly validation: CommandValidator = new RegisterTaskValidator();
- // ...properties, change tracking, and the React hook follow
-}
-```
+- **Commands**: a class per command with typed properties, `execute()`, the route, client-side validation, and a React `use()` hook.
+- **Queries**: a class per query method, snapshot or observable, with a parameters interface when the query takes arguments, sort helpers, and React hooks including paging.
+- **Models**: classes with `@field` metadata, so the client can turn JSON into `Guid` values, dates, and nested models. A concept arrives as its underlying type.
+- **Identity details**: the `detailsType` of an [identity details provider](../identity/provider-flow.md), ready for `useIdentity`.
+- **Barrels**: an `index.ts` per folder, unless you turn them off.
+- **Server metadata**, optionally: with `--metadata`, a module the server registers with `useGeneratedMetadata` to infer service and argument bindings. See [Generated artifact metadata](generated-artifact-metadata.md).
-The `TaskId` concept arrives in the frontend as its underlying `Guid`, and the validator carries the literal `notEmpty` and `maxLength` rules from `RegisterTaskValidator`. Your frontend creates a `RegisterTask`, sets `id` and `title`, and calls `execute()`, or uses `RegisterTask.use()` in a React component. See [Frontend](/arc/frontend/) on the shared Arc pages for the client side.
+[What the generator writes](generated-code.md) shows each of these for the Library sample.
## Compatibility
-The generated proxies compile against `@cratis/arc` and `@cratis/arc.react` 22.19.1 with `@cratis/fundamentals`, in strict `Bundler` mode with `skipLibCheck: false`. `yarn test:client-generation` builds the workspace, generates the Tasks proxies, compiles them, and runs commands, queries with arguments, paging, sorting, observable snapshots, and hub updates against live model-bound Express, Fastify, and Hono servers.
+The generated proxies target `@cratis/arc` and `@cratis/arc.react` 22.19.1 with `@cratis/fundamentals`, compiled in strict `Bundler` mode with `skipLibCheck: false`.
-Imports between generated files are extensionless by default, for Vite and other bundlers. Use `--js-import-specifiers` for native Node ESM after compilation. `NodeNext` consumer compilation is not supported with the published client declarations.
+Imports between generated files are extensionless by default, which suits Vite and other bundlers. Use `--js-import-specifiers` for native Node ESM after compilation. `NodeNext` consumer compilation is not supported with the published client declarations.
## Limits
-The analyzer keys generated models by namespace and class name, so two `Item` models in separate folders produce separate files; generated references use aliased imports if those names collide in one file. An exported class marked `@identityDetailsProvider()` can also contribute its `detailsType` or concrete `provide()` result model without an HTTP endpoint. Source-only identity provider configuration outside the artifacts root is not analyzed.
+The analyzer keys generated models by namespace and class name, so two `Item` models in separate folders produce separate files, and generated references use aliased imports if those names collide in one file. An exported class marked `@identityDetailsProvider()` contributes its `detailsType` or concrete `provide()` result model without an HTTP endpoint. Source-only identity provider configuration outside the artifacts root is not analyzed.
For client preferences, `@command({ treatWarningsAsErrors: true })` emits the command flag. `@query({ httpMethod: QueryHttpMethod.Query, treatWarningsAsErrors: true })` emits `setHttpMethod(QueryHttpMethod.Query)` and the query flag; import the enum from `@cratis/arc.core`. `Get` and `Auto` are also supported. These settings affect the generated client, not the server's acceptance of requests. The HTTP server still caps `X-Allowed-Severity` at Warning. Dynamic decorator options cannot be emitted safely and fail generation.
-Compared with Arc's .NET generator, this does not yet reproduce every template byte-for-byte; generator output has a different license/header and import layout. Nullable command types and interface-only model mode have compile coverage only, not live-client equivalence. Do not treat this output as complete .NET proxy parity; the [capability reference](../reference/capabilities.md#proxies-introspection-and-tooling) tracks the details.
+The output does not reproduce the .NET generator's templates byte for byte: the file header and import layout differ. Nullable command types and interface-only model mode compile, but have not been compared against a live client. The [capability reference](../reference/capabilities.md#proxies-introspection-and-tooling) tracks what is verified.
+
+## Choose your next step
-## Continue
+1. [Set up proxy generation](getting-started.md) for your backend and a dedicated frontend folder.
+2. [Use the proxies in React](frontend-usage.md): commands, queries, paging, and live updates.
+3. Look up [what the generator writes](generated-code.md), [type mapping](type-mapping.md), and [validation rules](validation.md).
+4. Adjust [configuration](configuration.md) when your routes or folder layout differ from the defaults.
-- [Configuration](configuration.md): every CLI option.
-- [Generated artifact metadata](generated-artifact-metadata.md): infer server bindings and validate them before startup.
-- [Type mapping](type-mapping.md): which TypeScript types become which client types.
-- [Validation rules](validation.md): which validator rules reach the client.
-- [File index tracking](file-index-tracking.md): ownership headers, barrels, and stale-file cleanup.
-- [Low-level manifest](low-level-manifest.md): proxies for `defineCommand` and `defineQuery`.
+For specialized output, see [generated artifact metadata](generated-artifact-metadata.md), [file index tracking](file-index-tracking.md), and the [low-level manifest](low-level-manifest.md) for `defineCommand` and `defineQuery`.
diff --git a/Documentation/proxy-generation/toc.yml b/Documentation/proxy-generation/toc.yml
index 8b30fe53..88b26f56 100644
--- a/Documentation/proxy-generation/toc.yml
+++ b/Documentation/proxy-generation/toc.yml
@@ -1,5 +1,11 @@
- name: Overview
href: index.md
+- name: Set up proxy generation
+ href: getting-started.md
+- name: Use generated proxies in React
+ href: frontend-usage.md
+- name: What the generator writes
+ href: generated-code.md
- name: Configuration
href: configuration.md
- name: Generated artifact metadata
diff --git a/Documentation/tenancy/index.md b/Documentation/tenancy/index.md
index 2ef1364f..16736f36 100644
--- a/Documentation/tenancy/index.md
+++ b/Documentation/tenancy/index.md
@@ -1,9 +1,28 @@
---
title: Tenancy
-description: Choose how Arc selects a tenant for each request, read it anywhere in the request, and keep tenant selection separate from proving membership.
+description: Serve several customers from one Arc application, choose how each request selects its tenant, prove the caller belongs to it, and keep tenant selection, membership, and storage isolation apart.
---
-A multi-tenant application must never serve one customer's data to another. Arc resolves a tenant for every request and makes it available to your commands, queries, and storage integrations. Resolving a tenant is not the same as proving the caller belongs to it, and this page shows where each happens.
+Acme and Globex both use your task application, on the same deployment. A request from an Acme user must only ever read and change Acme's tasks. One missed check, and a Globex user sees a competitor's data.
+
+Arc resolves a tenant for every request, before your code runs, and carries it through commands, queries, services, and storage integrations. You decide how the tenant is selected and how membership is proven. Arc makes sure the answer is the same everywhere in the request.
+
+## Three decisions, kept apart
+
+```mermaid
+flowchart LR
+ Request --> Selection["Selection which tenant does the request name?"]
+ Selection --> Membership["Membership may this caller use it?"]
+ Membership --> Storage["Storage isolation where do reads and writes go?"]
+```
+
+| Decision | Who makes it |
+| --- | --- |
+| **Selection** | Arc, from the source you configure: a header, a claim, a subdomain, or your own resolver |
+| **Membership** | Arc with `tenancy.membershipClaim`, your `tenancy.resolve`, or your authorization rules |
+| **Storage isolation** | The storage integration: a MongoDB database, a Drizzle connection, or a Chronicle namespace per tenant |
+
+Each decision depends on the one before it, and none replaces another. A separate database per tenant does not stop a Globex user from naming Acme in a header. [Storage isolation](isolation.md) covers the third decision.
## Three ways to select a tenant
@@ -22,7 +41,7 @@ const builder = ArcApplication.createBuilder({
});
```
-This tries an own `tenant` claim on the verified principal first, then the header. A missing tenant answers 400; a selected tenant that is not listed in the principal's comma-separated `tenants` claim answers 403.
+This tries an own `tenant` claim on the verified principal first, then the header. A missing tenant answers 400; a selected tenant that is not listed in the principal's comma-separated `tenants` claim answers 403. Both checks run before authorization, validation, or your code.
:::danger[A header is a request, not proof]
Without `tenancy.membershipClaim` or `tenancy.resolve`, Arc takes the header unchanged and does not check membership. When tenants separate customers' data, derive the tenant from the principal in `tenancy.resolve`, configure a membership claim, or check it in authorization. Never enforce it in a validator: a trusted direct caller can lower blocking severity.
@@ -32,17 +51,39 @@ Without `tenancy.membershipClaim` or `tenancy.resolve`, Arc takes the header unc
Every callback receives the execution context with `tenantId`, `principal`, `correlationId`, `signal`, and `allowedSeverity`. Code without access to that parameter, such as a repository deep in a call chain, can call `currentContext()`. It uses Node.js `AsyncLocalStorage`, so concurrent requests never see each other's context, and it returns `undefined` outside an Arc execution.
+In a spec, set the tenant the same way a trusted caller would: `CommandScenario.for(...).withContext({ tenantId: 'acme', principal })`.
+
## Storage follows the tenant
-The storage integrations select per-tenant storage from the resolved tenant, and fail when there is none:
+The storage integrations select per-tenant storage from the resolved tenant:
+
+- [MongoDB](../mongodb/tenancy.md) chooses a database per tenant, and fails without one.
+- [SQL with Drizzle](../sql/tenancy.md) calls your `databaseFactory` per tenant, and fails without one.
+- [Chronicle](../chronicle/index.md), experimental, appends in the tenant's namespace, or in `Default` when there is none.
+
+[Storage isolation](isolation.md) shows each mapping and how to verify it.
+
+## Best practices
+
+- **Derive the tenant from verified identity when tenants are customers.** Use the `claim` source, or `tenancy.resolve` reading the principal. Keep the header for trusted internal callers.
+- **Always prove membership.** Configure `membershipClaim`, check it in `tenancy.resolve`, or add a policy. Selection alone proves nothing.
+- **Set `required: true` when every operation is tenant-scoped.** A missing tenant then fails with 400 at the edge, instead of deep in a storage integration.
+- **Use stable, lowercase tenant IDs.** Built-in sources lowercase IDs and accept only DNS labels of up to 63 characters. Returning the same form from `tenancy.resolve` keeps every integration aligned.
+- **Put the tenant in cache keys, logs, and telemetry.** A cache keyed only by entity ID serves one tenant's data to another.
+- **Keep `fixed` and `development` sources for single-tenant or local setups.** Neither checks where a request came from.
+
+## Security considerations
-- [MongoDB](../mongodb/tenancy.md) chooses a database per tenant.
-- [SQL with Drizzle](../sql/tenancy.md) calls your `databaseFactory` per tenant.
-- [Chronicle](../chronicle/index.md) appends in the tenant's namespace.
+- Tenant resolution runs after authentication, so `tenancy.resolve` and the claim source see a verified principal. Never read identity from the request yourself in a resolver.
+- A header, query-string, or subdomain value is a request by the caller. The `subdomain` source reads only a host-verified authority, never the raw `Host` or `X-Forwarded-Host` header; see [Tenant resolvers](resolvers.md).
+- Enforce membership in tenancy options or authorization, never in validators.
+- `/.cratis/tenants` is an anonymous fixture list for development tools. Never return real tenant inventories from `developmentTenants`; see [Development users and tenants](../identity/development-users-and-tenants.md).
+- Treat tenant IDs as internal metadata. Avoid putting them in public URLs or error messages when a customer name would reveal who else uses the system.
-Storage isolation does not replace authorization: verify the caller may use the tenant before the query runs.
+## Recap
-## Related
+- Arc selects one tenant per request and exposes it as `context.tenantId` and through `currentContext()`.
+- Selection, membership, and storage isolation are separate decisions. Configure all three.
+- Headers select, principals prove.
-- [Tenant resolvers](resolvers.md)
-- [Development users and tenants](../identity/development-users-and-tenants.md)
+Next, pick your sources in [Tenant resolvers](resolvers.md), then check your storage in [Storage isolation](isolation.md).
diff --git a/Documentation/tenancy/isolation.md b/Documentation/tenancy/isolation.md
new file mode 100644
index 00000000..e373f808
--- /dev/null
+++ b/Documentation/tenancy/isolation.md
@@ -0,0 +1,45 @@
+---
+title: Storage isolation
+description: See how the MongoDB, Drizzle, and experimental Chronicle integrations turn the resolved tenant into a database, connection, or event store namespace, and how to verify the boundary.
+---
+
+Resolving `acme` for a request is only useful if the data really goes to Acme's storage. Each storage integration maps the resolved tenant to its own destination, and they do not all treat a missing tenant or letter case the same way. This page lists each mapping so you can line them up, then shows how to check that the boundary holds.
+
+## How each integration maps the tenant
+
+| Integration | Destination for tenant `acme` | Tenant `default` | No tenant |
+| --- | --- | --- | --- |
+| [MongoDB](../mongodb/tenancy.md), `database: 'tasks'` | Database `tasks+acme` | Database `tasks` | The request fails |
+| [SQL with Drizzle](../sql/tenancy.md) | The connection `databaseFactory('acme', context)` returns | `databaseFactory('default', context)`, or the `database` option when there is no factory | The request fails |
+| [Chronicle](../chronicle/index.md), experimental | Namespace `acme` in the configured event store | Namespace `default` | Namespace `Default` |
+
+MongoDB and Drizzle lowercase the tenant ID before they use it. The Chronicle integration passes the ID to Chronicle as it is.
+
+None of these checks membership. They trust the tenant Arc resolved, which is why [Tenancy](index.md) asks you to prove membership first.
+
+## The Chronicle namespace rule
+
+With `@cratis/arc.chronicle`, every append, and every read the integration makes through `ChronicleReadModels`, uses the event store namespace named by `context.tenantId`. When the execution has no tenant, it uses Chronicle's `Default` namespace. Arc does not rename tenants on the way: a built-in source that resolved `default` sends `default`, which is a different string from `Default`.
+
+Commands that a reactor returns run in the namespace of the event that triggered them. Arc sets their `tenantId` to that namespace, so a reactor reacting to Acme's event can only produce commands for Acme.
+
+To keep MongoDB, Drizzle, and Chronicle pointing at the same tenant, use the lowercase IDs that the built-in sources produce. If you write `tenancy.resolve`, return lowercase IDs too. The Library sample shows the single-tenant case: `tenancy: { resolve: () => 'Default' }` sends every request to Chronicle's `Default` namespace.
+
+## Verify the boundary
+
+Storage naming can look right while data still crosses tenants through a cache, a long-lived handle, or a background job. Test the boundary itself, with real storage where you can:
+
+- **Four tenant states.** No tenant, the default tenant, and two named tenants. Assert both the answer and where the data landed.
+- **A denied selection.** A caller naming a tenant they do not belong to gets 403, and nothing is read or written.
+- **Concurrent requests.** Run requests for two tenants at the same time. `currentContext()` is per request, but a singleton that caches a collection, a connection, or query results is not.
+- **Long-lived work.** Include observable queries, reactor-driven commands, and anything that runs after the request, since those carry the tenant they started with.
+- **Direct calls.** A job that calls `app.server` directly passes its own context; check that it passes a tenant on purpose.
+
+In specs, `CommandScenario.withContext({ tenantId })` sets the tenant for pipeline calls. It proves your code uses the tenant it is given. It does not prove your resolver or your storage configuration, so keep at least one check that goes through HTTP and real storage.
+
+## Related
+
+- [Tenancy](index.md)
+- [Tenant resolvers](resolvers.md)
+- [MongoDB tenancy](../mongodb/tenancy.md)
+- [SQL tenancy](../sql/tenancy.md)
diff --git a/Documentation/tenancy/resolvers.md b/Documentation/tenancy/resolvers.md
index 0090a16c..dcb2cbe0 100644
--- a/Documentation/tenancy/resolvers.md
+++ b/Documentation/tenancy/resolvers.md
@@ -3,6 +3,8 @@ title: Tenant resolvers
description: Select a tenant from a header, query string, trusted claim, fixed value, or verified subdomain, in the order you choose, with optional required and membership checks.
---
+Where does the tenant come from: a header your gateway sets, a claim in the user's token, or the `acme` in `acme.example.com`? Pick the source, or an ordered list of sources, that matches how your callers arrive.
+
Set `tenancy.resolverType` for one .NET-compatible source, or `tenancy.sources` for an ordered list. The first nonempty result wins. Do not set both. A header or query-string value is a **selection**, not proof of authority.
## Sources
@@ -39,5 +41,6 @@ Never use `development` or `fixed` to accept a browser-supplied tenant without v
## Related
+- [Storage isolation](isolation.md) for where the resolved tenant sends data
- [Native principal](../hosts/native-principal.md) for supplying a verified authority
- [Authentication](../core/authentication.md)
diff --git a/Documentation/tenancy/toc.yml b/Documentation/tenancy/toc.yml
index 5f781c28..647af52a 100644
--- a/Documentation/tenancy/toc.yml
+++ b/Documentation/tenancy/toc.yml
@@ -2,3 +2,5 @@
href: index.md
- name: Tenant resolvers
href: resolvers.md
+- name: Storage isolation
+ href: isolation.md