Files
m c6139699a9
CI / test (pull_request) Successful in 11s
CI / test (push) Successful in 11s
feat: enable creator notification command
2026-08-16 19:19:00 +03:00

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