Skip to content

[v3 auth] Make can() a typed full-stack authorization contract #173

Description

@olliethedev

Parent

Related

Migration experience

A representative production-style migration to @btst/stack@3.0.0-rc.2 successfully replaced plugin-specific page guards with one application resource/action policy. The same policy function could be supplied to the client auth provider and the server stack configuration, while existing lifecycle hooks continued to reject unauthorized mutations.

The resulting UI and all tested backend authorization paths were secure. The authoring model was still ambiguous:

  • CanParams.resource, CanParams.action, and params are open strings/records rather than a typed permission vocabulary;
  • client routes and controls consume can() as a presentation/navigation decision;
  • backend lifecycle hooks remain the documented authoritative security boundary;
  • StackServerAuthProvider exposes a can member, but RC2 registers and invokes only getIdentity; there is no request-scoped helper that invokes the configured server policy;
  • the application can pass the same policy to the client and server configurations while still duplicating session and role checks in every protected lifecycle hook; and
  • first-party components do not yet consume the resource/action policy uniformly, as [v3 RC2] CommentThread ignores delete permission and surfaces failed owner action #169 shows.

A developer can therefore believe they have defined authorization once while still needing separate, manually synchronized hook checks for every protected operation.

Problem

V3 currently combines two ideas under one story without giving them a precise or fully typed contract:

  1. optimistic client-side capability checks that hide or gate UI; and
  2. authoritative server-side authorization that must prevent data access and mutation.

It is correct that client checks are never a security boundary. It is also correct that record-aware decisions often belong close to lifecycle hooks. The DX problem is that consumers do not have one well-defined way to:

  • discover valid permission resources and actions;
  • catch a typo such as "blog:drafts" when the declared resource is "blog:draft";
  • prevent an invalid pairing such as blog:draft/delete;
  • type record-specific parameters for a resource/action pair;
  • share one policy implementation across the client and server without sharing environment-specific identity resolution;
  • apply the configured server can() policy from a request or lifecycle hook;
  • distinguish unauthenticated from forbidden outcomes; or
  • audit whether built-in routes, controls, and endpoints use matching permission tuples.

Typing resource and action as independent unions is insufficient because it permits their invalid cross-product. The request must be typed as a correlated resource/action/params tuple.

This ambiguity invites policy drift. A denied control may still render, a lifecycle hook may implement a subtly different role rule, or a consumer may mistakenly assume that setting stack({ auth: { can } }) automatically protects endpoints.

Current DX

Today the public contract accepts any strings:

interface CanParams {
  resource: string;
  action: string;
  params?: Record<string, unknown>;
}

A production-style application can write one apparently shared policy:

export function canUseStack({ resource, action, identity }: PermissionRequest) {
  const isAdmin = identity?.role === "admin";

  if (resource === "blog:draft") return isAdmin;
  if (resource === "blog:post") return action === "read" || isAdmin;
  return false;
}

It can pass that function to both environments:

<StackProvider
  auth={{
    getIdentity: getBrowserIdentity,
    can: canUseStack,
    loginPath: "/auth/sign-in",
  }}
/>
stack({
  auth: {
    getIdentity: getRequestIdentity,
    can: canUseStack,
  },
  // ...
});

However, the configured server can is not invoked by the RC2 request path. The application still has to create and synchronize a second authorization implementation:

blogBackendPlugin({
  onBeforeDeletePost: async (_postId, context) => {
    const session = await getSession(context.headers);
    if (session?.user.role !== "admin") {
      throw new Error("Unauthorized");
    }
  },
});

Consequences:

  • "blog:drafts" and other misspellings compile;
  • a resource can be paired with an action it never declared;
  • params.id, params.typeSlug, ownership context, and similar values are untyped;
  • the frontend policy and backend hook can drift;
  • repeated session lookup defeats the intended request-scoped identity abstraction;
  • generic thrown errors obscure 401 versus 403 behavior; and
  • adding a new plugin permission requires source archaeology and updates in multiple places.

Security model

One typed permission vocabulary, one authoritative server policy, and a client-safe projection of that policy.

The server is the source of truth. Reusing the exact same pure can function in the browser is an excellent happy path for claim-based policies, but it is an optimization rather than a requirement of the contract. Ownership, tenant, database, secret-dependent, or otherwise privileged decisions remain server-only. Their client representation may use safe session claims or a typed remote capability check.

The model is deliberately asymmetric:

  • permission types improve authoring, discovery, refactoring, and auditability; they are not runtime enforcement;
  • client checks control presentation, navigation, and optimistic UX only;
  • the authoritative server policy decides whether the operation proceeds;
  • built-in endpoints with declared permission semantics invoke that server policy before protected work;
  • lifecycle hooks add ownership and domain rules instead of recreating the base role policy;
  • the server reconstructs record identifiers and other security-relevant parameters from the request and loaded data; and
  • unknown external permission requests require runtime validation even when first-party TypeScript callers are typed.

The implementation is on the wrong track if it treats hidden controls or TypeScript as security, ships privileged policy dependencies to the browser, requires a policy function to cross a framework serialization boundary, or guesses permission semantics for undeclared custom endpoints.

Security DX outcome

A developer should gain:

  • autocomplete for every built-in permission;
  • type errors for misspelled resources, invalid actions, and invalid record parameters;
  • one authoritative place to change the base authorization decision;
  • automatic server enforcement for declared built-in operations;
  • consistent 401/403 behavior;
  • an auditable inventory connecting each control and route to its backend operation; and
  • lifecycle hooks that contain only application-specific record rules.

Proposed DX

The exact helper names are illustrative. The important contract is one server-safe permission definition, one authoritative server policy, and separate environment adapters. A browser-safe pure policy may be reused directly; a privileged server-only policy instead exposes a client-safe projection or typed transport.

1. Define the permission vocabulary once

Each built-in plugin exports its permission statements. Applications and third-party plugins can extend them without weakening built-in types:

import {
  definePermissions,
  type PermissionOf,
} from "@btst/stack/auth";
import { blogPermissions } from "@btst/stack/plugins/blog";
import { commentsPermissions } from "@btst/stack/plugins/comments";

export const permissions = definePermissions({
  ...blogPermissions,
  ...commentsPermissions,
  "app:billing-account": ["read", "update"],
} as const);

export type AppPermission = PermissionOf<typeof permissions>;

At minimum, the definition maps resource names to allowed actions in the same structural style as Better Auth access-control statements. The type model must also support resource/action-specific parameter types where an operation is record-aware.

The normal API remains the familiar object syntax, now with autocomplete and correlated validation:

useCan({
  resource: "blog:post",
  action: "update",
  params: { id: postId },
});

These fail at typecheck:

useCan({ resource: "blog:drafts", action: "read" });
useCan({ resource: "blog:draft", action: "delete" });

A public registry/augmentation mechanism should let application and third-party plugin permissions participate in useCan, <CanAccess>, route declarations, provider callbacks, and server helpers without threading repetitive generics through every React component. An explicit unsafe/dynamic request type may exist for values loaded from an external system, but it must not weaken the default authoring path.

2. Define the authoritative policy once

// authorization.shared.ts — no React, request objects, database clients, or secrets
export const authorization = defineAuthorization({
  permissions,
  can: ({ identity, resource, action, params }) => {
    const isAdmin = identity?.role === "admin";

    if (resource === "blog:draft") return isAdmin;
    if (resource === "blog:post") return action === "read" || isAdmin;
    if (resource === "app:billing-account") {
      return action === "read" || isAdmin;
    }

    return false;
  },
});

The permission request and application identity are narrowed inside the policy. #172 supplies the application-identity preservation path; this issue supplies the permission side of that same full-stack contract.

3. Add thin environment adapters

The policy is shared; identity resolution remains appropriate to each runtime:

// authorization.client.ts
export const clientAuth = authorization.client({
  getIdentity: getBrowserIdentity,
  loginPath: "/auth/sign-in",
});
// authorization.server.ts
export const serverAuth = authorization.server({
  getIdentity: ({ headers }) => getSessionUser(headers),
});
<StackProvider auth={clientAuth} />
stack({ auth: serverAuth, plugins });

This is one authorization contract, not one literal provider object crossing the client/server module boundary. When authorization.can is pure and browser-safe, both adapters may reuse it. When authorization requires a database or secret, the server policy remains the sole authority and the client uses a typed remote capability adapter or safe session-claim projection.

4. Enforce the same policy on the server

Provide a supported request-scoped primitive that resolves the memoized request identity and invokes StackServerAuthProvider.can:

await requireRequestPermission(context.headers, {
  resource: "blog:post",
  action: "delete",
  params: { id: postId },
});

The backend must derive record ids and other security-relevant parameters from the actual route/body/context rather than trusting a permission request supplied by the browser.

Built-in permission-sensitive endpoints should declare their resource/action semantics and reach this authoritative server path. Once an operation is declared, applications should not have to repeat the same role check in a lifecycle hook. Lifecycle hooks remain available for additional ownership, tenant, or data-dependent rules:

blogBackendPlugin({
  onBeforeDeletePost: async (postId, context) => {
    // The declared blog:post/delete policy has already run.
    // Add only application-specific record rules here when needed.
    await assertPostBelongsToTenant(postId, context);
  },
});

Endpoints without declared permission semantics must not be presented as automatically protected.

Contract boundaries

  • Client can() controls presentation, navigation, and optimistic UX only.
  • Server can() is authoritative only when the endpoint or lifecycle hook invokes the documented server path.
  • Every built-in permission-sensitive operation must publish and test its resource/action tuple.
  • Server checks derive trusted parameters from the server request and loaded records.
  • Omitting auth preserves the documented permissive/no-auth behavior unless a later breaking decision explicitly changes it.
  • A shared isomorphic policy must be safe to import in a client bundle; server-only policies use a client adapter rather than leaking server dependencies.
  • Permission definitions describe vocabulary and types, not a new role model or policy language.

What to build

  1. Introduce a server-safe permission statement/registry contract and derive a correlated resource/action/params request type from it.
  2. Export permission definitions for every built-in plugin and provide an extension path for application and third-party plugin resources.
  3. Apply the registered request type consistently to client and server auth providers, useCan, <CanAccess>, route permissions, request helpers, and built-in declarations.
  4. Provide a supported way to define one authoritative typed server policy, reuse the evaluator in the client when it is browser-safe, and otherwise attach a client-safe claim or transport projection.
  5. Provide a request-scoped server primitive for evaluating the configured server policy with the memoized identity and consistent 401/403 behavior.
  6. Audit first-party routes, controls, and endpoints so matching permission tuples reach both presentation gates and authoritative backend checks.
  7. Document precisely which behavior BTST performs automatically and which application/plugin lifecycle hooks must enforce.

Do not turn client gating into a claimed security boundary, and do not automatically apply a generic permission to endpoints whose resource/action semantics have not been declared.

Delivery plan

This issue is an umbrella contract and should not land as one oversized PR. Deliver it in three independently reviewable stages while keeping main shippable after each stage.

Sub-issue #176 — typed permission vocabulary

  • Add the neutral permission statement/registry contract.
  • Derive correlated resource/action/params request types.
  • Export built-in permission catalogs from neutral plugin subpaths.
  • Add application and third-party extension support.
  • Add positive and negative compile-time tests.
  • Do not change runtime authorization behavior in this PR.

Sub-issue #177 — authoritative server contract

  • Add request-scoped canRequest and requireRequestPermission primitives.
  • Invoke the configured server can with the memoized request identity.
  • Define fail-closed policy-error behavior and consistent unauthenticated/forbidden responses.
  • Derive security-relevant parameters from the server request and records.
  • Preserve lifecycle hooks as the extension point for ownership and domain rules.
  • Do not claim automatic protection for undeclared endpoints.

Sub-issue #178 — first-party adoption and portability

  • Declare and enforce matching permission tuples across every protected built-in control, route, and endpoint.
  • Remove duplicated first-party base role checks after equivalent server-policy enforcement is covered.
  • Add the Next.js, React Router, and TanStack Start production fixtures and boundary checks.
  • Document the browser-safe shared-policy and privileged server-only-policy variants.
  • Verify anonymous, authenticated-denied, authenticated-allowed, SSR, hydration, and client-navigation behavior.

Follow-ups that are not v3 blockers

  • Optional SSR initialIdentity or capability snapshots beyond documenting and testing the current pending behavior.
  • A generalized remote-capability client with batching, caching, and invalidation; v3 only needs a safe typed integration path.
  • Exhaustive coverage of experimental React Router RSC variants beyond the supported fixture.
  • A fluent role/authorization DSL.
  • Permission or authorization code generation.

Definition of success

A developer can answer all of these questions from autocomplete, type errors, and the public docs without reading package source:

  • Which resources and actions does each installed plugin declare?
  • Which actions are valid for a selected resource?
  • Which parameters are required or available for that resource/action pair?
  • What does configuring client can() protect?
  • What does configuring server can() protect automatically?
  • How does a lifecycle hook evaluate the configured policy for the current request?
  • How are identity, record id, ownership, and other parameters passed?
  • When should a denial be 401 versus 403?
  • Which permission tuple does each built-in route, control, and endpoint use?

For a representative protected mutation, one authoritative server policy determines whether the request is authorized. A client-safe projection determines whether the control is presented. When the evaluator is pure and isomorphic, changing it once updates both experiences; when it is server-only, the client reaches or projects the same authority without duplicating a second role policy. In either case, the server remains decisive and the developer does not hunt down another backend role check.

The following compile-time outcomes are part of success:

  • a misspelled built-in resource fails;
  • an undeclared resource/action pairing fails;
  • invalid or missing record parameters fail where the operation declares them;
  • a third-party permission extension is accepted across client and server APIs; and
  • an explicitly selected dynamic/unsafe path remains possible without degrading normal autocomplete or validation.

Acceptance criteria

Typed permission contract

  • Every built-in plugin exports one server-safe permission definition covering its protected resources and allowed actions.
  • The public permission request is a correlated resource/action/params union rather than independent string fields.
  • Resource/action-specific parameter types are supported, including required, optional, and absent parameters.
  • useCan, <CanAccess>, route permission declarations, client/server provider callbacks, and server request helpers use the same registered permission type.
  • Application and third-party plugin permissions can extend the registry without casts or repetitive generics at each call site.
  • The permission statements can be adapted structurally to Better Auth access-control statements without coupling BTST core to Better Auth.
  • Type tests reject blog:drafts, reject an invalid blog:draft/delete pairing, and reject invalid parameter shapes.
  • Type tests accept a representative third-party resource across the client and server surfaces.

Shared policy DX

  • Documentation shows both supported variants: one browser-safe pure evaluator reused by client/server adapters, and one privileged server-only policy exposed to the client through safe claims or typed transport.
  • The policy receives the registered permission request and, with [v3 auth] Preserve application identity types across client and server auth #172's typed path, the concrete application identity without assertions.
  • The design prevents server-only identity/session/database imports from being pulled into client bundles.
  • A documented path exists for server-only/dynamic policies whose client capability check must use safe claims or a remote adapter.
  • Existing consumers using the base identity and current provider object shapes have a documented migration path.

Authoritative server behavior

  • The client-versus-server authorization boundary is specified normatively, including an explicit statement that client gating is presentation only.
  • A public request-scoped server helper evaluates the configured StackServerAuthProvider.can using the memoized request identity and accepts the registered resource/action/params request.
  • The helper has defined behavior when auth is absent, can is absent, identity resolution fails, or the policy throws.
  • Unauthorized and forbidden outcomes map consistently to useful HTTP errors rather than generic thrown errors.
  • Security-relevant record/context parameters are derived on the server and are not trusted from client input.
  • Built-in plugins have a documented/testable permission inventory for protected routes, controls, and endpoints.
  • Every declared permission-sensitive built-in endpoint reaches the authoritative server policy; undeclared endpoints are not claimed to be protected automatically.
  • Permission-sensitive first-party controls consult the same tuple used by the corresponding backend path; [v3 RC2] CommentThread ignores delete permission and surfaces failed owner action #169 remains the focused defect for the known comment-delete mismatch.
  • Documentation and examples show an authoritative lifecycle hook adding an ownership/data rule after the shared policy rather than duplicating it.
  • Tests demonstrate anonymous, authenticated-denied, and authenticated-allowed behavior for both route-level reads and mutations.
  • Tests prove that merely configuring client auth cannot authorize a backend request and that configuring server can does not silently protect undeclared endpoints.
  • Existing lifecycle-hook authorization remains supported during migration, with a documented incremental adoption path.
  • Package typecheck, authorization tests, representative plugin tests, and docs build pass.

Framework boundary requirements

The supported frameworks have different compilation models, so “one authorization configuration” must mean one shared permission vocabulary, one authoritative server policy, and a client-safe projection with separate environment adapters. It must not require one provider object, closure, or server module to cross the client/server boundary.

Package and application module layout

BTST should expose three directional surfaces:

  • an isomorphic auth/permission entry containing only permission values, types, registry/factory utilities, and client-safe pure policy helpers;
  • the existing client context entry containing StackProvider, useCan, and <CanAccess>; and
  • the server API entry containing request identity lookup and canRequest/requireRequestPermission.

Each plugin’s permission catalog must be available from a neutral subpath such as @btst/stack/plugins/blog/permissions. Server code must not import permission definitions through a /client barrel, and client code must not import them through an /api barrel. The isomorphic auth barrel must not re-export server helpers, database integrations, request APIs, or client React components.

A recommended application layout is:

authorization/
├── permissions.ts              # isomorphic values and types
├── policy.ts                   # isomorphic only when safe for the browser
├── authorization.client.ts    # browser identity and login behavior
└── authorization.server.ts    # request identity, database, and secrets

The shared policy may use serializable identity claims and pure logic. It must not import secrets, database clients, Node-only modules, framework request helpers, or server auth instances. When the authoritative policy requires server-only data, the client uses a typed remote capability adapter or safe session-claim projection instead of importing that policy into the browser.

Next.js

  • A Client Component must import or construct the client adapter itself; a policy/provider function must not be created in a Server Component and passed as a prop.
  • Server identity resolution and database-aware policy code remain behind a server-only application module and outside the Client Component import graph.
  • Permission and identity snapshots passed from server to client, if supported, must be serializable; policy functions are never serialized.
  • A production fixture must prove that importing the client adapter does not pull server-only modules or secrets into the client build.

React Router

  • In normal Framework Mode, the application server adapter belongs in a .server.ts module or directory and must not enter the client graph.
  • In React Server Components mode, the equivalent boundary uses the supported server-only marker rather than relying on .server naming.
  • Route components may import the neutral permission vocabulary and client adapter; loaders/actions and BTST API resource routes may import the server adapter.
  • A production fixture must fail on an intentional server-module leak and pass with the documented split.

TanStack Start

  • Route loaders are isomorphic and must not directly call the request-scoped server authorizer or import a server-only policy.
  • Authoritative checks run in the BTST server route/handler or an explicitly server-bound function; client navigation continues to use only the client-safe adapter.
  • BTST core remains framework-neutral and must not require createServerFn, createServerOnlyFn, or createIsomorphicFn; the TanStack adapter or application may use those primitives at its boundary.
  • A production fixture must exercise both SSR and client navigation and prove that the server authorizer is absent from the client bundle.

Framework-neutral server behavior

  • Authorization helpers operate on standard Request, Response, and Headers contracts so the existing Next.js, React Router, and TanStack handler adapters can forward requests without framework-specific policy branches.
  • BTST maps authorization failures to its own typed HTTP error contract inside the API handler rather than calling framework-specific redirect/unauthorized helpers from core.
  • Security-relevant permission parameters are reconstructed from the server route, body, and loaded records in every framework.
  • Module-scope authorization state must not be treated as persistent user state across requests, isolates, serverless invocations, or worker deployments.

SSR and hydration

The current client auth boundary resolves identity after hydration. The contract must document whether a protected route renders a pending state, redirects after hydration, or accepts an optional serializable initial identity/capability snapshot. Whatever behavior is selected must be consistent and hydration-safe across all three framework fixtures. Backend authorization remains decisive regardless of the client SSR state.

Framework acceptance criteria

  • The isomorphic permission/auth entry imports no React runtime, framework runtime, database package, environment secret, request API, server-only, or client-only marker.
  • Client and server adapters are exported from directional subpaths; the isomorphic barrel does not re-export either environment implementation.
  • Every built-in plugin exposes its permission catalog from a neutral subpath usable by both client and server code.
  • Next.js production CI proves no server-only import reaches the client graph and no policy function crosses the Server Component serialization boundary.
  • React Router production CI proves the documented .server split in Framework Mode and the supported server-only boundary in the RSC variant, where covered.
  • TanStack Start production CI exercises SSR and client navigation and proves an isomorphic loader cannot import or invoke the request-scoped server authorizer.
  • All three fixtures exercise the same anonymous, authenticated-denied, and authenticated-allowed permission tuples through their BTST API handler.
  • All three fixtures verify that server-derived record parameters reach the policy and client-supplied authorization claims are not trusted.
  • Bundle inspection or an equivalent negative fixture proves that representative database/session/secret modules are absent from client output.
  • SSR pending, redirect, and hydration behavior for protected UI is documented and tested consistently across the three frameworks.
  • A documented server-only policy example uses a typed client transport or safe claim projection without weakening the registered permission types.

Non-goals

  • Building a new role, ACL, ABAC, or database policy engine.
  • Replacing Better Auth access control or coupling BTST core to a specific auth provider.
  • Treating client-side checks as security enforcement.
  • Importing one environment-specific provider object into both client and server bundles.
  • Requiring every policy implementation to execute identically in the browser and on the server.
  • Automatically guessing permission semantics for undeclared custom endpoints.
  • Adding authorization code generation, repair tooling, or migration orchestration.

Blocked by

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions