updates on the ai and agent and all thse things awith onboarding

This commit is contained in:
2026-09-30 14:47:58 +05:30
parent 44ba33eda2
commit 0ac5d3d54f
37 changed files with 7755 additions and 0 deletions

View File

@@ -326,6 +326,18 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Get("/tenants/:id", controllers.GetTenantDetails)
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
adminAuth.Delete("/tenants/:id", controllers.DeleteTenant)
// Client onboarding: a tenant + its console login in one transaction. Only
// the CLIENT_ONBOARDING_OWNERS logins (default admin@doormile.com), and
// only as Doormile staff with roleid 1 — onboarding mints credentials.
onboarding := adminAuth.Group("/clients", middlewares.ClientOnboardingOwnerOnly(cfg.ClientOnboardingOwners))
onboarding.Post("/onboard", controllers.OnboardClient)
onboarding.Get("/onboarded", controllers.GetOnboardedClients)
onboarding.Get("/cities", controllers.GetOnboardingCities)
// Edit and remove a client's console login (:id = doormile_auth id). Remove
// deletes the login only and marks the client Inactive; history is kept.
onboarding.Put("/:id", controllers.UpdateOnboardedClient)
onboarding.Delete("/:id", controllers.DeleteOnboardedClient)
adminAuth.Get("/tenants/:id/locations", controllers.GetTenantLocations)
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
@@ -437,6 +449,27 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
// AI agent registry (krow_talent_app/docs/agent-platform-plan.md, Phase 1).
// Doormile staff only — a partner-tenant login gets 403. Reads are open to
// roles 1/3/4; every write is roleid 1 only and is audited.
aiRegistry := adminAuth.Group("/ai", middlewares.DoormileStaffOnly)
aiRegistry.Get("/agents", controllers.GetAIAgents)
aiRegistry.Get("/agents/:id", controllers.GetAIAgent)
aiRegistry.Patch("/agents/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAIAgent)
aiRegistry.Get("/skills", controllers.GetAISkills)
aiRegistry.Post("/skills", middlewares.RoleCheckMiddleware(1), controllers.CreateAISkill)
aiRegistry.Patch("/skills/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAISkill)
aiRegistry.Get("/tools", controllers.GetAITools)
aiRegistry.Get("/audit", controllers.GetAIRegistryAudit)
// What the agents did (Phase 4): runs from AI_engine telemetry, decisions
// from agent_decisions, live heartbeat from Redis. Read-only.
aiRegistry.Get("/insights", controllers.GetAIInsights)
aiRegistry.Get("/decisions", controllers.GetAIDecisions)
// Test tab (Phase 6): one prompt through Claude with a skill's tools. Reads
// run redacted; writes only become proposals. Admin only — every run is a
// paid API call — and rate-limited per user in the handler.
aiRegistry.Post("/playground/run", middlewares.RoleCheckMiddleware(1), controllers.RunAIPlayground)
// --------------------
// HUB CONSOLE APIS
// --------------------
@@ -532,6 +565,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
internal.Post("/agent-decisions", controllers.CreateAgentDecision)
internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions)
internal.Patch("/agent-decisions/:id/outcome", controllers.UpdateDecisionOutcome)
// The agent registry, for AI_engine to poll (ETag / If-None-Match → 304).
internal.Get("/ai/registry", controllers.GetInternalAIRegistry)
// Express-batch dispatch: the ExpressDispatchAgent reads a tenant's riders
// and the batch's bookings, then writes back the assignments it decided.

View File

@@ -0,0 +1,151 @@
package routes_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"doormile/db"
"doormile/internal/ai/registry"
"doormile/internal/testpg"
"doormile/models"
)
// End to end through the real router and real handlers, against a real
// Postgres. Skipped unless REGISTRY_TEST_DSN is set; the DSN must be a
// THROWAWAY database — the five registry tables are dropped and recreated.
// See internal/ai/registry/store_integration_test.go for how to start one.
func registryApp(t *testing.T) func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres end-to-end test")
}
gdb := testpg.Open(t, dsn, "airegistry_routes_test")
all := []any{&models.AIRegistryAudit{}, &models.AISkillTool{}, &models.AISkill{}, &models.AITool{}, &models.AIAgent{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
if err := registry.Seed(gdb); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
app := newApp()
return func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) {
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, resp.Header, out
}
}
func TestPGRegistryEndToEnd(t *testing.T) {
call := registryApp(t)
admin := token(t, 1, 1)
// Read the inventory.
code, _, body := call(http.MethodGet, "/api/v1/admin/ai/agents", admin, "")
if code != http.StatusOK {
t.Fatalf("GET agents = %d %v", code, body)
}
if total, _ := body["total"].(float64); int(total) != len(registry.SeedAgents) {
t.Errorf("GET agents total = %v, want %d (body %v)", body["total"], len(registry.SeedAgents), body)
}
code, _, body = call(http.MethodGet, "/api/v1/admin/ai/skills?agent=CONSOLE_OPS_AGENT", token(t, 1, 3), "")
if code != http.StatusOK {
t.Fatalf("manager GET skills = %d %v", code, body)
}
// Patch a skill as admin: 200, version 2, thresholds as a JSON object.
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"enabled":false,"thresholds":{"arrivedStalledMin":30}}`)
if code != http.StatusOK {
t.Fatalf("PATCH skill = %d %v", code, body)
}
data, _ := body["data"].(map[string]any)
th, _ := data["thresholds"].(map[string]any)
if data["enabled"] != false || data["version"] != float64(2) || th["arrivedStalledMin"] != float64(30) {
t.Errorf("PATCH response = %v", data)
}
// A bad value is a 400 naming the problem, and changes nothing.
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"thresholds":{"arrivedStalledMin":999}}`)
if code != http.StatusBadRequest {
t.Errorf("out-of-range PATCH = %d %v, want 400", code, body)
}
code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/skills/nope", admin, `{"enabled":true}`)
if code != http.StatusNotFound {
t.Errorf("PATCH unknown skill = %d, want 404", code)
}
// Autonomy: refused without the typed confirmation, accepted with it.
code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":true}`)
if code != http.StatusBadRequest {
t.Errorf("autonomy without confirm = %d, want 400", code)
}
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":false,"model":"claude-haiku-4-5-20251001"}`)
if code != http.StatusOK {
t.Errorf("model PATCH = %d %v", code, body)
}
// Create a custom skill.
code, _, body = call(http.MethodPost, "/api/v1/admin/ai/skills", admin, `{"agentid":"CONSOLE_OPS_AGENT","title":"Night shift watch","tools":["scan_bookings"]}`)
if code != http.StatusCreated {
t.Fatalf("POST skill = %d %v", code, body)
}
// The audit shows all three changes.
code, _, body = call(http.MethodGet, "/api/v1/admin/ai/audit", admin, "")
if total, _ := body["total"].(float64); code != http.StatusOK || int(total) != 4 {
t.Errorf("audit = %d, total %v, want 4 rows (enabled, thresholds, model, created)", code, body["total"])
}
// Every row names who made the change, even when the login has no appusers row.
for _, row := range body["data"].([]any) {
if email := row.(map[string]any)["changedbyemail"]; email != "test@doormile.com" {
t.Errorf("audit row changedbyemail = %v, want the token's email", email)
}
}
// The engine's endpoint: 200 with an ETag, then 304 for the same tag.
t.Setenv("INTERNAL_API_KEY", "engine-key")
code, hdr, body := call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key")
tag := hdr.Get("ETag")
if code != http.StatusOK || tag == "" {
t.Fatalf("internal registry = %d, etag %q", code, tag)
}
if snap, _ := body["data"].(map[string]any); len(snap["agents"].([]any)) != len(registry.SeedAgents) {
t.Errorf("internal registry agents = %v", len(snap["agents"].([]any)))
}
code, _, _ = call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key", "If-None-Match", tag)
if code != http.StatusNotModified {
t.Errorf("If-None-Match with the current tag = %d, want 304", code)
}
}

View File

@@ -0,0 +1,190 @@
package routes_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"doormile/controllers"
"doormile/internal/ai/playground"
"doormile/utils"
)
// The AI agent registry's gates, over real HTTP (see routes_logistics_test.go
// for what this style of test does and does not prove). Every refusal below
// happens in middleware, before a handler could touch the database.
func tenantToken(t *testing.T, userID, roleID, tenantID int) string {
t.Helper()
tok, err := utils.GenerateToken(userID, "client@partner.example", roleID, tenantID, 1001, jwtSecret)
if err != nil {
t.Fatalf("could not mint a tenant token: %v", err)
}
return tok
}
var aiReads = []string{
"/api/v1/admin/ai/agents",
"/api/v1/admin/ai/agents/EXCEPTION_AGENT",
"/api/v1/admin/ai/skills",
"/api/v1/admin/ai/tools",
"/api/v1/admin/ai/audit",
"/api/v1/admin/ai/insights",
"/api/v1/admin/ai/insights?days=30",
"/api/v1/admin/ai/decisions",
}
var aiWrites = []struct{ method, path, body string }{
{http.MethodPatch, "/api/v1/admin/ai/skills/skill_sla_guardian", `{"enabled":false}`},
{http.MethodPost, "/api/v1/admin/ai/skills", `{"agentid":"CONSOLE_OPS_AGENT","title":"x","tools":["scan_bookings"]}`},
{http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", `{"autonomous":true,"confirm":"EXCEPTION_AGENT"}`},
{http.MethodPost, "/api/v1/admin/ai/playground/run", `{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`},
}
func TestAIRegistryRequiresALogin(t *testing.T) {
app := newApp()
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, "", ""); code != http.StatusUnauthorized {
t.Errorf("GET %s with no token = %d, want 401", p, code)
}
}
}
func TestAIRegistryRefusesNonConsoleRoles(t *testing.T) {
app := newApp()
for _, role := range []int{5, 6, 9} { // miler, hub staff, customer
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, token(t, 1, role), ""); code != http.StatusForbidden {
t.Errorf("role %d GET %s = %d, want 403", role, p, code)
}
}
}
}
// A partner-tenant login is refused outright — even an admin-role one — rather
// than shown an empty registry.
func TestAIRegistryRefusesPartnerTenantLogins(t *testing.T) {
app := newApp()
tok := tenantToken(t, 50, 1, 7)
for _, p := range aiReads {
code, body := do(t, app, http.MethodGet, p, tok, "")
if code != http.StatusForbidden {
t.Errorf("tenant GET %s = %d, want 403", p, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "Doormile staff only") {
t.Errorf("tenant GET %s refused with the wrong message: %s", p, body)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code != http.StatusForbidden {
t.Errorf("tenant %s %s = %d, want 403", w.method, w.path, code)
}
}
}
// Managers (3) and executives (4) may read the registry but not change it.
func TestAIRegistryWritesAreAdminOnly(t *testing.T) {
app := newApp()
for _, role := range []int{3, 4} {
for _, w := range aiWrites {
code, body := do(t, app, w.method, w.path, token(t, 1, role), w.body)
if code != http.StatusForbidden {
t.Errorf("role %d %s %s = %d, want 403", role, w.method, w.path, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "insufficient permissions") {
t.Errorf("role %d %s %s refused by the wrong gate: %s", role, w.method, w.path, body)
}
}
}
}
// Doormile staff with roleid 1 get through every gate. There is no database
// in this test, so the handler's first query panics and recover answers 500 —
// which is the proof the route exists and nothing in front of it refused.
func TestAIRegistryAdminPassesEveryGate(t *testing.T) {
app := newApp()
tok := token(t, 1, 1)
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, tok, ""); code == 401 || code == 403 || code == 404 {
t.Errorf("admin GET %s = %d; a gate refused or the route is missing", p, code)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code == 401 || code == 403 || code == 404 {
t.Errorf("admin %s %s = %d; a gate refused or the route is missing", w.method, w.path, code)
}
}
// Read roles get through the read gates too.
for _, role := range []int{3, 4} {
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/ai/agents", token(t, 1, role), ""); code == 401 || code == 403 {
t.Errorf("role %d was refused a registry read (%d)", role, code)
}
}
}
func TestInternalRegistryNeedsTheInternalKey(t *testing.T) {
t.Setenv("INTERNAL_API_KEY", "engine-key-for-tests")
app := newApp()
for _, key := range []string{"", "wrong-key"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/internal/ai/registry", nil)
if key != "" {
req.Header.Set("X-Internal-Key", key)
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("internal registry with key %q = %d, want 401", key, resp.StatusCode)
}
}
// A console JWT is not an internal key.
if code, _ := do(t, app, http.MethodGet, "/api/v1/internal/ai/registry", token(t, 1, 1), ""); code != http.StatusUnauthorized {
t.Errorf("internal registry with an admin JWT = %d, want 401", code)
}
}
// The Test playground (Phase 6). With no model client wired the endpoint
// says so plainly (503 with a code the console branches on) — it never
// pretends to run. With one wired, bad input is refused before any database
// or model call.
func TestAIPlaygroundWithoutAClientIs503(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = nil
defer func() { controllers.PlaygroundModel = prev }()
code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1),
`{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`)
if code != http.StatusServiceUnavailable || !strings.Contains(body, "PLAYGROUND_NOT_CONFIGURED") {
t.Fatalf("no client: %d %s, want 503 PLAYGROUND_NOT_CONFIGURED", code, body)
}
}
type noCallModel struct{ t *testing.T }
func (m noCallModel) Next(context.Context, playground.Request) (playground.Reply, error) {
m.t.Fatal("the model was called for a request that should have been refused")
return playground.Reply{}, nil
}
func TestAIPlaygroundRefusesBadInput(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = noCallModel{t}
defer func() { controllers.PlaygroundModel = prev }()
for _, b := range []string{
`{"agentid":"EXCEPTION_AGENT","prompt":" "}`,
`{"prompt":"hi"}`,
`{"agentid":"EXCEPTION_AGENT","prompt":"` + strings.Repeat("x", 2001) + `"}`,
`not json`,
} {
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1), b); code != http.StatusBadRequest {
t.Errorf("body %.40q = %d %s, want 400", b, code, body)
}
}
}

View File

@@ -0,0 +1,105 @@
package routes_test
import (
"net/http"
"strings"
"testing"
"doormile/config"
"doormile/routes"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/recover"
)
// Client onboarding's gate, over real HTTP. Only the configured owner login —
// as Doormile staff with roleid 1 — reaches the handler; everyone else is
// refused in middleware, before any database access.
const onboardingOwner = "admin@doormile.com"
var onboardingRoutes = []struct{ method, path, body string }{
{http.MethodPost, "/api/v1/admin/clients/onboard", `{"companyname":"Acme"}`},
{http.MethodGet, "/api/v1/admin/clients/onboarded", ""},
{http.MethodGet, "/api/v1/admin/clients/cities", ""},
{http.MethodPut, "/api/v1/admin/clients/5", `{"status":"Inactive"}`},
{http.MethodDelete, "/api/v1/admin/clients/5", ""},
}
func onboardingApp() *fiber.App {
app := fiber.New()
app.Use(recover.New())
routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret, ClientOnboardingOwners: []string{onboardingOwner}})
return app
}
func consoleToken(t *testing.T, email string, roleID, tenantID int) string {
t.Helper()
tok, err := utils.GenerateToken(1, email, roleID, tenantID, 1, jwtSecret)
if err != nil {
t.Fatalf("mint token: %v", err)
}
return tok
}
func TestClientOnboardingNeedsALogin(t *testing.T) {
app := onboardingApp()
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, "", r.body); code != http.StatusUnauthorized {
t.Errorf("%s %s with no token = %d, want 401", r.method, r.path, code)
}
}
}
func TestClientOnboardingRefusesEveryoneButTheOwner(t *testing.T) {
app := onboardingApp()
cases := []struct {
name string
token string
}{
{"another Doormile admin", consoleToken(t, "suriya@doormile.com", 1, 0)},
{"owner email as a manager", consoleToken(t, onboardingOwner, 3, 0)},
{"owner email as an executive", consoleToken(t, onboardingOwner, 4, 0)},
{"owner email on a client tenant", consoleToken(t, onboardingOwner, 1, 7)},
{"a client login", consoleToken(t, "ops@acme.example", 3, 7)},
{"a miler", consoleToken(t, onboardingOwner, 5, 0)},
{"a customer", consoleToken(t, onboardingOwner, 9, 0)},
}
for _, c := range cases {
for _, r := range onboardingRoutes {
code, body := do(t, app, r.method, r.path, c.token, r.body)
if code != http.StatusForbidden {
t.Errorf("%s: %s %s = %d, want 403", c.name, r.method, r.path, code)
}
if strings.Contains(body, onboardingOwner) {
t.Errorf("%s: the refusal leaked the owner email: %s", c.name, body)
}
}
}
}
// With no owners configured, nobody gets in — not even admin@doormile.com.
func TestClientOnboardingFailsClosedWithoutOwners(t *testing.T) {
app := newApp() // config without ClientOnboardingOwners
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, consoleToken(t, onboardingOwner, 1, 0), r.body); code != http.StatusForbidden {
t.Errorf("%s %s with no owners configured = %d, want 403", r.method, r.path, code)
}
}
}
// The owner passes every gate. There is no database here, so the handler's
// first query panics and recover answers 500 — proof that the route exists
// and nothing in front of it refused. Email matching ignores case.
func TestClientOnboardingOwnerPassesTheGate(t *testing.T) {
app := onboardingApp()
for _, email := range []string{onboardingOwner, "Admin@Doormile.com"} {
tok := consoleToken(t, email, 1, 0)
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, tok, r.body); code == 401 || code == 403 || code == 404 {
t.Errorf("owner %s %s %s = %d; a gate refused or the route is missing", email, r.method, r.path, code)
}
}
}
}