Sign users in to your app

Members of your organisation sign in to your app with their Kapable account. You write no callback and store no secret. Turn sign-in on, then one call reads the user and one call checks a permission.

First call
import { currentUser } from "@kapable/sdk/app-auth";

const user = currentUser(request); // { memberId, orgId, email, role }

With sign-in on, you should see the member's id, org id, email and role.

What you get

Turn on Kapable sign-in

Do this once per app, in the console. Any member of your organisation can. Your code does not change.

  1. Open the app, then Settings, then the Authentication section.
  2. Tick Require Kapable sign-in at the edge.
  3. Press Save authentication.

On a new app, Sign in with Kapable is already ticked and Kapable audience is already Org members only. On an older app, check both before you save.

From then on, every visitor signs in before a request reaches your app. Someone outside your organisation cannot open it. After sign-in, the visitor lands on the page they asked for.

The same settings are at GET and PUT /v1/apps/{app_id}/auth-config, for a member session of the app's organisation (see Authentication). Each console control maps to one field:

Console controlAPI field and value
Require Kapable sign-in at the edge"kapable_auto_adopt": true
Sign in with Kapable"kapable" in "providers"
Kapable audience: Org members only"kapable_audience": "org_members"

The PUT replaces all the app's sign-in settings, and any field you leave out goes back to its default. Read the settings with GET first and send them all back with your change.

Set up your app

The calls work in any Bun or Node app. The examples on this page use Hono. To start fresh, create an app from the Elysia (bun) template, then add Hono and the SDK:

bun add hono @kapable/sdk@^0.23.0

Apps created by Kapable already carry an .npmrc that points the @kapable scope at the Kapable registry.

Split the app into three files. Your routes go in src/app.ts, which exports app. Two small entry files serve it: one for production and one for your laptop.

src/app.ts: your routes

import { Hono } from "hono";
import { html } from "hono/html";
import {
  currentUser,
  requireUser,
  defineAccess,
  signOutUrl,
  KapableAuthRequired,
  KapableForbidden,
} from "@kapable/sdk/app-auth";

export const access = defineAccess({
  roles: {
    owner: ["*"],
    admin: ["notes.read", "notes.write"],
    member: ["notes.read"],
    "Key keeper": ["notes.read"],
  },
});

export const app = new Hono();

app.onError((err, c) => {
  if (err instanceof KapableAuthRequired || err instanceof KapableForbidden) {
    return err.response;
  }
  console.error(err);
  return c.json({ error: { code: "internal", message: "Something went wrong." } }, 500);
});

app.get("/health", (c) => c.json({ status: "ok" }));

app.get("/", (c) => {
  const user = currentUser(c.req.raw);
  if (user === null) return c.text("Hello. Sign-in is off for this app.");
  return c.html(html`<p>Hello, ${user.email}. <a href="${signOutUrl()}">Sign out</a></p>`);
});

app.get("/me", (c) => {
  const user = requireUser(c.req.raw);
  return c.json({ email: user.email, role: user.role });
});

app.post("/notes", async (c) => {
  const user = access.require(c.req.raw, "notes.write");
  // ... save the note for user.memberId
  return c.json({ saved: true, by: user.memberId }, 201);
});

src/index.ts: production

import { app } from "./app";

export default {
  port: Number(process.env.PORT ?? 3000),
  fetch: app.fetch,
};

src/dev.ts: your laptop only

On your laptop there is no Kapable router, so nobody is signed in. The SDK never invents a user. This file sets the four headers the router would set. Production code never imports it.

import { app } from "./app";

const devUser: Record<string, string> = {
  "x-kapable-member-id": "dev-member",
  "x-kapable-org-id": "dev-org",
  "x-kapable-email": "dev@example.com",
  "x-kapable-role": process.env.DEV_ROLE ?? "owner",
};

export default {
  port: Number(process.env.PORT ?? 3000),
  fetch(request: Request) {
    const headers = new Headers(request.headers);
    for (const [name, value] of Object.entries(devUser)) headers.set(name, value);
    return app.fetch(new Request(request, { headers }));
  },
};

package.json scripts

Point start at the production file and dev at the laptop file:

{
  "scripts": {
    "start": "bun run src/index.ts",
    "dev": "bun --watch src/dev.ts"
  }
}
bun run dev                    # signed in as an owner
DEV_ROLE=member bun run dev    # signed in as a member
bun run start                  # nobody signed in

Read the user

currentUser(request) tells you who is signed in. With sign-in on, every request carries a user. It returns null only when sign-in is off, or on your laptop without the dev headers.

The user has four fields:

FieldWhat it is
memberIdThe member's id in your organisation. Use it as the user's key.
orgIdYour organisation's id.
emailThe member's email, or null.
roleThe member's role name, exactly as the console shows it, or null.

currentUser accepts a Fetch Request, a Node IncomingMessage, or a Headers object.

Protect a route

requireUser(request) returns the signed-in user, never null, so your code needs no null check. If there is no user, it throws KapableAuthRequired, a clear 401.

With sign-in on, this never throws: the router signs every visitor in first. So a 401 means sign-in is off for the app, or the request reached the app without passing the router. The error says so:

{
  "error": {
    "code": "sign_in_required",
    "message": "Sign-in isn't switched on for this app. Any member of your organisation can turn it on in the app's Authentication settings.",
    "details": {
      "how_to_fix": "Open {org}.kapable.ai/apps/{app_id}/settings → Authentication, tick \"Require Kapable sign-in at the edge\", then Save authentication. Or PUT /v1/apps/{app_id}/auth-config with {\"providers\":[\"kapable\"],\"kapable_audience\":\"org_members\",\"kapable_auto_adopt\":true}."
    }
  }
}

{org} and {app_id} are placeholders. The SDK cannot know them. Put in your organisation's slug and your app's id. The app's settings address shows both.

Permissions

Your app decides what each role may do. Write the map once with defineAccess, as in src/app.ts above. Check it with access.require(request, "notes.write").

Use access.can(user, "notes.write") when you need a yes or no, for example to hide a button. A null user gets false.

Console roles grant nothing until you list them

A role you create in the console grants nothing in your app until you list it in defineAccess. Names match exactly, including capitals and spaces. A role the map does not list gets no permissions.

"*" grants every permission, and only to the roles that list it. Built-in roles arrive as owner, admin, member and viewer.

Changing a role's platform permissions in the console does not change your app's map. Moving a member to another role does.

The 403 body names the permission and the member's role in details. So when a custom role is missing from your map, the response tells you which role to add.

{
  "error": {
    "code": "permission_denied",
    "message": "You don't have permission to notes.write. Ask an owner of your organisation.",
    "details": { "permission": "notes.write", "role": "Key keeper" }
  }
}

Sign out

signOutUrl() returns /__kapable/auth/logout. Link to it, as the home page in src/app.ts does. Opening it ends the visitor's session in your app and sends them to /.

It does not sign them out of Kapable. If they are still signed in there, opening your app again signs them straight back in.

Send the error response

KapableAuthRequired and KapableForbidden each carry .response, a ready Fetch Response, plus .status and .message. Sending it is one line, as in the onError in src/app.ts.

Show a friendly page in the browser

.response is JSON, which is right for API calls. A person in a browser sees raw JSON. To show them a page instead, answer browsers with HTML:

app.onError((err, c) => {
  if (err instanceof KapableAuthRequired || err instanceof KapableForbidden) {
    const wantsHtml = (c.req.header("accept") ?? "").includes("text/html");
    if (!wantsHtml) return err.response;
    const title = err instanceof KapableForbidden ? "You can't do that here" : "Sign-in needed";
    return c.html(
      html`<h1>${title}</h1><p>${err.message}</p><p><a href="/">Back to the start</a></p>`,
      err.status as 401 | 403,
    );
  }
  console.error(err);
  return c.json({ error: { code: "internal", message: "Something went wrong." } }, 500);
});

API calls still get the JSON body with its code and details.

Elysia

Pass request from the handler context:

import { Elysia } from "elysia";
import { requireUser, KapableAuthRequired, KapableForbidden } from "@kapable/sdk/app-auth";

const app = new Elysia()
  .onError(({ error }) => {
    if (error instanceof KapableAuthRequired || error instanceof KapableForbidden) return error.response;
  })
  .get("/me", ({ request }) => requireUser(request));

React Router loaders and actions

import { requireUser, KapableAuthRequired, KapableForbidden } from "@kapable/sdk/app-auth";

export async function loader({ request }: Route.LoaderArgs) {
  try {
    const user = requireUser(request);
    return { email: user.email };
  } catch (err) {
    if (err instanceof KapableAuthRequired || err instanceof KapableForbidden) {
      throw err.response;
    }
    throw err;
  }
}

A thrown 401 or 403 reaches your error boundary.

How it works

  1. A visitor opens your app. The Kapable router sends them to your organisation's sign-in page.
  2. After sign-in, the router keeps the session in its own cookie on your app's host and sends the visitor back.
  3. On every request, the router checks that session. If it is valid, the router adds four headers: x-kapable-member-id, x-kapable-org-id, x-kapable-email and x-kapable-role.
  4. The SDK reads those headers. That is all it does.

The router removes any x-kapable-* headers a visitor sends before it adds its own. A visitor cannot pretend to be someone else.

Trust model

These headers only mean something behind the Kapable router. Never expose your app's port directly. If a request can reach your app without passing the router, anyone can set these headers.

Limits

Next

Apps & Deployment · Roles & Permissions · Authentication