feat: add agent user nodes with access token authentication
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m50s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 51s
Build and Publish Docker Image / build-apk (amd64, x86_64) (push) Successful in 53s
Build and Publish Docker Image / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Docker Image / build-and-push-docker (push) Successful in 12m50s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 1m50s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 51s
Build and Publish Docker Image / build-apk (amd64, x86_64) (push) Successful in 53s
Build and Publish Docker Image / build-apk (arm64, aarch64) (push) Successful in 48s
Build and Publish Docker Image / build-and-push-docker (push) Successful in 12m50s
This commit is contained in:
@@ -3,7 +3,9 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"axolotl/models"
|
"axolotl/models"
|
||||||
"axolotl/service"
|
"axolotl/service"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -64,6 +66,9 @@ var addCmd = &cobra.Command{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if n.GetProperty("type") == "agent" {
|
||||||
|
printAgentToken(cmd.OutOrStdout(), n)
|
||||||
|
}
|
||||||
PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
|
PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -81,3 +86,13 @@ func init() {
|
|||||||
f.StringArrayVar(&cTags, "tag", nil, "label tag (alias for --rel tagname)")
|
f.StringArrayVar(&cTags, "tag", nil, "label tag (alias for --rel tagname)")
|
||||||
f.StringArrayVar(&cRels, "rel", nil, "relation (prefix::value or relname:target)")
|
f.StringArrayVar(&cRels, "rel", nil, "relation (prefix::value or relname:target)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printAgentToken(w io.Writer, n *models.Node) {
|
||||||
|
var c struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(n.Content), &c); err == nil && c.AccessToken != "" {
|
||||||
|
fmt.Fprintf(w, "\nAgent access token: %s\n", c.AccessToken)
|
||||||
|
fmt.Fprintln(w, "Save this token — it cannot be retrieved later via the CLI.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
-1
@@ -23,6 +23,14 @@ var serveCmd = &cobra.Command{
|
|||||||
oidcCfg = &cfg.OIDC
|
oidcCfg = &cfg.OIDC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agentLookup := func(token string) string {
|
||||||
|
st, err := store.FindOrInitSQLiteStore()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return service.LookupAgentToken(st, token)
|
||||||
|
}
|
||||||
|
|
||||||
handler, err := serve.New(func(user string) (service.NodeService, error) {
|
handler, err := serve.New(func(user string) (service.NodeService, error) {
|
||||||
if user == "" {
|
if user == "" {
|
||||||
return nil, fmt.Errorf("user is required")
|
return nil, fmt.Errorf("user is required")
|
||||||
@@ -32,7 +40,7 @@ var serveCmd = &cobra.Command{
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return service.NewLocalNodeService(st, user), nil
|
return service.NewLocalNodeService(st, user), nil
|
||||||
}, oidcCfg)
|
}, oidcCfg, agentLookup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+8
-1
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
uTitle, uContent, uDue string
|
uTitle, uContent, uDue string
|
||||||
uClearDue bool
|
uClearDue, uRegenToken bool
|
||||||
uStatus, uPrio, uType string
|
uStatus, uPrio, uType string
|
||||||
uNamespace, uAssignee string
|
uNamespace, uAssignee string
|
||||||
uAddTags, uRmTags, uAddRels, uRmRels []string
|
uAddTags, uRmTags, uAddRels, uRmRels []string
|
||||||
@@ -83,12 +83,18 @@ var updateCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
input.RemoveRels = append(input.RemoveRels, ri)
|
input.RemoveRels = append(input.RemoveRels, ri)
|
||||||
}
|
}
|
||||||
|
if uRegenToken {
|
||||||
|
input.RegenerateAccessToken = true
|
||||||
|
}
|
||||||
|
|
||||||
n, err := svc.Update(args[0], input)
|
n, err := svc.Update(args[0], input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if uRegenToken {
|
||||||
|
printAgentToken(cmd.OutOrStdout(), n)
|
||||||
|
}
|
||||||
PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
|
PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -109,4 +115,5 @@ func init() {
|
|||||||
f.StringArrayVar(&uRmTags, "tag-remove", nil, "remove label tag")
|
f.StringArrayVar(&uRmTags, "tag-remove", nil, "remove label tag")
|
||||||
f.StringArrayVar(&uAddRels, "rel", nil, "add relation (prefix::value or relname:target)")
|
f.StringArrayVar(&uAddRels, "rel", nil, "add relation (prefix::value or relname:target)")
|
||||||
f.StringArrayVar(&uRmRels, "rel-remove", nil, "remove relation (prefix::value or relname:target)")
|
f.StringArrayVar(&uRmRels, "rel-remove", nil, "remove relation (prefix::value or relname:target)")
|
||||||
|
f.BoolVar(&uRegenToken, "regenerate-access-token", false, "regenerate agent access token")
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -13,7 +13,8 @@ const userContextKey contextKey = "ax_user"
|
|||||||
// withSessionAuth wraps a handler with ax session token authentication.
|
// withSessionAuth wraps a handler with ax session token authentication.
|
||||||
// Auth endpoints (/auth/*) are passed through without a token check.
|
// Auth endpoints (/auth/*) are passed through without a token check.
|
||||||
// All other requests must supply Authorization: Bearer <server_token>.
|
// All other requests must supply Authorization: Bearer <server_token>.
|
||||||
func withSessionAuth(ah *authHandler, next http.Handler) http.Handler {
|
// If the token is not a valid OIDC session, agentLookup is tried as a fallback.
|
||||||
|
func withSessionAuth(ah *authHandler, agentLookup func(string) string, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/auth/") {
|
if strings.HasPrefix(r.URL.Path, "/auth/") {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -26,6 +27,9 @@ func withSessionAuth(ah *authHandler, next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
token := strings.TrimPrefix(auth, "Bearer ")
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
username := ah.lookupSession(token)
|
username := ah.lookupSession(token)
|
||||||
|
if username == "" && agentLookup != nil {
|
||||||
|
username = agentLookup(token)
|
||||||
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "invalid or expired session; run 'ax login'")
|
writeError(w, http.StatusUnauthorized, "invalid or expired session; run 'ax login'")
|
||||||
return
|
return
|
||||||
|
|||||||
+10
-4
@@ -14,8 +14,8 @@ import (
|
|||||||
// When oidcCfg is non-nil, every request must carry a valid Bearer token;
|
// When oidcCfg is non-nil, every request must carry a valid Bearer token;
|
||||||
// the authenticated username is derived from the token claim configured in
|
// the authenticated username is derived from the token claim configured in
|
||||||
// OIDCConfig.UserClaim. Without OIDC, the X-Ax-User header is used instead.
|
// OIDCConfig.UserClaim. Without OIDC, the X-Ax-User header is used instead.
|
||||||
func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.OIDCConfig) (http.Handler, error) {
|
func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.OIDCConfig, agentLookup func(string) string) (http.Handler, error) {
|
||||||
s := &server{newSvc: newSvc}
|
s := &server{newSvc: newSvc, agentLookup: agentLookup}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /nodes", s.listNodes)
|
mux.HandleFunc("GET /nodes", s.listNodes)
|
||||||
mux.HandleFunc("POST /nodes", s.addNode)
|
mux.HandleFunc("POST /nodes", s.addNode)
|
||||||
@@ -35,17 +35,23 @@ func New(newSvc func(user string) (service.NodeService, error), oidcCfg *store.O
|
|||||||
mux.HandleFunc("POST /auth/device/start", ah.deviceStart)
|
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 withRateLimit(rl, withSessionAuth(ah, mux)), nil
|
return withRateLimit(rl, withSessionAuth(ah, agentLookup, mux)), nil
|
||||||
}
|
}
|
||||||
return withRateLimit(rl, mux), nil
|
return withRateLimit(rl, mux), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
newSvc func(user string) (service.NodeService, error)
|
newSvc func(user string) (service.NodeService, error)
|
||||||
|
agentLookup func(string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) svc(w http.ResponseWriter, r *http.Request) (service.NodeService, bool) {
|
func (s *server) svc(w http.ResponseWriter, r *http.Request) (service.NodeService, bool) {
|
||||||
user := userFromContext(r)
|
user := userFromContext(r)
|
||||||
|
if user == "" && s.agentLookup != nil {
|
||||||
|
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
user = s.agentLookup(strings.TrimPrefix(auth, "Bearer "))
|
||||||
|
}
|
||||||
|
}
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = r.Header.Get("X-Ax-User")
|
user = r.Header.Get("X-Ax-User")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ func (c *apiClient) do(method, path string, body any) (*http.Response, error) {
|
|||||||
// setAuth attaches either a Bearer token (when a session exists) or the
|
// setAuth attaches either a Bearer token (when a session exists) or the
|
||||||
// X-Ax-User header (no session / non-OIDC servers).
|
// X-Ax-User header (no session / non-OIDC servers).
|
||||||
func (c *apiClient) setAuth(req *http.Request) error {
|
func (c *apiClient) setAuth(req *http.Request) error {
|
||||||
|
// Agent token takes priority (stateless, no login needed).
|
||||||
|
if token := os.Getenv("AX_TOKEN"); token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
sess, err := store.LoadSession()
|
sess, err := store.LoadSession()
|
||||||
if err != nil || sess == nil || sess.Token == "" {
|
if err != nil || sess == nil || sess.Token == "" {
|
||||||
req.Header.Set("X-Ax-User", c.user)
|
req.Header.Set("X-Ax-User", c.user)
|
||||||
|
|||||||
@@ -47,12 +47,13 @@ type AddInput struct {
|
|||||||
// Adding an assignee rel 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.
|
// 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
|
Namespace *string // nil = no change; namespace name or ID to move node into
|
||||||
AddRels []RelInput
|
AddRels []RelInput
|
||||||
RemoveRels []RelInput
|
RemoveRels []RelInput
|
||||||
|
RegenerateAccessToken bool // when true, regenerates the access token for agent nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFilter specifies which nodes to return. Empty slices are ignored.
|
// ListFilter specifies which nodes to return. Empty slices are ignored.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package service
|
|||||||
import (
|
import (
|
||||||
"axolotl/models"
|
"axolotl/models"
|
||||||
"axolotl/store"
|
"axolotl/store"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -11,6 +15,39 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// agentContent is the JSON structure stored in agent node content.
|
||||||
|
type agentContent struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateAccessToken returns a cryptographically random base64url token.
|
||||||
|
func generateAccessToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupAgentToken finds the agent node whose content contains the given
|
||||||
|
// access token. Returns the agent node ID, or "" if not found.
|
||||||
|
func LookupAgentToken(st store.GraphStore, token string) string {
|
||||||
|
agents, err := st.FindNodes([]*models.Rel{{Type: "_type::agent", Target: ""}})
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, agent := range agents {
|
||||||
|
var c agentContent
|
||||||
|
if err := json.Unmarshal([]byte(agent.Content), &c); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(c.AccessToken), []byte(token)) == 1 {
|
||||||
|
return agent.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type nodeServiceImpl struct {
|
type nodeServiceImpl struct {
|
||||||
store store.GraphStore
|
store store.GraphStore
|
||||||
userID string
|
userID string
|
||||||
@@ -105,9 +142,16 @@ func (pc *permContext) hasOwnership(nodeID string) bool { return pc.levels[nodeI
|
|||||||
// If the user node doesn't exist yet, returns an empty permContext (no access);
|
// If the user node doesn't exist yet, returns an empty permContext (no access);
|
||||||
// Add operations still work because unresolved targets skip the permission check.
|
// Add operations still work because unresolved targets skip the permission check.
|
||||||
func (s *nodeServiceImpl) getPermContext() (*permContext, error) {
|
func (s *nodeServiceImpl) getPermContext() (*permContext, error) {
|
||||||
userNodeID, err := s.resolveIDByNameAndType(s.store, s.userID, "user")
|
// If userID is already a node ID (e.g. for agents), use it directly.
|
||||||
if err != nil {
|
var userNodeID string
|
||||||
return nil, err
|
if exists, _ := s.store.NodeExists(s.userID); exists {
|
||||||
|
userNodeID = s.userID
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
userNodeID, err = s.resolveIDByNameAndType(s.store, s.userID, "user")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pc := &permContext{levels: make(map[string]int)}
|
pc := &permContext{levels: make(map[string]int)}
|
||||||
if userNodeID == "" {
|
if userNodeID == "" {
|
||||||
@@ -158,7 +202,7 @@ func (s *nodeServiceImpl) getPermContext() (*permContext, error) {
|
|||||||
// --- Validation ---
|
// --- Validation ---
|
||||||
|
|
||||||
var (
|
var (
|
||||||
validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true}
|
validTypes = map[string]bool{"issue": true, "note": true, "user": true, "namespace": true, "agent": true}
|
||||||
validStatuses = map[string]bool{"open": true, "done": true}
|
validStatuses = map[string]bool{"open": true, "done": true}
|
||||||
validPrios = map[string]bool{"high": true, "medium": true, "low": true}
|
validPrios = map[string]bool{"high": true, "medium": true, "low": true}
|
||||||
)
|
)
|
||||||
@@ -349,6 +393,16 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent nodes get an auto-generated access token stored as JSON content.
|
||||||
|
if tmp.GetProperty("type") == "agent" {
|
||||||
|
token, err := generateAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(agentContent{AccessToken: token})
|
||||||
|
input.Content = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
dueDate, err := parseDueDate(input.DueDate)
|
dueDate, err := parseDueDate(input.DueDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -419,8 +473,15 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent nodes get self-ownership (like users).
|
||||||
|
if tmp.GetProperty("type") == "agent" {
|
||||||
|
if err := st.AddRel(id, string(models.RelHasOwnership), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Grant ownership of the new node.
|
// Grant ownership of the new node.
|
||||||
// Namespace nodes are owned by their creator (user node).
|
// Namespace and agent nodes are owned by their creator (user node).
|
||||||
// All other nodes are owned by the namespace they belong to — the user
|
// All other nodes are owned by the namespace they belong to — the user
|
||||||
// retains transitive ownership through the namespace's own ownership chain
|
// retains transitive ownership through the namespace's own ownership chain
|
||||||
// (e.g. user→has_ownership→default-ns→has_ownership→node).
|
// (e.g. user→has_ownership→default-ns→has_ownership→node).
|
||||||
@@ -429,7 +490,8 @@ func (s *nodeServiceImpl) Add(input AddInput) (*models.Node, error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ownerID := creatorID
|
ownerID := creatorID
|
||||||
if tmp.GetProperty("type") != "namespace" {
|
nodeType := tmp.GetProperty("type")
|
||||||
|
if nodeType != "namespace" && nodeType != "agent" {
|
||||||
nsRef := input.Namespace
|
nsRef := input.Namespace
|
||||||
if nsRef == "" {
|
if nsRef == "" {
|
||||||
nsRef = s.userID
|
nsRef = s.userID
|
||||||
@@ -465,7 +527,7 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Field/tag changes, rel removals, and namespace change 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 || input.Namespace != nil
|
needsWrite := input.Title != nil || input.Content != nil || input.DueDate != nil || input.Namespace != nil || input.RegenerateAccessToken
|
||||||
for _, ri := range input.AddRels {
|
for _, ri := range input.AddRels {
|
||||||
if ri.Target == "" {
|
if ri.Target == "" {
|
||||||
needsWrite = true
|
needsWrite = true
|
||||||
@@ -548,8 +610,22 @@ func (s *nodeServiceImpl) Update(id string, input UpdateInput) (*models.Node, er
|
|||||||
title = *input.Title
|
title = *input.Title
|
||||||
}
|
}
|
||||||
if input.Content != nil {
|
if input.Content != nil {
|
||||||
|
if current.GetProperty("type") == "agent" {
|
||||||
|
return fmt.Errorf("cannot set content on agent nodes; use --regenerate-access-token to rotate the token")
|
||||||
|
}
|
||||||
content = *input.Content
|
content = *input.Content
|
||||||
}
|
}
|
||||||
|
if input.RegenerateAccessToken {
|
||||||
|
if current.GetProperty("type") != "agent" {
|
||||||
|
return fmt.Errorf("cannot regenerate access token: node is not an agent")
|
||||||
|
}
|
||||||
|
token, err := generateAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate access token: %w", err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(agentContent{AccessToken: token})
|
||||||
|
content = string(data)
|
||||||
|
}
|
||||||
if input.DueDate != nil {
|
if input.DueDate != nil {
|
||||||
parsed, err := parseDueDate(*input.DueDate)
|
parsed, err := parseDueDate(*input.DueDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user