Skip to content
TypeWire

@tahanabavi/typefetch

documents v1.10.0

The typed HTTP client every other package is built around.

Authentication

Set auth: true on endpoints that require an authorization token.

ts
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:

ts
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
token: "my-token",
},
contracts,
);

Or use a dynamic token provider:

ts
const client = new ApiClient(
{
baseUrl: "https://api.example.com",
tokenProvider: async () => {
return localStorage.getItem("token") ?? "";
},
},
contracts,
);

You can also set the token provider later:

ts
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.

ts
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:

FieldTypeMeaning
requirereadonly string[]The actor must hold all of these flags (hasAll).
anyreadonly string[]The actor must hold at least one (hasAny).
reasonstringHuman 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:

ts
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.