feat(server): resolve the callback against its public base and authorize per route (#91)
## Summary
Two gaps Samva's cutover to 0.9 found: interactive connects answer 400
behind a proxy that rewrites
`Host`, and `Identity` yields one principal for the whole group, so a
host cannot open reads to
members while restricting writes to administrators.
## Usage
```ts
const IdentityLive = Layer.succeed(Server.Identity)({
principal: (request) =>
Effect.flatMap(yourSessions.verify(request.cookies.session), (session) =>
session === null
? Effect.fail(
new DomainKit.Error({ reason: new Reason.Unauthenticated({ message: "No session" }) }),
)
: Effect.succeed({ ownerId: session.orgId, actorId: session.userId }),
),
// New, optional: which routes this principal may reach.
authorize: (principal, endpoint) =>
principal.actorId === "admin" || !writeRoutes.has(endpoint)
? Effect.void
: Effect.fail(
new DomainKit.Error({
reason: new Reason.Forbidden({ message: `${endpoint} needs an administrator` }),
}),
),
});
// Behind an edge that rewrites Host, name the origin the browser actually reaches.
Server.layer(Api, { callbackBaseUrl: "https://app.example.com/api/domainkit" });
```
## API
```ts
export interface IdentityService {
readonly principal: (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<Principal.Interface, DomainKit.Error>;
readonly authorize?: (
principal: Principal.Interface,
endpoint: EndpointName,
) => Effect.Effect<void, DomainKit.Error>;
}
/** Every route in the group, by name. */
export type EndpointName = HttpApiGroup.Endpoints<Group>["identifier"];
```
`EndpointName` is derived from the group rather than written out, so it
cannot drift from the
routes; it resolves to the fifteen route names. A `Forbidden` reason
becomes the 403 through the
existing status table. Omitting `authorize` leaves behaviour identical.
## Callback origin
```text
browser -> https://app.example.com/api/domainkit/connections
edge rewrites Host, forwards to
server sees -> https://internal.svc/connections
```
The callback resolved `returnTo` against the incoming request, so the
same-origin check compared the
customer's destination to an origin the browser never sees, and every
interactive connect answered
400. `callbackUrlFor` now takes the route it is serving, returns a
`URL`, and prefers
`callbackBaseUrl` when the host set it; the callback resolves the
destination against that base and
keeps the same-origin rule relative to it. Without the option the base
still follows the mount the
request arrived on, so direct deployments are unchanged, and the
provider callback URL and the
post-connect redirect now agree on one origin.
## Tests
- `bun run --filter domainkit release:check` — 132 package tests and 10
artifact tests, including
two new ones:
- a rewritten `Host` end to end: start on the internal origin with
`callbackBaseUrl: "https://public.test/api/domainkit"`, the
authorization URL points at the
public callback, and the forwarded callback redirects to
`https://public.test/settings/domains`.
- a policy where members `inspect` and `observe` but only administrators
`approve` and `apply`,
asserting the 403 carries `reason._tag: "Forbidden"` and the endpoint
name.
- Root `bun run release:check` — domainkit, `@domainkit/capsuledb`, and
`@domainkit/react` all green.
- Root `bun run lint && bun run lint:rules && bun run format:check`.
## Docs
ADR 0007 records why one principal per request was not enough and why
`callbackBaseUrl` decides the
callback origin; the README's Identity snippet shows `authorize` and the
proxy note. S
Saatvik Arya committed
fc1459dc72463fd3e56fb41029cd680ca43e1240
Parent: d8bcdae
Committed by GitHub <noreply@github.com>
on 9/3/2026, 7:10:48 PM