Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63044a697d | |||
| ea8a9ca0c3 | |||
| bcd52e0ae6 | |||
| 4912f37688 | |||
| f2521be158 | |||
| 8357c80e75 | |||
| a30c703efa |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
.ax
|
.ax
|
||||||
quicknote.md
|
quicknote.md
|
||||||
plan.md
|
plan.md
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
47
CLAUDE.md
47
CLAUDE.md
@@ -1,47 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project
|
|
||||||
|
|
||||||
Axolotl (`ax`) is a CLI-native issue tracker built in Go, using a local SQLite file (`.ax.db`) as its database. It's designed for use by individuals and AI agents, with JSON output support for machine integration.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build -o ax . # Build the binary
|
|
||||||
go test ./... # Run all tests (e2e_test.go covers most functionality)
|
|
||||||
go test -run TestName . # Run a single test by name
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The codebase has three distinct layers:
|
|
||||||
|
|
||||||
### 1. `cmd/` — CLI layer (Cobra)
|
|
||||||
Parses flags into typed input structs and calls the service layer. `root.go` handles alias expansion (including `$me`, `$@`, `$1`-`$N` variable substitution) and wires up the `NodeService`. Also contains `output.go` for colored terminal output and JSON serialization.
|
|
||||||
|
|
||||||
### 2. `service/` — Business logic
|
|
||||||
`NodeService` is the central interface (`service/node_service.go`). The implementation (`node_service_impl.go`) enforces:
|
|
||||||
- Permission model via `getPermContext()` — BFS from the user's own node following permission rels
|
|
||||||
- Blocker validation (can't close an issue with open blockers)
|
|
||||||
- `@mention` extraction → automatic edge creation
|
|
||||||
- Single-value relation enforcement (`assignee`, `in_namespace`)
|
|
||||||
- Auto-creation of referenced user/namespace nodes
|
|
||||||
|
|
||||||
### 3. `store/` — Persistence and configuration
|
|
||||||
`GraphStore` interface wraps SQLite with graph primitives: nodes, tags, and typed directed edges. Schema is 3 tables (`nodes`, `tags`, `rels`). All multi-step ops use `store.Transaction()`. Also contains `Config` for user settings, aliases, and session management.
|
|
||||||
|
|
||||||
## Core Data Model
|
|
||||||
|
|
||||||
**Node**: a graph node with a 5-char ID, title, content, `Tags []string`, and `Relations map[string][]string`.
|
|
||||||
|
|
||||||
**Property tags** use the `_key::value` pattern: `_type::issue|note|user|namespace`, `_status::open|done`, `_prio::high|medium|low`.
|
|
||||||
|
|
||||||
**Relation types** (`models/rel_type.go`): `blocks`, `subtask`, `related`, `assignee` (single-value), `in_namespace` (single-value), `created`, `mentions`, `can_read`, `can_create_rel`, `can_write`, `has_ownership`.
|
|
||||||
|
|
||||||
**Permission model**: Four inclusive levels (1–4). Transitive via BFS from user's self-owned node. `can_read`=1, `can_create_rel`=2, `can_write`=3, `has_ownership`=4. Creator auto-gets `has_ownership` on every new node. Users self-own. Deleting a node cascades to all nodes it owns. User/namespace nodes are globally readable.
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
The CLI searches upward from CWD for an `.ax` directory (like git), falling back to `~/.config/ax/` for config and `~/.local/share/ax/` for data. The `AX_USER` env var overrides the configured username. The database file `ax.db` is similarly discovered by walking upward.
|
|
||||||
@@ -4,7 +4,7 @@ pkgver=0.1.0
|
|||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="CLI-native issue tracker using SQLite"
|
pkgdesc="CLI-native issue tracker using SQLite"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
url="https://g.eliaskohout.de/eliaskohout/axolotl"
|
url="https://g.eliaskohout.de/eliaskohout/ax"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
makedepends=('go')
|
makedepends=('go')
|
||||||
source=("$pkgname-$pkgver.tar.gz::https://g.eliaskohout.de/eliaskohout/axolotl/archive/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::https://g.eliaskohout.de/eliaskohout/axolotl/archive/v$pkgver.tar.gz")
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
var lTags, lRels []string
|
var lTags, lRels []string
|
||||||
var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string
|
var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string
|
||||||
|
var lDue bool
|
||||||
|
var lDueWithin int
|
||||||
|
|
||||||
var listCmd = &cobra.Command{
|
var listCmd = &cobra.Command{
|
||||||
Use: "list", Short: "List nodes",
|
Use: "list", Short: "List nodes",
|
||||||
@@ -22,6 +24,11 @@ var listCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var filter service.ListFilter
|
var filter service.ListFilter
|
||||||
|
filter.HasDueDate = lDue
|
||||||
|
if lDueWithin >= 0 {
|
||||||
|
n := lDueWithin
|
||||||
|
filter.DueWithin = &n
|
||||||
|
}
|
||||||
|
|
||||||
// --tag is an alias for a label filter with no target.
|
// --tag is an alias for a label filter with no target.
|
||||||
for _, tag := range lTags {
|
for _, tag := range lTags {
|
||||||
@@ -76,4 +83,6 @@ func init() {
|
|||||||
f.StringVar(&lNamespace, "namespace", "", "filter by namespace")
|
f.StringVar(&lNamespace, "namespace", "", "filter by namespace")
|
||||||
f.StringVar(&lAssignee, "assignee", "", "filter by assignee")
|
f.StringVar(&lAssignee, "assignee", "", "filter by assignee")
|
||||||
f.StringVar(&lMention, "mention", "", "filter by mention")
|
f.StringVar(&lMention, "mention", "", "filter by mention")
|
||||||
|
f.BoolVar(&lDue, "due", false, "filter to nodes with a due date")
|
||||||
|
f.IntVar(&lDueWithin, "due-within", -1, "filter to nodes due within N days (includes overdue)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
)
|
)
|
||||||
@@ -74,7 +76,21 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
|
|||||||
if si != sj {
|
if si != sj {
|
||||||
return statusRanks[si] > statusRanks[sj]
|
return statusRanks[si] > statusRanks[sj]
|
||||||
}
|
}
|
||||||
return prioRanks[nodes[i].GetProperty("prio")] > prioRanks[nodes[j].GetProperty("prio")]
|
pi, pj := prioRanks[nodes[i].GetProperty("prio")], prioRanks[nodes[j].GetProperty("prio")]
|
||||||
|
if pi != pj {
|
||||||
|
return pi > pj
|
||||||
|
}
|
||||||
|
di, dj := nodes[i].DueDate, nodes[j].DueDate
|
||||||
|
if di == nil && dj == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if di == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if dj == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return di.Before(dj.Time) // soonest due date first
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
@@ -89,13 +105,14 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
|
|||||||
}
|
}
|
||||||
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
|
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s %s %s %s %s %s",
|
fmt.Fprintf(w, " %s %s %s %s %s %s %s",
|
||||||
cDim.Sprint(n.ID),
|
cDim.Sprint(n.ID),
|
||||||
render(prioRM, n.GetProperty("prio"), true),
|
render(prioRM, n.GetProperty("prio"), true),
|
||||||
render(statusRM, n.GetProperty("status"), true),
|
render(statusRM, n.GetProperty("status"), true),
|
||||||
render(typeRM, n.GetProperty("type"), true),
|
render(typeRM, n.GetProperty("type"), true),
|
||||||
cTitle.Sprint(truncate(n.Title, 80)),
|
cTitle.Sprint(truncate(n.Title, 80)),
|
||||||
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
|
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
|
||||||
|
dueDateShort(n.DueDate),
|
||||||
)
|
)
|
||||||
tags := n.GetDisplayTags()
|
tags := n.GetDisplayTags()
|
||||||
if len(tags) > 0 {
|
if len(tags) > 0 {
|
||||||
@@ -117,7 +134,7 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo
|
|||||||
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
|
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
|
||||||
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
|
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
|
||||||
if n.DueDate != nil {
|
if n.DueDate != nil {
|
||||||
fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate.Format("2006-01-02"))
|
fmt.Fprintf(w, " Due: %s\n", dueDateLong(n.DueDate))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt))
|
fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt))
|
||||||
fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))
|
fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))
|
||||||
@@ -202,6 +219,57 @@ func render(rm RenderMap, key string, short bool) string {
|
|||||||
return v.c.Sprint(v.l)
|
return v.c.Sprint(v.l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dueDateShort returns a "+12d" / "-3d" style string colored by urgency.
|
||||||
|
func dueDateShort(d *models.Date) string {
|
||||||
|
if d == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Truncate(24 * time.Hour)
|
||||||
|
due := d.UTC().Truncate(24 * time.Hour)
|
||||||
|
days := int(math.Round(due.Sub(now).Hours() / 24))
|
||||||
|
|
||||||
|
var label string
|
||||||
|
if days == 0 {
|
||||||
|
label = "today"
|
||||||
|
} else if days > 0 {
|
||||||
|
label = fmt.Sprintf("+%dd", days)
|
||||||
|
} else {
|
||||||
|
label = fmt.Sprintf("%dd", days)
|
||||||
|
}
|
||||||
|
|
||||||
|
var c *color.Color
|
||||||
|
switch {
|
||||||
|
case days < 0:
|
||||||
|
c = cBad
|
||||||
|
case days <= 3:
|
||||||
|
c = cWarn
|
||||||
|
default:
|
||||||
|
c = cGood
|
||||||
|
}
|
||||||
|
return c.Sprint(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueDateLong returns the full date string colored by urgency.
|
||||||
|
func dueDateLong(d *models.Date) string {
|
||||||
|
if d == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Truncate(24 * time.Hour)
|
||||||
|
due := d.UTC().Truncate(24 * time.Hour)
|
||||||
|
days := int(math.Round(due.Sub(now).Hours() / 24))
|
||||||
|
|
||||||
|
var c *color.Color
|
||||||
|
switch {
|
||||||
|
case days < 0:
|
||||||
|
c = cBad
|
||||||
|
case days <= 3:
|
||||||
|
c = cWarn
|
||||||
|
default:
|
||||||
|
c = cGood
|
||||||
|
}
|
||||||
|
return c.Sprintf("%s %s", iconCalendar, d.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
func truncate(s string, max int) string {
|
func truncate(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -117,8 +117,26 @@ func TestPermissions(t *testing.T) {
|
|||||||
if !userNode.HasRelation("has_ownership", aliceUserID) {
|
if !userNode.HasRelation("has_ownership", aliceUserID) {
|
||||||
t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations)
|
t.Errorf("expected user node to have self-ownership, got relations: %v", userNode.Relations)
|
||||||
}
|
}
|
||||||
if !userNode.HasRelation("has_ownership", aliceNodeID) {
|
// Nodes are now owned by the namespace they belong to, not directly by the creator.
|
||||||
t.Errorf("expected alice to own her node, got relations: %v", userNode.Relations)
|
// Alice's default namespace is owned by alice, so she retains transitive ownership.
|
||||||
|
if userNode.HasRelation("has_ownership", aliceNodeID) {
|
||||||
|
t.Errorf("alice should not directly own her node (namespace owns it), got relations: %v", userNode.Relations)
|
||||||
|
}
|
||||||
|
// Find alice's default namespace and verify it owns the node.
|
||||||
|
namespaces := alice.parseNodes(alice.mustAx("list", "--type", "namespace", "--json"))
|
||||||
|
var aliceNsID string
|
||||||
|
for _, ns := range namespaces {
|
||||||
|
if ns.Title == "alice" {
|
||||||
|
aliceNsID = ns.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if aliceNsID == "" {
|
||||||
|
t.Fatal("could not find alice's default namespace")
|
||||||
|
}
|
||||||
|
nsOut := alice.mustAx("show", aliceNsID, "--json")
|
||||||
|
nsNode := alice.parseNode(nsOut)
|
||||||
|
if !nsNode.HasRelation("has_ownership", aliceNodeID) {
|
||||||
|
t.Errorf("expected alice's namespace to own her node, got relations: %v", nsNode.Relations)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"axolotl/store"
|
"axolotl/store"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,6 +85,14 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
|
|||||||
if v := q.Get("mention"); v != "" {
|
if v := q.Get("mention"); v != "" {
|
||||||
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
|
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
|
||||||
}
|
}
|
||||||
|
if q.Get("has_due_date") == "true" {
|
||||||
|
filter.HasDueDate = true
|
||||||
|
}
|
||||||
|
if v := q.Get("due_within"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
filter.DueWithin = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
nodes, err := svc.List(filter)
|
nodes, err := svc.List(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type apiClient struct {
|
type apiClient struct {
|
||||||
@@ -78,6 +79,12 @@ func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
|
|||||||
q.Add("rel", string(r.Type)+":"+r.Target)
|
q.Add("rel", string(r.Type)+":"+r.Target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if filter.HasDueDate {
|
||||||
|
q.Set("has_due_date", "true")
|
||||||
|
}
|
||||||
|
if filter.DueWithin != nil {
|
||||||
|
q.Set("due_within", strconv.Itoa(*filter.DueWithin))
|
||||||
|
}
|
||||||
path := "/nodes"
|
path := "/nodes"
|
||||||
if len(q) > 0 {
|
if len(q) > 0 {
|
||||||
path += "?" + q.Encode()
|
path += "?" + q.Encode()
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ type UpdateInput struct {
|
|||||||
// Tag filters (Target == "") match by rel_name prefix.
|
// Tag filters (Target == "") match by rel_name prefix.
|
||||||
// Edge filters (Target != "") are resolved to node IDs.
|
// Edge filters (Target != "") are resolved to node IDs.
|
||||||
type ListFilter struct {
|
type ListFilter struct {
|
||||||
Rels []RelInput
|
Rels []RelInput
|
||||||
|
HasDueDate bool // when true, only return nodes that have a due date set
|
||||||
|
DueWithin *int // when non-nil, only return nodes due within this many days (includes overdue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RelInput is a typed, directed rel with a target that may be a name or node ID.
|
// RelInput is a typed, directed rel with a target that may be a name or node ID.
|
||||||
|
|||||||
@@ -234,6 +234,26 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
|
|||||||
result = append(result, n)
|
result = append(result, n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if filter.HasDueDate || filter.DueWithin != nil {
|
||||||
|
now := time.Now().UTC().Truncate(24 * time.Hour)
|
||||||
|
filtered := result[:0]
|
||||||
|
for _, n := range result {
|
||||||
|
if n.DueDate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter.DueWithin != nil {
|
||||||
|
due := n.DueDate.UTC().Truncate(24 * time.Hour)
|
||||||
|
cutoff := now.AddDate(0, 0, *filter.DueWithin)
|
||||||
|
if due.After(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered = append(filtered, n)
|
||||||
|
}
|
||||||
|
result = filtered
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,8 +364,9 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge rels.
|
// Edge rels. Track the namespace the node is placed in for ownership.
|
||||||
hasCreated := false
|
hasCreated := false
|
||||||
|
var actualNsID string
|
||||||
for _, ri := range input.Rels {
|
for _, ri := range input.Rels {
|
||||||
if ri.Target == "" {
|
if ri.Target == "" {
|
||||||
continue // already stored as tag
|
continue // already stored as tag
|
||||||
@@ -357,6 +378,9 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if ri.Type == models.RelInNamespace {
|
||||||
|
actualNsID = resolved
|
||||||
|
}
|
||||||
if ri.Type == models.RelHasOwnership {
|
if ri.Type == models.RelHasOwnership {
|
||||||
// Ownership transfer: remove existing owner of the target.
|
// Ownership transfer: remove existing owner of the target.
|
||||||
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
|
existingOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: resolved}})
|
||||||
@@ -378,6 +402,7 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
|||||||
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
|
if err := st.AddRel(id, string(models.RelInNamespace), nsID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
actualNsID = nsID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default created.
|
// Default created.
|
||||||
@@ -391,12 +416,19 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant creator ownership of the new node.
|
// Grant ownership of the new node.
|
||||||
|
// Namespace nodes are owned by their creator. All other nodes are owned
|
||||||
|
// by the namespace they belong to — the user retains transitive ownership
|
||||||
|
// through the namespace's own ownership chain (e.g. user→owns→default-ns→owns→node).
|
||||||
creatorID, err := s.resolveUserRef(st, s.userID)
|
creatorID, err := s.resolveUserRef(st, s.userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := st.AddRel(creatorID, string(models.RelHasOwnership), id); err != nil {
|
ownerID := creatorID
|
||||||
|
if tmp.GetProperty("type") != "namespace" && actualNsID != "" {
|
||||||
|
ownerID = actualNsID
|
||||||
|
}
|
||||||
|
if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +822,37 @@ func (s *nodeServiceImpl) resolveUserRef(st store.GraphStore, ref string) (strin
|
|||||||
return s.ensureUser(st, ref)
|
return s.ensureUser(st, ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const globalNamespace = "_global"
|
||||||
|
|
||||||
|
func (s *nodeServiceImpl) ensureGlobalNamespace(st store.GraphStore) (string, error) {
|
||||||
|
nsID, err := s.resolveIDByNameAndType(st, globalNamespace, "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, globalNamespace, "", nil, 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
|
||||||
|
}
|
||||||
|
// Self-owned so no single user controls it.
|
||||||
|
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (string, error) {
|
func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (string, error) {
|
||||||
userID, err := s.resolveIDByNameAndType(st, username, "user")
|
userID, err := s.resolveIDByNameAndType(st, username, "user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -813,6 +876,17 @@ func (s *nodeServiceImpl) ensureUser(st store.GraphStore, username string) (stri
|
|||||||
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
|
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
// Every user gets can_create_rel access to the global namespace (inclusive
|
||||||
|
// of can_read), providing a shared space where all users can see and interact with
|
||||||
|
// nodes that are published there. User nodes themselves are already
|
||||||
|
// globally readable via the post-BFS identity override in getPermContext.
|
||||||
|
globalNsID, err := s.ensureGlobalNamespace(st)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := st.AddRel(id, string(models.RelCanCreateRel), globalNsID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user