@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.
Files changed (119) hide show
  1. package/.github/workflows/release.yml +44 -0
  2. package/.goreleaser.yml +44 -0
  3. package/AGENTS.md +60 -0
  4. package/CLAUDE.md +192 -0
  5. package/README.md +2 -0
  6. package/docs/architecture/awiki-command-v2.md +955 -0
  7. package/docs/architecture/awiki-skill-architecture.md +475 -0
  8. package/docs/architecture/awiki-v2-architecture.md +1063 -0
  9. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
  10. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
  11. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
  12. package/docs/harness/review-spec.md +474 -0
  13. package/docs/installation.md +372 -0
  14. package/docs/plan/awiki-v2-implementation-plan.md +903 -0
  15. package/docs/plan/phase-0/adr-index.md +56 -0
  16. package/docs/plan/phase-0/audit-findings.md +251 -0
  17. package/docs/plan/phase-0/capability-mapping.md +108 -0
  18. package/docs/plan/phase-0/implementation-constraints.md +363 -0
  19. package/docs/publish.md +169 -0
  20. package/go.mod +29 -0
  21. package/go.sum +73 -0
  22. package/internal/anpsdk/registry.go +63 -0
  23. package/internal/authsdk/session.go +351 -0
  24. package/internal/buildinfo/buildinfo.go +34 -0
  25. package/internal/cli/app.go +136 -0
  26. package/internal/cli/app_test.go +88 -0
  27. package/internal/cli/debug.go +104 -0
  28. package/internal/cli/group.go +263 -0
  29. package/internal/cli/id.go +473 -0
  30. package/internal/cli/init.go +134 -0
  31. package/internal/cli/msg.go +228 -0
  32. package/internal/cli/page.go +267 -0
  33. package/internal/cli/root.go +499 -0
  34. package/internal/cli/runtime.go +232 -0
  35. package/internal/cli/upgrade.go +60 -0
  36. package/internal/cmdmeta/catalog.go +203 -0
  37. package/internal/cmdmeta/catalog_test.go +21 -0
  38. package/internal/config/config.go +399 -0
  39. package/internal/config/config_test.go +104 -0
  40. package/internal/config/write.go +37 -0
  41. package/internal/content/service.go +314 -0
  42. package/internal/content/service_test.go +165 -0
  43. package/internal/content/types.go +44 -0
  44. package/internal/docs/topics.go +110 -0
  45. package/internal/doctor/doctor.go +306 -0
  46. package/internal/identity/client.go +267 -0
  47. package/internal/identity/did.go +85 -0
  48. package/internal/identity/did_test.go +50 -0
  49. package/internal/identity/layout.go +206 -0
  50. package/internal/identity/legacy.go +378 -0
  51. package/internal/identity/public.go +70 -0
  52. package/internal/identity/public_test.go +73 -0
  53. package/internal/identity/readiness.go +74 -0
  54. package/internal/identity/service.go +826 -0
  55. package/internal/identity/store.go +385 -0
  56. package/internal/identity/store_test.go +180 -0
  57. package/internal/identity/types.go +204 -0
  58. package/internal/message/auth.go +167 -0
  59. package/internal/message/group_service.go +838 -0
  60. package/internal/message/group_wire.go +350 -0
  61. package/internal/message/group_wire_test.go +67 -0
  62. package/internal/message/helpers.go +61 -0
  63. package/internal/message/http_client.go +334 -0
  64. package/internal/message/proof.go +156 -0
  65. package/internal/message/proof_test.go +61 -0
  66. package/internal/message/service.go +696 -0
  67. package/internal/message/service_test.go +97 -0
  68. package/internal/message/types.go +155 -0
  69. package/internal/message/wire.go +100 -0
  70. package/internal/message/wire_test.go +49 -0
  71. package/internal/message/ws_proxy_client.go +151 -0
  72. package/internal/output/output.go +350 -0
  73. package/internal/output/output_test.go +48 -0
  74. package/internal/runtime/config.go +117 -0
  75. package/internal/runtime/config_test.go +46 -0
  76. package/internal/runtime/listener/files.go +65 -0
  77. package/internal/runtime/listener/manager.go +142 -0
  78. package/internal/runtime/listener/server.go +983 -0
  79. package/internal/runtime/listener/server_test.go +319 -0
  80. package/internal/runtime/listener/sysproc_unix.go +17 -0
  81. package/internal/runtime/listener/sysproc_windows.go +13 -0
  82. package/internal/runtime/listener/types.go +21 -0
  83. package/internal/runtime/listener/wsclient.go +299 -0
  84. package/internal/runtime/listener/wsclient_test.go +41 -0
  85. package/internal/store/dao.go +632 -0
  86. package/internal/store/dao_test.go +87 -0
  87. package/internal/store/helpers.go +197 -0
  88. package/internal/store/import.go +499 -0
  89. package/internal/store/import_test.go +103 -0
  90. package/internal/store/open.go +71 -0
  91. package/internal/store/query.go +151 -0
  92. package/internal/store/schema.go +277 -0
  93. package/internal/store/schema_test.go +56 -0
  94. package/internal/store/types.go +177 -0
  95. package/internal/update/update.go +368 -0
  96. package/package.json +17 -0
  97. package/scripts/install.js +171 -0
  98. package/scripts/release/release-prerelease.sh +86 -0
  99. package/scripts/release/tag-release.sh +66 -0
  100. package/scripts/release/withdraw-release.sh +78 -0
  101. package/scripts/run.js +69 -0
  102. package/skills/README.md +32 -0
  103. package/skills/awiki-bundle/SKILL.md +76 -0
  104. package/skills/awiki-debug/SKILL.md +80 -0
  105. package/skills/awiki-group/SKILL.md +111 -0
  106. package/skills/awiki-id/SKILL.md +123 -0
  107. package/skills/awiki-msg/SKILL.md +131 -0
  108. package/skills/awiki-page/SKILL.md +93 -0
  109. package/skills/awiki-people/SKILL.md +66 -0
  110. package/skills/awiki-runtime/SKILL.md +137 -0
  111. package/skills/awiki-shared/SKILL.md +124 -0
  112. package/skills/awiki-workflow-discovery/SKILL.md +93 -0
  113. package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
  114. package/skills/manifests/skills.yaml +260 -0
  115. package/skills/templates/bundle-skill-template.md +42 -0
  116. package/skills/templates/debug-skill-template.md +44 -0
  117. package/skills/templates/domain-skill-template.md +56 -0
  118. package/skills/templates/shared-skill-template.md +46 -0
  119. package/skills/templates/workflow-skill-template.md +46 -0
@@ -0,0 +1,826 @@
1
+ package identity
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/url"
8
+ "os"
9
+ "regexp"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/agentconnect/awiki-cli/internal/authsdk"
14
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
15
+ )
16
+
17
+ var (
18
+ phoneIntlPattern = regexp.MustCompile(`^\+\d{1,3}\d{6,14}$`)
19
+ phoneCNLocalPattern = regexp.MustCompile(`^1[3-9]\d{9}$`)
20
+ )
21
+
22
+ type Service struct {
23
+ config *appconfig.Resolved
24
+ manager *Manager
25
+ remote *RemoteClient
26
+ }
27
+
28
+ func NewService(resolved *appconfig.Resolved) (*Service, error) {
29
+ remote, err := NewRemoteClient(resolved)
30
+ if err != nil {
31
+ return nil, err
32
+ }
33
+ return &Service{
34
+ config: resolved,
35
+ manager: NewManager(resolved.Paths),
36
+ remote: remote,
37
+ }, nil
38
+ }
39
+
40
+ func (s *Service) Manager() *Manager {
41
+ return s.manager
42
+ }
43
+
44
+ func (s *Service) Config() *appconfig.Resolved {
45
+ return s.config
46
+ }
47
+
48
+ func (s *Service) Status() (*CommandResult, error) {
49
+ current, currentErr := s.manager.Current()
50
+ legacy, err := s.manager.ScanLegacy()
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+ identities, err := s.manager.List()
55
+ if err != nil {
56
+ return nil, err
57
+ }
58
+ data := map[string]any{
59
+ "active_identity": current,
60
+ "identity_count": len(identities),
61
+ "legacy_scan": legacy,
62
+ }
63
+ warnings := make([]string, 0)
64
+ if legacy.HasLegacy {
65
+ warnings = append(warnings, LegacyLayoutHint)
66
+ }
67
+ summary := "Identity store is ready"
68
+ if currentErr != nil {
69
+ summary = "No default identity is configured yet"
70
+ } else if current != nil && !current.UserState.ReadyForMessaging {
71
+ summary = "Default identity exists but user setup is incomplete"
72
+ warnings = append(warnings, "Current identity is local-only. Register or recover a handle-backed user before using messaging.")
73
+ }
74
+ return &CommandResult{Data: data, Summary: summary, Warnings: warnings}, nil
75
+ }
76
+
77
+ func (s *Service) List() (*CommandResult, error) {
78
+ identities, err := s.manager.List()
79
+ if err != nil {
80
+ return nil, err
81
+ }
82
+ current, _ := s.manager.Current()
83
+ legacy, err := s.manager.ScanLegacy()
84
+ if err != nil {
85
+ return nil, err
86
+ }
87
+ warnings := make([]string, 0)
88
+ if legacy.HasLegacy {
89
+ warnings = append(warnings, LegacyLayoutHint)
90
+ }
91
+ if current != nil && !current.UserState.ReadyForMessaging {
92
+ warnings = append(warnings, "The default identity is local-only. Register or recover a handle-backed user before using messaging.")
93
+ }
94
+ return &CommandResult{
95
+ Data: map[string]any{
96
+ "identities": identities,
97
+ "default_identity": current,
98
+ "legacy_scan": legacy,
99
+ },
100
+ Summary: fmt.Sprintf("Found %d local identities", len(identities)),
101
+ Warnings: warnings,
102
+ }, nil
103
+ }
104
+
105
+ func (s *Service) Current() (*CommandResult, error) {
106
+ current, err := s.manager.Current()
107
+ if err != nil {
108
+ if strings.Contains(err.Error(), ErrNoDefaultIdentity.Error()) {
109
+ return &CommandResult{
110
+ Data: map[string]any{"identity": nil},
111
+ Summary: "No default identity is configured",
112
+ }, nil
113
+ }
114
+ return nil, err
115
+ }
116
+ summary := fmt.Sprintf("Current identity is %s", current.IdentityName)
117
+ warnings := make([]string, 0)
118
+ if !current.UserState.ReadyForMessaging {
119
+ summary = fmt.Sprintf("Current identity %s is local-only", current.IdentityName)
120
+ warnings = append(warnings, "Register or recover a handle-backed user before using messaging commands.")
121
+ }
122
+ return &CommandResult{
123
+ Data: map[string]any{"identity": current},
124
+ Summary: summary,
125
+ Warnings: warnings,
126
+ }, nil
127
+ }
128
+
129
+ func (s *Service) Use(identityName string) (*CommandResult, error) {
130
+ summary, err := s.manager.SetDefault(identityName)
131
+ if err != nil {
132
+ return nil, err
133
+ }
134
+ return &CommandResult{
135
+ Data: map[string]any{
136
+ "action": "set_default_identity",
137
+ "identity": summary,
138
+ },
139
+ Summary: fmt.Sprintf("Default identity switched to %s", summary.IdentityName),
140
+ }, nil
141
+ }
142
+
143
+ func (s *Service) Create(displayName string, identityName string) (*CommandResult, error) {
144
+ existing, err := s.manager.List()
145
+ if err != nil {
146
+ return nil, err
147
+ }
148
+ alias := chooseDefaultIdentityName(identityName, existing, displayName)
149
+ generated, err := GenerateIdentity(GenerateOptions{
150
+ Hostname: s.config.DIDDomain,
151
+ PathPrefix: []string{"user"},
152
+ ProofDomain: s.config.DIDDomain,
153
+ })
154
+ if err != nil {
155
+ return nil, err
156
+ }
157
+ record, err := s.manager.save(SaveInput{
158
+ IdentityName: alias,
159
+ DID: generated.DID,
160
+ UniqueID: generated.UniqueID,
161
+ DisplayName: displayName,
162
+ DIDDocument: generated.DIDDocument,
163
+ Key1PrivatePEM: generated.Key1PrivatePEM,
164
+ Key1PublicPEM: generated.Key1PublicPEM,
165
+ E2EESigningPrivatePEM: generated.E2EESigningPrivatePEM,
166
+ E2EEAgreementPrivatePEM: generated.E2EEAgreementPrivatePEM,
167
+ })
168
+ if err != nil {
169
+ return nil, err
170
+ }
171
+ summary := identitySummaryFromRecord(record)
172
+ return &CommandResult{
173
+ Data: map[string]any{
174
+ "action": "create_identity",
175
+ "identity": summary,
176
+ },
177
+ Summary: fmt.Sprintf("Created local identity %s", summary.IdentityName),
178
+ Warnings: []string{"This identity is local-only until you complete `awiki-cli id register --handle <handle> ...` or recover an existing handle."},
179
+ }, nil
180
+ }
181
+
182
+ func (s *Service) Register(ctx context.Context, params RegisterParams) (*CommandResult, error) {
183
+ handle := strings.TrimSpace(params.Handle)
184
+ if handle == "" {
185
+ return nil, fmt.Errorf("%w: handle is required", ErrInvalidInput)
186
+ }
187
+ phone := strings.TrimSpace(params.Phone)
188
+ email := strings.TrimSpace(strings.ToLower(params.Email))
189
+ if (phone == "" && email == "") || (phone != "" && email != "") {
190
+ return nil, fmt.Errorf("%w: exactly one of phone or email is required", ErrInvalidInput)
191
+ }
192
+
193
+ existing, err := s.manager.List()
194
+ if err != nil {
195
+ return nil, err
196
+ }
197
+ alias := chooseNamedIdentity(params.IdentityName, existing, handle)
198
+
199
+ if phone != "" && strings.TrimSpace(params.OTP) == "" {
200
+ normalizedPhone, err := normalizePhone(phone)
201
+ if err != nil {
202
+ return nil, err
203
+ }
204
+ var result map[string]any
205
+ if err := s.remote.rpcCall(ctx, handleRPCEndpoint, "send_otp", map[string]any{"phone": normalizedPhone}, "", &result); err != nil {
206
+ return nil, err
207
+ }
208
+ return &CommandResult{
209
+ Data: map[string]any{
210
+ "action": "send_handle_otp",
211
+ "identity_name": alias,
212
+ "handle": handle,
213
+ "method": "phone",
214
+ "phone": normalizedPhone,
215
+ "verification_state": "otp_sent",
216
+ "result": result,
217
+ },
218
+ Summary: fmt.Sprintf("OTP sent for handle %s", handle),
219
+ }, nil
220
+ }
221
+
222
+ if email != "" {
223
+ verified, verifiedAt, err := s.checkEmailVerified(ctx, email)
224
+ if err != nil {
225
+ return nil, err
226
+ }
227
+ if !verified {
228
+ var sendResult map[string]any
229
+ if err := s.remote.restPost(ctx, emailSendEndpoint, map[string]any{"email": email}, "", &sendResult); err != nil {
230
+ return nil, err
231
+ }
232
+ if !params.Wait {
233
+ return &CommandResult{
234
+ Data: map[string]any{
235
+ "action": "send_registration_email",
236
+ "identity_name": alias,
237
+ "handle": handle,
238
+ "method": "email",
239
+ "email": email,
240
+ "verification_state": "email_sent",
241
+ "result": sendResult,
242
+ },
243
+ Summary: fmt.Sprintf("Activation email sent for handle %s", handle),
244
+ }, nil
245
+ }
246
+ timeout := params.VerificationTimeout
247
+ if timeout <= 0 {
248
+ timeout = DefaultEmailVerificationSecs
249
+ }
250
+ pollInterval := params.PollIntervalSeconds
251
+ if pollInterval <= 0 {
252
+ pollInterval = DefaultEmailPollIntervalSecs
253
+ }
254
+ verified, verifiedAt, err = s.waitForEmailVerification(ctx, email, timeout, pollInterval)
255
+ if err != nil {
256
+ return nil, err
257
+ }
258
+ if !verified {
259
+ return &CommandResult{
260
+ Data: map[string]any{
261
+ "action": "wait_for_registration_email",
262
+ "identity_name": alias,
263
+ "handle": handle,
264
+ "method": "email",
265
+ "email": email,
266
+ "verification_state": "pending",
267
+ },
268
+ Summary: "Email verification is still pending",
269
+ }, nil
270
+ }
271
+ }
272
+ _ = verifiedAt
273
+ }
274
+
275
+ generated, err := GenerateIdentity(GenerateOptions{
276
+ Hostname: s.config.DIDDomain,
277
+ PathPrefix: []string{handle},
278
+ ProofDomain: s.config.DIDDomain,
279
+ })
280
+ if err != nil {
281
+ return nil, err
282
+ }
283
+ registerParams := map[string]any{
284
+ "did_document": generated.DIDDocument,
285
+ "handle": handle,
286
+ }
287
+ if phone != "" {
288
+ normalizedPhone, err := normalizePhone(phone)
289
+ if err != nil {
290
+ return nil, err
291
+ }
292
+ registerParams["phone"] = normalizedPhone
293
+ registerParams["otp_code"] = sanitizeOTP(params.OTP)
294
+ }
295
+ if email != "" {
296
+ registerParams["email"] = email
297
+ }
298
+ if params.InviteCode != "" {
299
+ registerParams["invite_code"] = params.InviteCode
300
+ }
301
+ var result map[string]any
302
+ if err := s.remote.rpcCall(ctx, didAuthRPCEndpoint, "register", registerParams, "", &result); err != nil {
303
+ return nil, err
304
+ }
305
+ record, err := s.manager.save(SaveInput{
306
+ IdentityName: alias,
307
+ DID: stringValue(result["did"], generated.DID),
308
+ UniqueID: generated.UniqueID,
309
+ UserID: stringValue(result["user_id"], ""),
310
+ DisplayName: handle,
311
+ Handle: handle,
312
+ JWTToken: stringValue(result["access_token"], ""),
313
+ DIDDocument: generated.DIDDocument,
314
+ Key1PrivatePEM: generated.Key1PrivatePEM,
315
+ Key1PublicPEM: generated.Key1PublicPEM,
316
+ E2EESigningPrivatePEM: generated.E2EESigningPrivatePEM,
317
+ E2EEAgreementPrivatePEM: generated.E2EEAgreementPrivatePEM,
318
+ })
319
+ if err != nil {
320
+ return nil, err
321
+ }
322
+ summary := identitySummaryFromRecord(record)
323
+ return &CommandResult{
324
+ Data: map[string]any{
325
+ "action": "register_handle",
326
+ "identity": summary,
327
+ "method": ternaryString(phone != "", "phone", "email"),
328
+ "verification_state": "completed",
329
+ "result": result,
330
+ },
331
+ Summary: fmt.Sprintf("Handle %s registered successfully", handle),
332
+ }, nil
333
+ }
334
+
335
+ func (s *Service) Bind(ctx context.Context, params BindParams) (*CommandResult, error) {
336
+ record, err := s.requireActiveIdentity()
337
+ if err != nil {
338
+ return nil, err
339
+ }
340
+ auth, err := s.authSession(record)
341
+ if err != nil {
342
+ return nil, err
343
+ }
344
+ phone := strings.TrimSpace(params.Phone)
345
+ email := strings.TrimSpace(strings.ToLower(params.Email))
346
+ if (phone == "" && email == "") || (phone != "" && email != "") {
347
+ return nil, fmt.Errorf("%w: exactly one of phone or email is required", ErrInvalidInput)
348
+ }
349
+ if phone != "" {
350
+ normalizedPhone, err := normalizePhone(phone)
351
+ if err != nil {
352
+ return nil, err
353
+ }
354
+ if strings.TrimSpace(params.OTP) == "" {
355
+ var result map[string]any
356
+ if err := s.remote.AuthenticatedRestPost(ctx, phoneBindSendEndpoint, map[string]any{"phone": normalizedPhone}, auth, &result); err != nil {
357
+ return nil, err
358
+ }
359
+ return &CommandResult{
360
+ Data: map[string]any{
361
+ "action": "send_bind_phone_otp",
362
+ "identity": identitySummaryFromRecord(record),
363
+ "phone": normalizedPhone,
364
+ "verification_state": "otp_sent",
365
+ "result": result,
366
+ },
367
+ Summary: "Phone binding OTP sent",
368
+ }, nil
369
+ }
370
+ var result map[string]any
371
+ if err := s.remote.AuthenticatedRestPost(ctx, phoneBindVerifyEndpoint, map[string]any{"phone": normalizedPhone, "code": sanitizeOTP(params.OTP)}, auth, &result); err != nil {
372
+ return nil, err
373
+ }
374
+ return &CommandResult{
375
+ Data: map[string]any{
376
+ "action": "bind_phone",
377
+ "identity": identitySummaryFromRecord(record),
378
+ "phone": normalizedPhone,
379
+ "verification_state": "completed",
380
+ "result": result,
381
+ },
382
+ Summary: "Phone bound successfully",
383
+ }, nil
384
+ }
385
+
386
+ verified, _, err := s.checkEmailVerified(ctx, email)
387
+ if err != nil {
388
+ return nil, err
389
+ }
390
+ if !verified {
391
+ var sendResult map[string]any
392
+ if err := s.remote.AuthenticatedRestPost(ctx, emailSendEndpoint, map[string]any{"email": email}, auth, &sendResult); err != nil {
393
+ return nil, err
394
+ }
395
+ if !params.Wait {
396
+ return &CommandResult{
397
+ Data: map[string]any{
398
+ "action": "send_bind_email",
399
+ "identity": identitySummaryFromRecord(record),
400
+ "email": email,
401
+ "verification_state": "email_sent",
402
+ "result": sendResult,
403
+ },
404
+ Summary: "Binding email sent",
405
+ }, nil
406
+ }
407
+ timeout := params.VerificationTimeout
408
+ if timeout <= 0 {
409
+ timeout = DefaultEmailVerificationSecs
410
+ }
411
+ pollInterval := params.PollIntervalSeconds
412
+ if pollInterval <= 0 {
413
+ pollInterval = DefaultEmailPollIntervalSecs
414
+ }
415
+ verified, _, err = s.waitForEmailVerification(ctx, email, timeout, pollInterval)
416
+ if err != nil {
417
+ return nil, err
418
+ }
419
+ if !verified {
420
+ return &CommandResult{
421
+ Data: map[string]any{
422
+ "action": "wait_for_bind_email",
423
+ "identity": identitySummaryFromRecord(record),
424
+ "email": email,
425
+ "verification_state": "pending",
426
+ },
427
+ Summary: "Email verification is still pending",
428
+ }, nil
429
+ }
430
+ }
431
+ return &CommandResult{
432
+ Data: map[string]any{
433
+ "action": "bind_email",
434
+ "identity": identitySummaryFromRecord(record),
435
+ "email": email,
436
+ "verification_state": "completed",
437
+ },
438
+ Summary: "Email binding verified successfully",
439
+ }, nil
440
+ }
441
+
442
+ func (s *Service) Resolve(ctx context.Context, handle string, did string) (*CommandResult, error) {
443
+ handle = strings.TrimSpace(handle)
444
+ did = strings.TrimSpace(did)
445
+ if (handle == "" && did == "") || (handle != "" && did != "") {
446
+ return nil, fmt.Errorf("%w: exactly one of handle or did is required", ErrInvalidInput)
447
+ }
448
+ data := map[string]any{}
449
+ warnings := make([]string, 0)
450
+ if handle != "" {
451
+ var lookup map[string]any
452
+ if err := s.remote.rpcCall(ctx, handleRPCEndpoint, "lookup", map[string]any{"handle": handle}, "", &lookup); err != nil {
453
+ return nil, err
454
+ }
455
+ data["lookup"] = lookup
456
+ did = stringValue(lookup["did"], "")
457
+ if did == "" {
458
+ return nil, fmt.Errorf("%w: handle %s did not resolve to a did", ErrIdentityNotFound, handle)
459
+ }
460
+ var profile map[string]any
461
+ if err := s.remote.rpcCall(ctx, didProfileRPCEndpoint, "get_public_profile", map[string]any{"handle": handle}, "", &profile); err == nil {
462
+ data["public_profile"] = profile
463
+ } else {
464
+ warnings = append(warnings, fmt.Sprintf("Public profile lookup failed: %v", err))
465
+ }
466
+ }
467
+ if did != "" {
468
+ var resolve map[string]any
469
+ if err := s.remote.rpcCall(ctx, didProfileRPCEndpoint, "resolve", map[string]any{"did": did}, "", &resolve); err != nil {
470
+ return nil, err
471
+ }
472
+ data["resolve"] = resolve
473
+ if handle == "" {
474
+ var lookup map[string]any
475
+ if err := s.remote.rpcCall(ctx, handleRPCEndpoint, "lookup", map[string]any{"did": did}, "", &lookup); err == nil {
476
+ data["lookup"] = lookup
477
+ } else {
478
+ warnings = append(warnings, fmt.Sprintf("Handle lookup failed: %v", err))
479
+ }
480
+ var profile map[string]any
481
+ if err := s.remote.rpcCall(ctx, didProfileRPCEndpoint, "get_public_profile", map[string]any{"did": did}, "", &profile); err == nil {
482
+ data["public_profile"] = profile
483
+ } else {
484
+ warnings = append(warnings, fmt.Sprintf("Public profile lookup failed: %v", err))
485
+ }
486
+ }
487
+ }
488
+ return &CommandResult{
489
+ Data: data,
490
+ Summary: "Identity resolved successfully",
491
+ Warnings: warnings,
492
+ }, nil
493
+ }
494
+
495
+ func (s *Service) Recover(ctx context.Context, params RecoverParams) (*CommandResult, error) {
496
+ handle := strings.TrimSpace(params.Handle)
497
+ phone := strings.TrimSpace(params.Phone)
498
+ otp := strings.TrimSpace(params.OTP)
499
+ if handle == "" || phone == "" || otp == "" {
500
+ return nil, fmt.Errorf("%w: handle, phone, and otp are required", ErrInvalidInput)
501
+ }
502
+ existing, err := s.manager.List()
503
+ if err != nil {
504
+ return nil, err
505
+ }
506
+ alias := chooseNamedIdentity(params.IdentityName, existing, handle)
507
+ normalizedPhone, err := normalizePhone(phone)
508
+ if err != nil {
509
+ return nil, err
510
+ }
511
+ generated, err := GenerateIdentity(GenerateOptions{
512
+ Hostname: s.config.DIDDomain,
513
+ PathPrefix: []string{handle},
514
+ ProofDomain: s.config.DIDDomain,
515
+ })
516
+ if err != nil {
517
+ return nil, err
518
+ }
519
+ recoverParams := map[string]any{
520
+ "did_document": generated.DIDDocument,
521
+ "handle": handle,
522
+ "phone": normalizedPhone,
523
+ "otp_code": sanitizeOTP(otp),
524
+ }
525
+ var result map[string]any
526
+ if err := s.remote.rpcCall(ctx, didAuthRPCEndpoint, "recover_handle", recoverParams, "", &result); err != nil {
527
+ return nil, err
528
+ }
529
+ record, err := s.manager.save(SaveInput{
530
+ IdentityName: alias,
531
+ DID: stringValue(result["did"], generated.DID),
532
+ UniqueID: generated.UniqueID,
533
+ UserID: stringValue(result["user_id"], ""),
534
+ DisplayName: handle,
535
+ Handle: handle,
536
+ JWTToken: stringValue(result["access_token"], ""),
537
+ DIDDocument: generated.DIDDocument,
538
+ Key1PrivatePEM: generated.Key1PrivatePEM,
539
+ Key1PublicPEM: generated.Key1PublicPEM,
540
+ E2EESigningPrivatePEM: generated.E2EESigningPrivatePEM,
541
+ E2EEAgreementPrivatePEM: generated.E2EEAgreementPrivatePEM,
542
+ })
543
+ if err != nil {
544
+ return nil, err
545
+ }
546
+ return &CommandResult{
547
+ Data: map[string]any{
548
+ "action": "recover_handle",
549
+ "identity": identitySummaryFromRecord(record),
550
+ "result": result,
551
+ },
552
+ Summary: fmt.Sprintf("Handle %s recovered successfully", handle),
553
+ }, nil
554
+ }
555
+
556
+ func (s *Service) GetProfile(ctx context.Context, self bool, handle string, did string) (*CommandResult, error) {
557
+ if !self && handle == "" && did == "" {
558
+ self = true
559
+ }
560
+ if self {
561
+ record, err := s.requireActiveIdentity()
562
+ if err != nil {
563
+ return nil, err
564
+ }
565
+ auth, err := s.authSession(record)
566
+ if err != nil {
567
+ return nil, err
568
+ }
569
+ var result map[string]any
570
+ if err := s.remote.AuthenticatedRPCCall(ctx, didProfileRPCEndpoint, "get_me", map[string]any{}, auth, &result); err != nil {
571
+ return nil, err
572
+ }
573
+ return &CommandResult{
574
+ Data: map[string]any{
575
+ "subject": "self",
576
+ "profile": result,
577
+ },
578
+ Summary: "Fetched current identity profile",
579
+ }, nil
580
+ }
581
+ params := map[string]any{}
582
+ if handle != "" {
583
+ params["handle"] = handle
584
+ }
585
+ if did != "" {
586
+ params["did"] = did
587
+ }
588
+ var result map[string]any
589
+ if err := s.remote.rpcCall(ctx, didProfileRPCEndpoint, "get_public_profile", params, "", &result); err != nil {
590
+ return nil, err
591
+ }
592
+ return &CommandResult{
593
+ Data: map[string]any{
594
+ "subject": params,
595
+ "profile": result,
596
+ },
597
+ Summary: "Fetched public profile",
598
+ }, nil
599
+ }
600
+
601
+ func (s *Service) SetProfile(ctx context.Context, params UpdateProfileParams) (*CommandResult, error) {
602
+ record, err := s.requireActiveIdentity()
603
+ if err != nil {
604
+ return nil, err
605
+ }
606
+ auth, err := s.authSession(record)
607
+ if err != nil {
608
+ return nil, err
609
+ }
610
+ payload := map[string]any{}
611
+ changedFields := make([]string, 0)
612
+ if strings.TrimSpace(params.DisplayName) != "" {
613
+ payload["nick_name"] = strings.TrimSpace(params.DisplayName)
614
+ changedFields = append(changedFields, "display_name")
615
+ }
616
+ if strings.TrimSpace(params.Bio) != "" {
617
+ payload["bio"] = strings.TrimSpace(params.Bio)
618
+ changedFields = append(changedFields, "bio")
619
+ }
620
+ if strings.TrimSpace(params.TagsCSV) != "" {
621
+ tags := splitCSV(params.TagsCSV)
622
+ payload["tags"] = tags
623
+ changedFields = append(changedFields, "tags")
624
+ }
625
+ markdown := strings.TrimSpace(params.Markdown)
626
+ if strings.TrimSpace(params.MarkdownFile) != "" {
627
+ raw, err := os.ReadFile(params.MarkdownFile)
628
+ if err != nil {
629
+ return nil, err
630
+ }
631
+ markdown = string(raw)
632
+ }
633
+ if strings.TrimSpace(markdown) != "" {
634
+ payload["profile_md"] = markdown
635
+ changedFields = append(changedFields, "profile_md")
636
+ }
637
+ if len(payload) == 0 {
638
+ return nil, fmt.Errorf("%w: no profile fields were provided", ErrInvalidInput)
639
+ }
640
+ var result map[string]any
641
+ if err := s.remote.AuthenticatedRPCCall(ctx, didProfileRPCEndpoint, "update_me", payload, auth, &result); err != nil {
642
+ return nil, err
643
+ }
644
+ if strings.TrimSpace(params.DisplayName) != "" {
645
+ _ = s.manager.UpdateDisplayName(record.IdentityName, strings.TrimSpace(params.DisplayName))
646
+ }
647
+ return &CommandResult{
648
+ Data: map[string]any{
649
+ "action": "update_profile",
650
+ "identity": identitySummaryFromRecord(record),
651
+ "changed_fields": changedFields,
652
+ "profile": result,
653
+ },
654
+ Summary: "Profile updated successfully",
655
+ }, nil
656
+ }
657
+
658
+ func (s *Service) ImportV1(name string, all bool) (*CommandResult, error) {
659
+ var (
660
+ result *ImportResult
661
+ err error
662
+ )
663
+ if all {
664
+ result, err = s.manager.ImportAllLegacy()
665
+ } else {
666
+ result, err = s.manager.ImportLegacy(name)
667
+ }
668
+ if err != nil {
669
+ return nil, err
670
+ }
671
+ return &CommandResult{
672
+ Data: map[string]any{
673
+ "result": result,
674
+ },
675
+ Summary: fmt.Sprintf("Imported %d legacy identities", len(result.Imported)),
676
+ }, nil
677
+ }
678
+
679
+ func (s *Service) requireActiveIdentity() (*StoredIdentity, error) {
680
+ if strings.TrimSpace(s.config.ActiveIdentity) == "" {
681
+ current, err := s.manager.Current()
682
+ if err != nil {
683
+ if errors.Is(err, ErrNoDefaultIdentity) {
684
+ return nil, fmt.Errorf("%w: no active identity is configured", ErrIdentityNotFound)
685
+ }
686
+ return nil, err
687
+ }
688
+ s.config.ActiveIdentity = current.IdentityName
689
+ }
690
+ record, err := s.manager.Load(s.config.ActiveIdentity)
691
+ if err != nil {
692
+ return nil, err
693
+ }
694
+ return record, nil
695
+ }
696
+
697
+ func (s *Service) authSession(record *StoredIdentity) (*authsdk.Session, error) {
698
+ if record == nil {
699
+ return nil, fmt.Errorf("%w: active identity is required", ErrAuthRequired)
700
+ }
701
+ paths, err := s.manager.PathsForIdentity(record.IdentityName)
702
+ if err != nil {
703
+ return nil, err
704
+ }
705
+ session := authsdk.NewSession(
706
+ paths.DIDDocumentPath,
707
+ paths.Key1PrivatePath,
708
+ record.IdentityName,
709
+ record.DID,
710
+ record.JWTToken,
711
+ func(token string) error { return s.manager.UpdateJWT(record.IdentityName, token) },
712
+ )
713
+ session.SetBearer(s.config.UserServiceURL, record.JWTToken)
714
+ if strings.TrimSpace(s.config.MessageServiceURL) != "" {
715
+ session.SetBearer(s.config.MessageServiceURL, record.JWTToken)
716
+ }
717
+ if strings.TrimSpace(record.JWTToken) == "" {
718
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
719
+ defer cancel()
720
+ if _, err := session.EnsureJWT(ctx, s.remote.client, strings.TrimRight(s.config.UserServiceURL, "/")+didAuthRPCEndpoint); err != nil {
721
+ return nil, fmt.Errorf("%w: active identity does not have a JWT yet", ErrAuthRequired)
722
+ }
723
+ record.JWTToken = session.CurrentJWT()
724
+ }
725
+ return session, nil
726
+ }
727
+
728
+ func normalizePhone(phone string) (string, error) {
729
+ phone = strings.TrimSpace(phone)
730
+ switch {
731
+ case phoneIntlPattern.MatchString(phone):
732
+ return phone, nil
733
+ case phoneCNLocalPattern.MatchString(phone):
734
+ return "+86" + phone, nil
735
+ default:
736
+ return "", fmt.Errorf("%w: invalid phone number %q", ErrInvalidInput, phone)
737
+ }
738
+ }
739
+
740
+ func sanitizeOTP(code string) string {
741
+ return strings.Join(strings.Fields(code), "")
742
+ }
743
+
744
+ func splitCSV(raw string) []string {
745
+ items := strings.Split(raw, ",")
746
+ values := make([]string, 0, len(items))
747
+ for _, item := range items {
748
+ trimmed := strings.TrimSpace(item)
749
+ if trimmed != "" {
750
+ values = append(values, trimmed)
751
+ }
752
+ }
753
+ return values
754
+ }
755
+
756
+ func (s *Service) checkEmailVerified(ctx context.Context, email string) (bool, string, error) {
757
+ var result struct {
758
+ Email string `json:"email"`
759
+ Verified bool `json:"verified"`
760
+ VerifiedAt string `json:"verified_at"`
761
+ }
762
+ if err := s.remote.restGet(ctx, emailStatusEndpoint, url.Values{"email": {strings.ToLower(strings.TrimSpace(email))}}, &result); err != nil {
763
+ if serviceErr, ok := err.(*ServiceError); ok && serviceErr.StatusCode == 404 {
764
+ return false, "", nil
765
+ }
766
+ return false, "", err
767
+ }
768
+ return result.Verified, result.VerifiedAt, nil
769
+ }
770
+
771
+ func (s *Service) waitForEmailVerification(ctx context.Context, email string, timeoutSecs int, pollIntervalSecs float64) (bool, string, error) {
772
+ if timeoutSecs <= 0 {
773
+ timeoutSecs = DefaultEmailVerificationSecs
774
+ }
775
+ if pollIntervalSecs <= 0 {
776
+ pollIntervalSecs = DefaultEmailPollIntervalSecs
777
+ }
778
+ deadline := time.Now().Add(time.Duration(timeoutSecs) * time.Second)
779
+ for time.Now().Before(deadline) {
780
+ verified, verifiedAt, err := s.checkEmailVerified(ctx, email)
781
+ if err != nil {
782
+ return false, "", err
783
+ }
784
+ if verified {
785
+ return true, verifiedAt, nil
786
+ }
787
+ select {
788
+ case <-ctx.Done():
789
+ return false, "", ctx.Err()
790
+ case <-time.After(time.Duration(pollIntervalSecs * float64(time.Second))):
791
+ }
792
+ }
793
+ return false, "", nil
794
+ }
795
+
796
+ func ternaryString(condition bool, yes string, no string) string {
797
+ if condition {
798
+ return yes
799
+ }
800
+ return no
801
+ }
802
+
803
+ func identitySummaryFromRecord(record *StoredIdentity) *IdentitySummary {
804
+ if record == nil {
805
+ return nil
806
+ }
807
+ summary := &IdentitySummary{
808
+ IdentityName: record.IdentityName,
809
+ DID: record.DID,
810
+ UniqueID: record.UniqueID,
811
+ UserID: record.UserID,
812
+ DisplayName: record.DisplayName,
813
+ Handle: record.Handle,
814
+ CreatedAt: record.CreatedAt,
815
+ DirName: record.DirName,
816
+ IsDefault: record.IsDefault,
817
+ HasJWT: record.JWTToken != "",
818
+ HasDIDDocument: record.DIDDocument != nil,
819
+ HasKey1Private: record.Key1PrivatePEM != "",
820
+ HasKey1Public: record.Key1PublicPEM != "",
821
+ HasE2EESigningPrivate: record.E2EESigningPrivatePEM != "",
822
+ HasE2EEAgreementPrivate: record.E2EEAgreementPrivatePEM != "",
823
+ }
824
+ summary.UserState = EvaluateIdentitySummaryUserState(summary)
825
+ return summary
826
+ }