feat: add authenticated Creator read API
CI / test (push) Failing after 5m45s

This commit is contained in:
m
2026-08-16 15:29:43 +03:00
parent 9b136621ef
commit cef59b709e
5 changed files with 221 additions and 10 deletions
+70 -10
View File
@@ -46,6 +46,22 @@ func main() {
}
defer identityRepository.Close()
mux := http.NewServeMux()
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
if err != nil {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return identity.CreatorSession{}, false
}
return session, true
}
resolveFocus := func(r *http.Request) (situation.FocusBinding, error) {
runtime := situation.RuntimeBinding{SessionID: situation.SessionID(r.URL.Query().Get("session_id")), Workspace: situation.WorkspaceID(r.URL.Query().Get("workspace_id"))}
bindings, err := repository.ByRuntime(r.Context(), runtime)
if err != nil {
return situation.FocusBinding{}, err
}
return situationdomain.NewResolver(bindings).Resolve(runtime)
}
mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.HandleFunc("POST /v1/creator/sessions", func(w http.ResponseWriter, r *http.Request) {
if !identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) {
@@ -64,14 +80,64 @@ func main() {
}{token, session})
})
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now())
if err != nil {
http.Error(w, "creator session denied", http.StatusUnauthorized)
session, ok := authenticateCreator(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(session)
})
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
focus, err := resolveFocus(r)
if err != nil {
http.Error(w, "creator focus unavailable", http.StatusConflict)
return
}
if focus.ActorID != session.ActorID {
http.Error(w, "creator focus denied", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(focus)
})
mux.HandleFunc("GET /v1/creator/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
focus, err := resolveFocus(r)
if err != nil || focus.ActorID != session.ActorID {
http.Error(w, "creator focus denied", http.StatusForbidden)
return
}
target, err := workRepository.Load(r.Context(), work.TaskID(r.PathValue("id")))
if err != nil {
http.Error(w, "task not found", http.StatusNotFound)
return
}
if err := situationdomain.ValidateTaskTarget(focus, target); err != nil {
http.Error(w, "task outside creator focus", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(target)
})
mux.HandleFunc("GET /v1/creator/capabilities/{id}/health", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
status, err := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID(r.PathValue("id")))
if err != nil {
http.Error(w, "capability health not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(status)
})
mux.HandleFunc("GET /v1/principal-bindings/{actor}/{principal}", func(w http.ResponseWriter, r *http.Request) {
binding, err := identityRepository.LoadBinding(r.Context(), identity.ActorID(r.PathValue("actor")), identity.PrincipalID(r.PathValue("principal")))
if err != nil {
@@ -136,13 +202,7 @@ func main() {
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("GET /v1/focus", func(w http.ResponseWriter, r *http.Request) {
runtime := situation.RuntimeBinding{SessionID: situation.SessionID(r.URL.Query().Get("session_id")), Workspace: situation.WorkspaceID(r.URL.Query().Get("workspace_id"))}
bindings, err := repository.ByRuntime(r.Context(), runtime)
if err != nil {
http.Error(w, "load focus", http.StatusInternalServerError)
return
}
focus, err := situationdomain.NewResolver(bindings).Resolve(runtime)
focus, err := resolveFocus(r)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"net/http/httptest"
"testing"
)
func TestBearerTokenRequiresBearerScheme(t *testing.T) {
request := httptest.NewRequest("GET", "/", nil)
request.Header.Set("Authorization", "Basic value")
if token := bearerToken(request); token != "" {
t.Fatalf("unexpected token %q", token)
}
request.Header.Set("Authorization", "Bearer creator-session")
if token := bearerToken(request); token != "creator-session" {
t.Fatalf("token = %q", token)
}
}
@@ -0,0 +1,15 @@
{
"task_id": "DEV-000024",
"task_revision": 1,
"architecture_revision": "bootstrap-1",
"base_repo_revision": "9b136621ef17982ae118705f9d0663dc014e5fa6",
"risk_tier": "C",
"intent": "Expose a session-authenticated, read-only Creator API for the Creator's own Focus, focus-matched Task, and Capability Health without granting mutation authority.",
"allowed_change_scope": {"paths": ["cmd/core-runtime/**", "tests/creator_api/**", "dev/tasks/DEV-000024-authenticated-creator-read-api.json"]},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/commit/**", "contracts/**", "adapters/**", "deploy/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0},
"semantic_owner": "none-read-composition",
"acceptance_scenarios": ["missing or invalid Creator session is denied", "Creator can read only a Focus bound to its Actor", "Task read must match the authenticated active Focus goal and scope", "Capability Health read does not mutate canonical state"],
"required_tests": ["unit", "runtime HTTP", "wrong-focus denial", "architecture"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP"]
}
+28
View File
@@ -0,0 +1,28 @@
package creator_api_test
import (
"testing"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/situation"
"git.cin.su/m/Assistant/contracts/work"
situationdomain "git.cin.su/m/Assistant/modules/situation/domain"
)
func TestCreatorTaskReadRequiresOwnFocusTarget(t *testing.T) {
session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal"}
focus := situation.FocusBinding{ActorID: session.ActorID, GoalID: "goal-a", ScopeID: "scope-a", Active: true}
allowed := work.TaskTarget{TaskID: "task-a", GoalID: focus.GoalID, ScopeID: focus.ScopeID, Revision: 2}
if focus.ActorID != session.ActorID {
t.Fatal("creator focus actor mismatch")
}
if err := situationdomain.ValidateTaskTarget(focus, allowed); err != nil {
t.Fatal(err)
}
outside := allowed
outside.ScopeID = authority.ScopeID("scope-b")
if err := situationdomain.ValidateTaskTarget(focus, outside); err == nil {
t.Fatal("out-of-focus task accepted")
}
}
+90
View File
@@ -0,0 +1,90 @@
param([string]$DockerContext = "docker-prod")
$ErrorActionPreference = "Stop"
$container = "assistant-core-runtime-1"
$baseURL = "http://localhost:8080"
function Post-Json([string]$url, [object]$body) {
$json = $body | ConvertTo-Json -Depth 8 -Compress
$local = New-TemporaryFile
$remote = "/tmp/domovoy-$([guid]::NewGuid().ToString('N')).json"
try {
[IO.File]::WriteAllText($local, $json, [Text.UTF8Encoding]::new($false))
docker --context $DockerContext cp $local "${container}:$remote" | Out-Null
$response = docker --context $DockerContext exec $container wget -qO- --header "Content-Type: application/json" --post-file $remote $url
if ($LASTEXITCODE -ne 0) { throw "POST $url failed" }
return $response
} finally {
docker --context $DockerContext exec --user root $container rm -f $remote 2>$null
Remove-Item -LiteralPath $local -Force
}
}
function Get-Authenticated([string]$url, [string]$token) {
$response = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $container sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$0"' $url
if ($LASTEXITCODE -ne 0) { throw "GET $url failed" }
return $response
}
function Expect-GetStatus([string]$url, [int]$status, [string]$token = "") {
$previous = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
$response = (docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $container sh -c 'wget -S -O /dev/null --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$0"' $url 2>&1) -join "`n"
} finally {
$ErrorActionPreference = $previous
}
if ($response -notmatch "HTTP/1\.[01] $status") { throw "GET $url did not return $status" }
}
for ($attempt = 0; $attempt -lt 15; $attempt++) {
docker --context $DockerContext exec $container wget -qO- "$baseURL/healthz" 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { break }
Start-Sleep -Seconds 2
}
if ($LASTEXITCODE -ne 0) { throw "core runtime did not become healthy" }
$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $container
$bootstrapEntry = $environment | Where-Object { $_ -like "CREATOR_BOOTSTRAP_TOKEN=*" } | Select-Object -First 1
if (-not $bootstrapEntry) { throw "Creator bootstrap token is not configured" }
$bootstrapToken = $bootstrapEntry.Substring("CREATOR_BOOTSTRAP_TOKEN=".Length)
$sessionResponse = docker --context $DockerContext exec --env "CREATOR_TEST_BOOTSTRAP=$bootstrapToken" $container sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_BOOTSTRAP" --post-data="" "$0/v1/creator/sessions"' $baseURL
if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" }
$creatorSession = ($sessionResponse | ConvertFrom-Json).Token
if (-not $creatorSession) { throw "Creator session token is empty" }
$suffix = [guid]::NewGuid().ToString("N")
$sessionID = "creator-session-$suffix"
$workspaceID = "creator-workspace-$suffix"
$goalID = "creator-goal-$suffix"
$scopeID = "domovoy-global-scope"
$taskID = "creator-task-$suffix"
$outsideTaskID = "outside-task-$suffix"
$capabilityID = "creator-capability-$suffix"
Post-Json "$baseURL/v1/focus-bindings" @{
ID = "creator-focus-$suffix"; ActorID = "domovoy-creator-actor"; SessionID = $sessionID
Workspace = $workspaceID; ProjectID = "creator-project-$suffix"; GoalID = $goalID
ScopeID = $scopeID; Active = $true
} | Out-Null
Post-Json "$baseURL/v1/tasks" @{TaskID = $taskID; GoalID = $goalID; ScopeID = $scopeID; Revision = 1} | Out-Null
Post-Json "$baseURL/v1/tasks" @{TaskID = $outsideTaskID; GoalID = "outside-goal-$suffix"; ScopeID = $scopeID; Revision = 1} | Out-Null
Post-Json "$baseURL/v1/capabilities/health" @{
CapabilityID = $capabilityID; State = "AVAILABLE"; Reason = "NONE"; Revision = 1
SemanticVersion = 2; ObservedAtUnixMillis = 1800000000000
} | Out-Null
$query = "session_id=$sessionID&workspace_id=$workspaceID"
Expect-GetStatus "$baseURL/v1/creator/focus?$query" 401
$focus = Get-Authenticated "$baseURL/v1/creator/focus?$query" $creatorSession | ConvertFrom-Json
if ($focus.ActorID -ne "domovoy-creator-actor" -or $focus.GoalID -ne $goalID) { throw "Creator focus response is incorrect" }
$task = Get-Authenticated "$baseURL/v1/creator/tasks/${taskID}?$query" $creatorSession | ConvertFrom-Json
if ($task.TaskID -ne $taskID -or $task.GoalID -ne $goalID) { throw "Creator task response is incorrect" }
Expect-GetStatus "$baseURL/v1/creator/tasks/${outsideTaskID}?$query" 403 $creatorSession
$health = Get-Authenticated "$baseURL/v1/creator/capabilities/$capabilityID/health" $creatorSession | ConvertFrom-Json
if ($health.CapabilityID -ne $capabilityID -or $health.State -ne "AVAILABLE") { throw "Creator capability health response is incorrect" }
Write-Output "DEV-000024 authenticated Creator read API: PASS"