# 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 ` 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=`. Response: `{ "items": [...], "next_cursor": "..." | null }`. - Tenant scope: enforced server-side from JWT `company_id`. Super-admin may pass `?company_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=; 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=` (EventSource workaround). Server emits `event: alert\ndata: {}\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=&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).