# Golden path & rekommenderad arkitektur

Följer ni det här mönstret blir integrationen både snabbare och stabilare — och ni behöver nästan aldrig kontakta supporten.

## Sekvensen

1. **Vid uppstart / på schema:** logga in server-side en gång. Spara access- och refresh-token i delad lagring.
2. **På schema:** förnya token proaktivt var 36:e timme, inte när något gått sönder.
3. **På schema eller vid behov:** hämta enhetslistan och skriv om mappningen. Ett dygn (24 h) är en rimlig utgångspunkt.
4. **I öppningsögonblicket:** slå upp `{deviceId}` i mappningen och gör **ett** unlock-anrop.

> **Antimönster: logga in vid varje öppning** — Det är det enskilt vanligaste felet. Varje inloggning avlivar den föregående sessionen, ger latens i öppningsögonblicket och gör att två parallella öppningar slår ut varandra. Logga in server-side, en gång.

## Ansvarsfördelningen

| Lager | Ansvar | Får inte |
| --- | --- | --- |
| Er klient / app | Visa dörrar, ta emot ett tryck | Aldrig hålla tokens eller anropa KiiOn direkt |
| Er backend | Behörighetsregler, tokenhantering, mappning, audit | Logga in per öppning |
| KiiOn-API:t | Utföra upplåsningen | Avgöra om användaren borde få öppna |

## 1. Tokenlagret

En singleton per API-konto som äger inloggning, förnyelse och 401-hantering. All annan kod ber om en giltig token, aldrig om en inloggning. Detaljerna finns i [Autentisering & tokens](/docs/authentication#failure-handling).

```typescript
const PROACTIVE_REFRESH_MS = 36 * 60 * 60 * 1000;

let inFlight: Promise<string> | null = null;

/** Single active session per account: one process, one token pair, one refresh at a time. */
export async function getValidAccessToken(): Promise<string> {
  const current = await session.read();

  if (current && Date.now() - current.rotatedAt < PROACTIVE_REFRESH_MS) {
    return current.accessToken;
  }

  // Single-flight guard: concurrent callers must not each burn a single-use refresh token.
  inFlight ??= (async () => {
    try {
      return current ? await rotate(current.refreshToken) : await login();
    } catch (err) {
      if (err instanceof RefreshFailed) return await login(); // refresh token spent or expired
      throw err;
    } finally {
      inFlight = null;
    }
  })();

  return inFlight;
}

/** Wrap every API call: one refresh, one replay, then surface the failure. */
export async function apiCall(path: string, init: RequestInit = {}) {
  let token = await getValidAccessToken();
  let res = await fetch(`${BASE_URL}${path}`, withAuth(init, token));

  if (res.status === 401) {
    await session.invalidateAccessToken();
    token = await getValidAccessToken();
    res = await fetch(`${BASE_URL}${path}`, withAuth(init, token));
  }
  return res; // still 401 -> stop and alert an operator; do not loop
}
```

## 2. Enhetsmappningen

Enhetslistan ändras sällan; unlock-anropen sker ofta. Hämta listan på schema och spara minst `Device.Id` och `Device.Name` — gärna även plats och företag så att felsökning går att göra utan att slå i API:t.

```typescript
/** Runs on a schedule, not on the unlock path. */
export async function syncDevices() {
  const res = await apiCall("/user/device");
  const devices: Device[] = await res.json();

  await db.deviceMapping.replaceAll(
    devices.map((d) => ({
      kiionDeviceId: d.id,      // fills {deviceId} in the unlock call
      kiionName: d.name,
      locationId: d.locationId,
      locationName: d.locationName,
      syncedAt: Date.now(),
    })),
  );
}
```

- Uppdatera mappningen var 24:e timme, samt när ni får `404` på ett känt `{deviceId}`.
- Låt mappningen vara nyckeln mellan er interna dörridentitet och KiiOns `id` — hårdkoda aldrig id:t.
- Spara `syncedAt` så att ni kan se hur gammal mappningen är när något ser fel ut.

## 3. Öppningsögonblicket

Här ska det bara finnas ett HTTP-anrop. Behörighetskontrollen är er, uppslaget är lokalt, token är redan giltig.

```typescript
/** Everything expensive already happened. This is one call. */
export async function openDoor(internalDoorId: string, actor: User) {
  // 1. YOUR rules decide. KiiOn does not.
  if (!(await mayOpen(actor, internalDoorId))) throw new Forbidden();

  // 2. Cached mapping — no device-list call here.
  const deviceId = await db.deviceMapping.kiionIdFor(internalDoorId);
  if (!deviceId) throw new NotMapped(internalDoorId);

  // 3. One call, with a token that is already valid.
  const res = await apiCall(`/device/${deviceId}/unlock`, { method: "PUT" });
  await audit.record({ actor, internalDoorId, deviceId, status: res.status });
  return res.ok;
}
```

> **Retry:a inte unlock blint** — Ett upprepat anrop kan öppna dörren en gång till. Visa felet i stället, och läs [Gränser & retry](/docs/limits#unlock-retry) innan ni bygger någon form av automatik.

## Checklista

- [ ] Inloggning sker server-side och återanvänds
- [ ] Proaktiv token-förnyelse med single-flight-lås
- [ ] 401-fallback som gör om anropet en gång
- [ ] Roterad refresh-token sparas atomiskt
- [ ] Enhetslistan cachelagras och hårdkodas inte
- [ ] Öppningsögonblicket innehåller exakt ett API-anrop
- [ ] Varje upplåsning skrivs till er egen audit-logg

**Vidare**
- [Kodexempel — de fyra funktionerna i cURL, Node och Python](/docs/code-examples)
- [Från sandbox till produktion — checklistan före PROD](/docs/production)
