diff --git a/adapters/notificationexecutor/executor.go b/adapters/notificationexecutor/executor.go index 9ba70f6..a2eaac6 100644 --- a/adapters/notificationexecutor/executor.go +++ b/adapters/notificationexecutor/executor.go @@ -75,21 +75,39 @@ func PayloadFromAction(action commitcontract.AuthorizedAction) (Payload, error) } type Executor struct { - binding capability.Binding - secret string - client *http.Client + binding capability.Binding + secret string + client *http.Client + executeAllowed bool } func New(binding capability.Binding, secret string, client *http.Client) (*Executor, error) { - if err := capabilitydomain.UsableBinding(binding); err != nil || strings.TrimSpace(secret) == "" || client == nil { + if err := capabilitydomain.UsableBinding(binding); err != nil { + return nil, ErrInvalidNotification + } + return newExecutor(binding, secret, client, true) +} + +func NewReconciler(binding capability.Binding, secret string, client *http.Client) (*Executor, error) { + if err := capabilitydomain.ValidateBinding(binding); err != nil { + return nil, ErrInvalidNotification + } + return newExecutor(binding, secret, client, false) +} + +func newExecutor(binding capability.Binding, secret string, client *http.Client, executeAllowed bool) (*Executor, error) { + if strings.TrimSpace(secret) == "" || client == nil { return nil, ErrInvalidNotification } copyClient := *client copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } - return &Executor{binding: binding, secret: secret, client: ©Client}, nil + return &Executor{binding: binding, secret: secret, client: ©Client, executeAllowed: executeAllowed}, nil } func (e *Executor) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult { + if !e.executeAllowed { + return ports.ExecutionResult{Known: true} + } payload, err := e.requestFor(action) if err != nil { return ports.ExecutionResult{Known: true} diff --git a/cmd/commit-runtime/creator_clients.go b/cmd/commit-runtime/creator_clients.go index 4b108d7..c7a29e7 100644 --- a/cmd/commit-runtime/creator_clients.go +++ b/cmd/commit-runtime/creator_clients.go @@ -8,8 +8,10 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" + "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/continuity" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/work" @@ -95,10 +97,38 @@ func (c creatorCoreClient) Binding(ctx context.Context, actor identity.ActorID, return binding, nil } +func (c creatorCoreClient) ActiveCapabilityBinding(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) (capability.Binding, error) { + var binding capability.Binding + target := *c.baseURL + target.Path = "/v1/capability-bindings/active" + query := target.Query() + query.Set("capability_id", string(capabilityID)) + query.Set("owner_principal_id", string(owner)) + target.RawQuery = query.Encode() + status, err := c.getURL(ctx, target, "", &binding) + if err != nil || status != http.StatusOK { + return capability.Binding{}, errCreatorQueryUnavailable + } + return binding, nil +} + +func (c creatorCoreClient) CapabilityBinding(ctx context.Context, id capability.CapabilityBindingID, revision int64) (capability.Binding, error) { + var binding capability.Binding + status, err := c.get(ctx, "/v1/capability-bindings/"+url.PathEscape(string(id))+"/revisions/"+strconv.FormatInt(revision, 10), "", &binding) + if err != nil || status != http.StatusOK { + return capability.Binding{}, errCreatorQueryUnavailable + } + return binding, nil +} + func (c creatorCoreClient) get(ctx context.Context, path, authorization string, destination any) (int, error) { target := *c.baseURL target.Path = path target.RawQuery = "" + return c.getURL(ctx, target, authorization, destination) +} + +func (c creatorCoreClient) getURL(ctx context.Context, target url.URL, authorization string, destination any) (int, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) if err != nil { return 0, errCreatorQueryUnavailable diff --git a/cmd/commit-runtime/creator_command.go b/cmd/commit-runtime/creator_command.go index 372c601..6bda93c 100644 --- a/cmd/commit-runtime/creator_command.go +++ b/cmd/commit-runtime/creator_command.go @@ -4,12 +4,16 @@ 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" @@ -25,6 +29,7 @@ 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 { @@ -60,6 +65,13 @@ type creatorSatisfyRequest struct { 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 @@ -83,6 +95,7 @@ type creatorActionHistoryResponse struct { 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) } @@ -113,9 +126,13 @@ func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *h 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: action.Proposal.ContinuitySubject.ProspectiveItemID, + Operation: action.Proposal.Operation, ProspectiveItemID: prospectiveItemID, State: action.State, AuthorizedAtUnixMillis: action.AuthorizedAtUnixMillis, } if found { @@ -129,6 +146,104 @@ func (h creatorCommandHandler) listAuthorizedActions(w http.ResponseWriter, r *h _ = 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) diff --git a/cmd/commit-runtime/creator_command_test.go b/cmd/commit-runtime/creator_command_test.go index d5a1b07..b83aeb4 100644 --- a/cmd/commit-runtime/creator_command_test.go +++ b/cmd/commit-runtime/creator_command_test.go @@ -11,6 +11,7 @@ import ( "time" "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" @@ -19,10 +20,12 @@ import ( ) type fakeCreatorCore struct { - session identity.CreatorSession - target work.TaskTarget - binding identity.PrincipalBinding - sessionErr error + session identity.CreatorSession + target work.TaskTarget + binding identity.PrincipalBinding + capabilityBinding capability.Binding + capabilityErr error + sessionErr error } func (f fakeCreatorCore) Session(context.Context, string) (identity.CreatorSession, error) { @@ -34,6 +37,9 @@ func (f fakeCreatorCore) Task(context.Context, work.TaskID) (work.TaskTarget, er func (f fakeCreatorCore) Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) { return f.binding, nil } +func (f fakeCreatorCore) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) { + return f.capabilityBinding, f.capabilityErr +} type fakeCreatorContinuity struct { item continuity.ProspectiveItem @@ -120,6 +126,41 @@ func TestCreatorCommandDerivesAuthorityAndActionFieldsServerSide(t *testing.T) { } } +func TestCreatorNotificationDerivesTypedEnvelopeAndLeavesContinuityState(t *testing.T) { + handler, repository, executor := validCreatorCommandHandler() + mux := http.NewServeMux() + registerCreatorCommandRoutes(mux, handler) + request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(`{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true}`)) + request.Header.Set("Authorization", "Bearer creator-session") + response := httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusCreated || executor.calls != 1 { + t.Fatalf("status=%d calls=%d body=%s", response.Code, executor.calls, response.Body.String()) + } + action := repository.action + subject := action.Proposal.NotificationSubject + if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification || action.Proposal.CapabilityID != commitcontract.CapabilityProspectiveNotification || subject.BindingID != "notification-binding" || subject.BindingRevision != 1 || len(subject.RequestDigest) != 64 || subject.RenderedTitle == "" || subject.RenderedMessage == "" || action.Proposal.ContinuitySubject.ProspectiveItemID != "" { + t.Fatalf("invalid notification action: %#v", action) + } +} + +func TestCreatorNotificationRejectsMissingConfirmationAndHostileFields(t *testing.T) { + for name, body := range map[string]string{"no confirmation": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4}`, "hostile destination": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true,"URL":"https://evil.invalid","Body":"arbitrary"}`} { + t.Run(name, func(t *testing.T) { + handler, repository, executor := validCreatorCommandHandler() + mux := http.NewServeMux() + registerCreatorCommandRoutes(mux, handler) + request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer creator-session") + response := httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || executor.calls != 0 || repository.action.ID != "" { + t.Fatalf("status=%d calls=%d", response.Code, executor.calls) + } + }) + } +} + func TestCreatorCommandRejectsSessionForeignOwnerStaleStateAndRevokedGrant(t *testing.T) { tests := []struct { name string @@ -230,7 +271,7 @@ func TestCreatorActionHistoryRejectsSessionAndInvalidLimitBeforeQuery(t *testing func validCreatorCommandHandler() (creatorCommandHandler, *fakeExecutionRepository, *knownExecutor) { session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000} target := work.TaskTarget{TaskID: "task-1", GoalID: "goal-1", ScopeID: "scope-1", Revision: 3} - core := fakeCreatorCore{session: session, target: target, binding: identity.PrincipalBinding{ActorID: session.ActorID, PrincipalID: session.PrincipalID, Active: true}} + core := fakeCreatorCore{session: session, target: target, binding: identity.PrincipalBinding{ActorID: session.ActorID, PrincipalID: session.PrincipalID, Active: true}, capabilityBinding: capability.Binding{ID: "notification-binding", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: session.PrincipalID, Revision: 1, State: capability.BindingActive, HTTPSOrigin: "https://assistent.cin.su", DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "notification-write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}} item := continuity.ProspectiveItem{ID: "prospective-1", OwnerPrincipalID: session.PrincipalID, ScopeID: target.ScopeID, GoalID: target.GoalID, State: continuity.ProspectiveDelivered, Revision: 4} grant := authority.AuthorityGrant{ID: creatorGrantID, ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: target.ScopeID, Class: authority.GrantClassCreator, Revision: 2, Active: true} repository := &fakeExecutionRepository{} diff --git a/cmd/commit-runtime/main.go b/cmd/commit-runtime/main.go index eb619bc..8720752 100644 --- a/cmd/commit-runtime/main.go +++ b/cmd/commit-runtime/main.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "os" + "strings" "time" "git.cin.su/m/Assistant/adapters/continuityexecutor" @@ -59,13 +60,10 @@ func main() { if executorToken == "" { log.Fatal("COMMIT_EXECUTOR_TOKEN is required") } - executor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second}) + continuityExecutor, err := continuityexecutor.New(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), executorToken, &http.Client{Timeout: 5 * time.Second}) if err != nil { log.Fatal(err) } - mux := http.NewServeMux() - mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) - registerExecutionRoutes(mux, repository, executor, executor, executorToken) creatorHTTPClient := &http.Client{Timeout: 5 * time.Second} creatorCore, err := newCreatorCoreClient(coreURL, creatorHTTPClient) if err != nil { @@ -75,9 +73,21 @@ func main() { if err != nil { log.Fatal(err) } + notificationSecret, err := readCommitSecretFile("NOTIFICATION_WRITE_TOKEN_FILE") + if err != nil { + log.Fatal(err) + } + notificationSecretRef := os.Getenv("NOTIFICATION_WRITE_SECRET_REF") + if notificationSecretRef == "" { + log.Fatal("NOTIFICATION_WRITE_SECRET_REF is required") + } + dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient} + mux := http.NewServeMux() + mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) + registerExecutionRoutes(mux, repository, dispatcher, dispatcher, executorToken) registerCreatorCommandRoutes(mux, creatorCommandHandler{ core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository, - executor: executor, reconciler: executor, newID: ids.New, now: time.Now, + executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now, }) mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) { var request authorizationRequest @@ -138,6 +148,22 @@ func main() { } } +func readCommitSecretFile(name string) (string, error) { + path := os.Getenv(name) + if path == "" { + return "", fmt.Errorf("%s is required", name) + } + value, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", name, err) + } + secret := strings.TrimSpace(string(value)) + if secret == "" { + return "", fmt.Errorf("%s is empty", name) + } + return secret, nil +} + func envOr(name, fallback string) string { if value := os.Getenv(name); value != "" { return value diff --git a/cmd/commit-runtime/notification_executor.go b/cmd/commit-runtime/notification_executor.go new file mode 100644 index 0000000..d09fd9e --- /dev/null +++ b/cmd/commit-runtime/notification_executor.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "net/http" + + "git.cin.su/m/Assistant/adapters/notificationexecutor" + "git.cin.su/m/Assistant/contracts/capability" + commitcontract "git.cin.su/m/Assistant/contracts/commit" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/modules/commit/ports" +) + +type notificationBindingSource interface { + ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) + CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) +} + +type operationDispatcher struct { + continuityExecutor ports.Executor + continuityReconciler ports.Reconciler + bindings notificationBindingSource + secretRef string + secret string + client *http.Client +} + +func (d operationDispatcher) Execute(action commitcontract.AuthorizedAction) ports.ExecutionResult { + if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification { + return d.continuityExecutor.Execute(action) + } + subject := action.Proposal.NotificationSubject + exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision) + if err != nil || exact.SecretRef != d.secretRef { + return ports.ExecutionResult{Known: true} + } + active, err := d.bindings.ActiveCapabilityBinding(context.Background(), action.Proposal.CapabilityID, action.PrincipalID) + if err != nil || active != exact { + return ports.ExecutionResult{Known: true} + } + executor, err := notificationexecutor.New(exact, d.secret, d.client) + if err != nil { + return ports.ExecutionResult{Known: true} + } + return executor.Execute(action) +} + +func (d operationDispatcher) Reconcile(action commitcontract.AuthorizedAction) ports.ExecutionResult { + if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification { + return d.continuityReconciler.Reconcile(action) + } + subject := action.Proposal.NotificationSubject + exact, err := d.bindings.CapabilityBinding(context.Background(), subject.BindingID, subject.BindingRevision) + if err != nil || exact.SecretRef != d.secretRef { + return ports.ExecutionResult{} + } + reconciler, err := notificationexecutor.NewReconciler(exact, d.secret, d.client) + if err != nil { + return ports.ExecutionResult{} + } + return reconciler.Reconcile(action) +} diff --git a/cmd/commit-runtime/notification_executor_test.go b/cmd/commit-runtime/notification_executor_test.go new file mode 100644 index 0000000..3bdfb4a --- /dev/null +++ b/cmd/commit-runtime/notification_executor_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.cin.su/m/Assistant/adapters/notificationexecutor" + "git.cin.su/m/Assistant/contracts/capability" + commitcontract "git.cin.su/m/Assistant/contracts/commit" + "git.cin.su/m/Assistant/contracts/identity" +) + +type fakeNotificationBindings struct { + exact capability.Binding + active capability.Binding + exactErr error + activeErr error +} + +func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) { + return f.exact, f.exactErr +} +func (f fakeNotificationBindings) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) { + return f.active, f.activeErr +} + +func dispatcherAction(t *testing.T, binding capability.Binding) commitcontract.AuthorizedAction { + t.Helper() + payload := notificationexecutor.Payload{ActionIntentID: "intent-1", TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 1, Title: "Домовой", Message: "Пора вернуться"} + digest, err := notificationexecutor.Digest(payload) + if err != nil { + t.Fatal(err) + } + return commitcontract.AuthorizedAction{PrincipalID: binding.OwnerPrincipalID, Proposal: commitcontract.ActionProposal{IntentID: payload.ActionIntentID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item-1", ExpectedRevision: 1, TemplateCode: payload.TemplateCode, BindingID: binding.ID, BindingRevision: binding.Revision, RenderedTitle: payload.Title, RenderedMessage: payload.Message, RequestDigest: digest}}} +} + +func dispatcherBinding(origin string) capability.Binding { + return capability.Binding{ID: "binding-1", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: "owner-1", Revision: 1, State: capability.BindingActive, HTTPSOrigin: origin, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable} +} + +func TestOperationDispatcherRequiresExactCurrentBinding(t *testing.T) { + var action commitcontract.AuthorizedAction + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1}) + })) + defer server.Close() + binding := dispatcherBinding(server.URL) + action = dispatcherAction(t, binding) + fallback := &knownExecutor{} + dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding, active: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()} + if result := dispatcher.Execute(action); !result.Known || !result.Succeeded { + t.Fatalf("execute=%+v", result) + } + stale := binding + stale.Revision = 2 + dispatcher.bindings = fakeNotificationBindings{exact: binding, active: stale} + if result := dispatcher.Execute(action); !result.Known || result.Succeeded { + t.Fatalf("stale binding=%+v", result) + } +} + +func TestOperationDispatcherReconcilesRetiredHistoricalBinding(t *testing.T) { + var action commitcontract.AuthorizedAction + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatal("reconciliation mutated receiver") + } + _ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1}) + })) + defer server.Close() + binding := dispatcherBinding(server.URL) + action = dispatcherAction(t, binding) + binding.State = capability.BindingRetired + binding.Revision = 1 + fallback := &knownExecutor{} + dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()} + if result := dispatcher.Reconcile(action); !result.Known || !result.Succeeded { + t.Fatalf("reconcile=%+v", result) + } +} diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 194cc69..474ff0d 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -159,6 +159,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) { return targetCommit, "/v1/creator/authorized-actions", true case method == http.MethodPost && path == "/api/actions/satisfy-delivered": return targetCommit, "/v1/creator/continuity/satisfy-delivered", true + case method == http.MethodPost && path == "/api/actions/notify-delivered": + return targetCommit, "/v1/creator/continuity/notify-delivered", true case method == http.MethodPost && strings.HasPrefix(path, "/api/actions/") && strings.HasSuffix(path, "/reconcile"): id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/actions/"), "/reconcile") if validID(id) { diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index d3557ca..8c78dd5 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -74,6 +74,7 @@ func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) { {http.MethodGet, "/api/tasks/../../healthz"}, {http.MethodPost, "/api/actions/arbitrary"}, {http.MethodGet, "/api/actions/satisfy-delivered"}, + {http.MethodGet, "/api/actions/notify-delivered"}, } { request := httptest.NewRequest(test.method, test.path, nil) response := httptest.NewRecorder() @@ -158,6 +159,34 @@ func TestGatewayRoutesOnlyBoundedCreatorCommandsToCommit(t *testing.T) { } } +func TestGatewayRoutesTypedNotificationCommandToCommit(t *testing.T) { + commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/continuity/notify-delivered" || r.Method != http.MethodPost { + t.Fatalf("unexpected Commit target %s %s", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + text := string(body) + if !strings.Contains(text, "prospective-1") || !strings.Contains(text, "Confirm") || strings.Contains(text, "URL") || strings.Contains(text, "CapabilityID") { + t.Fatalf("unexpected notification body %s", text) + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"State":"SUCCEEDED"}`) + })) + defer commitRuntime.Close() + handler, err := newHandler("http://core-runtime.invalid", "http://continuity-runtime.invalid", commitRuntime.URL, commitRuntime.Client()) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/actions/notify-delivered", strings.NewReader(`{"ProspectiveItemID":"prospective-1","Confirm":true}`)) + request.Header.Set("Authorization", "Bearer bounded-session") + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("response=%d %s", response.Code, response.Body.String()) + } +} + func TestGatewayRoutesCreatorActionHistoryToCommit(t *testing.T) { commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/creator/authorized-actions" || r.URL.RawQuery != "limit=25" || r.Method != http.MethodGet { diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index 95249d3..508b63c 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -198,6 +198,11 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .action-result strong[data-state="FAILED"] { color: var(--bad); } .action-result strong[data-state="UNKNOWN_OUTCOME"] { color: var(--warn); } .action-result .secondary-button { min-width: 154px; margin: 0; padding: 0 14px; } +.notification-command { grid-column: 1 / -1; padding: 14px 0 0; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-top: 1px solid var(--line-soft); } +.notification-command div { min-width: 0; } +.notification-command strong { display: block; margin-bottom: 5px; color: var(--ink); font: 600 10px/1.2 var(--mono); letter-spacing: .06em; text-transform: uppercase; } +.notification-command span { display: block; color: var(--muted); font-size: 11px; line-height: 1.45; } +.notification-command .secondary-button { flex: 0 0 auto; min-width: 190px; margin: 0; } .confirmation-dialog { width: min(560px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #111915; box-shadow: var(--shadow); } .confirmation-dialog::backdrop { background: rgba(3, 6, 5, .78); backdrop-filter: blur(5px); } .confirmation-dialog form { padding: 30px; } @@ -269,6 +274,8 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px .commit-zone-copy { grid-column: auto; grid-row: auto; margin-bottom: 6px; } .action-result { grid-template-columns: 1fr; align-items: stretch; } .action-result .secondary-button { width: 100%; } + .notification-command { align-items: stretch; flex-direction: column; } + .notification-command .secondary-button { width: 100%; } .confirmation-dialog form { padding: 24px 20px; } .confirmation-grid { grid-template-columns: 1fr; } .confirmation-grid .wide { grid-column: auto; } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index be5c7d7..08f67b2 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -11,6 +11,7 @@ let toastTimer; let currentTask = null; let currentProspective = null; let pendingCommand = null; +let pendingNotification = null; let lastAuthorizedActionID = ""; function setText(id, value, fallback = "—") { @@ -93,6 +94,7 @@ function endSession(message = "Сессия завершена.") { currentTask = null; currentProspective = null; pendingCommand = null; + pendingNotification = null; lastAuthorizedActionID = ""; notify(message); } @@ -426,6 +428,7 @@ async function loadCapability(capabilityID) { function updateActionReadiness() { const button = byId("prepare-satisfaction"); + const notificationButton = byId("prepare-notification"); if (!button) return; const evidence = byId("satisfaction-evidence").value.trim(); let message = "Доступно только для доставленного ожидания с проверенной Work-задачей."; @@ -441,6 +444,19 @@ function updateActionReadiness() { } button.disabled = !ready; setText("action-readiness", message); + + let notificationReady = false; + let notificationMessage = "Доступно для доставленного ожидания с проверенной Work-задачей."; + if (!currentProspective) notificationMessage = "Сначала выберите и загрузите ожидание."; + else if (currentProspective.State !== "DELIVERED") notificationMessage = `Состояние ${currentProspective.State || "UNKNOWN"}: уведомление доступно только для DELIVERED.`; + else if (!currentTask) notificationMessage = "Загрузите проверенную Work-задачу как обоснование."; + else if (currentTask.GoalID !== currentProspective.GoalID || currentTask.ScopeID !== currentProspective.ScopeID) notificationMessage = "Цель или область Work-задачи не совпадает с ожиданием."; + else { + notificationReady = true; + notificationMessage = `Готово: receiver получит фиксированный шаблон для ${currentProspective.ID} · rev ${currentProspective.Revision}.`; + } + notificationButton.disabled = !notificationReady; + setText("notification-readiness", notificationMessage); } byId("satisfaction-evidence").addEventListener("input", updateActionReadiness); @@ -473,6 +489,53 @@ byId("cancel-satisfaction").addEventListener("click", () => { byId("satisfaction-dialog").close(); }); +byId("prepare-notification").addEventListener("click", () => { + updateActionReadiness(); + if (byId("prepare-notification").disabled || !currentTask || !currentProspective) return; + pendingNotification = { + Target: {TaskID: currentTask.TaskID, GoalID: currentTask.GoalID, ScopeID: currentTask.ScopeID, Revision: currentTask.Revision}, + ProspectiveItemID: currentProspective.ID, + ExpectedRevision: currentProspective.Revision, + Confirm: true, + }; + setText("notify-confirm-prospective", pendingNotification.ProspectiveItemID); + setText("notify-confirm-prospective-revision", pendingNotification.ExpectedRevision); + setText("notify-confirm-task", pendingNotification.Target.TaskID); + setText("notify-confirm-task-revision", pendingNotification.Target.Revision); + setText("notification-confirmation-error", "", ""); + byId("notification-dialog").showModal(); +}); + +byId("cancel-notification").addEventListener("click", () => { + pendingNotification = null; + byId("notification-dialog").close(); +}); + +byId("confirm-notification").addEventListener("click", async () => { + if (!pendingNotification) return; + const command = pendingNotification; + const button = byId("confirm-notification"); + const prepare = byId("prepare-notification"); + button.disabled = true; + prepare.disabled = true; + button.textContent = "Отправляю…"; + setText("notification-confirmation-error", "", ""); + try { + const result = await request("/api/actions/notify-delivered", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(command)}); + pendingNotification = null; + byId("notification-dialog").close(); + renderActionResult(result); + await loadActionHistory(); + notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки."); + } catch (error) { + setText("notification-confirmation-error", error.message); + } finally { + button.disabled = false; + button.textContent = "Подтверждаю отправку"; + updateActionReadiness(); + } +}); + byId("confirm-satisfaction").addEventListener("click", async () => { if (!pendingCommand) return; const command = pendingCommand; diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 48a8558..b97d3cd 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -164,6 +164,10 @@ +
+
Windows-напоминаниеДоступно для доставленного ожидания с проверенной Work-задачей.
+ +