Wire-level contract for the M13 frontend. Two services:
admind(existing, gets new endpoints) andauthd(new). All endpoints JSON in/out unless noted. Auth =Authorization: Bearer <jwt>for access,Cookie: refresh=...for refresh.
/v1/auth/* (authd), /v1/* (admind).text/event-stream.{ "error": { "code": "string", "message": "string", "request_id": "uuid" } }.?limit=N&cursor=<opaque>. Response: { "items": [...], "next_cursor": "..." | null }.company_id. Super-admin
may pass ?company_id=<id> to scope; tenant-admin ?company_id
matches the JWT or returns 403.POST /v1/auth/loginRequest:
{ "username": "lrosales", "password": "..." }
Response 200:
{
"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).
POST /v1/auth/refreshRequest: empty body. Cookie must carry refresh.
Response 200: same shape as login. New refresh cookie sent, old one invalidated.
Errors: 401 invalid/expired refresh.
POST /v1/auth/logoutRequest: empty body. Requires access JWT. Response 204. Invalidates the refresh token server-side. Clears cookie. Errors: 401.
GET /v1/auth/meResponse 200:
{
"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"
}
POST /v1/auth/invites (super-admin only)Request:
{
"username": "gerente@acme.com",
"role": "tenant_admin",
"company_id": "acme-001",
"display_name": "Gerente ACME",
"email": "gerente@acme.com"
}
Response 201:
{
"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).
POST /v1/auth/invites/acceptRequest:
{ "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).
POST /v1/auth/password/change (any authenticated)Request: { "current_password": "...", "new_password": "..." }. Response 204.
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.
GET /health, GET /metricsSame as other services. /health returns 200 if Postgres + Redis up.
/metrics exposes ba_authd_* Prometheus metrics.
New tables in the same Postgres DB, schema auth:
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
);
GET /v1/companies — list. Super-admin sees all. Tenant-admin sees
only their own (returns 1 row).
Query: ?status=&search=&limit=&cursor=
Response 200:
{
"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:
{ "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.
GET /v1/sources?company_id=&status=&type=&limit=&cursor=
Response item:
{
"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.
GET /v1/companies/:id/telegram —
{
"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" }.
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:
{
"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": "..."
}
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.GET /v1/audit?actor=&action=&entity_type=&since=&until=&limit=&cursor=
Response item:
{
"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.
GET /health — public. 200 if Postgres + NATS + Redis up.GET /metrics — public (Prometheus). Exposes ba_admind_* counters.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:
authd and the verification code
in admind; just don't enable it.Interface in admind:
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.
admind and authd will each export an OpenAPI 3.1 spec at runtime:
authd: GET /openapi.jsonadmind: GET /openapi.jsonThe 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.
All errors return:
{
"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 tokenforbidden (403) — tenant scope violationnot_found (404) — entity missingconflict (409) — unique constraint, duplicate slugrate_limited (429) — too many requestsvalidation_failed (422) — Zod/RHF-style field errors in metadata.fieldsinternal_error (500) — server bug; request_id is the only thing the user can quote to supportsse_upgrade_failed (500) — only on /v1/tail/stream if NATS tail is downmagic_link_expired (410) — invite past 7daccount_locked (423) — 5 failed loginsauthd /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).