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}) }