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 rels. 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 edge rel target. Tag rels (empty Target) and targets that do not yet // exist are skipped. func (s *nodeServiceImpl) checkRelTargetWrite(ac *accessContext, addRels []RelInput) error { for _, ri := range addRels { if ri.Target == "" { continue // tag rel — no target node to check } 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} ) // validateRels checks that any _ -prefixed rel names are known system properties // and that their values are valid. Users may not define custom _ -prefixed rels. func validateRels(rels []RelInput) error { for _, r := range rels { name := string(r.Type) if !strings.HasPrefix(name, "_") { continue } if v, ok := strings.CutPrefix(name, "_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(name, "_status::"); ok { if !validStatuses[v] { return fmt.Errorf("invalid status %q: must be one of open, done", v) } } else if v, ok := strings.CutPrefix(name, "_prio::"); ok { if !validPrios[v] { return fmt.Errorf("invalid priority %q: must be one of high, medium, low", v) } } else { return fmt.Errorf("invalid relation %q: custom _ prefix not allowed", name) } } return nil } // --- 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 storeFilters []*models.Rel for _, ri := range filter.Rels { if ri.Target == "" { // Tag filter: pass through with empty target. storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: ""}) } else { // Edge filter: resolve target name to node ID. id, ok := s.lookupRelTarget(ri.Type, ri.Target) if !ok { return nil, nil // named target doesn't exist; no nodes can match } storeFilters = append(storeFilters, &models.Rel{Type: ri.Type, Target: id}) } } nodes, err := s.store.FindNodes(storeFilters) 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) { // Build tag set from tag rels (Target == ""), applying property-replacement semantics. tmp := models.NewNode() for _, r := range input.Rels { if r.Target == "" { tmp.AddTag(string(r.Type)) } } // Apply defaults. if tmp.GetProperty("type") == "" { tmp.AddTag("_type::issue") } if tmp.GetProperty("type") == "issue" && tmp.GetProperty("status") == "" { tmp.AddTag("_status::open") } // Validate all rels (including the resolved default tags). tagRels := make([]RelInput, len(tmp.Tags)) for i, t := range tmp.Tags { tagRels[i] = RelInput{Type: models.RelType(t)} } if err := validateRels(append(tagRels, input.Rels...)); err != nil { return nil, err } // --- Permission check --- ac, err := s.getAccessContext() if err != nil { return nil, err } targetNSName := s.userID for _, ri := range input.Rels { if ri.Type == models.RelInNamespace && ri.Target != "" { targetNSName = ri.Target break } } 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) } } var nonNSEdgeRels []RelInput for _, ri := range input.Rels { if ri.Target != "" && ri.Type != models.RelInNamespace { nonNSEdgeRels = append(nonNSEdgeRels, ri) } } if err := s.checkRelTargetWrite(ac, nonNSEdgeRels); err != nil { return nil, err } hasNamespace := false for _, ri := range input.Rels { if ri.Type == models.RelInNamespace && ri.Target != "" { hasNamespace = true } } 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 } // Store tag rels. for _, t := range tmp.Tags { if err := st.AddRel(id, t, ""); err != nil { return err } } // Mentions. for _, m := range mentions(input.Title + " " + input.Content) { userID, err := s.resolveUserRef(st, m) if err != nil { return err } if err := st.AddRel(id, string(models.RelMentions), userID); err != nil { return err } } // Edge rels. hasCreated := false for _, ri := range input.Rels { if ri.Target == "" { continue // already stored as tag } if ri.Type == models.RelCreated { hasCreated = true } resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } if err := st.AddRel(id, string(ri.Type), resolved); err != nil { return err } } // Default namespace. if !hasNamespace { nsID, err := s.resolveNamespaceRef(st, s.userID) if err != nil { return err } if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil { return err } } // Default created. if !hasCreated { userID, err := s.resolveUserRef(st, s.userID) if err != nil { return err } if err := st.AddRel(id, string(models.RelCreated), userID); err != nil { return err } } // Namespace bootstrap: when creating a namespace node directly, apply the // same setup as ensureNamespace — self in_namespace and creator write access. if tmp.GetProperty("type") == "namespace" { if !hasNamespace { // Replace the default namespace rel (user's ns) with self-reference. userNsID, _ := s.resolveIDByNameAndType(st, s.userID, "namespace") if userNsID != "" { if err := st.RemoveRel(id, string(models.RelInNamespace), userNsID); err != nil { return err } } if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil { return err } } creatorID, err := s.resolveUserRef(st, s.userID) if err != nil { return err } if err := st.AddRel(creatorID, string(models.RelHasWriteAccess), id); 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 rels before doing any I/O. if err := validateRels(input.AddRels); 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 _, r := range input.AddRels { if r.Target == "" && string(r.Type) == "_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 } 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 _, r := range input.AddRels { if r.Target == "" { tmp.AddTag(string(r.Type)) } } for _, r := range input.RemoveRels { if r.Target == "" { tmp.RemoveTag(string(r.Type)) } } currentTags, newTags := current.Tags, tmp.Tags for _, t := range currentTags { if !slices.Contains(newTags, t) { if err := st.RemoveRel(id, t, ""); err != nil { return err } } } for _, t := range newTags { if !slices.Contains(currentTags, t) { if err := st.AddRel(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 { if ri.Target == "" { continue // already handled as tag } 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.RemoveRel(id, string(ri.Type), oldTgt); err != nil { return err } } } if err := st.AddRel(id, string(ri.Type), resolved); err != nil { return err } } for _, ri := range input.RemoveRels { if ri.Target == "" { continue // already handled as tag } resolved, err := s.resolveRelTarget(st, ri) if err != nil { return err } if err := st.RemoveRel(id, string(ri.Type), resolved); 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([]*models.Rel{{Type: "_type::user", Target: ""}}) } // --- Internal helpers --- func (s *nodeServiceImpl) checkBlockers(id string) error { blockers, err := s.store.FindNodes([]*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.AddRel(id, string(models.RelMentions), userID); err != nil { return err } } } for uid := range existingMentionIDs { if !mentionedUserIDs[uid] { if err := st.RemoveRel(id, string(models.RelMentions), uid); 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/custom 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 property without creating it. func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) { nodes, err := st.FindNodes([]*models.Rel{{Type: models.RelType("_type::" + nodeType), Target: ""}}) 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.AddRel(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.AddRel(id, "_type::namespace", ""); err != nil { return "", err } if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil { return "", err } userID, err := s.resolveUserRef(st, s.userID) if err != nil { return "", err } if err := st.AddRel(id, string(models.RelCreated), userID); err != nil { return "", err } // Grant the creator write access to the new namespace. if err := st.AddRel(userID, string(models.RelHasWriteAccess), id); err != nil { return "", err } return id, nil }