403 lines
16 KiB
Go
403 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.cin.su/m/Assistant/adapters/notificationexecutor"
|
|
"git.cin.su/m/Assistant/contracts/authority"
|
|
"git.cin.su/m/Assistant/contracts/capability"
|
|
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
|
"git.cin.su/m/Assistant/contracts/continuity"
|
|
"git.cin.su/m/Assistant/contracts/identity"
|
|
"git.cin.su/m/Assistant/contracts/work"
|
|
commitdomain "git.cin.su/m/Assistant/modules/commit/domain"
|
|
"git.cin.su/m/Assistant/modules/commit/ports"
|
|
"git.cin.su/m/Assistant/platform/ids"
|
|
)
|
|
|
|
const creatorGrantID authority.GrantID = "domovoy-creator-grant"
|
|
|
|
type creatorSessionSource interface {
|
|
Session(context.Context, string) (identity.CreatorSession, error)
|
|
Task(context.Context, work.TaskID) (work.TaskTarget, error)
|
|
Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error)
|
|
ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error)
|
|
}
|
|
|
|
type creatorContinuitySource interface {
|
|
Item(context.Context, string, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
|
|
}
|
|
|
|
type creatorAuthoritySource interface {
|
|
LoadGrant(context.Context, authority.GrantID) (authority.AuthorityGrant, authority.Scope, error)
|
|
}
|
|
|
|
type creatorCommitRepository interface {
|
|
executionRepository
|
|
SaveAuthorized(context.Context, commitcontract.AuthorizedAction) error
|
|
ListAuthorizedByOwner(context.Context, identity.ActorID, identity.PrincipalID, int) ([]commitcontract.AuthorizedAction, error)
|
|
FindLatestAttempt(context.Context, commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error)
|
|
}
|
|
|
|
type creatorCommandHandler struct {
|
|
core creatorSessionSource
|
|
continuity creatorContinuitySource
|
|
authority creatorAuthoritySource
|
|
repository creatorCommitRepository
|
|
executor ports.Executor
|
|
reconciler ports.Reconciler
|
|
newID func() (ids.ID, error)
|
|
now func() time.Time
|
|
}
|
|
|
|
type creatorSatisfyRequest struct {
|
|
Target work.TaskTarget
|
|
ProspectiveItemID continuity.ProspectiveItemID
|
|
ExpectedRevision continuity.ProspectiveRevision
|
|
EvidenceRef string
|
|
}
|
|
|
|
type creatorNotifyRequest struct {
|
|
Target work.TaskTarget
|
|
ProspectiveItemID continuity.ProspectiveItemID
|
|
ExpectedRevision continuity.ProspectiveRevision
|
|
Confirm bool
|
|
}
|
|
|
|
type creatorActionResponse struct {
|
|
AuthorizedActionID commitcontract.AuthorizedActionID
|
|
ActionIntentID commitcontract.ActionIntentID
|
|
ActionAttemptID commitcontract.ActionAttemptID
|
|
State commitcontract.ActionState
|
|
}
|
|
|
|
type creatorActionHistoryItem struct {
|
|
AuthorizedActionID commitcontract.AuthorizedActionID
|
|
ActionIntentID commitcontract.ActionIntentID
|
|
ActionAttemptID commitcontract.ActionAttemptID
|
|
Operation commitcontract.ExternalOperation
|
|
ProspectiveItemID continuity.ProspectiveItemID
|
|
State commitcontract.ActionState
|
|
AuthorizedAtUnixMillis int64
|
|
}
|
|
|
|
type creatorActionHistoryResponse struct {
|
|
Items []creatorActionHistoryItem
|
|
}
|
|
|
|
func registerCreatorCommandRoutes(mux *http.ServeMux, handler creatorCommandHandler) {
|
|
mux.HandleFunc("POST /v1/creator/continuity/satisfy-delivered", handler.satisfyDelivered)
|
|
mux.HandleFunc("POST /v1/creator/continuity/notify-delivered", handler.notifyDelivered)
|
|
mux.HandleFunc("POST /v1/creator/authorized-actions/{id}/reconcile", handler.reconcile)
|
|
mux.HandleFunc("GET /v1/creator/authorized-actions", handler.listAuthorizedActions)
|
|
}
|
|
|
|
func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *http.Request) {
|
|
session, sessionOK := h.creatorSession(w, r, r.Header.Get("Authorization"))
|
|
if !sessionOK {
|
|
return
|
|
}
|
|
limit := 25
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil || parsed < 1 || parsed > 50 {
|
|
http.Error(w, "invalid action history limit", http.StatusBadRequest)
|
|
return
|
|
}
|
|
limit = parsed
|
|
}
|
|
actions, err := h.repository.ListAuthorizedByOwner(r.Context(), session.ActorID, session.PrincipalID, limit)
|
|
if err != nil {
|
|
http.Error(w, "Commit history unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
items := make([]creatorActionHistoryItem, 0, len(actions))
|
|
for _, action := range actions {
|
|
attempt, found, findErr := h.repository.FindLatestAttempt(r.Context(), action.ID)
|
|
if findErr != nil {
|
|
http.Error(w, "Commit history unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
prospectiveItemID := action.Proposal.ContinuitySubject.ProspectiveItemID
|
|
if action.Proposal.Operation == commitcontract.OperationDeliverProspectiveNotification {
|
|
prospectiveItemID = action.Proposal.NotificationSubject.ProspectiveItemID
|
|
}
|
|
item := creatorActionHistoryItem{
|
|
AuthorizedActionID: action.ID, ActionIntentID: action.Proposal.IntentID,
|
|
Operation: action.Proposal.Operation, ProspectiveItemID: prospectiveItemID,
|
|
State: action.State, AuthorizedAtUnixMillis: action.AuthorizedAtUnixMillis,
|
|
}
|
|
if found {
|
|
item.ActionAttemptID = attempt.ID
|
|
item.State = attempt.State
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(creatorActionHistoryResponse{Items: items})
|
|
}
|
|
|
|
func (h creatorCommandHandler) notifyDelivered(w http.ResponseWriter, r *http.Request) {
|
|
authorization := r.Header.Get("Authorization")
|
|
session, ok := h.creatorSession(w, r, authorization)
|
|
if !ok {
|
|
return
|
|
}
|
|
var request creatorNotifyRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || !request.Confirm || request.Target.TaskID == "" || request.Target.GoalID == "" || request.Target.ScopeID == "" || request.Target.Revision == 0 || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 {
|
|
http.Error(w, "invalid Creator notification request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
item, err := h.continuity.Item(r.Context(), authorization, request.ProspectiveItemID)
|
|
if errors.Is(err, errCreatorOwnerDenied) {
|
|
http.Error(w, "prospective item denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, errCreatorSubjectMissing) {
|
|
http.Error(w, "prospective item not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "Continuity owner validation unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if item.OwnerPrincipalID != session.PrincipalID {
|
|
http.Error(w, "prospective item denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.ScopeID != request.Target.ScopeID || item.GoalID != request.Target.GoalID {
|
|
http.Error(w, "prospective item is stale or outside justification", http.StatusConflict)
|
|
return
|
|
}
|
|
target, err := h.core.Task(r.Context(), request.Target.TaskID)
|
|
if err != nil || target != request.Target {
|
|
http.Error(w, "work target is stale or unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
|
|
if err != nil {
|
|
http.Error(w, "Creator authority unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
identityBinding, err := h.core.Binding(r.Context(), session.ActorID, session.PrincipalID)
|
|
if err != nil {
|
|
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, session.PrincipalID)
|
|
if err != nil {
|
|
http.Error(w, "notification capability unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
intentID, authorizedID, attemptID, err := h.actionIDs()
|
|
if err != nil {
|
|
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
title := "Домовой: напоминание"
|
|
message := fmt.Sprintf("Проспективная запись %s ожидает вашего внимания.", item.ID)
|
|
payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message}
|
|
digest, err := notificationexecutor.Digest(payload)
|
|
if err != nil {
|
|
http.Error(w, "notification evidence unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
proposal := commitcontract.ActionProposal{IntentID: intentID, ActorID: session.ActorID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Target: request.Target, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest}}
|
|
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
|
|
if err != nil {
|
|
http.Error(w, "Creator authorization denied", http.StatusConflict)
|
|
return
|
|
}
|
|
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
|
|
http.Error(w, "commit authorized action", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
|
|
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
|
http.Error(w, "execute committed notification", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
status := http.StatusCreated
|
|
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
|
status = http.StatusAccepted
|
|
}
|
|
writeCreatorActionResponse(w, status, action, attempt)
|
|
}
|
|
|
|
func ensureCreatorEOF(decoder *json.Decoder) error {
|
|
var extra any
|
|
err := decoder.Decode(&extra)
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return errors.New("trailing JSON")
|
|
}
|
|
|
|
func (h creatorCommandHandler) satisfyDelivered(w http.ResponseWriter, r *http.Request) {
|
|
authorization := r.Header.Get("Authorization")
|
|
session, ok := h.creatorSession(w, r, authorization)
|
|
if !ok {
|
|
return
|
|
}
|
|
var request creatorSatisfyRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&request); err != nil ||
|
|
request.Target.TaskID == "" || request.Target.GoalID == "" || request.Target.ScopeID == "" || request.Target.Revision == 0 ||
|
|
request.ProspectiveItemID == "" || request.ExpectedRevision == 0 || strings.TrimSpace(request.EvidenceRef) == "" || len(request.EvidenceRef) > 512 {
|
|
http.Error(w, "invalid Creator satisfaction request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
item, err := h.continuity.Item(r.Context(), authorization, request.ProspectiveItemID)
|
|
if errors.Is(err, errCreatorOwnerDenied) {
|
|
http.Error(w, "prospective item denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if errors.Is(err, errCreatorSubjectMissing) {
|
|
http.Error(w, "prospective item not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "Continuity owner validation unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if item.OwnerPrincipalID != session.PrincipalID {
|
|
http.Error(w, "prospective item denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision ||
|
|
item.ScopeID != request.Target.ScopeID || item.GoalID != request.Target.GoalID {
|
|
http.Error(w, "prospective item is stale or outside justification", http.StatusConflict)
|
|
return
|
|
}
|
|
target, err := h.core.Task(r.Context(), request.Target.TaskID)
|
|
if err != nil || target != request.Target {
|
|
http.Error(w, "work target is stale or unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
|
|
if err != nil {
|
|
http.Error(w, "Creator authority unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
binding, err := h.core.Binding(r.Context(), session.ActorID, session.PrincipalID)
|
|
if err != nil {
|
|
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
|
|
return
|
|
}
|
|
intentID, authorizedID, attemptID, err := h.actionIDs()
|
|
if err != nil {
|
|
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
proposal := commitcontract.ActionProposal{
|
|
IntentID: intentID, ActorID: session.ActorID, CapabilityID: commitcontract.CapabilityContinuityTransition,
|
|
Target: request.Target, Operation: commitcontract.OperationSatisfyDelivered,
|
|
ContinuitySubject: continuity.SatisfyDeliveredSubject{
|
|
ProspectiveItemID: request.ProspectiveItemID, ExpectedRevision: request.ExpectedRevision,
|
|
FromState: continuity.ProspectiveDelivered, ToState: continuity.ProspectiveSatisfied,
|
|
EvidenceRef: strings.TrimSpace(request.EvidenceRef),
|
|
},
|
|
}
|
|
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: binding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
|
|
if err != nil {
|
|
http.Error(w, "Creator authorization denied", http.StatusConflict)
|
|
return
|
|
}
|
|
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
|
|
http.Error(w, "commit authorized action", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
|
|
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
|
http.Error(w, "execute committed action", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
status := http.StatusCreated
|
|
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
|
status = http.StatusAccepted
|
|
}
|
|
writeCreatorActionResponse(w, status, action, attempt)
|
|
}
|
|
|
|
func (h creatorCommandHandler) reconcile(w http.ResponseWriter, r *http.Request) {
|
|
authorization := r.Header.Get("Authorization")
|
|
session, ok := h.creatorSession(w, r, authorization)
|
|
if !ok {
|
|
return
|
|
}
|
|
action, err := h.repository.LoadAuthorized(r.Context(), commitcontract.AuthorizedActionID(r.PathValue("id")))
|
|
if err != nil {
|
|
http.Error(w, "authorized action not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if action.PrincipalID != session.PrincipalID || action.Proposal.ActorID != session.ActorID {
|
|
http.Error(w, "authorized action denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
newID, err := h.newID()
|
|
if err != nil {
|
|
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
attempt, operationErr := reconcileAuthorized(r.Context(), h.repository, h.reconciler, action, commitcontract.ActionAttemptID(newID))
|
|
if errors.Is(operationErr, errActionDoesNotNeedReconcile) {
|
|
http.Error(w, "action does not require reconciliation", http.StatusConflict)
|
|
return
|
|
}
|
|
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
|
http.Error(w, "reconciliation unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
status := http.StatusOK
|
|
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
|
|
status = http.StatusAccepted
|
|
}
|
|
writeCreatorActionResponse(w, status, action, attempt)
|
|
}
|
|
|
|
func (h creatorCommandHandler) creatorSession(w http.ResponseWriter, r *http.Request, authorization string) (identity.CreatorSession, bool) {
|
|
session, err := h.core.Session(r.Context(), authorization)
|
|
if errors.Is(err, errCreatorSessionDenied) {
|
|
http.Error(w, "Creator session denied", http.StatusUnauthorized)
|
|
return identity.CreatorSession{}, false
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "Identity validation unavailable", http.StatusServiceUnavailable)
|
|
return identity.CreatorSession{}, false
|
|
}
|
|
return session, true
|
|
}
|
|
|
|
func (h creatorCommandHandler) actionIDs() (commitcontract.ActionIntentID, commitcontract.AuthorizedActionID, commitcontract.ActionAttemptID, error) {
|
|
intent, err := h.newID()
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
authorized, err := h.newID()
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
attempt, err := h.newID()
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return commitcontract.ActionIntentID(intent), commitcontract.AuthorizedActionID(authorized), commitcontract.ActionAttemptID(attempt), nil
|
|
}
|
|
|
|
func writeCreatorActionResponse(w http.ResponseWriter, status int, action commitcontract.AuthorizedAction, attempt commitcontract.ActionAttempt) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(creatorActionResponse{AuthorizedActionID: action.ID, ActionIntentID: action.Proposal.IntentID, ActionAttemptID: attempt.ID, State: attempt.State})
|
|
}
|