# kebab-hub SDK

Everything an app needs to become a layer on the skewer: single sign-on
through the hub, the people directory, bounded access revocation, notifications.
Two files, no framework.

| Piece | For | What it gives you |
|---|---|---|
| `sdk/motoko` (`mo:kebab-hub`) | your app **backend** canister | typed hub interface, ticket redeem, sessions, directory cache, connector-contract gate |
| `sdk/js/hub-client.js` | your app **frontend** | read the hub ticket from the URL, jump back to the hub, session store, initials, **the shared topbar** (`mountTopbar`: brand · app · menu · bell · theme · person) |
| `hub/dist/tokens.css` | your app **frontend** | the design tokens (colours, type, radii) — copy it next to `hub-client.js`; `check-sdk` fails on a stale copy |
| `docs/agent/onboard-app.md` | you or your coding agent | the step-by-step, in prompt form |
| `sdk/example` | copy-paste start | a minimal app that is fully wired |

## Backend contract

Start from [the compiled example](https://github.com/kebabstack/kebabstack/blob/main/sdk/example/backend/main.mo); it contains the full
sign-in, controller-only configuration and directory-lease flow. The code is a
minimal session example; add the shared topbar/suite-token pass-through below for
a complete product UI.

- Pin the local SDK dependency in `mops.toml`; use the repository's pinned Mops
  (`npm ci`, then put `node_modules/.bin` on PATH) and commit generated lockfiles.
- Only a controller may configure `setHub`. Validate the principal and clear the
  old directory/sessions when switching Hub.
- Redeem a one-time Hub ticket, fetch a fresh complete directory when needed,
  generate random session entropy, then recheck Hub binding and active access
  after awaits before minting a session.
- Pull `connectorDirectory()` every 30 seconds. Use `Hub.syncDirectory` for
  this complete snapshot: absence or inactivity revokes cached sessions, every
  address's person id is recorded, a re-issued address parks its previous holder.
  Record the **request start time**, not response arrival. Every protected method
  must call `Hub.directoryFresh` and refuse access at 60 seconds, even during outage.
- **People are ids.** Store `Hub.pidOf(ids, email)` in your data, never the
  address; render with `Hub.personById(people, ids, former, pid)`. Addresses
  change and get re-issued, the hub's `p_…` id does not (docs/PERSON-IDS.md,
  migration recipe in the onboarding guide §5b).
- `hub_upsert` is a partial push: use `Hub.upsertRows` (SDK ≥ 0.4) and do not renew the lease.
  `hub_deactivate` marks people inactive and kills their sessions. Authenticate
  both callbacks with `Hub.isHub`; discard pulls overtaken by a push/config change.
- The `roles` and `groups` lanes supply current role/routing attributes. An app
  manifest cannot grant itself the `ai` lane: sharing the company API key requires
  an explicit owner decision. A controller recovery list is a separate local
  privilege and should be documented and periodically reviewed.

The lease bounds app-side stale authorization after the Hub has learned a change.
It does not bound upstream provisioning latency or revoke tokens already held by
external services. Run the real backend regression tests when adapting this flow.

## Frontend: sign-in in three lines, the topbar in one call

```js
import { takeHubTicket, hubJumpUrl, session, mountTopbar, topbarIdlFactory } from "./hub-client.js";
const ticket = takeHubTicket();
if (ticket) { const r = opt(await app.loginWithTicket(ticket)); if (r) { session.save(r.token); session.saveSuite(r.suiteToken); } }
```

No session and no ticket? `location.href = hubJumpUrl(HUB_URL, location.href)` —
the hub signs the person in (passkey or SSO) and bounces back with a ticket.

**The topbar.** Every app in the suite shows the same bar — brand (the
company logo the hub holds, or the suite mark) · app name · **Apps ▾** (the
person's apps) · **bell** · theme · **you ▾** — so switching apps never
changes the top. Do not build your own header; mount the shared one:

```js
const hubActor = Actor.createActor(topbarIdlFactory, { agent, canisterId: info.hubId }); // your backend's info() knows the hub
const topbar = mountTopbar(document.getElementById("topbar"), {
  hub: { actor: hubActor, token: session.loadSuite() }, hubUrl: HUB_URL,
  app: { name: "my app", eyebrow: info.orgName }, person: { email: me.email, displayName: me.displayName, role: me.role },
  onSignOut: signOut,
});
```

The bell polls the unread count every 30 s (one cheap `suiteState` query),
loads the list when opened, refreshes when the tab comes back and after every
action; an ended suite token shows *Sign in again*; "Sign out" ends the app's
session and the hub's. The suite token is read-only
and good for six hub calls (`suiteState`, `myNotifications`,
`markNotificationsRead`, `portalApps`, `portalWhoami`, `myAvatarPortal`) —
nothing else accepts it. Copy `hub-client.js` and `tokens.css` into your
`dist/` unchanged; `sdk/tools/check-sdk.py` fails when a copy differs.

## Declare yourself, then get connected

```motoko
public shared query func hub_manifest() : async Hub.Manifest {
  { name = "My app"; version = "0.1.0"; description = "…"; needs = ["identity"]; wants = ["avatars"] };
};
```

Lanes = what you receive about people: `identity` (always) · `profile` ·
`groups` · `roles` · `avatars` · `notify` · `push`. Ask for the minimum —
the admin sees your `needs` pinned on the skewer and can refuse the rest.

Hub → **Apps** → 🍢 **Connect an app**: the admin pastes your **backend**
canister id, the hub reads your manifest, skewers the lanes, picks who may
use the app and creates the bound portal tile — one call. From then on
`Hub.hub(hubId)` calls succeed within your lanes and the hub pushes
deactivations.

## Installable by the kitchen

Apps that follow four conventions can be installed and updated from the
hub's Kitchen page in one click: `hub_ping()` returns the recipe id,
`setHub(Text)` is controller-gated, the backend takes no init argument, and
`dist/` carries the placeholders `__BACKEND_CANISTER_ID__` / `__HUB_URL__`.
Add a `RECIPES` entry in `kitchen/tools/pack-recipes.py` — see
`docs/agent/onboard-app.md` § 10.

## Rules that keep the suite coherent

- Identity is the person id (`p_…`); the address is display data and the key of the cache — it can change and be re-issued. Never merge accounts on display names.
- Directory rows are read-only in your app; the hub is the source of truth.
- Deactivation = Slack model: the person vanishes from your UI within
  seconds, their data stays as archive.
- Stable vars are append-only; run `moc --stable-compatible` against your
  committed `.most` before every backend deploy.
- Notifications carry a title and a deep link only — content stays behind
  your own sign-in.
