17 Commits

Author SHA1 Message Date
eliaskohout 388e24a8df ci: cross-compile binaries in workflow instead of downloading from APK registry
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Failing after 1m2s
Build and Publish Docker Image / build-apk (amd64, x86_64) (push) Successful in 52s
Build and Publish Docker Image / build-apk (arm64, aarch64) (push) Failing after 47s
Build and Publish Docker Image / build-and-push-docker (push) Successful in 10m50s
2026-04-14 11:24:37 +02:00
eliaskohout b5ef107f9c ci: use pre-built APK in Docker image instead of compiling from source
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Has been cancelled
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Has been cancelled
Build and Publish Docker Image / build-apk (amd64, x86_64) (push) Successful in 46s
Build and Publish Docker Image / build-apk (arm64, aarch64) (push) Successful in 51s
Build and Publish Docker Image / build-and-push-docker (push) Failing after 11m58s
2026-04-14 00:59:31 +02:00
eliaskohout 21a01e9412 fix: include client_secret in device authorization request
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Failing after 46s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m2s
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Waiting to run
Build and Push Docker Container / build-and-push (push) Has been cancelled
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Has been cancelled
2026-04-14 00:48:16 +02:00
eliaskohout 77e2610fe8 feat: add RFC 8628 device authorization flow for out-of-VPN authentication
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 52s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 43s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 2m11s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m6s
Build and Push Docker Container / build-and-push (push) Successful in 20m0s
2026-04-13 23:32:18 +02:00
eliaskohout 2c48c75387 fix: use FindDataRoot in FindOrInitSQLiteStore for consistent database location
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Has been cancelled
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Has been cancelled
Build and Push Docker Container / build-and-push (push) Successful in 17m44s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Failing after 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Failing after 1m0s
2026-04-02 14:26:13 +02:00
eliaskohout e04a44cdcf remove line count from readme 2026-04-02 13:41:47 +02:00
eliaskohout b6c8a158af docs: simplify README and extract full reference to USAGE.md
- Condensed README to quick overview (~40 lines) with key features
- Added install instructions for Alpine, Arch, source, and Docker
- Created USAGE.md with full command reference, server mode, OIDC, and permission model docs
- New features documented: server mode, OIDC authentication, per-node permissions, multiplatform auto-builds

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:38:38 +02:00
eliaskohout 5f0f8f3396 fix: update Dockerfile Go version to 1.25 and disable toolchain auto-download
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m4s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m6s
Build and Push Docker Container / build-and-push (push) Successful in 17m50s
- Bump golang:1.24-alpine to golang:1.25-alpine to match go.mod requirement
- Set GOTOOLCHAIN=local to prevent segfault from toolchain download in Docker

This fixes the multiplatform build failure where go mod download would
attempt to download Go 1.25.0 in the container and crash.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:25:19 +02:00
eliaskohout 24fb3a8b62 fix: prevent segfault in login command when session doesn't exist
LoadSession() was returning nil when the session file didn't exist (first login).
The login command then tried to dereference nil when setting session.Token,
causing a panic. Now LoadSession() returns an empty Session with the path set,
so callers can always use the returned session.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:23:34 +02:00
eliaskohout 89432e608b feat: replace in_namespace relation with ownership-based namespace membership
Remove the in_namespace edge relation. A node now belongs to a namespace if that
namespace has has_ownership on it. This simplifies the model: namespace membership
is determined by the ownership chain rather than a separate relation type.

Changes:
- Remove RelInNamespace constant
- Add Namespace fields to AddInput, UpdateInput, and ListFilter
- Update Add() to resolve namespace from input and assign it as owner
- Update List() to filter by namespace ownership instead of in_namespace edges
- Update() can now transfer nodes between namespaces via ownership transfer
- Remove in_namespace self-references from ensureNamespace/ensureGlobalNamespace

The ownership chain now fully describes both permissions and namespace membership,
reducing redundancy. All tests pass with the new model.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 13:20:03 +02:00
eliaskohout 63044a697d feat: change node ownership to namespace instead of creator
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 44s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 51s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 58s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m5s
Build and Push Docker Container / build-and-push (push) Failing after 10m5s
When a node is created in a namespace, the namespace now owns it rather than the creator. This allows namespaces to manage content ownership. Namespace nodes themselves remain owned by their creator. Users retain transitive ownership through their default namespace: user→has_ownership→namespace→has_ownership→node.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:41:13 +02:00
eliaskohout ea8a9ca0c3 feat: add global user namespace for shared access
Create a special _global namespace that every new user automatically
gets can_create_rel access to (inclusive of can_read). This provides
a shared space where all users can see and interact with published nodes.

- ensureGlobalNamespace creates _global on first use with self-ownership
- ensureUser grants can_create_rel to each new user on the global namespace
- User visibility still relies on existing post-BFS global readability

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:31:04 +02:00
eliaskohout bcd52e0ae6 update project page url for package
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 43s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 59s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 59s
Build and Push Docker Container / build-and-push (push) Failing after 10m8s
2026-04-02 05:09:02 +02:00
eliaskohout 4912f37688 remove claude markdown 2026-04-02 05:08:44 +02:00
eliaskohout f2521be158 feat: add due date filters for list command
- --due: show only nodes with a due date set
- --due-within N: show only nodes due within N days (includes overdue)

Implemented in service layer with post-fetch filtering, threaded through
API client and server, and exposed via CLI flags.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:06:35 +02:00
eliaskohout 8357c80e75 feat: add due date to list sorting priority
Sort nodes by: status → priority → due date (soonest first).
Nodes without due dates sort last.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:57:51 +02:00
eliaskohout a30c703efa feat: add colored due date indicators to list and show views
- List view: show relative due date (+12d, -3d, today) after namespace
- Show view: colorize full due date based on urgency
- Color scheme: red (overdue), yellow (due within 3 days), green (4+ days)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:54:15 +02:00
25 changed files with 816 additions and 569 deletions
-68
View File
@@ -1,68 +0,0 @@
name: Build and Publish APK Package
on:
push:
tags:
- 'v*'
jobs:
build-apk:
runs-on:
- ubuntu-24.04
container:
image: alpine:latest
strategy:
matrix:
include:
- goarch: amd64
pkgarch: x86_64
- goarch: arm64
pkgarch: aarch64
steps:
- name: Install build dependencies
run: |
apk update
apk add --no-cache git nodejs go abuild curl sudo build-base
- name: Checkout repository
uses: actions/checkout@v4
- name: Create build user
run: |
adduser -D -G abuild build
echo "build ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
chown -R build:abuild .
- name: Configure git safe directory
run: git config --global --add safe.directory "$PWD"
- name: Setup abuild for package signing
run: |
su build -c "abuild-keygen -a -n"
cp /home/build/.abuild/*.pub /etc/apk/keys/
- name: Prepare source
run: |
pkgver=$(echo "${{ github.ref_name }}" | sed 's/^v//')
pkgname="axolotl"
sed -i "s/pkgver=.*/pkgver=$pkgver/" packaging/alpine/APKBUILD
sed -i "s/^arch=.*/arch=\"${{ matrix.pkgarch }}\"/" packaging/alpine/APKBUILD
git archive --format=tar.gz --prefix="$pkgname-$pkgver/" -o "packaging/alpine/$pkgname-$pkgver.tar.gz" HEAD
sed -i "s|source=.*|source=\"\$pkgname-\$pkgver.tar.gz\"|" packaging/alpine/APKBUILD
chown -R build:abuild .
- name: Generate checksums
run: su build -c "cd $PWD/packaging/alpine && abuild checksum"
- name: Build package
run: su build -c "cd $PWD/packaging/alpine && GOARCH=${{ matrix.goarch }} CARCH=${{ matrix.pkgarch }} abuild -r"
- name: Publish to Gitea Registry
run: |
apk_file=$(find ~build/packages -name "*.apk" -type f | head -1)
curl --fail-with-body \
--user "${{ github.repository_owner }}:${{ secrets.ACCESS_TOKEN }}" \
--upload-file "$apk_file" \
"${{ github.server_url }}/api/packages/${{ github.repository_owner }}/alpine/edge/main"
+80 -3
View File
@@ -1,15 +1,91 @@
name: Build and Push Docker Container name: Build and Publish Docker Image
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
jobs: jobs:
build-and-push: build-apk:
runs-on:
- ubuntu-24.04
container:
image: alpine:latest
strategy:
matrix:
include:
- goarch: amd64
pkgarch: x86_64
- goarch: arm64
pkgarch: aarch64
steps:
- name: Install build dependencies
run: |
apk update
apk add --no-cache git nodejs go abuild curl sudo build-base
- name: Checkout repository
uses: actions/checkout@v4
- name: Create build user
run: |
adduser -D -G abuild build
echo "build ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
chown -R build:abuild .
- name: Configure git safe directory
run: git config --global --add safe.directory "$PWD"
- name: Setup abuild for package signing
run: |
su build -c "abuild-keygen -a -n"
cp /home/build/.abuild/*.pub /etc/apk/keys/
- name: Prepare source
run: |
pkgver=$(echo "${{ github.ref_name }}" | sed 's/^v//')
pkgname="axolotl"
sed -i "s/pkgver=.*/pkgver=$pkgver/" packaging/alpine/APKBUILD
sed -i "s/^arch=.*/arch=\"${{ matrix.pkgarch }}\"/" packaging/alpine/APKBUILD
git archive --format=tar.gz --prefix="$pkgname-$pkgver/" -o "packaging/alpine/$pkgname-$pkgver.tar.gz" HEAD
sed -i "s|source=.*|source=\"\$pkgname-\$pkgver.tar.gz\"|" packaging/alpine/APKBUILD
chown -R build:abuild .
- name: Generate checksums
run: su build -c "cd $PWD/packaging/alpine && abuild checksum"
- name: Build package
run: su build -c "cd $PWD/packaging/alpine && GOARCH=${{ matrix.goarch }} CARCH=${{ matrix.pkgarch }} abuild -r"
- name: Publish to Gitea Registry
run: |
apk_file=$(find ~build/packages -name "*.apk" -type f | head -1)
curl --fail-with-body \
--user "${{ github.repository_owner }}:${{ secrets.ACCESS_TOKEN }}" \
--upload-file "$apk_file" \
"${{ github.server_url }}/api/packages/${{ github.repository_owner }}/alpine/edge/main"
build-and-push-docker:
runs-on: runs-on:
- ubuntu-24.04 - ubuntu-24.04
steps: steps:
- name: Login to Docker Hub - name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: src/go.mod
- name: Cross-compile binaries
run: |
cd src
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o ../out/amd64/ax .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o ../out/arm64/ax .
- name: Login to Docker Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: g.eliaskohout.de registry: g.eliaskohout.de
@@ -25,6 +101,7 @@ jobs:
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: "g.eliaskohout.de/eliaskohout/axolotl-server:${{gitea.ref_name}},g.eliaskohout.de/eliaskohout/axolotl-server:latest" tags: "g.eliaskohout.de/eliaskohout/axolotl-server:${{gitea.ref_name}},g.eliaskohout.de/eliaskohout/axolotl-server:latest"
+1
View File
@@ -2,3 +2,4 @@
.ax .ax
quicknote.md quicknote.md
plan.md plan.md
CLAUDE.md
-47
View File
@@ -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 (14). 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 -18
View File
@@ -1,25 +1,11 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
ENV GOTOOLCHAIN=auto
COPY src/go.mod src/go.sum ./
RUN go mod download
COPY src/ ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags="-s -w" -trimpath -o /ax .
FROM alpine:latest FROM alpine:latest
ARG TARGETARCH
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates
COPY out/${TARGETARCH}/ax /usr/local/bin/ax
WORKDIR /data WORKDIR /data
COPY --from=builder /ax /usr/local/bin/ax
EXPOSE 7000 EXPOSE 7000
ENTRYPOINT ["ax", "serve"] ENTRYPOINT ["ax", "serve"]
+33 -247
View File
@@ -1,266 +1,52 @@
# Axolotl # Axolotl
CLI-native lightweight issue tracker for you and your agents. A SQLite-based CLI-native issue tracker for you and your agents. Single binary, SQLite-backed, a few lines of Go.
single portable binary, built from ~1300 lines of Go code.
## Features ## Install
- **Issues with dependencies** - blocks, subtask, related relations **Alpine Linux** (apk):
- **Tagging system** - flexible tags with `_key::value` property pattern Download from the Gitea package registry. Have a look
- **Namespacing** - organize issues by project or team [here](https://g.eliaskohout.de/eliaskohout/-/packages/alpine/axolotl/).
- **Due dates** - track deadlines
- **Thread-safe** - WAL mode for concurrent access
- **Multiuser support** - @mentions and assignments, inbox per user
- **JSON output** - all commands support `--json` for agent integration
- **Alias system** - define custom command shortcuts with argument expansion
- **Single binary** - no dependencies, portable `.ax.db` file
## Installation **Arch Linux** (pacman):
Download from the Gitea package registry. Have a look
[here](https://g.eliaskohout.de/eliaskohout/-/packages/arch/axolotl/).
**From source:**
```bash ```bash
go build -o ax . go build -o ax ./src
``` ```
**Docker (server mode):**
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
Packages are built automatically on every version tag for `linux/amd64` and `linux/arm64`.
## Quick Start ## Quick Start
```bash ```bash
# Initialize a new database ax init . # create .ax.db in current dir
ax init . ax add "Fix login bug" --prio high # create an issue
ax list --status open # list open issues
# Create an issue ax show abc12 # show issue details
ax add "Implement feature X" --tag backend --prio high ax update abc12 --status done # close issue
ax inbox # your @mention inbox
# Create with relations
ax add "Fix bug in auth" --rel blocks:abc12
# List open issues
ax list --status open
# Show issue details
ax show abc12
# Update an issue
ax update abc12 --status done
# View your inbox
ax inbox
# Define an alias
ax alias mywork "list --namespace myproject --status open" --desc "My project tasks"
``` ```
## Commands ## Key Features
### `ax init [path]` - **Graph relations** — `blocks`, `subtask`, `related`, `assignee`
- **Namespaces** — organize issues by project or team
- **Permissions** — per-node access control (`can_read`, `can_write`, `has_ownership`)
- **Aliases** — custom shortcuts with `$me`, `$1`, `$@` expansion
- **JSON output** — `--json` flag on all commands for agent integration
- **Multiuser** — `@mention` auto-creates inbox entries; `AX_USER` to switch users
- **Server mode** — HTTP JSON API with optional OIDC authentication (`ax serve` / `ax login`)
- **Portable** — single `.ax.db` file, no server required
Create a new `.ax.db` database in the specified directory (default: current). For full command reference and examples, see [USAGE.md](USAGE.md).
### `ax add <title> [flags]`
Create a new node.
| Flag | Description |
|------|-------------|
| `--type` | Node type: `issue` (default), `note`, `user`, `namespace` |
| `--status` | Status: `open` (default), `done` |
| `--prio` | Priority: `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Content/body text |
| `--rel` | Add relation `type:id` (repeatable) |
### `ax update <id> [flags]`
Update a node.
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | New namespace |
| `--assignee` | New assignee |
| `--due` | New due date |
| `--clear-due` | Clear due date |
| `--content` | New content |
| `--tag` | Add tag (repeatable) |
| `--tag-remove` | Remove tag (repeatable) |
| `--rel` | Add relation `type:id` (repeatable) |
| `--rel-remove` | Remove relation `type:id` (repeatable) |
### `ax show <id>`
Display node details.
### `ax list [flags]`
Query and list nodes.
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f|--force]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command] [flags]`
Manage aliases.
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create alias
ax alias mywork # show alias command
ax alias mywork "list --tag work2" # update alias
ax alias del mywork # delete alias
```
**Default aliases:**
| Alias | Command | Description |
|-------|---------|-------------|
| `mine` | `list --assignee $me --type issue --status open` | Show open issues assigned to you |
| `due` | `list --type issue --status open` | Show open issues |
| `inbox` | `list --mention $me` | Show your inbox |
**Alias argument expansion:**
| Variable | Expands to |
|----------|------------|
| `$me` | Current username |
| `$@` | All arguments |
| `$1`, `$2`, ... | Positional arguments |
```bash
# Create alias with argument expansion
ax alias find "list --tag $1 --status $2"
ax find backend open # expands to: list --tag backend --status open
```
## Relations
Relations connect nodes together:
| Type | Meaning | Behavior |
|------|---------|----------|
| `blocks` | A blocks B — B can't close until A is done | Enforced on status=done |
| `subtask` | A is a subtask of B | |
| `related` | A is related to B | |
| `assignee` | A is assigned to user | Single-value; set via `--assignee` flag |
| `in_namespace` | A belongs to namespace | Single-value; set via `--namespace` flag |
```bash
# Block an issue (B can't close until A is done)
ax update A --rel blocks:B
# Assign to user
ax update abc12 --assignee alice
# Create subtask
ax update abc12 --rel subtask:parent12
```
## Tags and Properties
Tags are flexible labels. Tags with pattern `_key::value` are properties:
```bash
# Regular tag
ax add "Task" --tag backend
# Property tags (set via flags)
ax add "Task" --type issue --status open --prio high
# Equivalent to: --tag _type::issue --tag _status::open --tag _prio::high
```
**Built-in properties:**
| Property | Values | Required |
|----------|--------|----------|
| `_type` | `issue`, `note`, `user`, `namespace` | Yes (default: `issue`) |
| `_status` | `open`, `done` | No |
| `_prio` | `high`, `medium`, `low` | No |
## Mentions and Inbox
Use `@username` in title or content to automatically add to user's inbox:
```bash
ax add "Review PR @alice" --content "@bob please check"
# Both alice and bob get this in their inbox
```
View inbox:
```bash
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json` for machine-readable output:
```bash
ax list --status open --json
ax show abc12 --json
```
Example output:
```json
{
"id": "abc12",
"title": "Implement feature",
"content": "Description here",
"created_at": "2026-03-25T10:00:00Z",
"updated_at": "2026-03-25T10:00:00Z",
"tags": ["_type::issue", "_status::open", "backend"],
"relations": {
"blocks": ["def34"]
}
}
```
## Configuration
`ax` stores user configuration in a JSON file. It searches for `.axconfig` in the
current directory and parent directories (like git finds `.git`), falling back to
`~/.config/ax/config.json`.
**Config file format:**
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Database Location
`ax` searches for `.ax.db` in the current directory and parent directories,
similar to how git finds `.git`. This allows you to run commands from any
subdirectory.
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
## License ## License
+194
View File
@@ -0,0 +1,194 @@
# Axolotl Usage Reference
## Commands
### `ax init [path]`
Create a new `.ax.db` in the specified directory (default: current).
### `ax add <title> [flags]`
| Flag | Description |
|------|-------------|
| `--type` | `issue` (default), `note`, `user`, `namespace` |
| `--status` | `open` (default), `done` |
| `--prio` | `high`, `medium`, `low` |
| `--namespace` | Namespace (default: current user) |
| `--tag` | Add tag (repeatable) |
| `--due` | Due date |
| `--content` | Body text |
| `--rel` | Relation `type:id` (repeatable) |
### `ax update <id> [flags]`
| Flag | Description |
|------|-------------|
| `--title` | New title |
| `--status` | New status |
| `--prio` | New priority |
| `--type` | New type |
| `--namespace` | Transfer to namespace |
| `--assignee` | Assign to user |
| `--due` / `--clear-due` | Set or clear due date |
| `--content` | New body text |
| `--tag` / `--tag-remove` | Add or remove tag |
| `--rel` / `--rel-remove` | Add or remove relation `type:id` |
### `ax show <id>`
Display node details.
### `ax list [flags]`
| Flag | Description |
|------|-------------|
| `--type` | Filter by type |
| `--status` | Filter by status |
| `--prio` | Filter by priority |
| `--namespace` | Filter by namespace |
| `--tag` | Filter by tag (repeatable) |
| `--assignee` | Filter by assignee |
| `--mention` | Filter by mention |
### `ax edit <id>`
Open node content in `$EDITOR`.
### `ax del <id> [-f]`
Delete a node. Prompts for confirmation unless `--force`.
### `ax alias [name] [command]`
```bash
ax alias # list all aliases
ax alias mywork "list --tag work" # create/update alias
ax alias del mywork # delete alias
```
**Built-in aliases:** `mine`, `due`, `inbox`
**Argument expansion:** `$me` → current user, `$@` → all args, `$1`/`$2`/… → positional
## Relations
| Type | Description |
|------|-------------|
| `blocks` | Prevents target from closing until this is done |
| `subtask` | Marks as subtask of target |
| `related` | General association |
| `assignee` | Assigns to a user (single-value) |
```bash
ax update A --rel blocks:B # A blocks B
ax update abc12 --assignee alice # assign to alice
```
## Tags and Properties
Tags follow the `_key::value` pattern for properties:
| Property | Values |
|----------|--------|
| `_type` | `issue`, `note`, `user`, `namespace` |
| `_status` | `open`, `done` |
| `_prio` | `high`, `medium`, `low` |
## Mentions and Inbox
Use `@username` in title or content to add to a user's inbox:
```bash
ax add "Review PR @alice" # alice gets an inbox entry
ax inbox # your inbox
AX_USER=alice ax inbox # alice's inbox
```
## JSON Output
All commands support `--json`:
```bash
ax list --status open --json
ax show abc12 --json
```
## Configuration
`ax` searches upward from CWD for `.axconfig`, falling back to `~/.config/ax/config.json`.
```json
{
"user": "alice",
"aliases": [
{"name": "mywork", "command": "list --namespace myproject", "description": "My tasks"}
]
}
```
## Server Mode
`ax` can run as a shared HTTP JSON API server:
```bash
ax serve # starts server on configured host:port (default: 0.0.0.0:7000)
```
The server exposes the same operations (add, list, show, update, delete) over HTTP. Clients connect by setting `remote.host` / `remote.port` in their config — the CLI then transparently routes calls to the server instead of a local database.
### OIDC Authentication
The server supports OIDC for authentication. Configure in `.axconfig`:
```json
{
"serve": { "host": "0.0.0.0", "port": 7000 },
"oidc": {
"issuer": "https://your-idp.example.com",
"client_id": "axolotl",
"client_secret": "secret",
"public_url": "https://ax.example.com",
"user_claim": "preferred_username"
}
}
```
Client login:
```bash
ax login # opens browser for OIDC flow, saves session token
```
Without OIDC configured, the server accepts an `X-Ax-User` header for the username (development/trusted networks only).
### Docker
```bash
docker run -v ./data:/data g.eliaskohout.de/eliaskohout/axolotl-server:latest
```
The image runs `ax serve` and exposes port 7000. Mount a volume at `/data` to persist the database.
## Permission Model
Every node has per-node access control. Permissions are transitive via BFS from the requesting user's own node.
| Level | Relation | Grants |
|-------|----------|--------|
| 1 | `can_read` | Read / show / list |
| 2 | `can_create_rel` | Create relations pointing to this node |
| 3 | `can_write` | Update title, content, tags |
| 4 | `has_ownership` | Full control including delete and granting access |
- Creators automatically get `has_ownership` on nodes they create.
- Namespace nodes own regular nodes within them; users own their namespaces.
- Deleting an owner cascades to all nodes it owns.
- User nodes and namespace nodes are globally readable.
```bash
# Grant bob read access to a node
ax update <bob-user-id> --rel can_read:<node-id>
# Grant bob write access
ax update <bob-user-id> --rel can_write:<node-id>
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AX_USER` | Override current username |
| `EDITOR` | Editor for `ax edit` (default: `vi`) |
## Database Location
`ax` searches for `.ax.db` upward from CWD (like git finds `.git`), so commands work from any subdirectory.
Executable
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -43,7 +43,7 @@ var addCmd = &cobra.Command{
input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""}) input.Rels = append(input.Rels, service.RelInput{Type: models.RelType("_prio::" + cPrio), Target: ""})
} }
if cNamespace != "" { if cNamespace != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelInNamespace, Target: cNamespace}) input.Namespace = cNamespace
} }
if cAssignee != "" { if cAssignee != "" {
input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee}) input.Rels = append(input.Rels, service.RelInput{Type: models.RelAssignee, Target: cAssignee})
+10 -1
View File
@@ -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 {
@@ -39,7 +46,7 @@ var listCmd = &cobra.Command{
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelType("_type::" + lType), Target: ""})
} }
if lNamespace != "" { if lNamespace != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: lNamespace}) filter.Namespace = lNamespace
} }
if lAssignee != "" { if lAssignee != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: lAssignee})
@@ -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)")
} }
+96 -51
View File
@@ -22,67 +22,112 @@ var loginCmd = &cobra.Command{
} }
base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port) base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port)
resp, err := http.Post(base+"/auth/start", "application/json", nil) sessionID := tryDeviceFlow(base)
if sessionID == "" {
sessionID = tryCallbackFlow(base)
}
pollForToken(base, sessionID)
},
}
// tryDeviceFlow attempts the device authorization flow. Returns a session ID
// on success, or "" if the server does not support it.
func tryDeviceFlow(base string) string {
resp, err := http.Post(base+"/auth/device/start", "application/json", nil)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var start struct {
SessionID string `json:"session_id"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
}
json.NewDecoder(resp.Body).Decode(&start)
if start.SessionID == "" {
return ""
}
uri := start.VerificationURI
if start.VerificationURIComplete != "" {
uri = start.VerificationURIComplete
}
fmt.Printf("To sign in, open this URL in any browser:\n\n %s\n\nThen enter this code: %s\n\nWaiting for authentication...\n", uri, start.UserCode)
return start.SessionID
}
// tryCallbackFlow initiates the traditional callback-based OIDC flow.
// Exits the process on failure.
func tryCallbackFlow(base string) string {
resp, err := http.Post(base+"/auth/start", "application/json", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to contact server: %v\n", err)
os.Exit(1)
}
var start struct {
URL string `json:"url"`
SessionID string `json:"session_id"`
}
json.NewDecoder(resp.Body).Decode(&start)
resp.Body.Close()
if start.URL == "" {
fmt.Fprintln(os.Stderr, "server did not return an auth URL; is OIDC configured on the server?")
os.Exit(1)
}
fmt.Printf("Open this URL in your browser:\n\n %s\n\nWaiting for login...\n", start.URL)
return start.SessionID
}
// pollForToken polls the server until the login completes or times out.
func pollForToken(base, sessionID string) {
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
resp, err := http.Get(fmt.Sprintf("%s/auth/poll?session_id=%s", base, sessionID))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to contact server: %v\n", err) continue
}
if resp.StatusCode == http.StatusAccepted {
resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
fmt.Fprintln(os.Stderr, "login failed")
os.Exit(1) os.Exit(1)
} }
var start struct {
URL string `json:"url"` var result struct {
SessionID string `json:"session_id"` Token string `json:"token"`
Username string `json:"username"`
} }
json.NewDecoder(resp.Body).Decode(&start) json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close() resp.Body.Close()
if start.URL == "" { session, err := store.LoadSession()
fmt.Fprintln(os.Stderr, "server did not return an auth URL; is OIDC configured on the server?") if err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1) os.Exit(1)
} }
session.Token = result.Token
fmt.Printf("Open this URL in your browser:\n\n %s\n\nWaiting for login...\n", start.URL) if err := session.Save(); err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
deadline := time.Now().Add(5 * time.Minute) os.Exit(1)
for time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
resp, err := http.Get(fmt.Sprintf("%s/auth/poll?session_id=%s", base, start.SessionID))
if err != nil {
continue
}
if resp.StatusCode == http.StatusAccepted {
resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
fmt.Fprintln(os.Stderr, "login failed")
os.Exit(1)
}
var result struct {
Token string `json:"token"`
Username string `json:"username"`
}
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
session, err := store.LoadSession()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1)
}
session.Token = result.Token
if err := session.Save(); err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1)
}
fmt.Printf("Logged in as %s\n", result.Username)
return
} }
fmt.Printf("Logged in as %s\n", result.Username)
return
}
fmt.Fprintln(os.Stderr, "login timed out") fmt.Fprintln(os.Stderr, "login timed out")
os.Exit(1) os.Exit(1)
},
} }
func init() { func init() {
+71 -15
View File
@@ -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"
) )
@@ -47,7 +49,7 @@ var (
"low": {" ", "low", cDim}, "low": {" ", "low", cDim},
"": {" ", "n/a", cDim}, "": {" ", "n/a", cDim},
} }
relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007", "in_namespace": "\uf07b"} relIcons = map[string]string{"blocks": "\uf068", "subtask": "\uf0da", "related": "\uf0c1", "assignee": "\uf007"}
prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1} prioRanks = map[string]int{"high": 3, "medium": 2, "low": 1}
statusRanks = map[string]int{"open": 2, "": 1, "done": 0} statusRanks = map[string]int{"open": 2, "": 1, "done": 0}
) )
@@ -74,28 +76,31 @@ 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 {
n_rels := n.Relations
ns_rel_node_ids := n_rels[string(models.RelInNamespace)]
ns_rel_node_titles := make([]string, 0, len(ns_rel_node_ids))
for _, id := range ns_rel_node_ids {
ns_rel_node, err := svc.GetByID(id)
if err != nil {
ns_rel_node_titles = append(ns_rel_node_titles, id)
continue
}
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",
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, ",")+"]"), dueDateShort(n.DueDate),
) )
tags := n.GetDisplayTags() tags := n.GetDisplayTags()
if len(tags) > 0 { if len(tags) > 0 {
@@ -117,7 +122,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 +207,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
+1 -1
View File
@@ -61,7 +61,7 @@ var updateCmd = &cobra.Command{
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""}) input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelType("_prio::" + uPrio), Target: ""})
} }
if cmd.Flags().Changed("namespace") { if cmd.Flags().Changed("namespace") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelInNamespace, Target: uNamespace}) input.Namespace = &uNamespace
} }
if cmd.Flags().Changed("assignee") { if cmd.Flags().Changed("assignee") {
input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee}) input.AddRels = append(input.AddRels, service.RelInput{Type: models.RelAssignee, Target: uAssignee})
-6
View File
@@ -33,9 +33,6 @@ func TestCRUD(t *testing.T) {
if len(n.Relations["created"]) == 0 { if len(n.Relations["created"]) == 0 {
t.Error("expected created relation to be set") t.Error("expected created relation to be set")
} }
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation to be set")
}
if n.CreatedAt == "" || n.UpdatedAt == "" { if n.CreatedAt == "" || n.UpdatedAt == "" {
t.Error("expected timestamps to be set") t.Error("expected timestamps to be set")
} }
@@ -73,9 +70,6 @@ func TestCRUD(t *testing.T) {
if n.Content != "some body" { if n.Content != "some body" {
t.Errorf("content: want %q, got %q", "some body", n.Content) t.Errorf("content: want %q, got %q", "some body", n.Content)
} }
if len(n.Relations["in_namespace"]) == 0 {
t.Error("expected in_namespace relation")
}
if len(n.Relations["assignee"]) == 0 { if len(n.Relations["assignee"]) == 0 {
t.Error("expected assignee relation") t.Error("expected assignee relation")
} }
+20 -6
View File
@@ -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)
} }
}) })
@@ -150,10 +168,6 @@ func TestNamespaceExplicitCreate(t *testing.T) {
nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json")) nsNode := env.parseNode(env.mustAx("add", "myworkspace", "--type", "namespace", "--json"))
if !nsNode.HasRelation("in_namespace", nsNode.ID) {
t.Errorf("expected namespace to have in_namespace pointing to itself, got relations: %v", nsNode.Relations)
}
users := env.parseNodes(env.mustAx("list", "--type", "user", "--json")) users := env.parseNodes(env.mustAx("list", "--type", "user", "--json"))
var userNode *NodeResponse var userNode *NodeResponse
for i := range users { for i := range users {
+1 -1
View File
@@ -89,7 +89,7 @@ func (n *Node) AddRelation(relType RelType, target string) {
if n.Relations == nil { if n.Relations == nil {
n.Relations = make(map[string][]string) n.Relations = make(map[string][]string)
} }
if relType == RelAssignee || relType == RelCreated || relType == RelInNamespace { if relType == RelAssignee || relType == RelCreated {
n.Relations[string(relType)] = []string{target} n.Relations[string(relType)] = []string{target}
return return
} }
+6 -7
View File
@@ -8,13 +8,12 @@ type Rel struct {
} }
const ( const (
RelBlocks RelType = "blocks" RelBlocks RelType = "blocks"
RelSubtask RelType = "subtask" RelSubtask RelType = "subtask"
RelRelated RelType = "related" RelRelated RelType = "related"
RelCreated RelType = "created" RelCreated RelType = "created"
RelAssignee RelType = "assignee" RelAssignee RelType = "assignee"
RelInNamespace RelType = "in_namespace" RelMentions RelType = "mentions"
RelMentions RelType = "mentions"
// Permission rels (subject → object). Levels are inclusive and transitive. // Permission rels (subject → object). Levels are inclusive and transitive.
RelCanRead RelType = "can_read" // level 1: visible in list/show RelCanRead RelType = "can_read" // level 1: visible in list/show
+127 -17
View File
@@ -23,16 +23,26 @@ type pendingLogin struct {
serverToken string // set by callback when complete; empty while pending serverToken string // set by callback when complete; empty while pending
} }
// pendingDeviceLogin tracks an in-progress device authorization flow.
type pendingDeviceLogin struct {
created time.Time
serverToken string // set when device token exchange completes
username string // set when device token exchange completes
err string // set if the flow fails
}
// authHandler owns the OIDC provider connection, the pending login store, // authHandler owns the OIDC provider connection, the pending login store,
// and the active server-side session map. // and the active server-side session map.
type authHandler struct { type authHandler struct {
mu sync.Mutex mu sync.Mutex
pending map[string]*pendingLogin // loginID → pending state pending map[string]*pendingLogin // loginID → pending state
sessions map[string]string // serverToken → username pendingDevice map[string]*pendingDeviceLogin // loginID → pending device state
sessions map[string]string // serverToken → username
cfg store.OIDCConfig cfg store.OIDCConfig
provider *oidc.Provider provider *oidc.Provider
oauth2 oauth2.Config oauth2 oauth2.Config
deviceFlowAvailable bool
} }
func newAuthHandler(cfg store.OIDCConfig) (*authHandler, error) { func newAuthHandler(cfg store.OIDCConfig) (*authHandler, error) {
@@ -43,18 +53,21 @@ func newAuthHandler(cfg store.OIDCConfig) (*authHandler, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("OIDC provider: %w", err) return nil, fmt.Errorf("OIDC provider: %w", err)
} }
endpoint := provider.Endpoint()
h := &authHandler{ h := &authHandler{
pending: make(map[string]*pendingLogin), pending: make(map[string]*pendingLogin),
sessions: make(map[string]string), pendingDevice: make(map[string]*pendingDeviceLogin),
cfg: cfg, sessions: make(map[string]string),
provider: provider, cfg: cfg,
provider: provider,
oauth2: oauth2.Config{ oauth2: oauth2.Config{
ClientID: cfg.ClientID, ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret, ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(), Endpoint: endpoint,
RedirectURL: cfg.PublicURL + "/auth/callback", RedirectURL: cfg.PublicURL + "/auth/callback",
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "offline_access"}, Scopes: []string{oidc.ScopeOpenID, "profile", "email", "offline_access"},
}, },
deviceFlowAvailable: endpoint.DeviceAuthURL != "",
} }
go h.cleanup() go h.cleanup()
return h, nil return h, nil
@@ -68,6 +81,11 @@ func (h *authHandler) cleanup() {
delete(h.pending, id) delete(h.pending, id)
} }
} }
for id, p := range h.pendingDevice {
if time.Since(p.created) > 15*time.Minute {
delete(h.pendingDevice, id)
}
}
h.mu.Unlock() h.mu.Unlock()
} }
} }
@@ -148,6 +166,71 @@ func (h *authHandler) callback(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Login successful! You can close this tab.") fmt.Fprintln(w, "Login successful! You can close this tab.")
} }
// POST /auth/device/start → {session_id, user_code, verification_uri, verification_uri_complete}
func (h *authHandler) deviceStart(w http.ResponseWriter, r *http.Request) {
if !h.deviceFlowAvailable {
writeError(w, http.StatusNotFound, "device flow not supported by OIDC provider")
return
}
da, err := h.oauth2.DeviceAuth(r.Context(),
oauth2.SetAuthURLParam("client_secret", h.cfg.ClientSecret),
)
if err != nil {
writeError(w, http.StatusBadGateway, "device authorization request failed: "+err.Error())
return
}
loginID := randomToken(16)
h.mu.Lock()
h.pendingDevice[loginID] = &pendingDeviceLogin{created: time.Now()}
h.mu.Unlock()
// Exchange device code for token in the background.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
token, err := h.oauth2.DeviceAccessToken(ctx, da)
if err != nil {
h.mu.Lock()
if p := h.pendingDevice[loginID]; p != nil {
p.err = err.Error()
}
h.mu.Unlock()
return
}
username, err := h.extractUsername(ctx, token)
if err != nil {
h.mu.Lock()
if p := h.pendingDevice[loginID]; p != nil {
p.err = "failed to identify user: " + err.Error()
}
h.mu.Unlock()
return
}
serverToken := randomToken(32)
h.mu.Lock()
h.sessions[serverToken] = username
if p := h.pendingDevice[loginID]; p != nil {
p.serverToken = serverToken
p.username = username
}
h.mu.Unlock()
}()
writeJSON(w, map[string]string{
"session_id": loginID,
"user_code": da.UserCode,
"verification_uri": da.VerificationURI,
"verification_uri_complete": da.VerificationURIComplete,
})
}
// GET /auth/poll?session_id=... // GET /auth/poll?session_id=...
// Returns 202 while pending, 200 {token, username} when done, 404 if expired. // Returns 202 while pending, 200 {token, username} when done, 404 if expired.
func (h *authHandler) poll(w http.ResponseWriter, r *http.Request) { func (h *authHandler) poll(w http.ResponseWriter, r *http.Request) {
@@ -157,25 +240,52 @@ func (h *authHandler) poll(w http.ResponseWriter, r *http.Request) {
p := h.pending[loginID] p := h.pending[loginID]
h.mu.Unlock() h.mu.Unlock()
if p == nil { // Check callback-based flow first.
if p != nil {
h.mu.Lock()
serverToken := p.serverToken
if serverToken != "" {
delete(h.pending, loginID)
}
h.mu.Unlock()
if serverToken == "" {
w.WriteHeader(http.StatusAccepted)
return
}
username := h.lookupSession(serverToken)
writeJSON(w, map[string]string{"token": serverToken, "username": username})
return
}
// Check device flow.
h.mu.Lock()
dp := h.pendingDevice[loginID]
h.mu.Unlock()
if dp == nil {
writeError(w, http.StatusNotFound, "session not found or expired") writeError(w, http.StatusNotFound, "session not found or expired")
return return
} }
h.mu.Lock() h.mu.Lock()
serverToken := p.serverToken serverToken := dp.serverToken
if serverToken != "" { errMsg := dp.err
delete(h.pending, loginID) // consume once delivered if serverToken != "" || errMsg != "" {
delete(h.pendingDevice, loginID)
} }
h.mu.Unlock() h.mu.Unlock()
if errMsg != "" {
writeError(w, http.StatusGone, errMsg)
return
}
if serverToken == "" { if serverToken == "" {
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
return return
} }
username := h.lookupSession(serverToken) writeJSON(w, map[string]string{"token": serverToken, "username": dp.username})
writeJSON(w, map[string]string{"token": serverToken, "username": username})
} }
func (h *authHandler) extractUsername(ctx context.Context, token *oauth2.Token) (string, error) { func (h *authHandler) extractUsername(ctx context.Context, token *oauth2.Token) (string, error) {
+11 -1
View File
@@ -6,6 +6,7 @@ import (
"axolotl/store" "axolotl/store"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv"
"strings" "strings"
) )
@@ -29,6 +30,7 @@ func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.O
return nil, err return nil, err
} }
mux.HandleFunc("POST /auth/start", ah.start) mux.HandleFunc("POST /auth/start", ah.start)
mux.HandleFunc("POST /auth/device/start", ah.deviceStart)
mux.HandleFunc("GET /auth/callback", ah.callback) mux.HandleFunc("GET /auth/callback", ah.callback)
mux.HandleFunc("GET /auth/poll", ah.poll) mux.HandleFunc("GET /auth/poll", ah.poll)
return withSessionAuth(ah, mux), nil return withSessionAuth(ah, mux), nil
@@ -76,7 +78,7 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
} }
} }
if v := q.Get("namespace"); v != "" { if v := q.Get("namespace"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelInNamespace, Target: v}) filter.Namespace = v
} }
if v := q.Get("assignee"); v != "" { if v := q.Get("assignee"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v}) filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelAssignee, Target: v})
@@ -84,6 +86,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())
+10
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
) )
type apiClient struct { type apiClient struct {
@@ -71,6 +72,9 @@ func (c *apiClient) GetByID(id string) (*models.Node, error) {
func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) { func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q := url.Values{} q := url.Values{}
if filter.Namespace != "" {
q.Set("namespace", filter.Namespace)
}
for _, r := range filter.Rels { for _, r := range filter.Rels {
if r.Target == "" { if r.Target == "" {
q.Add("rel", string(r.Type)) q.Add("rel", string(r.Type))
@@ -78,6 +82,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()
+12 -6
View File
@@ -34,20 +34,23 @@ type NodeService interface {
// Type is "prefix::value"), and edge rels (Target is a node name or ID). // Type is "prefix::value"), and edge rels (Target is a node name or ID).
// The service applies defaults (type=issue, status=open for issues) and validates. // The service applies defaults (type=issue, status=open for issues) and validates.
type AddInput struct { type AddInput struct {
Title string Title string
Content string Content string
DueDate string DueDate string
Rels []RelInput Namespace string // namespace name or ID; defaults to the user's personal namespace
Rels []RelInput
} }
// UpdateInput describes changes to apply to an existing node. // UpdateInput describes changes to apply to an existing node.
// AddRels and RemoveRels accept both tag rels (Target == "") and edge rels. // AddRels and RemoveRels accept both tag rels (Target == "") and edge rels.
// Setting _status::done in AddRels is rejected when the node has open blockers. // Setting _status::done in AddRels is rejected when the node has open blockers.
// Adding assignee or in_namespace rels replaces the previous single target. // Adding an assignee rel replaces the previous single target.
// Setting Namespace transfers ownership from the current namespace to the new one.
type UpdateInput struct { type UpdateInput struct {
Title *string Title *string
Content *string Content *string
DueDate *string // nil = no change; pointer to "" = clear due date DueDate *string // nil = no change; pointer to "" = clear due date
Namespace *string // nil = no change; namespace name or ID to move node into
AddRels []RelInput AddRels []RelInput
RemoveRels []RelInput RemoveRels []RelInput
} }
@@ -56,7 +59,10 @@ 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 Namespace string // when non-empty, only return nodes owned by this namespace
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.
+127 -51
View File
@@ -75,7 +75,7 @@ const (
// namespaces are globally readable and any node can reference them. // namespaces are globally readable and any node can reference them.
func isReferenceRel(t models.RelType) bool { func isReferenceRel(t models.RelType) bool {
switch t { switch t {
case models.RelAssignee, models.RelCreated, models.RelMentions, models.RelInNamespace: case models.RelAssignee, models.RelCreated, models.RelMentions:
return true return true
} }
return false return false
@@ -208,6 +208,28 @@ func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) {
} }
func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) { func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
// Resolve namespace filter to owned node IDs.
var nsOwnedIDs map[string]bool
if filter.Namespace != "" {
nsID, _ := s.resolveIDByNameAndType(s.store, filter.Namespace, "namespace")
if nsID == "" {
if exists, _ := s.store.NodeExists(filter.Namespace); exists {
nsID = filter.Namespace
}
}
if nsID == "" {
return nil, nil // namespace not found
}
nsNode, err := s.store.GetNode(nsID)
if err != nil {
return nil, nil
}
nsOwnedIDs = make(map[string]bool)
for _, ownedID := range nsNode.Relations[string(models.RelHasOwnership)] {
nsOwnedIDs[ownedID] = true
}
}
var storeFilters []*models.Rel var storeFilters []*models.Rel
for _, ri := range filter.Rels { for _, ri := range filter.Rels {
if ri.Target == "" { if ri.Target == "" {
@@ -230,10 +252,34 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
} }
var result []*models.Node var result []*models.Node
for _, n := range nodes { for _, n := range nodes {
if pc.canRead(n.ID) { if !pc.canRead(n.ID) {
result = append(result, n) continue
} }
if nsOwnedIDs != nil && !nsOwnedIDs[n.ID] {
continue
}
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
} }
@@ -303,13 +349,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
hasNamespace := false
for _, ri := range input.Rels {
if ri.Type == models.RelInNamespace && ri.Target != "" {
hasNamespace = true
}
}
dueDate, err := parseDueDate(input.DueDate) dueDate, err := parseDueDate(input.DueDate)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -369,17 +408,6 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
} }
} }
// 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. // Default created.
if !hasCreated { if !hasCreated {
userID, err := s.resolveUserRef(st, s.userID) userID, err := s.resolveUserRef(st, s.userID)
@@ -391,31 +419,29 @@ 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 (user node).
// 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→has_ownership→default-ns→has_ownership→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
return err if tmp.GetProperty("type") != "namespace" {
} nsRef := input.Namespace
if nsRef == "" {
// Namespace bootstrap: when creating a namespace node directly, apply the nsRef = s.userID
// same setup as ensureNamespace — self in_namespace and creator ownership.
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
}
} }
// Creator already gets ownership via the block above; nothing more to do. nsID, err := s.resolveNamespaceRef(st, nsRef)
if err != nil {
return err
}
ownerID = nsID
}
if err := st.AddRel(ownerID, string(models.RelHasOwnership), id); err != nil {
return err
} }
return nil return nil
@@ -438,8 +464,8 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return nil, err return nil, err
} }
// Field/tag changes and rel removals require can_write on the node. // Field/tag changes, rel removals, and namespace change require can_write on the node.
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil
for _, ri := range input.AddRels { for _, ri := range input.AddRels {
if ri.Target == "" { if ri.Target == "" {
needsWrite = true needsWrite = true
@@ -587,7 +613,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
return err return err
} }
// Single-value relations replace the previous target. // Single-value relations replace the previous target.
if ri.Type == models.RelAssignee || ri.Type == models.RelInNamespace { if ri.Type == models.RelAssignee {
for _, oldTgt := range currentRels[string(ri.Type)] { for _, oldTgt := range currentRels[string(ri.Type)] {
if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil { if err := st.RemoveRel(id, string(ri.Type), oldTgt); err != nil {
return err return err
@@ -619,6 +645,24 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
} }
} }
// Namespace change: transfer ownership from the current namespace to the new one.
if input.Namespace != nil {
newNsID, err := s.resolveNamespaceRef(st, *input.Namespace)
if err != nil {
return err
}
// Remove ownership from any current namespace owner.
currentOwners, _ := st.FindNodes([]*models.Rel{{Type: models.RelHasOwnership, Target: id}})
for _, owner := range currentOwners {
if owner.GetProperty("type") == "namespace" {
st.RemoveRel(owner.ID, string(models.RelHasOwnership), id) //nolint:errcheck
}
}
if err := st.AddRel(newNsID, string(models.RelHasOwnership), id); err != nil {
return err
}
}
return nil return nil
}) })
if err != nil { if err != nil {
@@ -738,8 +782,6 @@ func (s *nodeServiceImpl) resolveRelTarget(st store.GraphStore, ri RelInput) (st
switch ri.Type { switch ri.Type {
case models.RelAssignee, models.RelCreated, models.RelMentions: case models.RelAssignee, models.RelCreated, models.RelMentions:
return s.resolveUserRef(st, ri.Target) return s.resolveUserRef(st, ri.Target)
case models.RelInNamespace:
return s.resolveNamespaceRef(st, ri.Target)
default: default:
// Permission rels and all other edge rels expect raw node IDs. // Permission rels and all other edge rels expect raw node IDs.
return ri.Target, nil return ri.Target, nil
@@ -756,8 +798,6 @@ func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string)
switch relType { switch relType {
case models.RelAssignee, models.RelCreated, models.RelMentions: case models.RelAssignee, models.RelCreated, models.RelMentions:
nodeType = "user" nodeType = "user"
case models.RelInNamespace:
nodeType = "namespace"
default: default:
// Permission rels and other edge rels use raw node IDs. // Permission rels and other edge rels use raw node IDs.
return "", false return "", false
@@ -790,6 +830,34 @@ 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
}
// 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 +881,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
} }
@@ -842,9 +921,6 @@ func (s *nodeServiceImpl) ensureNamespace(st store.GraphStore, name string) (str
if err := st.AddRel(id, "_type::namespace", ""); err != nil { if err := st.AddRel(id, "_type::namespace", ""); err != nil {
return "", err return "", err
} }
if err := st.AddRel(id, string(models.RelInNamespace), id); err != nil {
return "", err
}
userID, err := s.resolveUserRef(st, s.userID) userID, err := s.resolveUserRef(st, s.userID)
if err != nil { if err != nil {
return "", err return "", err
+9 -20
View File
@@ -74,31 +74,20 @@ func FindAndOpenSQLiteStore() (GraphStore, error) {
} }
// FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server // FindOrInitSQLiteStore is like FindAndOpenSQLiteStore but intended for server
// mode: if no .ax.db is found it creates and initialises one in the current // mode: if no database is found it creates and initialises one in the
// working directory instead of returning an error. // ~/.local/share/ax/ directory instead of returning an error.
func FindOrInitSQLiteStore() (GraphStore, error) { func FindOrInitSQLiteStore() (GraphStore, error) {
dir, err := filepath.Abs(".") dataRoot, err := FindDataRoot(".local", "share")
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to find data dir: %w", err)
} }
for { dbPath := filepath.Join(dataRoot, "ax.db")
dbpath := filepath.Join(dir, ".ax.db") if _, err := os.Stat(dbPath); err != nil {
if _, err := os.Stat(dbpath); err == nil { if err := InitSQLiteStore(dbPath); err != nil {
return NewSQLiteStore(dbpath) return nil, err
}
if parent := filepath.Dir(dir); parent == dir {
break
} else {
dir = parent
} }
} }
// Not found — create and initialise in CWD. return NewSQLiteStore(dbPath)
cwd, _ := filepath.Abs(".")
dbpath := filepath.Join(cwd, ".ax.db")
if err := InitSQLiteStore(dbpath); err != nil {
return nil, err
}
return NewSQLiteStore(dbpath)
} }
// NewSQLiteStore opens a SQLite database at the given path, runs a one-time // NewSQLiteStore opens a SQLite database at the given path, runs a one-time
+1 -1
View File
@@ -22,7 +22,7 @@ func LoadSession() (*Session, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, nil return &Session{path: path}, nil
} }
return nil, err return nil, err
} }