@awiki/cli 0.0.1-beta.2
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/.github/workflows/release.yml +44 -0
- package/.goreleaser.yml +44 -0
- package/AGENTS.md +60 -0
- package/CLAUDE.md +192 -0
- package/README.md +2 -0
- package/docs/architecture/awiki-command-v2.md +955 -0
- package/docs/architecture/awiki-skill-architecture.md +475 -0
- package/docs/architecture/awiki-v2-architecture.md +1063 -0
- package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
- package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
- package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
- package/docs/harness/review-spec.md +474 -0
- package/docs/installation.md +372 -0
- package/docs/plan/awiki-v2-implementation-plan.md +903 -0
- package/docs/plan/phase-0/adr-index.md +56 -0
- package/docs/plan/phase-0/audit-findings.md +251 -0
- package/docs/plan/phase-0/capability-mapping.md +108 -0
- package/docs/plan/phase-0/implementation-constraints.md +363 -0
- package/docs/publish.md +169 -0
- package/go.mod +29 -0
- package/go.sum +73 -0
- package/internal/anpsdk/registry.go +63 -0
- package/internal/authsdk/session.go +351 -0
- package/internal/buildinfo/buildinfo.go +34 -0
- package/internal/cli/app.go +136 -0
- package/internal/cli/app_test.go +88 -0
- package/internal/cli/debug.go +104 -0
- package/internal/cli/group.go +263 -0
- package/internal/cli/id.go +473 -0
- package/internal/cli/init.go +134 -0
- package/internal/cli/msg.go +228 -0
- package/internal/cli/page.go +267 -0
- package/internal/cli/root.go +499 -0
- package/internal/cli/runtime.go +232 -0
- package/internal/cli/upgrade.go +60 -0
- package/internal/cmdmeta/catalog.go +203 -0
- package/internal/cmdmeta/catalog_test.go +21 -0
- package/internal/config/config.go +399 -0
- package/internal/config/config_test.go +104 -0
- package/internal/config/write.go +37 -0
- package/internal/content/service.go +314 -0
- package/internal/content/service_test.go +165 -0
- package/internal/content/types.go +44 -0
- package/internal/docs/topics.go +110 -0
- package/internal/doctor/doctor.go +306 -0
- package/internal/identity/client.go +267 -0
- package/internal/identity/did.go +85 -0
- package/internal/identity/did_test.go +50 -0
- package/internal/identity/layout.go +206 -0
- package/internal/identity/legacy.go +378 -0
- package/internal/identity/public.go +70 -0
- package/internal/identity/public_test.go +73 -0
- package/internal/identity/readiness.go +74 -0
- package/internal/identity/service.go +826 -0
- package/internal/identity/store.go +385 -0
- package/internal/identity/store_test.go +180 -0
- package/internal/identity/types.go +204 -0
- package/internal/message/auth.go +167 -0
- package/internal/message/group_service.go +838 -0
- package/internal/message/group_wire.go +350 -0
- package/internal/message/group_wire_test.go +67 -0
- package/internal/message/helpers.go +61 -0
- package/internal/message/http_client.go +334 -0
- package/internal/message/proof.go +156 -0
- package/internal/message/proof_test.go +61 -0
- package/internal/message/service.go +696 -0
- package/internal/message/service_test.go +97 -0
- package/internal/message/types.go +155 -0
- package/internal/message/wire.go +100 -0
- package/internal/message/wire_test.go +49 -0
- package/internal/message/ws_proxy_client.go +151 -0
- package/internal/output/output.go +350 -0
- package/internal/output/output_test.go +48 -0
- package/internal/runtime/config.go +117 -0
- package/internal/runtime/config_test.go +46 -0
- package/internal/runtime/listener/files.go +65 -0
- package/internal/runtime/listener/manager.go +142 -0
- package/internal/runtime/listener/server.go +983 -0
- package/internal/runtime/listener/server_test.go +319 -0
- package/internal/runtime/listener/sysproc_unix.go +17 -0
- package/internal/runtime/listener/sysproc_windows.go +13 -0
- package/internal/runtime/listener/types.go +21 -0
- package/internal/runtime/listener/wsclient.go +299 -0
- package/internal/runtime/listener/wsclient_test.go +41 -0
- package/internal/store/dao.go +632 -0
- package/internal/store/dao_test.go +87 -0
- package/internal/store/helpers.go +197 -0
- package/internal/store/import.go +499 -0
- package/internal/store/import_test.go +103 -0
- package/internal/store/open.go +71 -0
- package/internal/store/query.go +151 -0
- package/internal/store/schema.go +277 -0
- package/internal/store/schema_test.go +56 -0
- package/internal/store/types.go +177 -0
- package/internal/update/update.go +368 -0
- package/package.json +17 -0
- package/scripts/install.js +171 -0
- package/scripts/release/release-prerelease.sh +86 -0
- package/scripts/release/tag-release.sh +66 -0
- package/scripts/release/withdraw-release.sh +78 -0
- package/scripts/run.js +69 -0
- package/skills/README.md +32 -0
- package/skills/awiki-bundle/SKILL.md +76 -0
- package/skills/awiki-debug/SKILL.md +80 -0
- package/skills/awiki-group/SKILL.md +111 -0
- package/skills/awiki-id/SKILL.md +123 -0
- package/skills/awiki-msg/SKILL.md +131 -0
- package/skills/awiki-page/SKILL.md +93 -0
- package/skills/awiki-people/SKILL.md +66 -0
- package/skills/awiki-runtime/SKILL.md +137 -0
- package/skills/awiki-shared/SKILL.md +124 -0
- package/skills/awiki-workflow-discovery/SKILL.md +93 -0
- package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
- package/skills/manifests/skills.yaml +260 -0
- package/skills/templates/bundle-skill-template.md +42 -0
- package/skills/templates/debug-skill-template.md +44 -0
- package/skills/templates/domain-skill-template.md +56 -0
- package/skills/templates/shared-skill-template.md +46 -0
- package/skills/templates/workflow-skill-template.md +46 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
package doctor
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/agentconnect/awiki-cli/internal/buildinfo"
|
|
11
|
+
"github.com/agentconnect/awiki-cli/internal/config"
|
|
12
|
+
"github.com/agentconnect/awiki-cli/internal/identity"
|
|
13
|
+
"github.com/agentconnect/awiki-cli/internal/store"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type Check struct {
|
|
17
|
+
Name string `json:"name"`
|
|
18
|
+
Status string `json:"status"`
|
|
19
|
+
Summary string `json:"summary"`
|
|
20
|
+
Details map[string]any `json:"details,omitempty"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Report struct {
|
|
24
|
+
Checks []Check `json:"checks"`
|
|
25
|
+
Summary string `json:"summary"`
|
|
26
|
+
Counts Counts `json:"counts"`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Counts struct {
|
|
30
|
+
OK int `json:"ok"`
|
|
31
|
+
Warn int `json:"warn"`
|
|
32
|
+
Error int `json:"error"`
|
|
33
|
+
Info int `json:"info"`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func Run(resolved *config.Resolved) Report {
|
|
37
|
+
checks := []Check{
|
|
38
|
+
buildCheck(resolved),
|
|
39
|
+
configFileCheck(resolved),
|
|
40
|
+
envCheck(resolved),
|
|
41
|
+
runtimeCheck(resolved),
|
|
42
|
+
identityStoreCheck(resolved),
|
|
43
|
+
sqliteCheck(resolved),
|
|
44
|
+
legacyCheck(resolved),
|
|
45
|
+
}
|
|
46
|
+
counts := Counts{}
|
|
47
|
+
for _, check := range checks {
|
|
48
|
+
switch check.Status {
|
|
49
|
+
case "ok":
|
|
50
|
+
counts.OK++
|
|
51
|
+
case "warn":
|
|
52
|
+
counts.Warn++
|
|
53
|
+
case "error":
|
|
54
|
+
counts.Error++
|
|
55
|
+
default:
|
|
56
|
+
counts.Info++
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
summary := "Doctor completed successfully"
|
|
60
|
+
if counts.Error > 0 {
|
|
61
|
+
summary = "Doctor found blocking issues"
|
|
62
|
+
} else if counts.Warn > 0 {
|
|
63
|
+
summary = "Doctor found warnings"
|
|
64
|
+
}
|
|
65
|
+
return Report{Checks: checks, Summary: summary, Counts: counts}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func buildCheck(resolved *config.Resolved) Check {
|
|
69
|
+
info := buildinfo.Current()
|
|
70
|
+
status := "ok"
|
|
71
|
+
summary := "Pure-Go build target is aligned"
|
|
72
|
+
if strings.EqualFold(info.CGOEnabled, "1") || strings.EqualFold(info.CGOEnabled, "true") {
|
|
73
|
+
status = "warn"
|
|
74
|
+
summary = "Build metadata indicates CGO was enabled"
|
|
75
|
+
}
|
|
76
|
+
return Check{
|
|
77
|
+
Name: "build",
|
|
78
|
+
Status: status,
|
|
79
|
+
Summary: summary,
|
|
80
|
+
Details: map[string]any{
|
|
81
|
+
"go_version": info.GoVersion,
|
|
82
|
+
"goos": info.GOOS,
|
|
83
|
+
"goarch": info.GOARCH,
|
|
84
|
+
"compiler": info.Compiler,
|
|
85
|
+
"cgo_enabled": info.CGOEnabled,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func configFileCheck(resolved *config.Resolved) Check {
|
|
91
|
+
status := "warn"
|
|
92
|
+
summary := "No config file found yet"
|
|
93
|
+
if resolved.ConfigExists {
|
|
94
|
+
status = "ok"
|
|
95
|
+
summary = "Config file loaded"
|
|
96
|
+
}
|
|
97
|
+
if resolved.ConfigError != "" {
|
|
98
|
+
status = "error"
|
|
99
|
+
summary = "Config file exists but failed to parse"
|
|
100
|
+
}
|
|
101
|
+
return Check{
|
|
102
|
+
Name: "config_file",
|
|
103
|
+
Status: status,
|
|
104
|
+
Summary: summary,
|
|
105
|
+
Details: map[string]any{
|
|
106
|
+
"path": resolved.Paths.ConfigFile,
|
|
107
|
+
"exists": resolved.ConfigExists,
|
|
108
|
+
"error": resolved.ConfigError,
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func envCheck(resolved *config.Resolved) Check {
|
|
114
|
+
status := "info"
|
|
115
|
+
summary := "No environment overrides detected"
|
|
116
|
+
aliasHits := 0
|
|
117
|
+
legacyHits := 0
|
|
118
|
+
for _, hit := range resolved.EnvHits {
|
|
119
|
+
if hit.Tier == "draft_alias_env" {
|
|
120
|
+
aliasHits++
|
|
121
|
+
}
|
|
122
|
+
if hit.Tier == "legacy_env" {
|
|
123
|
+
legacyHits++
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if len(resolved.EnvHits) > 0 {
|
|
127
|
+
status = "ok"
|
|
128
|
+
summary = "Environment overrides detected"
|
|
129
|
+
}
|
|
130
|
+
if aliasHits > 0 || legacyHits > 0 {
|
|
131
|
+
status = "warn"
|
|
132
|
+
summary = "Compatibility environment variables are in use"
|
|
133
|
+
}
|
|
134
|
+
return Check{
|
|
135
|
+
Name: "environment",
|
|
136
|
+
Status: status,
|
|
137
|
+
Summary: summary,
|
|
138
|
+
Details: map[string]any{
|
|
139
|
+
"hits": resolved.EnvHits,
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func runtimeCheck(resolved *config.Resolved) Check {
|
|
145
|
+
status := "ok"
|
|
146
|
+
summary := "Runtime mode resolved"
|
|
147
|
+
if strings.TrimSpace(resolved.RuntimeMode) == "websocket" {
|
|
148
|
+
summary = "Runtime mode is websocket"
|
|
149
|
+
} else {
|
|
150
|
+
summary = "Runtime mode is http"
|
|
151
|
+
}
|
|
152
|
+
return Check{
|
|
153
|
+
Name: "runtime",
|
|
154
|
+
Status: status,
|
|
155
|
+
Summary: summary,
|
|
156
|
+
Details: map[string]any{
|
|
157
|
+
"mode": resolved.RuntimeMode,
|
|
158
|
+
"socket_path": resolved.RuntimeSocketPath,
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func identityStoreCheck(resolved *config.Resolved) Check {
|
|
164
|
+
manager := identity.NewManager(resolved.Paths)
|
|
165
|
+
indexPath := filepath.Join(resolved.Paths.IdentityDir, identity.IndexFileName)
|
|
166
|
+
identityDirExists := pathExists(resolved.Paths.IdentityDir)
|
|
167
|
+
indexExists := pathExists(indexPath)
|
|
168
|
+
status := "warn"
|
|
169
|
+
summary := "Identity store has not been initialized"
|
|
170
|
+
if identityDirExists || indexExists {
|
|
171
|
+
status = "ok"
|
|
172
|
+
summary = "Identity store path resolved"
|
|
173
|
+
}
|
|
174
|
+
index, indexErr := manager.LoadIndex()
|
|
175
|
+
if indexErr != nil {
|
|
176
|
+
status = "error"
|
|
177
|
+
summary = "Identity index exists but failed to parse"
|
|
178
|
+
}
|
|
179
|
+
current, currentErr := manager.Current()
|
|
180
|
+
if currentErr != nil && !errors.Is(currentErr, identity.ErrNoDefaultIdentity) && len(index.Credentials) > 0 {
|
|
181
|
+
status = "error"
|
|
182
|
+
summary = "Identity index is missing a valid default identity"
|
|
183
|
+
} else if current != nil && !current.UserState.ReadyForMessaging {
|
|
184
|
+
status = "warn"
|
|
185
|
+
summary = "Default identity is local-only and cannot be used for messaging yet"
|
|
186
|
+
}
|
|
187
|
+
return Check{
|
|
188
|
+
Name: "identity_store",
|
|
189
|
+
Status: status,
|
|
190
|
+
Summary: summary,
|
|
191
|
+
Details: map[string]any{
|
|
192
|
+
"identity_dir": resolved.Paths.IdentityDir,
|
|
193
|
+
"dir_exists": identityDirExists,
|
|
194
|
+
"index_path": indexPath,
|
|
195
|
+
"index_exists": indexExists,
|
|
196
|
+
"index_entries": len(index.Credentials),
|
|
197
|
+
"default_identity": current,
|
|
198
|
+
"user_state": defaultIdentityUserState(current),
|
|
199
|
+
"index_error": errorText(indexErr),
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func defaultIdentityUserState(current *identity.IdentitySummary) any {
|
|
205
|
+
if current == nil {
|
|
206
|
+
return nil
|
|
207
|
+
}
|
|
208
|
+
return current.UserState
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func sqliteCheck(resolved *config.Resolved) Check {
|
|
212
|
+
databaseExists := pathExists(resolved.Paths.DatabaseFile)
|
|
213
|
+
status := "info"
|
|
214
|
+
summary := "SQLite target path resolved"
|
|
215
|
+
if databaseExists {
|
|
216
|
+
status = "ok"
|
|
217
|
+
summary = "SQLite database file already exists"
|
|
218
|
+
}
|
|
219
|
+
schemaVersion := 0
|
|
220
|
+
schemaError := ""
|
|
221
|
+
if databaseExists {
|
|
222
|
+
db, err := store.OpenReadOnly(resolved.Paths.DatabaseFile)
|
|
223
|
+
if err != nil {
|
|
224
|
+
status = "error"
|
|
225
|
+
summary = "SQLite database file exists but cannot be opened"
|
|
226
|
+
schemaError = err.Error()
|
|
227
|
+
} else {
|
|
228
|
+
defer db.Close()
|
|
229
|
+
version, err := store.CurrentSchemaVersion(db)
|
|
230
|
+
if err != nil {
|
|
231
|
+
status = "error"
|
|
232
|
+
summary = "SQLite database is readable but schema version could not be inspected"
|
|
233
|
+
schemaError = err.Error()
|
|
234
|
+
} else {
|
|
235
|
+
schemaVersion = version
|
|
236
|
+
if version != store.SchemaVersion {
|
|
237
|
+
status = "warn"
|
|
238
|
+
summary = "SQLite database exists but schema version is not current"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return Check{
|
|
244
|
+
Name: "sqlite",
|
|
245
|
+
Status: status,
|
|
246
|
+
Summary: summary,
|
|
247
|
+
Details: map[string]any{
|
|
248
|
+
"database_file": resolved.Paths.DatabaseFile,
|
|
249
|
+
"exists": databaseExists,
|
|
250
|
+
"parent_dir": filepath.Dir(resolved.Paths.DatabaseFile),
|
|
251
|
+
"schema_version": schemaVersion,
|
|
252
|
+
"target_schema_version": store.SchemaVersion,
|
|
253
|
+
"schema_error": schemaError,
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func legacyCheck(resolved *config.Resolved) Check {
|
|
259
|
+
manager := identity.NewManager(resolved.Paths)
|
|
260
|
+
scan, scanErr := manager.ScanLegacy()
|
|
261
|
+
credentialsExists := pathExists(resolved.Paths.LegacyCredentialsDir)
|
|
262
|
+
dataExists := pathExists(resolved.Paths.LegacyDataDir)
|
|
263
|
+
legacyDB, dbErr := store.ScanLegacyDatabase(context.Background(), resolved.Paths)
|
|
264
|
+
status := "info"
|
|
265
|
+
summary := "No legacy v1 paths detected"
|
|
266
|
+
if scanErr != nil {
|
|
267
|
+
status = "error"
|
|
268
|
+
summary = "Legacy credential scan failed"
|
|
269
|
+
} else if scan != nil && scan.HasLegacy {
|
|
270
|
+
status = "warn"
|
|
271
|
+
summary = "Legacy awiki-agent-id-message credential layout detected"
|
|
272
|
+
} else if (legacyDB != nil && legacyDB.Exists) || credentialsExists || dataExists {
|
|
273
|
+
status = "warn"
|
|
274
|
+
summary = "Legacy awiki-agent-id-message paths detected"
|
|
275
|
+
}
|
|
276
|
+
return Check{
|
|
277
|
+
Name: "legacy_paths",
|
|
278
|
+
Status: status,
|
|
279
|
+
Summary: summary,
|
|
280
|
+
Details: map[string]any{
|
|
281
|
+
"legacy_credentials_dir": resolved.Paths.LegacyCredentialsDir,
|
|
282
|
+
"credentials_exists": credentialsExists,
|
|
283
|
+
"legacy_data_dir": resolved.Paths.LegacyDataDir,
|
|
284
|
+
"data_exists": dataExists,
|
|
285
|
+
"legacy_scan": scan,
|
|
286
|
+
"scan_error": errorText(scanErr),
|
|
287
|
+
"legacy_database": legacyDB,
|
|
288
|
+
"legacy_database_error": errorText(dbErr),
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
func pathExists(path string) bool {
|
|
294
|
+
if strings.TrimSpace(path) == "" {
|
|
295
|
+
return false
|
|
296
|
+
}
|
|
297
|
+
_, err := os.Stat(path)
|
|
298
|
+
return err == nil
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func errorText(err error) string {
|
|
302
|
+
if err == nil {
|
|
303
|
+
return ""
|
|
304
|
+
}
|
|
305
|
+
return err.Error()
|
|
306
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
package identity
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"crypto/tls"
|
|
7
|
+
"crypto/x509"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
10
|
+
"fmt"
|
|
11
|
+
"io"
|
|
12
|
+
"net/http"
|
|
13
|
+
"net/url"
|
|
14
|
+
"os"
|
|
15
|
+
"path/filepath"
|
|
16
|
+
"strings"
|
|
17
|
+
|
|
18
|
+
"github.com/agentconnect/awiki-cli/internal/authsdk"
|
|
19
|
+
appconfig "github.com/agentconnect/awiki-cli/internal/config"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const (
|
|
23
|
+
didAuthRPCEndpoint = "/user-service/did-auth/rpc"
|
|
24
|
+
handleRPCEndpoint = "/user-service/handle/rpc"
|
|
25
|
+
didProfileRPCEndpoint = "/user-service/did/profile/rpc"
|
|
26
|
+
|
|
27
|
+
emailSendEndpoint = "/user-service/auth/email-send"
|
|
28
|
+
emailStatusEndpoint = "/user-service/auth/email-status"
|
|
29
|
+
phoneBindSendEndpoint = "/user-service/auth/phone-bind-send"
|
|
30
|
+
phoneBindVerifyEndpoint = "/user-service/auth/phone-bind-verify"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
type ServiceError struct {
|
|
34
|
+
StatusCode int
|
|
35
|
+
RPCCode int
|
|
36
|
+
Message string
|
|
37
|
+
Data any
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func (e *ServiceError) Error() string {
|
|
41
|
+
if e == nil {
|
|
42
|
+
return ""
|
|
43
|
+
}
|
|
44
|
+
switch {
|
|
45
|
+
case e.RPCCode != 0:
|
|
46
|
+
return fmt.Sprintf("service rpc error %d: %s", e.RPCCode, e.Message)
|
|
47
|
+
case e.StatusCode != 0:
|
|
48
|
+
return fmt.Sprintf("service http error %d: %s", e.StatusCode, e.Message)
|
|
49
|
+
default:
|
|
50
|
+
return e.Message
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type rpcResponse struct {
|
|
55
|
+
Result json.RawMessage `json:"result"`
|
|
56
|
+
Error *struct {
|
|
57
|
+
Code int `json:"code"`
|
|
58
|
+
Message string `json:"message"`
|
|
59
|
+
Data any `json:"data,omitempty"`
|
|
60
|
+
} `json:"error,omitempty"`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type RemoteClient struct {
|
|
64
|
+
baseURL string
|
|
65
|
+
client *http.Client
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func NewRemoteClient(resolved *appconfig.Resolved) (*RemoteClient, error) {
|
|
69
|
+
if resolved == nil {
|
|
70
|
+
return nil, fmt.Errorf("%w: resolved config is required", ErrInvalidInput)
|
|
71
|
+
}
|
|
72
|
+
httpClient, err := newHTTPClient(resolved.CABundle)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return nil, err
|
|
75
|
+
}
|
|
76
|
+
return &RemoteClient{
|
|
77
|
+
baseURL: strings.TrimRight(resolved.UserServiceURL, "/"),
|
|
78
|
+
client: httpClient,
|
|
79
|
+
}, nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (c *RemoteClient) Client() *http.Client {
|
|
83
|
+
if c == nil {
|
|
84
|
+
return nil
|
|
85
|
+
}
|
|
86
|
+
return c.client
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func newHTTPClient(caBundle string) (*http.Client, error) {
|
|
90
|
+
transport := &http.Transport{}
|
|
91
|
+
if strings.TrimSpace(caBundle) != "" {
|
|
92
|
+
rootCAs, err := x509.SystemCertPool()
|
|
93
|
+
if err != nil || rootCAs == nil {
|
|
94
|
+
rootCAs = x509.NewCertPool()
|
|
95
|
+
}
|
|
96
|
+
bundle, err := os.ReadFile(filepath.Clean(caBundle))
|
|
97
|
+
if err != nil {
|
|
98
|
+
return nil, fmt.Errorf("read ca bundle: %w", err)
|
|
99
|
+
}
|
|
100
|
+
if ok := rootCAs.AppendCertsFromPEM(bundle); !ok {
|
|
101
|
+
return nil, fmt.Errorf("invalid ca bundle: %s", caBundle)
|
|
102
|
+
}
|
|
103
|
+
transport.TLSClientConfig = &tls.Config{RootCAs: rootCAs, MinVersion: tls.VersionTLS12}
|
|
104
|
+
}
|
|
105
|
+
return &http.Client{Transport: transport}, nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func (c *RemoteClient) rpcCall(ctx context.Context, endpoint string, method string, params any, bearer string, out any) error {
|
|
109
|
+
payload := map[string]any{
|
|
110
|
+
"jsonrpc": "2.0",
|
|
111
|
+
"method": method,
|
|
112
|
+
"params": params,
|
|
113
|
+
"id": "req-1",
|
|
114
|
+
}
|
|
115
|
+
body, err := json.Marshal(payload)
|
|
116
|
+
if err != nil {
|
|
117
|
+
return err
|
|
118
|
+
}
|
|
119
|
+
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, bytes.NewReader(body))
|
|
120
|
+
if err != nil {
|
|
121
|
+
return err
|
|
122
|
+
}
|
|
123
|
+
request.Header.Set("Content-Type", "application/json")
|
|
124
|
+
if bearer != "" {
|
|
125
|
+
request.Header.Set("Authorization", "Bearer "+bearer)
|
|
126
|
+
}
|
|
127
|
+
response, err := c.client.Do(request)
|
|
128
|
+
if err != nil {
|
|
129
|
+
return err
|
|
130
|
+
}
|
|
131
|
+
defer response.Body.Close()
|
|
132
|
+
raw, err := io.ReadAll(response.Body)
|
|
133
|
+
if err != nil {
|
|
134
|
+
return err
|
|
135
|
+
}
|
|
136
|
+
if response.StatusCode >= 400 {
|
|
137
|
+
return &ServiceError{
|
|
138
|
+
StatusCode: response.StatusCode,
|
|
139
|
+
Message: strings.TrimSpace(string(raw)),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
var decoded rpcResponse
|
|
143
|
+
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
144
|
+
return fmt.Errorf("parse rpc response: %w", err)
|
|
145
|
+
}
|
|
146
|
+
if decoded.Error != nil {
|
|
147
|
+
return &ServiceError{
|
|
148
|
+
RPCCode: decoded.Error.Code,
|
|
149
|
+
Message: decoded.Error.Message,
|
|
150
|
+
Data: decoded.Error.Data,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if out == nil {
|
|
154
|
+
return nil
|
|
155
|
+
}
|
|
156
|
+
return json.Unmarshal(decoded.Result, out)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func (c *RemoteClient) RPCCall(ctx context.Context, endpoint string, method string, params any, bearer string, out any) error {
|
|
160
|
+
return c.rpcCall(ctx, endpoint, method, params, bearer, out)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func (c *RemoteClient) AuthenticatedRPCCall(ctx context.Context, endpoint string, method string, params any, auth *authsdk.Session, out any) error {
|
|
164
|
+
return c.authenticatedRPCCall(ctx, endpoint, method, params, auth, out)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func (c *RemoteClient) restPost(ctx context.Context, endpoint string, requestPayload any, bearer string, out any) error {
|
|
168
|
+
body, err := json.Marshal(requestPayload)
|
|
169
|
+
if err != nil {
|
|
170
|
+
return err
|
|
171
|
+
}
|
|
172
|
+
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+endpoint, bytes.NewReader(body))
|
|
173
|
+
if err != nil {
|
|
174
|
+
return err
|
|
175
|
+
}
|
|
176
|
+
request.Header.Set("Content-Type", "application/json")
|
|
177
|
+
if bearer != "" {
|
|
178
|
+
request.Header.Set("Authorization", "Bearer "+bearer)
|
|
179
|
+
}
|
|
180
|
+
response, err := c.client.Do(request)
|
|
181
|
+
if err != nil {
|
|
182
|
+
return err
|
|
183
|
+
}
|
|
184
|
+
defer response.Body.Close()
|
|
185
|
+
raw, err := io.ReadAll(response.Body)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return err
|
|
188
|
+
}
|
|
189
|
+
if response.StatusCode >= 400 {
|
|
190
|
+
return &ServiceError{
|
|
191
|
+
StatusCode: response.StatusCode,
|
|
192
|
+
Message: strings.TrimSpace(string(raw)),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if out == nil {
|
|
196
|
+
return nil
|
|
197
|
+
}
|
|
198
|
+
return json.Unmarshal(raw, out)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func (c *RemoteClient) RestPost(ctx context.Context, endpoint string, requestPayload any, bearer string, out any) error {
|
|
202
|
+
return c.restPost(ctx, endpoint, requestPayload, bearer, out)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func (c *RemoteClient) AuthenticatedRestPost(ctx context.Context, endpoint string, requestPayload any, auth *authsdk.Session, out any) error {
|
|
206
|
+
body, err := json.Marshal(requestPayload)
|
|
207
|
+
if err != nil {
|
|
208
|
+
return err
|
|
209
|
+
}
|
|
210
|
+
requestURL := c.baseURL + endpoint
|
|
211
|
+
if err := auth.DoJSON(ctx, c.client, http.MethodPost, requestURL, requestPayload, out); err != nil {
|
|
212
|
+
var httpErr *authsdk.HTTPError
|
|
213
|
+
if errors.As(err, &httpErr) {
|
|
214
|
+
return &ServiceError{StatusCode: httpErr.StatusCode, Message: httpErr.Message}
|
|
215
|
+
}
|
|
216
|
+
return err
|
|
217
|
+
}
|
|
218
|
+
_ = body
|
|
219
|
+
return nil
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func (c *RemoteClient) restGet(ctx context.Context, endpoint string, query url.Values, out any) error {
|
|
223
|
+
target := c.baseURL + endpoint
|
|
224
|
+
if len(query) > 0 {
|
|
225
|
+
target += "?" + query.Encode()
|
|
226
|
+
}
|
|
227
|
+
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
228
|
+
if err != nil {
|
|
229
|
+
return err
|
|
230
|
+
}
|
|
231
|
+
response, err := c.client.Do(request)
|
|
232
|
+
if err != nil {
|
|
233
|
+
return err
|
|
234
|
+
}
|
|
235
|
+
defer response.Body.Close()
|
|
236
|
+
raw, err := io.ReadAll(response.Body)
|
|
237
|
+
if err != nil {
|
|
238
|
+
return err
|
|
239
|
+
}
|
|
240
|
+
if response.StatusCode >= 400 {
|
|
241
|
+
return &ServiceError{
|
|
242
|
+
StatusCode: response.StatusCode,
|
|
243
|
+
Message: strings.TrimSpace(string(raw)),
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return json.Unmarshal(raw, out)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func (c *RemoteClient) RestGet(ctx context.Context, endpoint string, query url.Values, out any) error {
|
|
250
|
+
return c.restGet(ctx, endpoint, query, out)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func (c *RemoteClient) authenticatedRPCCall(ctx context.Context, endpoint string, method string, params any, auth *authsdk.Session, out any) error {
|
|
254
|
+
requestURL := c.baseURL + endpoint
|
|
255
|
+
if err := auth.DoJSONRPC(ctx, c.client, requestURL, http.MethodPost, method, params, out); err != nil {
|
|
256
|
+
var rpcErr *authsdk.RPCError
|
|
257
|
+
if errors.As(err, &rpcErr) {
|
|
258
|
+
return &ServiceError{RPCCode: rpcErr.Code, Message: rpcErr.Message, Data: rpcErr.Data}
|
|
259
|
+
}
|
|
260
|
+
var httpErr *authsdk.HTTPError
|
|
261
|
+
if errors.As(err, &httpErr) {
|
|
262
|
+
return &ServiceError{StatusCode: httpErr.StatusCode, Message: httpErr.Message}
|
|
263
|
+
}
|
|
264
|
+
return err
|
|
265
|
+
}
|
|
266
|
+
return nil
|
|
267
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package identity
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/rand"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/agentconnect/awiki-cli/internal/anpsdk"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func GenerateIdentity(options GenerateOptions) (*GeneratedIdentity, error) {
|
|
13
|
+
hostname := strings.TrimSpace(options.Hostname)
|
|
14
|
+
if hostname == "" {
|
|
15
|
+
return nil, fmt.Errorf("%w: hostname is required", ErrInvalidInput)
|
|
16
|
+
}
|
|
17
|
+
pathSegments := clonePathPrefix(options.PathPrefix)
|
|
18
|
+
if len(pathSegments) == 0 {
|
|
19
|
+
pathSegments = []string{"user"}
|
|
20
|
+
}
|
|
21
|
+
proofDomain := strings.TrimSpace(options.ProofDomain)
|
|
22
|
+
if proofDomain == "" {
|
|
23
|
+
proofDomain = hostname
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
bundle, err := anpsdk.CreateDidWBADocumentWithKeyBinding(hostname, anpsdk.DidDocumentOptions{
|
|
27
|
+
PathSegments: pathSegments,
|
|
28
|
+
Domain: proofDomain,
|
|
29
|
+
Challenge: randomHex(16),
|
|
30
|
+
})
|
|
31
|
+
if err != nil {
|
|
32
|
+
return nil, fmt.Errorf("generate did document: %w", err)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
did := stringValue(bundle.DidDocument["id"], "")
|
|
36
|
+
if did == "" {
|
|
37
|
+
return nil, fmt.Errorf("generated did document is missing id")
|
|
38
|
+
}
|
|
39
|
+
key1, ok := bundle.Keys["key-1"]
|
|
40
|
+
if !ok {
|
|
41
|
+
return nil, fmt.Errorf("generated did document is missing key-1")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
generated := &GeneratedIdentity{
|
|
45
|
+
DID: did,
|
|
46
|
+
UniqueID: didSuffix(did),
|
|
47
|
+
DIDDocument: bundle.DidDocument,
|
|
48
|
+
Key1PrivatePEM: key1.PrivateKeyPEM,
|
|
49
|
+
Key1PublicPEM: key1.PublicKeyPEM,
|
|
50
|
+
}
|
|
51
|
+
if key2, ok := bundle.Keys["key-2"]; ok {
|
|
52
|
+
generated.E2EESigningPrivatePEM = key2.PrivateKeyPEM
|
|
53
|
+
}
|
|
54
|
+
if key3, ok := bundle.Keys["key-3"]; ok {
|
|
55
|
+
generated.E2EEAgreementPrivatePEM = key3.PrivateKeyPEM
|
|
56
|
+
}
|
|
57
|
+
return generated, nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func didSuffix(did string) string {
|
|
61
|
+
index := strings.LastIndex(did, ":")
|
|
62
|
+
if index == -1 || index == len(did)-1 {
|
|
63
|
+
return did
|
|
64
|
+
}
|
|
65
|
+
return did[index+1:]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func clonePathPrefix(pathPrefix []string) []string {
|
|
69
|
+
cloned := make([]string, 0, len(pathPrefix))
|
|
70
|
+
for _, segment := range pathPrefix {
|
|
71
|
+
trimmed := strings.TrimSpace(segment)
|
|
72
|
+
if trimmed != "" {
|
|
73
|
+
cloned = append(cloned, trimmed)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return cloned
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func randomHex(numBytes int) string {
|
|
80
|
+
buffer := make([]byte, numBytes)
|
|
81
|
+
if _, err := rand.Read(buffer); err != nil {
|
|
82
|
+
return ""
|
|
83
|
+
}
|
|
84
|
+
return hex.EncodeToString(buffer)
|
|
85
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package identity
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
anp "github.com/agent-network-protocol/anp/golang"
|
|
7
|
+
anpauth "github.com/agent-network-protocol/anp/golang/authentication"
|
|
8
|
+
anpproof "github.com/agent-network-protocol/anp/golang/proof"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestGenerateIdentity(t *testing.T) {
|
|
12
|
+
t.Parallel()
|
|
13
|
+
|
|
14
|
+
generated, err := GenerateIdentity(GenerateOptions{
|
|
15
|
+
Hostname: "awiki.ai",
|
|
16
|
+
PathPrefix: []string{"user"},
|
|
17
|
+
ProofDomain: "awiki.ai",
|
|
18
|
+
})
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatalf("GenerateIdentity() error = %v", err)
|
|
21
|
+
}
|
|
22
|
+
if generated.DID == "" || generated.UniqueID == "" {
|
|
23
|
+
t.Fatalf("generated identity is missing did/unique_id: %+v", generated)
|
|
24
|
+
}
|
|
25
|
+
if generated.DIDDocument == nil {
|
|
26
|
+
t.Fatal("generated identity is missing did_document")
|
|
27
|
+
}
|
|
28
|
+
if got := stringValue(generated.DIDDocument["id"], ""); got != generated.DID {
|
|
29
|
+
t.Fatalf("did document id mismatch: got %q want %q", got, generated.DID)
|
|
30
|
+
}
|
|
31
|
+
if !anpauth.ValidateDIDDocumentBinding(generated.DIDDocument, true) {
|
|
32
|
+
t.Fatal("generated did document failed did:wba binding validation")
|
|
33
|
+
}
|
|
34
|
+
publicKey, err := anp.PublicKeyFromPEM(generated.Key1PublicPEM)
|
|
35
|
+
if err != nil {
|
|
36
|
+
t.Fatalf("PublicKeyFromPEM() error = %v", err)
|
|
37
|
+
}
|
|
38
|
+
if !anpproof.VerifyW3CProof(generated.DIDDocument, publicKey, anpproof.VerificationOptions{
|
|
39
|
+
ExpectedPurpose: "assertionMethod",
|
|
40
|
+
ExpectedDomain: "awiki.ai",
|
|
41
|
+
}) {
|
|
42
|
+
t.Fatal("generated did proof verification failed")
|
|
43
|
+
}
|
|
44
|
+
if generated.E2EESigningPrivatePEM == "" {
|
|
45
|
+
t.Fatal("generated identity is missing e2ee signing private key")
|
|
46
|
+
}
|
|
47
|
+
if generated.E2EEAgreementPrivatePEM == "" {
|
|
48
|
+
t.Fatal("generated identity is missing e2ee agreement private key")
|
|
49
|
+
}
|
|
50
|
+
}
|