Authentication
Set auth: true on endpoints that require an authorization token.
const contracts = {
user: {
getProfile: {
method: "GET",
path: "/profile",
auth: true,
request: z.object({}),
response: z.object({
id: z.string(),
name: z.string(),
}),
},
},
} as const;Use a static token:
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
token: "my-token",
},
contracts,
);Or use a dynamic token provider:
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
tokenProvider: async () => {
return localStorage.getItem("token") ?? "";
},
},
contracts,
);You can also set the token provider later:
client.setTokenProvider(async () => "new-token");Permissions
Where auth answers "is there a token?", permission answers "is this actor
allowed?" — declared once on the contract, enforced on the server and mirrored
on the client. It is optional and additive: endpoints without it are unaffected.
const contracts = {
message: {
remove: {
method: "DELETE",
path: "/messages/:id",
auth: true,
permission: { require: ["chat.MANAGE_MESSAGES"] },
request: z.object({ path: z.object({ id: z.string() }) }),
response: z.object({ removed: z.string() }),
},
},
} as const;The permission value is a PermissionRequirement:
| Field | Type | Meaning |
|---|---|---|
require | readonly string[] | The actor must hold all of these flags (hasAll). |
any | readonly string[] | The actor must hold at least one (hasAny). |
reason | string | Human message, surfaced in the 403 body / audit log. |
The flag names reference a @tahanabavi/type-permission bit
map. To keep TypeFetch dependency-free, PermissionRequirement is redeclared
structurally here — it is structurally identical to that package's type, so
P.authorize(perms, endpoint.permission) type-checks with no adapter.
Server — @tahanabavi/typewire-nestjs ships
createPermissionGuard({ getPermissions, authorize }) that reads the requirement
off the contract and rejects with a 403 naming the missing flags.
Client — register createPermissionMiddleware once and every endpoint with a
permission key is pre-blocked before the request leaves the browser. It's the
mirror of the server guard — inject where the bits are (getPermissions) and how
to evaluate (authorize), and it stays dependency-free:
import { createPermissionMiddleware } from "@tahanabavi/typefetch";
import { P } from "./permissions";
client.use(createPermissionMiddleware({
getPermissions: () => store.getSnapshot().global, // keep it cheap; runs per request
authorize: P.authorize,
onDeny: ({ decision }) => console.warn("denied", decision.missing),
}));
// throws PermissionDeniedError *before* sending — only when the contract
// declares a permission the user lacks; other endpoints are untouched:
await api.message.remove({ path: { id } });PermissionDeniedError carries { status: 403, missing, missingAny? }, so a
client block reads like the server's 403. The check is UX only — the server
recomputes from the session and remains the real enforcement point. Same
declaration, both ends. Full details in
docs/releases/v1.8.0.md.