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