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