orch-code 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -0
- package/README.md +6 -0
- package/cmd/auth.go +89 -10
- package/cmd/auth_test.go +57 -4
- package/cmd/doctor.go +13 -5
- package/cmd/interactive.go +14 -2
- package/cmd/version.go +1 -1
- package/internal/auth/account.go +27 -2
- package/internal/auth/account_session.go +154 -0
- package/internal/auth/account_session_test.go +71 -0
- package/internal/auth/store.go +477 -116
- package/internal/auth/store_test.go +73 -0
- package/internal/orchestrator/orchestrator.go +14 -2
- package/internal/providers/openai/client.go +83 -5
- package/internal/providers/openai/client_test.go +52 -0
- package/internal/providers/state_test.go +42 -0
- package/package.json +1 -1
package/internal/auth/store.go
CHANGED
|
@@ -25,14 +25,24 @@ type State struct {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
type Credential struct {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
ID string `json:"id,omitempty"`
|
|
29
|
+
Label string `json:"label,omitempty"`
|
|
30
|
+
Type string `json:"type"`
|
|
31
|
+
Key string `json:"key,omitempty"`
|
|
32
|
+
AccessToken string `json:"accessToken,omitempty"`
|
|
33
|
+
RefreshToken string `json:"refreshToken,omitempty"`
|
|
34
|
+
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
|
35
|
+
AccountID string `json:"accountId,omitempty"`
|
|
36
|
+
Email string `json:"email,omitempty"`
|
|
37
|
+
CooldownUntil time.Time `json:"cooldownUntil,omitempty"`
|
|
38
|
+
LastError string `json:"lastError,omitempty"`
|
|
39
|
+
LastUsedAt time.Time `json:"lastUsedAt,omitempty"`
|
|
40
|
+
UpdatedAt time.Time `json:"updatedAt"`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type ProviderCredentials struct {
|
|
44
|
+
ActiveID string `json:"activeId,omitempty"`
|
|
45
|
+
Credentials []Credential `json:"credentials"`
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
func Load(repoRoot string) (*State, error) {
|
|
@@ -67,82 +77,31 @@ func Load(repoRoot string) (*State, error) {
|
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
func LoadAll(repoRoot string) (map[string]Credential, error) {
|
|
70
|
-
|
|
71
|
-
data, err := os.ReadFile(path)
|
|
80
|
+
stores, err := readProviderStores(repoRoot)
|
|
72
81
|
if err != nil {
|
|
73
|
-
|
|
74
|
-
return map[string]Credential{}, nil
|
|
75
|
-
}
|
|
76
|
-
return nil, fmt.Errorf("read auth state: %w", err)
|
|
82
|
+
return nil, err
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if id == "" {
|
|
85
|
-
continue
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
cred.Type = strings.ToLower(strings.TrimSpace(cred.Type))
|
|
89
|
-
cred.Key = strings.TrimSpace(cred.Key)
|
|
90
|
-
cred.AccessToken = strings.TrimSpace(cred.AccessToken)
|
|
91
|
-
cred.RefreshToken = strings.TrimSpace(cred.RefreshToken)
|
|
92
|
-
cred.AccountID = strings.TrimSpace(cred.AccountID)
|
|
93
|
-
cred.Email = strings.TrimSpace(cred.Email)
|
|
94
|
-
|
|
95
|
-
if cred.Type == "" {
|
|
96
|
-
continue
|
|
97
|
-
}
|
|
98
|
-
if cred.Type == "api_key" {
|
|
99
|
-
cred.Type = "api"
|
|
100
|
-
}
|
|
101
|
-
if cred.Type == "account" {
|
|
102
|
-
cred.Type = "oauth"
|
|
103
|
-
}
|
|
104
|
-
clean[id] = cred
|
|
105
|
-
}
|
|
106
|
-
if len(clean) > 0 {
|
|
107
|
-
return clean, nil
|
|
85
|
+
active := map[string]Credential{}
|
|
86
|
+
for provider, store := range stores {
|
|
87
|
+
cred, ok := activeCredential(store)
|
|
88
|
+
if ok {
|
|
89
|
+
active[provider] = cred
|
|
108
90
|
}
|
|
109
91
|
}
|
|
92
|
+
return active, nil
|
|
93
|
+
}
|
|
110
94
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
provider := strings.ToLower(strings.TrimSpace(state.Provider))
|
|
117
|
-
if provider == "" {
|
|
118
|
-
provider = "openai"
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
mode := strings.ToLower(strings.TrimSpace(state.Mode))
|
|
122
|
-
cred := Credential{
|
|
123
|
-
RefreshToken: strings.TrimSpace(state.RefreshToken),
|
|
124
|
-
ExpiresAt: state.ExpiresAt,
|
|
125
|
-
AccountID: strings.TrimSpace(state.AccountID),
|
|
126
|
-
Email: strings.TrimSpace(state.Email),
|
|
127
|
-
UpdatedAt: state.UpdatedAt,
|
|
128
|
-
}
|
|
129
|
-
if mode == "account" {
|
|
130
|
-
cred.Type = "oauth"
|
|
131
|
-
cred.AccessToken = strings.TrimSpace(state.AccessToken)
|
|
132
|
-
}
|
|
133
|
-
if mode == "api_key" {
|
|
134
|
-
cred.Type = "api"
|
|
135
|
-
cred.Key = strings.TrimSpace(state.AccessToken)
|
|
136
|
-
}
|
|
137
|
-
if cred.Type == "" {
|
|
138
|
-
cred.Type = mode
|
|
95
|
+
func List(repoRoot, provider string) ([]Credential, string, error) {
|
|
96
|
+
store, err := loadProviderCredentials(repoRoot, provider)
|
|
97
|
+
if err != nil {
|
|
98
|
+
return nil, "", err
|
|
139
99
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return map[string]Credential{}, nil
|
|
100
|
+
if store == nil {
|
|
101
|
+
return []Credential{}, "", nil
|
|
143
102
|
}
|
|
144
|
-
|
|
145
|
-
return
|
|
103
|
+
creds := append([]Credential(nil), store.Credentials...)
|
|
104
|
+
return creds, strings.TrimSpace(store.ActiveID), nil
|
|
146
105
|
}
|
|
147
106
|
|
|
148
107
|
func Save(repoRoot string, state *State) error {
|
|
@@ -186,15 +145,14 @@ func Clear(repoRoot string) error {
|
|
|
186
145
|
}
|
|
187
146
|
|
|
188
147
|
func Get(repoRoot, provider string) (*Credential, error) {
|
|
189
|
-
|
|
148
|
+
store, err := loadProviderCredentials(repoRoot, provider)
|
|
190
149
|
if err != nil {
|
|
191
150
|
return nil, err
|
|
192
151
|
}
|
|
193
|
-
|
|
194
|
-
if id == "" {
|
|
152
|
+
if store == nil {
|
|
195
153
|
return nil, nil
|
|
196
154
|
}
|
|
197
|
-
cred, ok :=
|
|
155
|
+
cred, ok := activeCredential(*store)
|
|
198
156
|
if !ok {
|
|
199
157
|
return nil, nil
|
|
200
158
|
}
|
|
@@ -207,57 +165,131 @@ func Set(repoRoot, provider string, cred Credential) error {
|
|
|
207
165
|
return fmt.Errorf("provider is required")
|
|
208
166
|
}
|
|
209
167
|
|
|
210
|
-
|
|
211
|
-
if
|
|
212
|
-
|
|
168
|
+
stores, err := readProviderStores(repoRoot)
|
|
169
|
+
if err != nil {
|
|
170
|
+
return err
|
|
213
171
|
}
|
|
214
|
-
|
|
215
|
-
|
|
172
|
+
|
|
173
|
+
store := stores[id]
|
|
174
|
+
cred = normalizeCredential(cred)
|
|
175
|
+
if err := validateCredential(cred); err != nil {
|
|
176
|
+
return err
|
|
216
177
|
}
|
|
217
|
-
|
|
218
|
-
|
|
178
|
+
|
|
179
|
+
index := matchCredential(store.Credentials, cred)
|
|
180
|
+
if index >= 0 {
|
|
181
|
+
if cred.ID == "" {
|
|
182
|
+
cred.ID = store.Credentials[index].ID
|
|
183
|
+
}
|
|
184
|
+
store.Credentials[index] = mergeCredential(store.Credentials[index], cred)
|
|
185
|
+
} else {
|
|
186
|
+
cred.ID = ensureCredentialID(store.Credentials, cred)
|
|
187
|
+
store.Credentials = append(store.Credentials, cred)
|
|
219
188
|
}
|
|
220
|
-
|
|
221
|
-
|
|
189
|
+
store.ActiveID = cred.ID
|
|
190
|
+
stores[id] = normalizeProviderStore(store)
|
|
191
|
+
|
|
192
|
+
return writeProviderStores(repoRoot, stores)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func SetActive(repoRoot, provider, credentialID string) error {
|
|
196
|
+
store, err := loadProviderCredentials(repoRoot, provider)
|
|
197
|
+
if err != nil {
|
|
198
|
+
return err
|
|
222
199
|
}
|
|
223
|
-
if
|
|
224
|
-
return fmt.Errorf("
|
|
200
|
+
if store == nil {
|
|
201
|
+
return fmt.Errorf("no stored credentials for provider %s", provider)
|
|
202
|
+
}
|
|
203
|
+
credentialID = strings.TrimSpace(credentialID)
|
|
204
|
+
if credentialID == "" {
|
|
205
|
+
return fmt.Errorf("credential id is required")
|
|
225
206
|
}
|
|
226
207
|
|
|
227
|
-
|
|
228
|
-
|
|
208
|
+
for _, cred := range store.Credentials {
|
|
209
|
+
if cred.ID == credentialID {
|
|
210
|
+
store.ActiveID = credentialID
|
|
211
|
+
return saveProviderCredentials(repoRoot, provider, *store)
|
|
212
|
+
}
|
|
229
213
|
}
|
|
230
214
|
|
|
231
|
-
|
|
215
|
+
return fmt.Errorf("credential %s not found for provider %s", credentialID, provider)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func Remove(repoRoot, provider string) error {
|
|
219
|
+
stores, err := readProviderStores(repoRoot)
|
|
232
220
|
if err != nil {
|
|
233
221
|
return err
|
|
234
222
|
}
|
|
223
|
+
id := strings.ToLower(strings.TrimSpace(provider))
|
|
224
|
+
if id == "" {
|
|
225
|
+
return fmt.Errorf("provider is required")
|
|
226
|
+
}
|
|
227
|
+
if _, ok := stores[id]; !ok {
|
|
228
|
+
return nil
|
|
229
|
+
}
|
|
230
|
+
delete(stores, id)
|
|
235
231
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
cred.Email = strings.TrimSpace(cred.Email)
|
|
242
|
-
cred.UpdatedAt = time.Now().UTC()
|
|
243
|
-
|
|
244
|
-
all[id] = cred
|
|
232
|
+
if len(stores) == 0 {
|
|
233
|
+
return Clear(repoRoot)
|
|
234
|
+
}
|
|
235
|
+
return writeProviderStores(repoRoot, stores)
|
|
236
|
+
}
|
|
245
237
|
|
|
246
|
-
|
|
238
|
+
func RemoveCredential(repoRoot, provider, credentialID string) error {
|
|
239
|
+
store, err := loadProviderCredentials(repoRoot, provider)
|
|
247
240
|
if err != nil {
|
|
248
|
-
return
|
|
241
|
+
return err
|
|
242
|
+
}
|
|
243
|
+
if store == nil {
|
|
244
|
+
return fmt.Errorf("no stored credentials for provider %s", provider)
|
|
245
|
+
}
|
|
246
|
+
credentialID = strings.TrimSpace(credentialID)
|
|
247
|
+
if credentialID == "" {
|
|
248
|
+
return fmt.Errorf("credential id is required")
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
updated := make([]Credential, 0, len(store.Credentials))
|
|
252
|
+
removed := false
|
|
253
|
+
for _, cred := range store.Credentials {
|
|
254
|
+
if cred.ID == credentialID {
|
|
255
|
+
removed = true
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
updated = append(updated, cred)
|
|
259
|
+
}
|
|
260
|
+
if !removed {
|
|
261
|
+
return fmt.Errorf("credential %s not found for provider %s", credentialID, provider)
|
|
262
|
+
}
|
|
263
|
+
if len(updated) == 0 {
|
|
264
|
+
return Remove(repoRoot, provider)
|
|
254
265
|
}
|
|
255
266
|
|
|
256
|
-
|
|
267
|
+
store.Credentials = updated
|
|
268
|
+
if strings.TrimSpace(store.ActiveID) == credentialID {
|
|
269
|
+
store.ActiveID = updated[0].ID
|
|
270
|
+
}
|
|
271
|
+
return saveProviderCredentials(repoRoot, provider, *store)
|
|
257
272
|
}
|
|
258
273
|
|
|
259
|
-
func
|
|
260
|
-
|
|
274
|
+
func loadProviderCredentials(repoRoot, provider string) (*ProviderCredentials, error) {
|
|
275
|
+
stores, err := readProviderStores(repoRoot)
|
|
276
|
+
if err != nil {
|
|
277
|
+
return nil, err
|
|
278
|
+
}
|
|
279
|
+
id := strings.ToLower(strings.TrimSpace(provider))
|
|
280
|
+
if id == "" {
|
|
281
|
+
return nil, fmt.Errorf("provider is required")
|
|
282
|
+
}
|
|
283
|
+
store, ok := stores[id]
|
|
284
|
+
if !ok {
|
|
285
|
+
return nil, nil
|
|
286
|
+
}
|
|
287
|
+
store = normalizeProviderStore(store)
|
|
288
|
+
return &store, nil
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func saveProviderCredentials(repoRoot, provider string, store ProviderCredentials) error {
|
|
292
|
+
stores, err := readProviderStores(repoRoot)
|
|
261
293
|
if err != nil {
|
|
262
294
|
return err
|
|
263
295
|
}
|
|
@@ -265,16 +297,125 @@ func Remove(repoRoot, provider string) error {
|
|
|
265
297
|
if id == "" {
|
|
266
298
|
return fmt.Errorf("provider is required")
|
|
267
299
|
}
|
|
268
|
-
|
|
269
|
-
|
|
300
|
+
stores[id] = normalizeProviderStore(store)
|
|
301
|
+
return writeProviderStores(repoRoot, stores)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func mutateCredential(repoRoot, provider, credentialID string, mutator func(*Credential) error) error {
|
|
305
|
+
store, err := loadProviderCredentials(repoRoot, provider)
|
|
306
|
+
if err != nil {
|
|
307
|
+
return err
|
|
308
|
+
}
|
|
309
|
+
if store == nil {
|
|
310
|
+
return fmt.Errorf("no stored credentials for provider %s", provider)
|
|
311
|
+
}
|
|
312
|
+
credentialID = strings.TrimSpace(credentialID)
|
|
313
|
+
if credentialID == "" {
|
|
314
|
+
return fmt.Errorf("credential id is required")
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for i := range store.Credentials {
|
|
318
|
+
if store.Credentials[i].ID != credentialID {
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if err := mutator(&store.Credentials[i]); err != nil {
|
|
322
|
+
return err
|
|
323
|
+
}
|
|
324
|
+
return saveProviderCredentials(repoRoot, provider, *store)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return fmt.Errorf("credential %s not found for provider %s", credentialID, provider)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func readProviderStores(repoRoot string) (map[string]ProviderCredentials, error) {
|
|
331
|
+
path := filepath.Join(repoRoot, config.OrchDir, authFile)
|
|
332
|
+
data, err := os.ReadFile(path)
|
|
333
|
+
if err != nil {
|
|
334
|
+
if os.IsNotExist(err) {
|
|
335
|
+
return map[string]ProviderCredentials{}, nil
|
|
336
|
+
}
|
|
337
|
+
return nil, fmt.Errorf("read auth state: %w", err)
|
|
270
338
|
}
|
|
271
|
-
delete(all, id)
|
|
272
339
|
|
|
273
|
-
|
|
340
|
+
stores := map[string]ProviderCredentials{}
|
|
341
|
+
if err := json.Unmarshal(data, &stores); err == nil {
|
|
342
|
+
stores = normalizeProviderStores(stores)
|
|
343
|
+
if len(stores) > 0 {
|
|
344
|
+
return stores, nil
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
legacy, legacyErr := loadLegacyCredentials(data)
|
|
349
|
+
if legacyErr == nil && len(legacy) > 0 {
|
|
350
|
+
return normalizeProviderStores(legacy), nil
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
var state State
|
|
354
|
+
if err := json.Unmarshal(data, &state); err != nil {
|
|
355
|
+
return nil, fmt.Errorf("parse auth state: %w", err)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
provider := strings.ToLower(strings.TrimSpace(state.Provider))
|
|
359
|
+
if provider == "" {
|
|
360
|
+
provider = "openai"
|
|
361
|
+
}
|
|
362
|
+
mode := strings.ToLower(strings.TrimSpace(state.Mode))
|
|
363
|
+
cred := Credential{
|
|
364
|
+
RefreshToken: strings.TrimSpace(state.RefreshToken),
|
|
365
|
+
ExpiresAt: state.ExpiresAt,
|
|
366
|
+
AccountID: strings.TrimSpace(state.AccountID),
|
|
367
|
+
Email: strings.TrimSpace(state.Email),
|
|
368
|
+
UpdatedAt: state.UpdatedAt,
|
|
369
|
+
}
|
|
370
|
+
if mode == "account" {
|
|
371
|
+
cred.Type = "oauth"
|
|
372
|
+
cred.AccessToken = strings.TrimSpace(state.AccessToken)
|
|
373
|
+
}
|
|
374
|
+
if mode == "api_key" {
|
|
375
|
+
cred.Type = "api"
|
|
376
|
+
cred.Key = strings.TrimSpace(state.AccessToken)
|
|
377
|
+
}
|
|
378
|
+
if cred.Type == "" {
|
|
379
|
+
cred.Type = mode
|
|
380
|
+
}
|
|
381
|
+
if strings.TrimSpace(cred.Type) == "" {
|
|
382
|
+
return map[string]ProviderCredentials{}, nil
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return normalizeProviderStores(map[string]ProviderCredentials{provider: {Credentials: []Credential{cred}}}), nil
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func loadLegacyCredentials(data []byte) (map[string]ProviderCredentials, error) {
|
|
389
|
+
parsed := map[string]Credential{}
|
|
390
|
+
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
391
|
+
return nil, err
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
stores := map[string]ProviderCredentials{}
|
|
395
|
+
for provider, cred := range parsed {
|
|
396
|
+
id := strings.ToLower(strings.TrimSpace(provider))
|
|
397
|
+
if id == "" {
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
cred = normalizeCredential(cred)
|
|
401
|
+
if strings.TrimSpace(cred.Type) == "" {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
stores[id] = ProviderCredentials{Credentials: []Credential{cred}}
|
|
405
|
+
}
|
|
406
|
+
return stores, nil
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
func writeProviderStores(repoRoot string, stores map[string]ProviderCredentials) error {
|
|
410
|
+
if len(stores) == 0 {
|
|
274
411
|
return Clear(repoRoot)
|
|
275
412
|
}
|
|
413
|
+
if err := config.EnsureOrchDir(repoRoot); err != nil {
|
|
414
|
+
return err
|
|
415
|
+
}
|
|
276
416
|
|
|
277
|
-
|
|
417
|
+
stores = normalizeProviderStores(stores)
|
|
418
|
+
data, err := json.MarshalIndent(stores, "", " ")
|
|
278
419
|
if err != nil {
|
|
279
420
|
return fmt.Errorf("serialize auth state: %w", err)
|
|
280
421
|
}
|
|
@@ -285,3 +426,223 @@ func Remove(repoRoot, provider string) error {
|
|
|
285
426
|
}
|
|
286
427
|
return nil
|
|
287
428
|
}
|
|
429
|
+
|
|
430
|
+
func normalizeProviderStores(stores map[string]ProviderCredentials) map[string]ProviderCredentials {
|
|
431
|
+
normalized := map[string]ProviderCredentials{}
|
|
432
|
+
for provider, store := range stores {
|
|
433
|
+
id := strings.ToLower(strings.TrimSpace(provider))
|
|
434
|
+
if id == "" {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
store = normalizeProviderStore(store)
|
|
438
|
+
if len(store.Credentials) == 0 {
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
normalized[id] = store
|
|
442
|
+
}
|
|
443
|
+
return normalized
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
func normalizeProviderStore(store ProviderCredentials) ProviderCredentials {
|
|
447
|
+
seen := map[string]struct{}{}
|
|
448
|
+
creds := make([]Credential, 0, len(store.Credentials))
|
|
449
|
+
for _, cred := range store.Credentials {
|
|
450
|
+
cred = normalizeCredential(cred)
|
|
451
|
+
if strings.TrimSpace(cred.Type) == "" {
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
cred.ID = ensureCredentialID(creds, cred)
|
|
455
|
+
if _, ok := seen[cred.ID]; ok {
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
seen[cred.ID] = struct{}{}
|
|
459
|
+
creds = append(creds, cred)
|
|
460
|
+
}
|
|
461
|
+
store.Credentials = creds
|
|
462
|
+
store.ActiveID = strings.TrimSpace(store.ActiveID)
|
|
463
|
+
if len(store.Credentials) == 0 {
|
|
464
|
+
store.ActiveID = ""
|
|
465
|
+
return store
|
|
466
|
+
}
|
|
467
|
+
if store.ActiveID == "" || indexOfCredential(store.Credentials, store.ActiveID) < 0 {
|
|
468
|
+
store.ActiveID = store.Credentials[0].ID
|
|
469
|
+
}
|
|
470
|
+
return store
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
func normalizeCredential(cred Credential) Credential {
|
|
474
|
+
cred.ID = strings.TrimSpace(cred.ID)
|
|
475
|
+
cred.Label = strings.TrimSpace(cred.Label)
|
|
476
|
+
cred.Type = strings.ToLower(strings.TrimSpace(cred.Type))
|
|
477
|
+
cred.Key = strings.TrimSpace(cred.Key)
|
|
478
|
+
cred.AccessToken = strings.TrimSpace(cred.AccessToken)
|
|
479
|
+
cred.RefreshToken = strings.TrimSpace(cred.RefreshToken)
|
|
480
|
+
cred.AccountID = strings.TrimSpace(cred.AccountID)
|
|
481
|
+
cred.Email = strings.TrimSpace(cred.Email)
|
|
482
|
+
cred.LastError = strings.TrimSpace(cred.LastError)
|
|
483
|
+
if cred.Type == "api_key" {
|
|
484
|
+
cred.Type = "api"
|
|
485
|
+
}
|
|
486
|
+
if cred.Type == "account" {
|
|
487
|
+
cred.Type = "oauth"
|
|
488
|
+
}
|
|
489
|
+
if cred.Label == "" {
|
|
490
|
+
switch {
|
|
491
|
+
case cred.Email != "":
|
|
492
|
+
cred.Label = cred.Email
|
|
493
|
+
case cred.AccountID != "":
|
|
494
|
+
cred.Label = cred.AccountID
|
|
495
|
+
default:
|
|
496
|
+
cred.Label = cred.Type
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if cred.UpdatedAt.IsZero() {
|
|
500
|
+
cred.UpdatedAt = time.Now().UTC()
|
|
501
|
+
}
|
|
502
|
+
return cred
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
func validateCredential(cred Credential) error {
|
|
506
|
+
if cred.Type != "api" && cred.Type != "oauth" && cred.Type != "wellknown" {
|
|
507
|
+
return fmt.Errorf("unsupported credential type: %s", cred.Type)
|
|
508
|
+
}
|
|
509
|
+
if cred.Type == "api" && strings.TrimSpace(cred.Key) == "" {
|
|
510
|
+
return fmt.Errorf("api credential key cannot be empty")
|
|
511
|
+
}
|
|
512
|
+
if cred.Type == "oauth" && strings.TrimSpace(cred.AccessToken) == "" {
|
|
513
|
+
return fmt.Errorf("oauth access token cannot be empty")
|
|
514
|
+
}
|
|
515
|
+
return nil
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
func activeCredential(store ProviderCredentials) (Credential, bool) {
|
|
519
|
+
store = normalizeProviderStore(store)
|
|
520
|
+
if len(store.Credentials) == 0 {
|
|
521
|
+
return Credential{}, false
|
|
522
|
+
}
|
|
523
|
+
index := indexOfCredential(store.Credentials, store.ActiveID)
|
|
524
|
+
if index < 0 {
|
|
525
|
+
index = 0
|
|
526
|
+
}
|
|
527
|
+
return store.Credentials[index], true
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
func indexOfCredential(creds []Credential, credentialID string) int {
|
|
531
|
+
for i, cred := range creds {
|
|
532
|
+
if cred.ID == credentialID {
|
|
533
|
+
return i
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return -1
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
func matchCredential(creds []Credential, incoming Credential) int {
|
|
540
|
+
if incoming.ID != "" {
|
|
541
|
+
return indexOfCredential(creds, incoming.ID)
|
|
542
|
+
}
|
|
543
|
+
if incoming.Type == "oauth" {
|
|
544
|
+
for i, cred := range creds {
|
|
545
|
+
if cred.Type != "oauth" {
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
if incoming.AccountID != "" && incoming.AccountID == cred.AccountID {
|
|
549
|
+
return i
|
|
550
|
+
}
|
|
551
|
+
if incoming.Email != "" && strings.EqualFold(incoming.Email, cred.Email) {
|
|
552
|
+
return i
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if incoming.Type == "api" {
|
|
557
|
+
for i, cred := range creds {
|
|
558
|
+
if cred.Type == "api" {
|
|
559
|
+
return i
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return -1
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
func mergeCredential(existing, incoming Credential) Credential {
|
|
567
|
+
merged := existing
|
|
568
|
+
merged.Type = incoming.Type
|
|
569
|
+
merged.Label = incoming.Label
|
|
570
|
+
if incoming.Key != "" {
|
|
571
|
+
merged.Key = incoming.Key
|
|
572
|
+
}
|
|
573
|
+
if incoming.AccessToken != "" {
|
|
574
|
+
merged.AccessToken = incoming.AccessToken
|
|
575
|
+
}
|
|
576
|
+
if incoming.RefreshToken != "" {
|
|
577
|
+
merged.RefreshToken = incoming.RefreshToken
|
|
578
|
+
}
|
|
579
|
+
if !incoming.ExpiresAt.IsZero() {
|
|
580
|
+
merged.ExpiresAt = incoming.ExpiresAt
|
|
581
|
+
}
|
|
582
|
+
if incoming.AccountID != "" {
|
|
583
|
+
merged.AccountID = incoming.AccountID
|
|
584
|
+
}
|
|
585
|
+
if incoming.Email != "" {
|
|
586
|
+
merged.Email = incoming.Email
|
|
587
|
+
}
|
|
588
|
+
if !incoming.CooldownUntil.IsZero() || !merged.CooldownUntil.IsZero() {
|
|
589
|
+
merged.CooldownUntil = incoming.CooldownUntil
|
|
590
|
+
}
|
|
591
|
+
if incoming.LastError != "" || merged.LastError != "" {
|
|
592
|
+
merged.LastError = incoming.LastError
|
|
593
|
+
}
|
|
594
|
+
if !incoming.LastUsedAt.IsZero() || !merged.LastUsedAt.IsZero() {
|
|
595
|
+
merged.LastUsedAt = incoming.LastUsedAt
|
|
596
|
+
}
|
|
597
|
+
merged.UpdatedAt = incoming.UpdatedAt
|
|
598
|
+
return normalizeCredential(merged)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
func ensureCredentialID(existing []Credential, cred Credential) string {
|
|
602
|
+
if cred.ID != "" && indexOfCredential(existing, cred.ID) < 0 {
|
|
603
|
+
return cred.ID
|
|
604
|
+
}
|
|
605
|
+
base := credentialIDBase(cred)
|
|
606
|
+
if base == "" {
|
|
607
|
+
base = cred.Type
|
|
608
|
+
}
|
|
609
|
+
base = sanitizeCredentialID(base)
|
|
610
|
+
if base == "" {
|
|
611
|
+
base = "credential"
|
|
612
|
+
}
|
|
613
|
+
id := base
|
|
614
|
+
for suffix := 2; indexOfCredential(existing, id) >= 0; suffix++ {
|
|
615
|
+
id = fmt.Sprintf("%s-%d", base, suffix)
|
|
616
|
+
}
|
|
617
|
+
return id
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
func credentialIDBase(cred Credential) string {
|
|
621
|
+
switch {
|
|
622
|
+
case cred.AccountID != "":
|
|
623
|
+
return cred.AccountID
|
|
624
|
+
case cred.Email != "":
|
|
625
|
+
return strings.ToLower(cred.Email)
|
|
626
|
+
case cred.Label != "":
|
|
627
|
+
return strings.ToLower(cred.Label)
|
|
628
|
+
case !cred.UpdatedAt.IsZero():
|
|
629
|
+
return fmt.Sprintf("%s-%d", cred.Type, cred.UpdatedAt.Unix())
|
|
630
|
+
default:
|
|
631
|
+
return cred.Type
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
func sanitizeCredentialID(value string) string {
|
|
636
|
+
var b strings.Builder
|
|
637
|
+
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
|
|
638
|
+
switch {
|
|
639
|
+
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
640
|
+
b.WriteRune(r)
|
|
641
|
+
case r == '-', r == '_', r == '.':
|
|
642
|
+
b.WriteRune(r)
|
|
643
|
+
case r == '@':
|
|
644
|
+
b.WriteRune('-')
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return strings.Trim(b.String(), "-._")
|
|
648
|
+
}
|