Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
759328972a | ||
|
|
01e1bdea1e | ||
|
|
7a3280b007 | ||
|
|
c6139699a9 |
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -164,6 +164,10 @@
|
||||
<button id="prepare-satisfaction" class="commit-button" type="button" disabled>
|
||||
<span>Отметить исполненным</span><span aria-hidden="true">→</span>
|
||||
</button>
|
||||
<div class="notification-command">
|
||||
<div><strong>Windows-напоминание</strong><span id="notification-readiness">Доступно для доставленного ожидания с проверенной Work-задачей.</span></div>
|
||||
<button id="prepare-notification" class="secondary-button" type="button" disabled>Отправить уведомление</button>
|
||||
</div>
|
||||
<div id="action-result" class="action-result" hidden>
|
||||
<div><span>ИСХОД</span><strong id="action-state">—</strong></div>
|
||||
<div><span>ACTION</span><code id="action-id">—</code></div>
|
||||
@@ -209,6 +213,26 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="notification-dialog" class="confirmation-dialog" aria-labelledby="notification-confirmation-title">
|
||||
<form method="dialog">
|
||||
<p class="eyebrow">commit / внешнее действие</p>
|
||||
<h2 id="notification-confirmation-title">Отправить напоминание?</h2>
|
||||
<p class="confirmation-copy">Домовой создаст неизменяемое авторизованное действие и передаст типизированное уведомление в закреплённый receiver. Состояние Continuity не изменится; доставка не означает прочтение.</p>
|
||||
<dl class="confirmation-grid">
|
||||
<div><dt>Ожидание</dt><dd id="notify-confirm-prospective">—</dd></div>
|
||||
<div><dt>Ревизия</dt><dd id="notify-confirm-prospective-revision">—</dd></div>
|
||||
<div><dt>Work-задача</dt><dd id="notify-confirm-task">—</dd></div>
|
||||
<div><dt>Ревизия Work</dt><dd id="notify-confirm-task-revision">—</dd></div>
|
||||
<div class="wide"><dt>Канал</dt><dd>Локальное уведомление Windows · закреплённый receiver</dd></div>
|
||||
</dl>
|
||||
<p id="notification-confirmation-error" class="form-error" role="alert"></p>
|
||||
<div class="confirmation-actions">
|
||||
<button id="cancel-notification" class="text-button" type="button">Отмена</button>
|
||||
<button id="confirm-notification" class="commit-button" type="button">Подтверждаю отправку</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
<script src="/assets/console.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
assistent.cin.su {
|
||||
encode zstd gzip
|
||||
|
||||
handle /delivery/* {
|
||||
reverse_proxy host.docker.internal:18081
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy host.docker.internal:18080
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,25 @@ services:
|
||||
command: ["-js", "-m", "8222"]
|
||||
volumes: ["assistant_nats:/data"]
|
||||
|
||||
notification-receiver:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: notification-receiver } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: ":8085"
|
||||
RECEIPT_DIRECTORY: /var/lib/notification-receiver
|
||||
RECEIVER_WRITE_TOKEN_FILE: /run/notification-writer/token
|
||||
RECEIVER_READ_TOKEN_FILE: /run/notification-reader/token
|
||||
ports: ["${NOTIFICATION_RECEIVER_PORT:-18081}:8085"]
|
||||
volumes:
|
||||
- assistant_notification_receipts:/var/lib/notification-receiver
|
||||
- assistant_notification_writer_secret:/run/notification-writer:ro
|
||||
- assistant_notification_reader_secret:/run/notification-reader:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8085/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
core-runtime:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: core-runtime } }
|
||||
restart: unless-stopped
|
||||
@@ -82,8 +101,12 @@ services:
|
||||
CORE_RUNTIME_URL: http://core-runtime:8080
|
||||
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
|
||||
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
|
||||
NOTIFICATION_WRITE_TOKEN_FILE: /run/notification-writer/token
|
||||
NOTIFICATION_WRITE_SECRET_REF: domovoy-notification-write-v1
|
||||
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
|
||||
depends_on: [core-runtime, continuity-runtime]
|
||||
depends_on: [core-runtime, continuity-runtime, notification-receiver]
|
||||
volumes:
|
||||
- assistant_notification_writer_secret:/run/notification-writer:ro
|
||||
|
||||
cognitive-worker:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker } }
|
||||
@@ -111,3 +134,8 @@ volumes:
|
||||
assistant_nats:
|
||||
assistant_core_secrets:
|
||||
external: true
|
||||
assistant_notification_receipts:
|
||||
assistant_notification_writer_secret:
|
||||
external: true
|
||||
assistant_notification_reader_secret:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Endpoint = 'https://assistent.cin.su/delivery/v1/receipts?limit=100',
|
||||
[string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-read-key.dpapi.json",
|
||||
[string]$StatePath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-state.json",
|
||||
[ValidateRange(1, 5)]
|
||||
[int]$MaximumDisplays = 3,
|
||||
[switch]$NoDisplay
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Write-AtomicJson {
|
||||
param([Parameter(Mandatory)]$Value, [Parameter(Mandatory)][string]$Path)
|
||||
$directory = Split-Path -Parent $Path
|
||||
New-Item -ItemType Directory -Force -Path $directory | Out-Null
|
||||
$temporary = Join-Path $directory ('.{0}.{1}.tmp' -f (Split-Path -Leaf $Path), [guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
[IO.File]::WriteAllText($temporary, (($Value | ConvertTo-Json -Depth 8) + "`n"), [Text.UTF8Encoding]::new($false))
|
||||
Move-Item -LiteralPath $temporary -Destination $Path -Force
|
||||
}
|
||||
finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force } }
|
||||
}
|
||||
|
||||
$uri = [Uri]$Endpoint
|
||||
if ($uri.Scheme -ne 'https' -or $uri.Host -ne 'assistent.cin.su' -or $uri.AbsolutePath -ne '/delivery/v1/receipts') {
|
||||
throw 'Notification receiver endpoint is outside the fixed HTTPS binding'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf)) { throw "Notification receiver key not found: $KeyRecordPath" }
|
||||
$keyRecord = Get-Content -LiteralPath $KeyRecordPath -Raw | ConvertFrom-Json
|
||||
if ($keyRecord.version -ne 1 -or $keyRecord.project -ne 'domovoy' -or $keyRecord.purpose -ne 'notification receiver read') { throw 'Unsupported notification key record' }
|
||||
$secure = ConvertTo-SecureString ([string]$keyRecord.protected_value)
|
||||
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try { $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) }
|
||||
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) }
|
||||
if ([string]::IsNullOrWhiteSpace($token)) { throw 'Notification receiver key is empty' }
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $uri.AbsoluteUri -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 15
|
||||
}
|
||||
finally { $token = $null; $secure.Dispose() }
|
||||
|
||||
$seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
|
||||
if (Test-Path -LiteralPath $StatePath -PathType Leaf) {
|
||||
$previous = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
|
||||
if ($previous.version -eq 1) { foreach ($id in @($previous.displayed_receipt_ids)) { [void]$seen.Add([string]$id) } }
|
||||
}
|
||||
|
||||
$candidates = @($response.items | Where-Object {
|
||||
$_.receipt_id -match '^receipt-[A-Za-z0-9_-]{1,128}$' -and
|
||||
$_.action_intent_id -match '^[A-Za-z0-9_-]{1,128}$' -and
|
||||
$_.request_digest -match '^[0-9a-f]{64}$' -and
|
||||
$_.accepted_at_unix_millis -gt 0 -and
|
||||
$_.payload.template_code -eq 'prospective-delivered-v1' -and
|
||||
-not [string]::IsNullOrWhiteSpace([string]$_.payload.title) -and
|
||||
-not [string]::IsNullOrWhiteSpace([string]$_.payload.message) -and
|
||||
-not $seen.Contains([string]$_.receipt_id)
|
||||
} | Sort-Object accepted_at_unix_millis | Select-Object -First $MaximumDisplays)
|
||||
|
||||
if (-not $NoDisplay -and $candidates.Count -gt 0) {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$notification = [System.Windows.Forms.NotifyIcon]::new()
|
||||
try {
|
||||
$notification.Icon = [System.Drawing.SystemIcons]::Information
|
||||
$notification.Visible = $true
|
||||
$notification.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info
|
||||
foreach ($receipt in $candidates) {
|
||||
$notification.BalloonTipTitle = ([string]$receipt.payload.title).Substring(0, [Math]::Min(63, ([string]$receipt.payload.title).Length))
|
||||
$notification.BalloonTipText = ([string]$receipt.payload.message).Substring(0, [Math]::Min(255, ([string]$receipt.payload.message).Length))
|
||||
$notification.ShowBalloonTip(6000)
|
||||
Start-Sleep -Seconds 7
|
||||
[void]$seen.Add([string]$receipt.receipt_id)
|
||||
}
|
||||
}
|
||||
finally { $notification.Dispose() }
|
||||
}
|
||||
|
||||
if (-not $NoDisplay) {
|
||||
$bounded = @($seen | Select-Object -Last 1000)
|
||||
Write-AtomicJson -Path $StatePath -Value ([ordered]@{ version = 1; displayed_receipt_ids = $bounded; last_poll_at_utc = [DateTime]::UtcNow.ToString('o') })
|
||||
}
|
||||
Write-Output "notification_receiver=poll_ok new=$($candidates.Count) displayed=$(if ($NoDisplay) { 0 } else { $candidates.Count })"
|
||||
@@ -0,0 +1,40 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ScriptPath = '\\nas\MST\codex\assistent\deploy\windows\Invoke-DomovoyNotificationReceiver.ps1',
|
||||
[string]$KeyRecordPath = "$env:LOCALAPPDATA\Domovoy\notification-receiver-read-key.dpapi.json",
|
||||
[string]$NasKeyBackupPath = '\\nas\MST\codex\_backups\domovoy\keys\notification-receiver-read-key.dpapi.json',
|
||||
[string]$TaskName = 'Domovoy Notification Receiver'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf) -and (Test-Path -LiteralPath $NasKeyBackupPath -PathType Leaf)) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $KeyRecordPath) | Out-Null
|
||||
Copy-Item -LiteralPath $NasKeyBackupPath -Destination $KeyRecordPath
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $KeyRecordPath -PathType Leaf)) { throw 'DPAPI-protected notification read key is missing' }
|
||||
$record = Get-Content -LiteralPath $KeyRecordPath -Raw | ConvertFrom-Json
|
||||
if ($record.version -ne 1 -or $record.project -ne 'domovoy' -or $record.purpose -ne 'notification receiver read') { throw 'Unsupported notification key record' }
|
||||
$probe = ConvertTo-SecureString ([string]$record.protected_value)
|
||||
$probe.Dispose()
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
& icacls.exe $KeyRecordPath /inheritance:r /grant:r "${identity}:F" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'DPAPI key ACL hardening failed' }
|
||||
$nasDirectory = Split-Path -Parent $NasKeyBackupPath
|
||||
New-Item -ItemType Directory -Force -Path $nasDirectory | Out-Null
|
||||
$partial = "$NasKeyBackupPath.$PID.partial"
|
||||
Copy-Item -LiteralPath $KeyRecordPath -Destination $partial
|
||||
Move-Item -LiteralPath $partial -Destination $NasKeyBackupPath -Force
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ScriptPath -PathType Leaf)) { throw 'Notification receiver script not found' }
|
||||
$pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source
|
||||
$arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`""
|
||||
$action = New-ScheduledTaskAction -Execute $pwsh -Argument $arguments
|
||||
$repeat = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 2) -RepetitionDuration (New-TimeSpan -Days 3650)
|
||||
$logon = New-ScheduledTaskTrigger -AtLogOn -User $identity
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 2)
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger @($repeat, $logon) -Principal $principal -Settings $settings -Description 'Read typed Domovoy receipts and show local Windows notifications.' -Force | Out-Null
|
||||
Write-Output "Scheduled task registered: $TaskName"
|
||||
@@ -14,6 +14,7 @@ param(
|
||||
'assistant-core-runtime-1',
|
||||
'assistant-creator-web-1',
|
||||
'assistant-commit-runtime-1',
|
||||
'assistant-notification-receiver-1',
|
||||
'assistant-continuity-runtime-1',
|
||||
'assistant-cognitive-worker-1',
|
||||
'assistant-postgres-1',
|
||||
@@ -130,6 +131,14 @@ try {
|
||||
}
|
||||
catch { Add-Check 'offhost-backup-task' $false $_.Exception.Message }
|
||||
|
||||
try {
|
||||
$notificationTask = Get-ScheduledTask -TaskName 'Domovoy Notification Receiver'
|
||||
$notificationInfo = Get-ScheduledTaskInfo -TaskName $notificationTask.TaskName
|
||||
$ok = $notificationTask.State -ne 'Disabled' -and $notificationInfo.LastTaskResult -eq 0
|
||||
Add-Check 'notification-receiver-task' $ok "state=$($notificationTask.State) last_result=$($notificationInfo.LastTaskResult) next=$($notificationInfo.NextRunTime.ToString('o'))"
|
||||
}
|
||||
catch { Add-Check 'notification-receiver-task' $false $_.Exception.Message }
|
||||
|
||||
$overall = if (@($checks | Where-Object { -not $_.ok }).Count -eq 0) { 'healthy' } else { 'degraded' }
|
||||
$record = [ordered]@{
|
||||
version = 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000041-notification-delivery-foundation.json",
|
||||
"task_id": "DEV-000041",
|
||||
"task_file": "dev/tasks/DEV-000043-production-notification-delivery.json",
|
||||
"task_id": "DEV-000043",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"task_id": "DEV-000042",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0005",
|
||||
"base_repo_revision": "6e0040fe89c938549295798c77006877902a338d",
|
||||
"risk_tier": "E",
|
||||
"intent": "Enable the ADR-0005 typed prospective notification command through the authenticated Creator flow, exact CapabilityBinding validation, dynamic Commit Executor dispatch, and an explicit confirmation control in the Creator console without installing production credentials or deploying.",
|
||||
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001", "INV-CONT-001", "ADR-0005"],
|
||||
"allowed_change_scope": {
|
||||
"modules": ["commit"],
|
||||
"paths": [
|
||||
"modules/commit/domain/commit.go",
|
||||
"modules/commit/domain/notification_test.go",
|
||||
"adapters/notificationexecutor/executor.go",
|
||||
"adapters/notificationexecutor/executor_test.go",
|
||||
"cmd/commit-runtime/creator_clients.go",
|
||||
"cmd/commit-runtime/creator_command.go",
|
||||
"cmd/commit-runtime/creator_command_test.go",
|
||||
"cmd/commit-runtime/main.go",
|
||||
"cmd/commit-runtime/notification_executor.go",
|
||||
"cmd/commit-runtime/notification_executor_test.go",
|
||||
"cmd/creator-web/server.go",
|
||||
"cmd/creator-web/server_test.go",
|
||||
"cmd/creator-web/static/index.html",
|
||||
"cmd/creator-web/static/assets/console.js",
|
||||
"cmd/creator-web/static/assets/console.css",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000042-creator-notification-command.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {
|
||||
"paths": ["architecture/**", "ci/**", ".gitea/**", "database/**", "deploy/**", "security/**", "modules/identity/**", "modules/authority/**", "modules/work/**", "modules/continuity/**"]
|
||||
},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["contracts.identity@v1", "contracts.authority@v1", "contracts.work@v1", "contracts.continuity@v1", "contracts.capability@v1", "contracts.commit@v1", "ADR-0005"], "may_change": []},
|
||||
"semantic_owner": "Commit owns action evidence and attempts; Capability owns binding; Continuity owns the unchanged ProspectiveItem; Creator UI is a replaceable projection",
|
||||
"canonical_state": ["existing Commit authorized action envelopes and attempts only"],
|
||||
"state_transitions": ["notification action AUTHORIZED to EXECUTING to SUCCEEDED, FAILED, or UNKNOWN_OUTCOME", "ProspectiveItem remains DELIVERED"],
|
||||
"side_effects_and_privileges": ["new Creator route may invoke only deliver-prospective-notification-v1 after explicit confirmation", "Executor resolves only the exact committed binding and configured external secret reference", "no credential installation or production call in this task"],
|
||||
"persistence_and_migrations": ["uses DEV-000041 additive schema; no migration in this task"],
|
||||
"failure_retry_restart": ["stale item, Work target, grant, binding, owner or digest fail closed", "ambiguous PUT records UNKNOWN_OUTCOME", "reconciliation uses GET only", "retired historical binding may reconcile but cannot execute a new send"],
|
||||
"assumptions": ["one configured write secret reference is sufficient for the initial receiver", "browser confirmation is explicit per notification request", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["browser cannot submit actor, principal, capability, operation, destination, template, body, IDs, headers or credentials", "confirmation=false is rejected", "foreign owner, non-DELIVERED or stale item, stale Work, invalid grant, and missing/unhealthy/revised binding are rejected before external execution", "server renders the fixed V1 message and digest", "dynamic executor dispatch leaves existing Continuity satisfaction unchanged", "action history identifies notification actions", "UI exposes clear pending/success/unknown/error states and remains usable on desktop/mobile"],
|
||||
"required_tests": ["domain authorization", "hostile request fields", "dynamic executor dispatch", "Creator HTTP", "gateway allowlist", "responsive browser", "browser console", "architecture preflight", "exact diff scope", "go test", "go vet", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"task_id": "DEV-000043",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0005",
|
||||
"base_repo_revision": "c6139699a9efed624b49b15c801f5c04e884a0aa",
|
||||
"risk_tier": "E",
|
||||
"intent": "Deploy the accepted notification operation on docker-prod using available port 18081, fixed HTTPS routing, isolated writer/reader secret volumes, a durable receiver volume, and a current-user Windows display agent with DPAPI-protected read credential; verify an exact-artifact end-to-end receipt without docker-test.",
|
||||
"requirement_ids": ["ADR-0005", "INV-AUTH-001", "INV-OWN-001", "INV-TOPO-001", "USER-DOCKER-PROD-ONLY"],
|
||||
"allowed_change_scope": {
|
||||
"modules": [],
|
||||
"paths": [
|
||||
"deploy/docker-compose.prod.yml",
|
||||
"deploy/caddy/assistent.caddy",
|
||||
"deploy/windows/Invoke-DomovoyNotificationReceiver.ps1",
|
||||
"deploy/windows/Register-DomovoyNotificationTask.ps1",
|
||||
"deploy/windows/Test-DomovoyProductionHealth.ps1",
|
||||
"tests/creator_commit/verify-remote.ps1",
|
||||
"docs/CREDENTIALS_AND_SECRETS.md",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"dev/ACTIVE_TASK.json",
|
||||
"dev/tasks/DEV-000043-production-notification-delivery.json"
|
||||
]
|
||||
},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/**", "database/**", "security/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 2, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["ADR-0005", "DEV-000041 receiver protocol", "DEV-000042 Creator command", "existing production compose and monitoring"], "may_change": []},
|
||||
"semantic_owner": "no new canonical owner; receiver is external receipt state and Windows state is a rebuildable projection",
|
||||
"canonical_state": ["CapabilityBinding inserted through Capability-owned schema", "Commit action/attempt evidence", "receiver immutable receipt volume"],
|
||||
"state_transitions": ["install ACTIVE binding revision 1", "external receipt absent to present", "Windows local projection unseen to displayed"],
|
||||
"side_effects_and_privileges": ["generate isolated write and read tokens outside Git", "deploy notification-receiver on docker-prod port 18081", "route only /delivery/* through assistent.cin.su", "mount write secret into Commit and receiver only", "mount read secret into receiver only", "register current-user Windows polling task"],
|
||||
"persistence_and_migrations": ["apply migrations 010 and 011 on production", "create external secret volumes and durable receiver volume", "store Windows read token only as CurrentUser DPAPI ciphertext and back up ciphertext to the private NAS key directory"],
|
||||
"failure_retry_restart": ["compose restart preserves receipts", "Windows polls read-only and deduplicates by receipt ID", "poll failure leaves receipts unseen for later retry", "receiver outage yields UNKNOWN_OUTCOME and read-only reconciliation", "no blind mutation retry"],
|
||||
"assumptions": ["docker.cin.su SSH alias docker is the production host", "port 18081 is available", "Caddy host routing can reach host.docker.internal:18081", "assistent.cin.su TLS remains valid", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["all expected containers including notification-receiver are healthy", "Caddy exposes receiver only under HTTPS /delivery/*", "unauthorized/write-with-read-token/arbitrary path requests are denied", "same intent and digest returns one receipt and mismatched digest conflicts", "production migration and binding match exact ADR fields", "Creator command yields SUCCEEDED or recoverable UNKNOWN_OUTCOME with durable evidence", "Windows agent reads without write privilege and displays/deduplicates the receipt", "restart preserves receipt and scheduled task succeeds", "no plaintext token appears in Git, task arguments, logs, database, browser storage or NAS"],
|
||||
"required_tests": ["PowerShell parser", "unit", "architecture preflight", "exact diff scope", "go test", "go vet", "CI", "production compose health", "HTTPS receiver adversarial smoke", "Creator authenticated smoke", "receiver restart persistence", "Windows scheduled-task result", "browser console", "docker-test absence"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -24,3 +24,5 @@ Each privileged runtime gets only its required secret/capability bindings. `comm
|
||||
Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together.
|
||||
|
||||
The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile.
|
||||
|
||||
Prospective notification delivery uses two unrelated random tokens. The write token is stored only in external Docker volume `assistant_notification_writer_secret`, mounted read-only into `commit-runtime` and `notification-receiver`. The read token is stored in external Docker volume `assistant_notification_reader_secret`, mounted read-only only into `notification-receiver`; its workstation copy is CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\notification-receiver-read-key.dpapi.json`. Only that ciphertext is backed up to `\\nas\MST\codex\_backups\domovoy\keys\notification-receiver-read-key.dpapi.json`. Plaintext tokens must not enter compose environment, Git, PostgreSQL, task arguments, logs, browser storage, or NAS.
|
||||
|
||||
@@ -14,6 +14,12 @@ Temporal is intentionally **not** the canonical owner of future obligations. If/
|
||||
|
||||
The initial stack definition is `deploy/docker-compose.prod.yml`. Portainer may deploy it from Git after the repository has been pushed.
|
||||
|
||||
The first external operation adds `assistant-notification-receiver-1` on production host port `18081`. Caddy applies `deploy/caddy/assistent.caddy`, routing only `https://assistent.cin.su/delivery/*` to that port and all other paths to Creator Web on `18080`. The receiver has an isolated durable volume and no Assistant database access. Commit success means an immutable receiver receipt exists; Windows display and human reading are not Commit outcomes.
|
||||
|
||||
Migrations `010_capability_notification_binding.sql` and `011_commit_notification_envelope.sql` must be applied before the new runtimes start. The initial Capability binding is owner-local for `domovoy-creator-principal`, revision 1, capability `prospective-notification-delivery`, origin `https://assistent.cin.su`, delivery path `/delivery/v1/deliveries`, receipt path `/delivery/v1/receipts`, protocol `IDEMPOTENT_RECEIPT_V1`, and secret reference `domovoy-notification-write-v1`.
|
||||
|
||||
`deploy/windows/Invoke-DomovoyNotificationReceiver.ps1` polls the receipt list with the read-only credential, displays up to three unseen local notifications, and records only rebuildable receipt IDs under `%LOCALAPPDATA%\Domovoy`. `Register-DomovoyNotificationTask.ps1` registers the current-user task every two minutes and at logon. It requires an interactive session for display and never uses `docker-test`.
|
||||
|
||||
Credentials/API deployment automation is deliberately not embedded. Use `PORTAINER_API_KEY` from an external secret source or deploy through Portainer UI/API after authenticating.
|
||||
|
||||
The single Creator login is configured with external `CREATOR_USERNAME` and `CREATOR_PASSWORD` values. They issue the same bounded Creator session and never select Actor, Principal, Scope or Authority. Keep `CREATOR_BOOTSTRAP_TOKEN` as a separate recovery/automation credential; none of these values belongs in Git, compose defaults for production, image layers or logs.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
@@ -71,11 +72,22 @@ func allowedOperation(proposal commitcontract.ActionProposal, context Authorizat
|
||||
return subject.ProspectiveItemID != "" && subject.ExpectedRevision > 0 &&
|
||||
subject.FromState == continuity.ProspectiveDelivered && subject.ToState == continuity.ProspectiveSatisfied &&
|
||||
strings.TrimSpace(subject.EvidenceRef) != ""
|
||||
case context.Grant.Class == authority.GrantClassCreator && proposal.CapabilityID == commitcontract.CapabilityProspectiveNotification && proposal.Operation == commitcontract.OperationDeliverProspectiveNotification:
|
||||
subject := proposal.NotificationSubject
|
||||
return subject.ProspectiveItemID != "" && subject.ExpectedRevision > 0 &&
|
||||
subject.TemplateCode == commitcontract.ProspectiveDeliveredTemplateV1 && subject.BindingID != "" && subject.BindingRevision > 0 &&
|
||||
strings.TrimSpace(subject.RenderedTitle) != "" && len(subject.RenderedTitle) <= 160 &&
|
||||
strings.TrimSpace(subject.RenderedMessage) != "" && len(subject.RenderedMessage) <= 2048 && validDigest(subject.RequestDigest)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validDigest(value string) bool {
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && len(decoded) == 32 && value == strings.ToLower(value)
|
||||
}
|
||||
|
||||
// Execute records UNKNOWN_OUTCOME rather than silently retrying an ambiguous
|
||||
// external mutation.
|
||||
func Execute(action commitcontract.AuthorizedAction, attemptID commitcontract.ActionAttemptID, executor ports.Executor) (commitcontract.ActionAttempt, error) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
commitcontract "git.cin.su/m/Assistant/contracts/commit"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
type allowValidator struct{}
|
||||
|
||||
func (allowValidator) Validate(authority.AuthorizationContext) error { return nil }
|
||||
|
||||
func notificationProposal() commitcontract.ActionProposal {
|
||||
return commitcontract.ActionProposal{IntentID: "intent", ActorID: "actor", CapabilityID: commitcontract.CapabilityProspectiveNotification, Target: work.TaskTarget{TaskID: "task", GoalID: "goal", ScopeID: "scope", Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item", ExpectedRevision: 1, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: "binding", BindingRevision: 1, RenderedTitle: "Домовой", RenderedMessage: "Пора вернуться", RequestDigest: strings.Repeat("a", 64)}}
|
||||
}
|
||||
func notificationContext() AuthorizationContext {
|
||||
var context AuthorizationContext
|
||||
context.Binding.ActorID, context.Binding.PrincipalID, context.Binding.Active = "actor", "owner", true
|
||||
context.Scope = authority.Scope{ID: "scope"}
|
||||
context.Grant = authority.AuthorityGrant{ID: "grant", ActorID: "actor", PrincipalID: "owner", ScopeID: "scope", Class: authority.GrantClassCreator, Revision: 1, Active: true}
|
||||
return context
|
||||
}
|
||||
|
||||
func TestAuthorizeProspectiveNotification(t *testing.T) {
|
||||
action, err := Authorize(notificationProposal(), notificationContext(), allowValidator{}, "authorized", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification {
|
||||
t.Fatal("operation changed")
|
||||
}
|
||||
}
|
||||
func TestAuthorizeProspectiveNotificationRejectsInvalidEvidence(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*commitcontract.ActionProposal){"binding": func(p *commitcontract.ActionProposal) { p.NotificationSubject.BindingID = "" }, "digest": func(p *commitcontract.ActionProposal) { p.NotificationSubject.RequestDigest = "AA" }, "message": func(p *commitcontract.ActionProposal) { p.NotificationSubject.RenderedMessage = "" }, "operation": func(p *commitcontract.ActionProposal) { p.Operation = "arbitrary-webhook" }} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
proposal := notificationProposal()
|
||||
mutate(&proposal)
|
||||
if _, err := Authorize(proposal, notificationContext(), allowValidator{}, "authorized", 1); err == nil {
|
||||
t.Fatal("expected denial")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ $ErrorActionPreference = "Stop"
|
||||
$coreContainer = "assistant-core-runtime-1"
|
||||
$commitContainer = "assistant-commit-runtime-1"
|
||||
$continuityContainer = "assistant-continuity-runtime-1"
|
||||
$receiverContainer = "assistant-notification-receiver-1"
|
||||
|
||||
function Post-Json([string]$container, [string]$url, [object]$body) {
|
||||
$json = $body | ConvertTo-Json -Depth 10 -Compress
|
||||
@@ -101,4 +102,30 @@ if ($updated.State -ne "SATISFIED" -or $updated.Revision -ne 2) { throw "Creator
|
||||
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/satisfy-delivered $command $creatorSession 409
|
||||
Expect-CreatorStatus "http://localhost:8082/v1/creator/authorized-actions/$($action.AuthorizedActionID)/reconcile" @{} $creatorSession 409
|
||||
|
||||
$notificationProspective = "prospective-notification-$suffix"
|
||||
Post-Json $continuityContainer http://localhost:8081/v1/prospective-items @{
|
||||
ID=$notificationProspective;CommitmentID="commitment-notification-$suffix";OwnerPrincipalID="domovoy-creator-principal"
|
||||
ScopeID=$scope;GoalID=$goal;TriggerAtUnixMillis=1800000000000;State="DELIVERED";Revision=1
|
||||
} | Out-Null
|
||||
$notificationCommand = @{
|
||||
Target=@{TaskID=$task;GoalID=$goal;ScopeID=$scope;Revision=1}
|
||||
ProspectiveItemID=$notificationProspective;ExpectedRevision=1;Confirm=$true
|
||||
}
|
||||
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/notify-delivered $notificationCommand "invalid" 401
|
||||
$hostileNotification = $notificationCommand.Clone()
|
||||
$hostileNotification.URL = 'https://example.invalid'
|
||||
$hostileNotification.Body = 'arbitrary'
|
||||
Expect-CreatorStatus http://localhost:8082/v1/creator/continuity/notify-delivered $hostileNotification $creatorSession 400
|
||||
$notificationAction = Creator-Post http://localhost:8082/v1/creator/continuity/notify-delivered $notificationCommand $creatorSession | ConvertFrom-Json
|
||||
if ($notificationAction.State -ne 'SUCCEEDED' -or -not $notificationAction.ActionIntentID) { throw 'Creator notification did not produce a successful receipt' }
|
||||
$notificationItem = docker --context $DockerContext exec $continuityContainer wget -qO- "http://localhost:8081/v1/prospective-items/$notificationProspective" | ConvertFrom-Json
|
||||
if ($notificationItem.State -ne 'DELIVERED' -or $notificationItem.Revision -ne 1) { throw 'Notification changed Continuity canonical state' }
|
||||
$receiptRaw = docker --context $DockerContext exec $receiverContainer sh -c 'token=$(tr -d "\r\n" < /run/notification-reader/token); wget -qO- --header="Authorization: Bearer $token" "$1"' sh "http://localhost:8085/delivery/v1/receipts/$($notificationAction.ActionIntentID)"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Notification receipt lookup failed' }
|
||||
$receipt = ($receiptRaw -join "`n") | ConvertFrom-Json
|
||||
if ($receipt.action_intent_id -ne $notificationAction.ActionIntentID -or $receipt.request_digest -notmatch '^[0-9a-f]{64}$') { throw 'Notification receipt evidence mismatch' }
|
||||
$notificationHistory = Creator-Get 'http://localhost:8082/v1/creator/authorized-actions?limit=50' $creatorSession | ConvertFrom-Json
|
||||
$notificationHistoryItem = @($notificationHistory.Items | Where-Object { $_.AuthorizedActionID -eq $notificationAction.AuthorizedActionID })
|
||||
if ($notificationHistoryItem.Count -ne 1 -or $notificationHistoryItem[0].Operation -ne 'deliver-prospective-notification-v1' -or $notificationHistoryItem[0].State -ne 'SUCCEEDED') { throw 'Notification action history is incomplete' }
|
||||
|
||||
Write-Output "Creator Commit command verification: PASS"
|
||||
|
||||
Reference in New Issue
Block a user