Authorization (UX only)

Client authorization helpers answer “should this control be visible or enabled?” They do not secure APIs. Servers enforce every privileged action.

Trust boundary

LayerResponsibility
can / cannot / authorize / assertAuthorizedHide or disable UI
Route guards in SPARedirect UX
API / gateway / RLSReal enforcement

Treat any browser-derived role, permission, or claim as a hint. Attackers can call endpoints directly.

Policies

ts
import {
    can,
    cannot,
    authorize,
    assertAuthorized,
    createPolicy,
    requireAuthenticated,
    requireRole,
    requirePermission,
    requireClaim,
} from "@sometic/auth/authorization";

Via the controller (uses current session):

js
import {
    requireAuthenticated,
    requireRole,
    requirePermission,
    requireClaim,
} from "@sometic/auth/authorization";

auth.can(requireAuthenticated());
auth.can(requireRole("admin"));
auth.can(requirePermission("billing:write"));
auth.can(requireClaim("plan", "pro"));
auth.cannot(requireRole("banned"));

if (!auth.authorize(requireAuthenticated())) {
    location.assign("/login");
}

auth.assertAuthorized(requireRole("admin"));
ts
import {
    requireAuthenticated,
    requireRole,
    requirePermission,
    requireClaim,
} from "@sometic/auth/authorization";
import type { AuthController } from "@sometic/auth";

declare const auth: AuthController;

auth.can(requireAuthenticated());
auth.can(requireRole("admin"));
auth.can(requirePermission("billing:write"));
auth.can(requireClaim("plan", "pro"));
auth.cannot(requireRole("banned"));

if (!auth.authorize(requireAuthenticated())) {
    location.assign("/login");
}

auth.assertAuthorized(requireRole("admin"));
js
import {
    requireAuthenticated,
    requireRole,
    requirePermission,
    requireClaim,
} from "@sometic/auth/authorization";

auth.can(requireAuthenticated());
auth.can(requireRole("admin"));
auth.can(requirePermission("billing:write"));
auth.can(requireClaim("plan", "pro"));
auth.cannot(requireRole("banned"));

if (!auth.authorize(requireAuthenticated())) {
    location.assign("/login");
}

auth.assertAuthorized(requireRole("admin"));

Standalone helpers take an explicit context:

ts
import { can, requirePermission } from "@sometic/auth/authorization";

can(requirePermission("orders:read"), {
    session: auth.getSession(),
});

Composing policies

ts
const canManageTeam = createPolicy((ctx) => {
    const user = ctx.session.user;
    if (!user) {
        return false;
    }
    return (
        user.roles?.includes("admin") === true || user.permissions?.includes("team:manage") === true
    );
});

auth.can(canManageTeam);

Keep policies pure and free of network I/O. Fetch fresh permissions on the server; mirror a subset into AuthUser for UX only.

User shape

ts
type AuthUser = {
    id: string;
    email?: string;
    displayName?: string;
    roles?: string[];
    permissions?: string[];
    // claims may appear via provider mappers
};

Local / Firebase / Supabase mappers can populate roles and permissions from your API payload. OIDC may map from userinfo. Empty arrays mean “unknown,” not “deny all on the server.”

Patterns

Disable a button

ts
button.disabled = auth.cannot(requirePermission("invoice:void"));

Nested UI

ts
if (auth.can(requireAuthenticated())) {
    renderAccountMenu();
}
if (auth.can(requireRole("admin"))) {
    renderAdminLink();
}

Tests

ts
import { createTestAuthProvider, createAuth } from "@sometic/auth";

const auth = createAuth({ provider: createTestAuthProvider({/* roles */}) });
await auth.signIn({ email: "a@b.c", password: "x" });
expect(auth.can(requireRole("admin"))).toBe(true);

Edge cases

  • Signed-out session: requireAuthenticated() is false; role checks fail closed on the client.
  • Stale roles after privilege change: refresh session or re-fetch user; do not cache forever in module scope.
  • assertAuthorized throws AUTH_UNAUTHORIZED; catch at UI boundaries only, never to “soft fail” a privileged API call on the client alone.

FAQ

Is can() secure?

No. It is UX only. Document this in any internal security review.

Why ship client policies at all?

Consistent hide/disable behavior across Vanilla, React, and Vue without each app inventing incompatible role checks. Enforcement remains server-side.

How is this different from Firebase Security Rules / Supabase RLS?

Those run on the backend. Sometic policies never replace them.