package service import ( "axolotl/models" "axolotl/store" "fmt" "maps" "regexp" "slices" "strings" "time" ) type nodeServiceImpl struct { store store.Store userID string } var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) func mentions(t string) []string { seen := make(map[string]bool) for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { seen[m[1]] = true } return slices.Collect(maps.Keys(seen)) } func (s *nodeServiceImpl) User() string { return s.userID } // --- Access control --- // accessContext holds namespace IDs readable/writable by the current user. // Nodes with no in_namespace are globally accessible (empty namespaceID always passes). type accessContext struct { readable map[string]bool writable map[string]bool } func (ac *accessContext) canRead(namespaceID string) bool { if namespaceID == "" { return true } return ac.readable[namespaceID] } func (ac *accessContext) canWrite(namespaceID string) bool { if namespaceID == "" { return true } return ac.writable[namespaceID] } // getAccessContext builds an accessContext by reading the current user's outgoing // has_write_access and has_read_access edges. If the user node does not yet exist // (first-time bootstrap) both maps are empty. func (s *nodeServiceImpl) getAccessContext() (*accessContext, error) { ctx := &accessContext{ readable: make(map[string]bool), writable: make(map[string]bool), } userNodeID, err := s.resolveIDByNameAndType(s.store, s.userID, "user") if err != nil { return nil, err } if userNodeID == "" { return ctx, nil // not yet bootstrapped; no namespace permissions } userNode, err := s.store.GetNode(userNodeID) if err != nil { return nil, err } rels := userNode.Relations for _, nsID := range rels[string(models.RelHasWriteAccess)] { ctx.writable[nsID] = true ctx.readable[nsID] = true } for _, nsID := range rels[string(models.RelHasReadAccess)] { ctx.readable[nsID] = true } return ctx, nil } // nodeNamespaceID returns the first in_namespace target of n, or "" if none. func (s *nodeServiceImpl) nodeNamespaceID(n *models.Node) string { ids := n.Relations[string(models.RelInNamespace)] if len(ids) == 0 { return "" } return ids[0] } // checkRelTargetWrite verifies the current user has write access to the namespace // of each relation target. Targets that do not yet exist are skipped (they will // be created during the transaction and access granted there). func (s *nodeServiceImpl) checkRelTargetWrite(ac *accessContext, addRels []RelInput) error { for _, ri := range addRels { targetID, found := s.lookupRelTarget(ri.Type, ri.Target) if !found || targetID == "" { continue } targetNode, err := s.store.GetNode(targetID) if err != nil { continue // let the transaction surface missing-node errors } if !ac.canWrite(s.nodeNamespaceID(targetNode)) { return fmt.Errorf("permission denied: no write access to namespace of %s target %q", ri.Type, ri.Target) } } return nil } // --- Validation --- var ( validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true} validStatuses = map[string]bool{"open": true, "done": true} validPrios = map[string]bool{"high": true, "medium": true, "low": true} ) func validateTags(tags []string) error { for _, t := range tags { if v, ok := strings.CutPrefix(t, "_type::"); ok { if !validTypes[v] { return fmt.Errorf("invalid type %q: must be one of issue, note, user, namespace", v) } } else if v, ok := strings.CutPrefix(t, "_status::"); ok { if !validStatuses[v] { return fmt.Errorf("invalid status %q: must be one of open, done", v) } } else if v, ok := strings.CutPrefix(t, "_prio::"); ok { if !validPrios[v] { return fmt.Errorf("invalid priority %q: must be one of high, medium, low", v) } } } return nil } // tagValue returns the value of the first tag with the given prefix, or "". func tagValue(tags []string, prefix string) string { for _, t := range tags { if v, ok := strings.CutPrefix(t, prefix); ok { return v } } return "" } // --- Query --- func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { n, err := s.store.GetNode(id) if err != nil { return nil, err } ac, err := s.getAccessContext() if err != nil { return nil, err } if !ac.canRead(s.nodeNamespaceID(n)) { return nil, fmt.Errorf("permission denied: no read access to node %s", id) } return n, nil } func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { var relFilters []*models.Rel for _, ri := range filter.Rels { id, ok := s.lookupRelTarget(ri.Type, ri.Target) if !ok { return nil, nil // named target doesn't exist; no nodes can match } relFilters = append(relFilters, &models.Rel{Type: ri.Type, Target: id}) } nodes, err := s.store.FindNodes(filter.Tags, relFilters) if err != nil { return nil, err } ac, err := s.getAccessContext() if err != nil { return nil, err } var result []*models.Node for _, n := range nodes { if ac.canRead(s.nodeNamespaceID(n)) { result = append(result, n) } } return result, nil } // --- Lifecycle --- func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) { // Copy tags so we can extend without mutating the input. tags := make([]string, len(input.Tags)) copy(tags, input.Tags) // Apply defaults. nodeType := tagValue(tags, "_type::") if nodeType == "" { nodeType = "issue" tags = append(tags, "_type::issue") } if nodeType == "issue" && tagValue(tags, "_status::") == "" { tags = append(tags, "_status::open") } // Validate special tags. if err := validateTags(tags); err != nil { return nil, err } // --- Permission check --- ac, err := s.getAccessContext() if err != nil { return nil, err } // Determine the target namespace name (explicit or default). targetNSName := s.userID for _, ri := range input.Rels { if ri.Type == models.RelInNamespace { targetNSName = ri.Target break } } // Check write access only when the namespace already exists; if it doesn't // exist yet it will be created in the transaction and access granted there. if nsID, found := s.lookupRelTarget(models.RelInNamespace, targetNSName); found { if !ac.canWrite(nsID) { return nil, fmt.Errorf("permission denied: no write access to namespace %q", targetNSName) } } // Check write access for all other relation targets. var nonNSRels []RelInput for _, ri := range input.Rels { if ri.Type != models.RelInNamespace { nonNSRels = append(nonNSRels, ri) } } if err := s.checkRelTargetWrite(ac, nonNSRels); err != nil { return nil, err } // Build initial relation map from rels input. rels := make(map[models.RelType][]string) hasNamespace := false for _, ri := range input.Rels { if ri.Type == models.RelInNamespace { hasNamespace = true } rels[ri.Type] = append(rels[ri.Type], ri.Target) } if !hasNamespace { rels[models.RelInNamespace] = []string{s.userID} } id, err := s.store.GenerateID() if err != nil { return nil, err } err = s.store.Transaction(func(st store.Store) error { now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, input.Title, input.Content, input.DueDate, now, now); err != nil { return err } for _, m := range mentions(input.Title + " " + input.Content) { userID, err := s.resolveUserRef(st, m) if err != nil { return err } if err := st.AddEdge(id, userID, models.RelMentions); err != nil { return err } } for _, t := range tags { if err := st.AddTag(id, t); err != nil { return err } } hasCreated := false for rt, tgts := range rels { for _, tgt := range tgts { if rt == models.RelCreated { hasCreated = true } resolved, err := s.resolveRelTarget(st, RelInput{Type: rt, Target: tgt}) if err != nil { return err } if err := st.AddEdge(id, resolved, rt); err != nil { return err } } } if !hasCreated { userID, err := s.resolveUserRef(st, s.userID) if err != nil { return err } if err := st.AddEdge(id, userID, models.RelCreated); err != nil { return err } } return nil }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, error) { // Validate tags before doing any I/O. if err := validateTags(input.AddTags); err != nil { return nil, err } // --- Permission check --- current, err := s.store.GetNode(id) if err != nil { return nil, err } ac, err := s.getAccessContext() if err != nil { return nil, err } if !ac.canWrite(s.nodeNamespaceID(current)) { return nil, fmt.Errorf("permission denied: no write access to node %s", id) } if err := s.checkRelTargetWrite(ac, input.AddRels); err != nil { return nil, err } // Enforce blocking constraint before allowing status=done. for _, t := range input.AddTags { if t == "_status::done" { if err := s.checkBlockers(id); err != nil { return nil, err } break } } err = s.store.Transaction(func(st store.Store) error { current, err := st.GetNode(id) if err != nil { return err } // Determine final scalar values. title, content, dueDate := current.Title, current.Content, current.DueDate if input.Title != nil { title = *input.Title } if input.Content != nil { content = *input.Content } if input.DueDate != nil { dueDate = *input.DueDate } now := time.Now().UTC().Format(time.RFC3339) if err := st.UpdateNode(id, title, content, dueDate, now); err != nil { return err } // Compute new tag set using the model's AddTag/RemoveTag to preserve // property-prefix replacement semantics. tmp := models.NewNode() for _, t := range current.Tags { tmp.AddTag(t) } for _, t := range input.AddTags { tmp.AddTag(t) } for _, t := range input.RemoveTags { tmp.RemoveTag(t) } currentTags, newTags := current.Tags, tmp.Tags for _, t := range currentTags { if !slices.Contains(newTags, t) { if err := st.RemoveTag(id, t); err != nil { return err } } } for _, t := range newTags { if !slices.Contains(currentTags, t) { if err := st.AddTag(id, t); err != nil { return err } } } // Sync mention edges when title or content changed. if input.Title != nil || input.Content != nil { if err := s.syncMentions(st, id, current, title, content); err != nil { return err } } currentRels := current.Relations for _, ri := range input.AddRels { resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } // Single-value relations replace the previous target. if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace { for _, oldTgt := range currentRels[string(ri.Type)] { if err := st.RemoveEdge(id, oldTgt, ri.Type); err != nil { return err } } } if err := st.AddEdge(id, resolved, ri.Type); err != nil { return err } } for _, ri := range input.RemoveRels { resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } if err := st.RemoveEdge(id, resolved, ri.Type); err != nil { return err } } return nil }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) Delete(id string) error { n, err := s.store.GetNode(id) if err != nil { return err } ac, err := s.getAccessContext() if err != nil { return err } if !ac.canWrite(s.nodeNamespaceID(n)) { return fmt.Errorf("permission denied: no write access to node %s", id) } return s.store.DeleteNode(id) } // --- User management --- func (s *nodeServiceImpl) AddUser(name string) (*models.Node, error) { var id string err := s.store.Transaction(func(st store.Store) error { var err error id, err = s.ensureUser(st, name) return err }) if err != nil { return nil, err } return s.store.GetNode(id) } func (s *nodeServiceImpl) ListUsers() ([]*models.Node, error) { return s.store.FindNodes([]string{"_type::user"}, nil) } // --- Internal helpers --- func (s *nodeServiceImpl) checkBlockers(id string) error { // Find all nodes that declare a blocks → id relation (i.e., open blockers). blockers, err := s.store.FindNodes(nil, []*models.Rel{{Type: models.RelBlocks, Target: id}}) if err != nil { return err } var blocking []string for _, b := range blockers { if b.GetProperty("status") == "open" { blocking = append(blocking, b.ID) } } if len(blocking) > 0 { return fmt.Errorf("cannot close: blocked by %v", blocking) } return nil } func (s *nodeServiceImpl) syncMentions(st store.Store, id string, current *models.Node, newTitle, newContent string) error { existingMentionIDs := make(map[string]bool) for _, uid := range current.Relations[string(models.RelMentions)] { existingMentionIDs[uid] = true } mentionedUserIDs := make(map[string]bool) for _, m := range mentions(newTitle + " " + newContent) { userID, err := s.resolveUserRef(st, m) if err != nil { return err } mentionedUserIDs[userID] = true if !existingMentionIDs[userID] { if err := st.AddEdge(id, userID, models.RelMentions); err != nil { return err } } } for uid := range existingMentionIDs { if !mentionedUserIDs[uid] { if err := st.RemoveEdge(id, uid, models.RelMentions); err != nil { return err } } } return nil } // resolveRelTarget resolves a RelInput target to a node ID, auto-creating users // and namespaces as needed. Use only inside a transaction. func (s *nodeServiceImpl) resolveRelTarget(st store.Store, ri RelInput) (string, error) { switch ri.Type { case models.RelAssignee, models.RelCreated, models.RelMentions: return s.resolveUserRef(st, ri.Target) case models.RelInNamespace, models.RelHasReadAccess, models.RelHasWriteAccess: return s.resolveNamespaceRef(st, ri.Target) default: return ri.Target, nil // blocks/subtask/related expect raw node IDs } } // lookupRelTarget resolves a filter target to a node ID without creating anything. // Returns ("", false) when the target doesn't exist. func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) (string, bool) { if exists, _ := s.store.NodeExists(target); exists { return target, true } var nodeType string switch relType { case models.RelAssignee, models.RelCreated, models.RelMentions: nodeType = "user" case models.RelInNamespace, models.RelHasReadAccess, models.RelHasWriteAccess: nodeType = "namespace" default: return target, true } id, err := s.resolveIDByNameAndType(s.store, target, nodeType) if err != nil || id == "" { return "", false } return id, true } // resolveIDByNameAndType finds a node by title and _type tag without creating it. func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) { nodes, err := st.FindNodes([]string{"_type::" + nodeType}, nil) if err != nil { return "", err } for _, n := range nodes { if n.Title == title { return n.ID, nil } } return "", nil } func (s *nodeServiceImpl) resolveUserRef(st store.Store, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureUser(st, ref) } func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, error) { userID, err := s.resolveIDByNameAndType(st, username, "user") if err != nil { return "", err } if userID != "" { return userID, nil } id, err := st.GenerateID() if err != nil { return "", err } now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, username, "", "", now, now); err != nil { return "", err } if err := st.AddTag(id, "_type::user"); err != nil { return "", err } return id, nil } func (s *nodeServiceImpl) resolveNamespaceRef(st store.Store, ref string) (string, error) { if exists, _ := st.NodeExists(ref); exists { return ref, nil } return s.ensureNamespace(st, ref) } func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, error) { nsID, err := s.resolveIDByNameAndType(st, name, "namespace") if err != nil { return "", err } if nsID != "" { return nsID, nil } id, err := st.GenerateID() if err != nil { return "", err } now := time.Now().UTC().Format(time.RFC3339) if err := st.AddNode(id, name, "", "", now, now); err != nil { return "", err } if err := st.AddTag(id, "_type::namespace"); err != nil { return "", err } if err := st.AddEdge(id, id, models.RelInNamespace); err != nil { return "", err } userID, err := s.resolveUserRef(st, s.userID) if err != nil { return "", err } if err := st.AddEdge(id, userID, models.RelCreated); err != nil { return "", err } // Grant the creator write access to the new namespace. if err := st.AddEdge(userID, id, models.RelHasWriteAccess); err != nil { return "", err } return id, nil }