# M13 — API Contract (v1)

> Wire-level contract for the M13 frontend. Two services: `admind`
> (existing, gets new endpoints) and `authd` (new). All endpoints
> JSON in/out unless noted. Auth = `Authorization: Bearer <jwt>` for
> access, `Cookie: refresh=...` for refresh.

---

## 0. Conventions

- Base paths: `/v1/auth/*` (authd), `/v1/*` (admind).
- SSE: `text/event-stream`.
- All timestamps RFC 3339 UTC.
- IDs: UUID v4 unless otherwise noted.
- Errors: `{ "error": { "code": "string", "message": "string", "request_id": "uuid" } }`.
- Pagination: `?limit=N&cursor=<opaque>`. Response: `{ "items": [...], "next_cursor": "..." | null }`.
- Tenant scope: enforced server-side from JWT `company_id`. Super-admin
  may pass `?company_id=<id>` to scope; tenant-admin `?company_id`
  matches the JWT or returns 403.

---

## 1. authd endpoints (port 8804)

### 1.1 `POST /v1/auth/login`
Request:
```json
{ "username": "lrosales", "password": "..." }
```
Response 200:
```json
{
  "access_token": "eyJ...",
  "access_expires_in": 900,
  "user": { "id": "uuid", "username": "lrosales", "role": "super_admin", "company_id": null }
}
```
Sets cookie: `refresh=<opaque>; HttpOnly; Secure; SameSite=Lax; Path=/v1/auth; Max-Age=604800`.

Errors: 401 invalid credentials, 423 account locked (5 failed attempts in 5min).

### 1.2 `POST /v1/auth/refresh`
Request: empty body. Cookie must carry `refresh`.
Response 200: same shape as login. New refresh cookie sent, old one invalidated.
Errors: 401 invalid/expired refresh.

### 1.3 `POST /v1/auth/logout`
Request: empty body. Requires access JWT.
Response 204. Invalidates the refresh token server-side. Clears cookie.
Errors: 401.

### 1.4 `GET /v1/auth/me`
Response 200:
```json
{
  "id": "uuid",
  "username": "lrosales",
  "role": "super_admin",
  "company_id": null,
  "display_name": "Luis Rosales",
  "email": "lrosales@techno-world.net",
  "created_at": "2026-06-16T18:00:00Z",
  "last_login_at": "2026-06-16T20:30:00Z"
}
```

### 1.5 `POST /v1/auth/invites` (super-admin only)
Request:
```json
{
  "username": "gerente@acme.com",
  "role": "tenant_admin",
  "company_id": "acme-001",
  "display_name": "Gerente ACME",
  "email": "gerente@acme.com"
}
```
Response 201:
```json
{
  "invite": { "id": "uuid", "token": "raw-token-shown-once", "expires_at": "..." },
  "magic_link": "https://admind.netpolice.online/invite/accept?token=...",
  "email_sent": true
}
```
`email_sent=false` when SMTP env vars are unset — frontend shows the
`magic_link` directly so super-admin can paste it. This is the "B with
fallback to A" decision.

Errors: 403 (not super-admin), 409 (username exists), 404 (company_id not found).

### 1.6 `POST /v1/auth/invites/accept`
Request:
```json
{ "token": "raw-token-from-invite", "password": "new-password-here" }
```
Response 200: same as login shape. User is now `status=active`.
Errors: 410 (expired), 404 (not found), 422 (weak password).

### 1.7 `POST /v1/auth/password/change` (any authenticated)
Request: `{ "current_password": "...", "new_password": "..." }`. Response 204.

### 1.8 `GET /v1/auth/jwks` (public)
JWKS document for the public key (RS256) or the current secret id (HS256).
**v1 uses HS256 — JWKS returns `{ "alg": "HS256", "kid": "current" }` only.**
RS256 + JWKS becomes meaningful when we ship multi-cluster (M12 W1+)
where `admind` and `authd` can't share an env var. For v1, `admind`
verifies tokens using the same `BA_AUTH_JWT_SECRET` env var.

### 1.9 `GET /health`, `GET /metrics`
Same as other services. `/health` returns 200 if Postgres + Redis up.
`/metrics` exposes `ba_authd_*` Prometheus metrics.

### 1.10 Schema additions
New tables in the same Postgres DB, schema `auth`:

```sql
CREATE SCHEMA auth;

CREATE TABLE auth.users (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username        TEXT UNIQUE NOT NULL,
    email           TEXT UNIQUE NOT NULL,
    display_name    TEXT NOT NULL,
    password_hash   TEXT,                -- argon2id, NULL until invite accepted
    role            TEXT NOT NULL CHECK (role IN ('super_admin', 'tenant_admin')),
    company_id      TEXT REFERENCES companies(slug), -- NULL for super_admin
    status          TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'suspended', 'locked')),
    failed_attempts INT NOT NULL DEFAULT 0,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX auth_users_company_id_idx ON auth.users(company_id);
CREATE INDEX auth_users_status_idx ON auth.users(status);

CREATE TABLE auth.refresh_tokens (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    token_hash  TEXT NOT NULL UNIQUE,    -- sha256(refresh_token) for fast lookup
    issued_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ NOT NULL,
    rotated_to  UUID REFERENCES auth.refresh_tokens(id),  -- chain
    revoked_at  TIMESTAMPTZ
);
CREATE INDEX auth_refresh_user_idx ON auth.refresh_tokens(user_id);
CREATE INDEX auth_refresh_expires_idx ON auth.refresh_tokens(expires_at);

CREATE TABLE auth.invites (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    token_hash  TEXT NOT NULL UNIQUE,    -- sha256(raw_token)
    expires_at  TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '7 days'),
    accepted_at TIMESTAMPTZ,
    created_by  UUID NOT NULL REFERENCES auth.users(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE auth.audit_log (
    id          BIGSERIAL PRIMARY KEY,
    ts          TIMESTAMPTZ NOT NULL DEFAULT now(),
    user_id     UUID REFERENCES auth.users(id),
    action      TEXT NOT NULL,           -- 'login', 'logout', 'invite_create', 'invite_accept', 'password_change', 'login_failed'
    target_user UUID REFERENCES auth.users(id),
    ip          INET,
    user_agent  TEXT,
    metadata    JSONB
);
```

---

## 2. admind endpoints (port 8803, JWT-gated except /health /metrics)

### 2.1 Companies
- `GET /v1/companies` — list. Super-admin sees all. Tenant-admin sees
  only their own (returns 1 row).
  Query: `?status=&search=&limit=&cursor=`
  Response 200:
  ```json
  {
    "items": [
      {
        "id": "acme-001",
        "name": "ACME Networks",
        "slug": "acme-001",
        "status": "active",
        "fcm_shared": true,
        "rate_limit_per_sec": 1000,
        "telegram_configured": true,
        "alerts_24h": 12345,
        "created_at": "2026-01-15T10:00:00Z"
      }
    ],
    "next_cursor": null
  }
  ```
- `POST /v1/companies` — super-admin only. Body:
  ```json
  { "name": "ACME Networks", "slug": "acme-001", "rate_limit_per_sec": 1000, "fcm_shared": true }
  ```
  Response 201 with full company. 409 on duplicate slug.
- `GET /v1/companies/:id` — scoped. 403 if tenant-admin requests other company.
- `PATCH /v1/companies/:id` — scoped. Tenant-admin can only update
  `telegram_bot_token`, `display_name`, `quiet_hours_default` (whatever
  subset the SPEC allows). Super-admin can update anything.
- `POST /v1/companies/:id/suspend` — super-admin only. 204. Writes audit.
- `POST /v1/companies/:id/activate` — super-admin only. 204. Writes audit.

### 2.2 Sources
- `GET /v1/sources?company_id=&status=&type=&limit=&cursor=`
  Response item:
  ```json
  {
    "id": "uuid",
    "company_id": "acme-001",
    "name": "snmp-edge-01",
    "type": "snmp",
    "hmac_secret_masked": "sk_****abcd",   // never the raw secret
    "api_key_masked": "ak_****efgh",
    "rate_limit_per_sec": 100,
    "allowed_ips": ["10.0.0.0/24"],
    "topic_prefix": "snmp.acme",
    "status": "active",
    "quarantine_until": null,
    "alerts_24h": 4321,
    "created_at": "..."
  }
  ```
- `POST /v1/sources` — body without secrets. Response includes
  `hmac_secret_raw` and `api_key_raw` ONCE in a `one_time_secrets` field
  (frontend must show the "save these now" modal). Server stores the
  hashes only.
- `PATCH /v1/sources/:id` — body without secrets. To rotate, set
  `rotate_secret=true` and the response includes new one-time secrets.
- `GET /v1/sources/:id` — full detail including quarantine_until.

### 2.3 Telegram
- `GET /v1/companies/:id/telegram` —
  ```json
  {
    "bot_configured": true,
    "bot_username": "acme_alerts_bot",
    "invite_count": 3,
    "binding_count": 47
  }
  ```
  The bot token is NEVER returned. To set/rotate, use PUT.
- `PUT /v1/companies/:id/telegram` — body: `{ "bot_token": "..." }`. 204.
  Bot token encrypted at rest (AES-256-GCM with `BA_ADMIND_MASTER_KEY`).
- `GET /v1/companies/:id/telegram/invites` — list of invite codes.
  Item: `{ "id": "uuid", "code": "ABC123", "status": "active"|"used"|"expired", "created_at": "...",
  "used_by_individual_id": "uuid" | null, "expires_at": "..." }`.
- `POST /v1/companies/:id/telegram/invites` — body: `{}`. Response 201
  with the new invite. Default TTL 24h, configurable per request.
- `DELETE /v1/companies/:id/telegram/invites/:id` — revoke. 204.
- `GET /v1/companies/:id/telegram/bindings` — list of
  `individual_id → telegram_user_id` mappings. Item: `{ "individual_id",
  "individual_display_name", "telegram_user_id", "telegram_chat_id",
  "status", "last_seen_at" }`.

### 2.4 Live tail
- `GET /v1/tail/stream` (SSE) — query: `?access_token=<jwt>` (EventSource
  workaround). Server emits `event: alert\ndata: {<alert_json>}\n\n` per
  inbound. Initial burst sends last 50 events from in-memory ring buffer
  (`mem:` tailhub). Heartbeat `: ping` every 15s to keep proxies alive.
  Auto-disconnect after 30min idle.
- `GET /v1/tail/recent?since=<rfc3339>&limit=200` — HTTP fallback for
  corporate proxies that strip SSE. Reads from the same ring buffer.

  Alert JSON:
  ```json
  {
    "id": "uuid",
    "company_id": "acme-001",
    "source_id": "uuid",
    "source_name": "snmp-edge-01",
    "severity": "warning",
    "category": "network",
    "title_key": "link_down",
    "data": { "iface": "eth0", "since": "..." },
    "dedupe_count": 3,
    "received_at": "..."
  }
  ```

### 2.5 DLQ (M8 — existing, frontend now uses these)
- `GET /v1/dlq?company_id=&status=&channel=&limit=&cursor=` — list.
  Item: `{ "id", "alert_id", "channel", "target", "attempts",
  "last_error", "next_attempt_at", "created_at", "status" }`.
- `GET /v1/dlq/:id` — full alert payload + delivery history.
- `POST /v1/dlq/:id/replay` — 204. Audit row.
- `POST /v1/dlq/:id/discard` — 204. Audit row.

### 2.6 Audit log (new)
- `GET /v1/audit?actor=&action=&entity_type=&since=&until=&limit=&cursor=`
  Response item:
  ```json
  {
    "id": 12345,
    "ts": "...",
    "actor_id": "uuid" | null,
    "actor_username": "lrosales" | "system",
    "action": "company.suspend",
    "entity_type": "company",
    "entity_id": "acme-001",
    "ip": "1.2.3.4",
    "user_agent": "...",
    "metadata": { "reason": "non-payment" }
  }
  ```
- Read-only. Filterable. No mutations from the API.
- The same `audit_log` table is written from `ingestd`, `routerd`,
  `deliverd-*`, and `admind` directly (every service has its own
  `INSERT`). M9 already created the schema; we just add the
  read endpoint here.

### 2.7 System
- `GET /health` — public. 200 if Postgres + NATS + Redis up.
- `GET /metrics` — public (Prometheus). Exposes `ba_admind_*` counters.

---

## 3. JWT verification — design call

**Default (v1):** HS256, `BA_AUTH_JWT_SECRET` shared env var between
`authd` and `admind`. Simple, works in docker-compose, works in K8s
with one Secret.

**When this breaks:** when we run multiple `admind` replicas across
clusters (M12 W1 carries the K8s manifests). At that point, we flip
to RS256 with `authd` exposing the public key via `/v1/auth/jwks` and
`admind` fetching + caching it. The flag is a single env var:
`BA_AUTH_ASYMMETRIC=true`.

**Why we don't ship RS256 in v1:**
- v1 runs in one cluster, one secret works.
- Asymmetric setup means we need key rotation, JWKS caching, and a
  way to disable a leaked key without downtime. That's ~3 days of
  work that's wasted if v1 stays single-cluster.
- We pre-wire the JWKS endpoint in `authd` and the verification code
  in `admind`; just don't enable it.

**Interface in `admind`:**
```go
type JWTVerifier interface {
    Verify(token string) (Claims, error)
}
```
Two implementations:
- `HS256Verifier` (default): reads `BA_AUTH_JWT_SECRET` from env.
- `JWKSVerifier` (future): fetches from `BA_AUTH_JWKS_URL`, caches 5min.

`admind` picks based on `BA_AUTH_ASYMMETRIC` env var. No code change
needed to flip.

---

## 4. OpenAPI generation

`admind` and `authd` will each export an OpenAPI 3.1 spec at runtime:
- `authd`: `GET /openapi.json`
- `admind`: `GET /openapi.json`

The frontend's `pnpm run gen:api` step fetches both, merges them into
`web/src/types/api.d.ts`, and generates the client. CI runs this on
every PR that touches `cmd/**/main.go` or `internal/**/api.go`.

This is how the frontend gets end-to-end type safety without a
GraphQL or tRPC middle layer.

---

## 5. Error model

All errors return:
```json
{
  "error": {
    "code": "company_not_found",
    "message": "Company 'acme-001' does not exist or you do not have access.",
    "request_id": "uuid"
  }
}
```
Standard codes:
- `unauthorized` (401) — no token / invalid token
- `forbidden` (403) — tenant scope violation
- `not_found` (404) — entity missing
- `conflict` (409) — unique constraint, duplicate slug
- `rate_limited` (429) — too many requests
- `validation_failed` (422) — Zod/RHF-style field errors in `metadata.fields`
- `internal_error` (500) — server bug; `request_id` is the only thing the user can quote to support
- `sse_upgrade_failed` (500) — only on `/v1/tail/stream` if NATS tail is down
- `magic_link_expired` (410) — invite past 7d
- `account_locked` (423) — 5 failed logins

---

## 6. Rate limits

- `authd /v1/auth/login`: 5/min per IP, 10/min per username.
- `admind /v1/*`: 600/min per access JWT (10/sec average).
- `admind /v1/tail/stream`: 1 concurrent connection per user.

Enforced via the same Redis-based limiter used by ingestd (M9 layer 1).
