1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package client
4
5import (
6 "fmt"
7 "slices"
8 "strconv"
9 "strings"
10 "time"
11
12 "bnema.dev/ermite-api/gen/ermite"
13)
14
15// Token presets (ADR 0006). The API knows scopes only; a preset is a client
16// shortcut that expands to scopes.
17const (
18 presetCLI = "cli"
19 presetAgent = "agent"
20 presetCI = "ci"
21 // presetRunner is for a CI runner host: it only registers runners.
22 presetRunner = "runner"
23)
24
25// PresetNames lists the token presets, see PresetScopes.
26var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner}
27
28// ScopeName is the name of a scope as users type it and as the docs write
29// it: SCOPE_REPO_READ is "repo:read".
30func ScopeName(s ermite.Scope) string {
31 if s == ermite.Scope_SCOPE_MR_FORCE_MERGE {
32 return "mr:force-merge"
33 }
34 name, ok := ermite.Scope_name[int32(s)]
35 if !ok {
36 return strconv.Itoa(int(s))
37 }
38 return strings.Replace(strings.ToLower(strings.TrimPrefix(name, "SCOPE_")), "_", ":", 1)
39}
40
41// AllScopes lists every scope the contract defines, in enum order. Deriving it
42// keeps the `cli` preset ("every scope") true when the contract grows.
43func AllScopes() []ermite.Scope {
44 var scopes []ermite.Scope
45 for n := range ermite.Scope_name {
46 if s := ermite.Scope(n); s != ermite.Scope_SCOPE_UNSPECIFIED {
47 scopes = append(scopes, s)
48 }
49 }
50 slices.Sort(scopes)
51 return scopes
52}
53
54// ParseScope reads a scope as users type it, for example "repo:read".
55func ParseScope(name string) (ermite.Scope, error) {
56 want := strings.ToLower(strings.TrimSpace(name))
57 for _, s := range AllScopes() {
58 if ScopeName(s) == want {
59 return s, nil
60 }
61 }
62 return 0, fmt.Errorf("unknown scope %q: use one of %s", name, strings.Join(ScopeNames(AllScopes()), ", "))
63}
64
65// ScopeNames maps ScopeName over scopes.
66func ScopeNames(scopes []ermite.Scope) []string {
67 names := make([]string, len(scopes))
68 for i, s := range scopes {
69 names[i] = ScopeName(s)
70 }
71 return names
72}
73
74// PresetScopes expands a preset to its scopes.
75func PresetScopes(name string) ([]ermite.Scope, error) {
76 switch strings.ToLower(name) {
77 case presetCLI:
78 return AllScopes(), nil
79 case presetAgent:
80 return []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, nil
81 case presetCI:
82 return []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, nil
83 case presetRunner:
84 return []ermite.Scope{ermite.Scope_SCOPE_RUNNER_REGISTER}, nil
85 default:
86 return nil, fmt.Errorf("unknown preset %q: use one of %s", name, strings.Join(PresetNames, ", "))
87 }
88}
89
90// MergeScopes merges a preset and explicit scopes into a sorted, unique list.
91// Neither given is an error: a token's power is never chosen by default.
92func MergeScopes(preset string, scopeFlags []string) ([]ermite.Scope, error) {
93 var scopes []ermite.Scope
94 if preset != "" {
95 s, err := PresetScopes(preset)
96 if err != nil {
97 return nil, err
98 }
99 scopes = append(scopes, s...)
100 }
101 for _, f := range scopeFlags {
102 s, err := ParseScope(f)
103 if err != nil {
104 return nil, err
105 }
106 scopes = append(scopes, s)
107 }
108 if len(scopes) == 0 {
109 return nil, fmt.Errorf("choose what the token may do: --preset %s, or one or more --scope", strings.Join(PresetNames, "|"))
110 }
111 slices.Sort(scopes)
112 return slices.Compact(scopes), nil
113}
114
115// ParseExpiry reads a lifetime such as "30d", "2w", "12h", "90m" or "1h30m"
116// (days and weeks are 24 h and 7 d), or an absolute date, "2027-01-31" (end of
117// that day, UTC) or an RFC 3339 instant. The result must lie in the future.
118func ParseExpiry(s string, now time.Time) (time.Time, error) {
119 s = strings.TrimSpace(s)
120 bad := fmt.Errorf("invalid --expires %q: use a duration like 30d, 12h or 2w, or a date like 2027-01-31", s)
121 var at time.Time
122 switch {
123 case s == "":
124 return time.Time{}, bad
125 case strings.HasSuffix(s, "d") || strings.HasSuffix(s, "w"):
126 n, err := strconv.Atoi(s[:len(s)-1])
127 if err != nil || n <= 0 {
128 return time.Time{}, bad
129 }
130 unit := 24 * time.Hour
131 if strings.HasSuffix(s, "w") {
132 unit *= 7
133 }
134 if n > 100*365 {
135 return time.Time{}, bad
136 }
137 at = now.Add(time.Duration(n) * unit)
138 default:
139 if d, err := time.ParseDuration(s); err == nil {
140 at = now.Add(d)
141 } else if t, err := time.Parse(time.RFC3339, s); err == nil {
142 at = t
143 } else if t, err := time.Parse(time.DateOnly, s); err == nil {
144 at = t.Add(24*time.Hour - time.Second)
145 } else {
146 return time.Time{}, bad
147 }
148 }
149 if !at.After(now) {
150 return time.Time{}, fmt.Errorf("invalid --expires %q: it is in the past", s)
151 }
152 return at.UTC().Truncate(time.Second), nil
153}