# AI-integrationsdirektiv

Den här sidan är avsedd att läsas i sin helhet av en AI-agent. Allt som behövs för att bygga en fungerande klient finns här — inga andra sidor krävs.

## Bas-URL:er

| Miljö | `{{BASE_URL}}` |
| --- | --- |
| STAGE (utveckling och test) | `https://staging.zesec.com/api` |
| PROD (verifierade partners) | `https://api.zesec.com/api` |

## Operationer

**Fältnamnen nedan är exakta. Begäransfälten kommer från OpenAPI-kontraktet.**
| Metod & sökväg | Auth-header | Begäransbody | Svar |
| --- | --- | --- | --- |
| `POST /auth/login-by-phone` | ingen | `{"phoneNumber": "{{phoneNumber}}", "password": "{{password}}"}` | `{"AccessToken": "...", "RefreshToken": "..."}` |
| `POST /auth/refresh` | ingen | `{"refreshToken": "<RefreshToken>"}` | `{"AccessToken": "...", "RefreshToken": "..."}` — **båda nya** |
| `GET /user/device` | `Bearer <AccessToken>` | ingen | Array av `{id, name, locationId, locationName, online}` |
| `PUT /device/{deviceId}/unlock` | `Bearer <AccessToken>` | ingen | `200` = kommandot accepterat |
| `POST /auth/logout` | `Bearer <AccessToken>` | ingen | `200` — avslutar sessionen |

- `{deviceId}` är ett **heltal**, hämtat ur fältet `id` i svaret från `GET /user/device`.
- Unlock är `PUT`, inte `POST`.
- `phoneNumber` måste matcha mönstret `^[+][0-9]{9,13}$`.
- Sökvägarna ovan läggs till `{{BASE_URL}}`, som redan slutar på `/api`.

## Tokenlivslängder

- Access-token: ~48 timmar. Exakt utgång står i `exp` i JWT:n och är auktoritativ.
- Refresh-token: upp till 30 dagar, men **engångs**.
- Förnya proaktivt var 36:e timme. Vänta inte på ett fel.

## Felsignaler och åtgärd

| Signal | Betydelse | Obligatorisk åtgärd |
| --- | --- | --- |
| `401` på ett vanligt anrop | Access-token utgången | Kör en refresh, gör om anropet **en** gång |
| `400`/`401` på `/auth/refresh` | Refresh-token förbrukad eller utgången | Full ominloggning. Retry:a **aldrig** samma refresh-token |
| `401` igen direkt efter refresh | Sessionen övertagen av annan inloggning | Stoppa. Larma. Loopa inte |
| `403` på unlock | Kontot saknar nyckel till enheten | Stoppa. Detta löser sig inte med retry |
| `404` på unlock | Okänt `{deviceId}` | Hämta enhetslistan på nytt och uppdatera mappningen |
| `429` | För många anrop | Respektera `Retry-After`. Exponentiell backoff med jitter |
| `5xx` | Tillfälligt fel | Backoff. Aldrig på unlock — se nedan |

## Hårda begränsningar

1. **En aktiv session per konto.** Implementera tokenlagret som en singleton med delad lagring. Två inloggningar med samma konto avlivar varandras sessioner.
2. **Refresh-token är engångs.** Ett samtidigt anropspar utan lås förbrukar två tokens och dödar sessionen. Single-flight-lås krävs.
3. **Skriv båda tokens atomiskt.** En halv skrivning förlorar sessionen permanent.
4. **Logga in server-side, aldrig per öppning.** Öppningsögonblicket ska innehålla exakt ett API-anrop.
5. **Retry:a aldrig unlock automatiskt.** Ett upprepat anrop kan öppna dörren igen. Visa felet för en människa.
6. **Hårdkoda aldrig `{deviceId}`.** Hämta och cachelagra mappningen.
7. **Tokens lämnar aldrig servern.** Inte till webbklient, inte till mobilapp, inte till loggar.

## Referensimplementation

```text
STATE  session = { accessToken, refreshToken, rotatedAt }   # ONE per API account, server-side
CONST  PROACTIVE_REFRESH = 36 hours
LOCK   refreshLock                                        # single-flight

FUNCTION getValidAccessToken():
    IF session EXISTS AND now() - session.rotatedAt < PROACTIVE_REFRESH:
        RETURN session.accessToken
    ACQUIRE refreshLock                                   # concurrent callers wait, never race
        IF session EXISTS:
            TRY:
                RETURN rotate(session.refreshToken)
            CATCH RefreshFailed:                          # 400/401 => token spent or expired
                RETURN login()
        ELSE:
            RETURN login()
    RELEASE refreshLock

FUNCTION rotate(refreshToken):
    res = POST {{BASE_URL}}/auth/refresh  body {"refreshToken": refreshToken}
    IF res.status IN (400, 401): THROW RefreshFailed      # do NOT retry this token, ever
    IF res.status >= 500: BACKOFF once, THEN THROW RefreshFailed
    session = { accessToken: res.AccessToken,
                refreshToken: res.RefreshToken,           # BOTH, in ONE atomic write
                rotatedAt: now() }
    RETURN session.accessToken

FUNCTION login():
    res = POST {{BASE_URL}}/auth/login-by-phone  body {"phoneNumber": ..., "password": ...}
    IF NOT res.ok: ALERT operator; STOP                   # never loop on a failed login
    session = { accessToken: res.AccessToken, refreshToken: res.RefreshToken, rotatedAt: now() }
    RETURN session.accessToken

FUNCTION apiCall(method, path, body = null):
    res = REQUEST method, {{BASE_URL}} + path, Authorization: "Bearer " + getValidAccessToken()
    IF res.status == 401:
        invalidate session.accessToken
        res = REQUEST method, {{BASE_URL}} + path, Authorization: "Bearer " + getValidAccessToken()
    RETURN res                                            # still 401 => STOP, alert; do not loop

FUNCTION unlock(internalDoorId):
    ASSERT callerIsAuthorized(internalDoorId)             # YOUR rules, not KiiOn's
    deviceId = cachedMapping[internalDoorId]              # from syncDevices(), never hard-coded
    RETURN apiCall("PUT", "/device/" + deviceId + "/unlock")   # exactly one call, no retry loop
```

## Maskinläsbara källor

- [/llms.txt — index över portalen](/llms.txt)
- [/llms-full.txt — hela dokumentationen som en textfil](/llms-full.txt)
- [/openapi.json — OpenAPI-dokumentet](/openapi.json)
- [Färdiga prompter för att generera klienten](/docs/prompts)
