@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,41 @@
1
+ package listener
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/agentconnect/awiki-cli/internal/authsdk"
7
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
8
+ )
9
+
10
+ func TestNewWSClientDoesNotDoubleAppendWSEndpoint(t *testing.T) {
11
+ t.Parallel()
12
+
13
+ client, err := NewWSClient(&appconfig.Resolved{
14
+ MessageServiceURL: "http://127.0.0.1:18080",
15
+ MessageServiceWSURL: "ws://127.0.0.1:18080/ws",
16
+ }, dummyAuthSession())
17
+ if err != nil {
18
+ t.Fatalf("NewWSClient() error = %v", err)
19
+ }
20
+ if client.websocketURL != "ws://127.0.0.1:18080/ws" {
21
+ t.Fatalf("client.websocketURL = %q, want ws://127.0.0.1:18080/ws", client.websocketURL)
22
+ }
23
+ if client.requestURL != "http://127.0.0.1:18080/ws" {
24
+ t.Fatalf("client.requestURL = %q, want http://127.0.0.1:18080/ws", client.requestURL)
25
+ }
26
+ }
27
+
28
+ func TestAppendEndpointIfMissing(t *testing.T) {
29
+ t.Parallel()
30
+
31
+ if got := appendEndpointIfMissing("ws://127.0.0.1:18080", "/ws"); got != "ws://127.0.0.1:18080/ws" {
32
+ t.Fatalf("appendEndpointIfMissing() = %q", got)
33
+ }
34
+ if got := appendEndpointIfMissing("ws://127.0.0.1:18080/ws", "/ws"); got != "ws://127.0.0.1:18080/ws" {
35
+ t.Fatalf("appendEndpointIfMissing() = %q", got)
36
+ }
37
+ }
38
+
39
+ func dummyAuthSession() *authsdk.Session {
40
+ return authsdk.NewSession("", "", "alice", "did:wba:example.com:user:alice", "token", nil)
41
+ }
@@ -0,0 +1,632 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+ "regexp"
8
+ "strings"
9
+ "time"
10
+ )
11
+
12
+ func StoreMessage(ctx context.Context, db *sql.DB, record MessageRecord) error {
13
+ if strings.TrimSpace(record.MsgID) == "" {
14
+ return fmt.Errorf("msg_id is required")
15
+ }
16
+ if strings.TrimSpace(record.ThreadID) == "" {
17
+ return fmt.Errorf("thread_id is required")
18
+ }
19
+ now := nowUTC()
20
+ _, err := db.ExecContext(ctx, `
21
+ INSERT OR IGNORE INTO messages
22
+ (msg_id, owner_did, thread_id, direction, sender_did, receiver_did, group_id, group_did,
23
+ content_type, content, title, server_seq, sent_at, stored_at, is_e2ee, is_read,
24
+ sender_name, metadata, credential_name)
25
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
26
+ record.MsgID,
27
+ normalizeOwnerDID(record.OwnerDID),
28
+ record.ThreadID,
29
+ record.Direction,
30
+ normalizeOptionalString(record.SenderDID),
31
+ normalizeOptionalString(record.ReceiverDID),
32
+ normalizeOptionalString(record.GroupID),
33
+ normalizeOptionalString(record.GroupDID),
34
+ defaultString(record.ContentType, "text"),
35
+ record.Content,
36
+ normalizeOptionalString(record.Title),
37
+ normalizeOptionalInt64(record.ServerSeq),
38
+ normalizeOptionalString(record.SentAt),
39
+ now,
40
+ boolToInt(record.IsE2EE),
41
+ boolToInt(record.IsRead),
42
+ normalizeOptionalString(record.SenderName),
43
+ normalizeMetadata(record.Metadata),
44
+ normalizeCredentialName(record.CredentialName),
45
+ )
46
+ return err
47
+ }
48
+
49
+ func StoreMessagesBatch(ctx context.Context, db *sql.DB, batch []MessageRecord) error {
50
+ if len(batch) == 0 {
51
+ return nil
52
+ }
53
+ tx, err := db.BeginTx(ctx, nil)
54
+ if err != nil {
55
+ return err
56
+ }
57
+ stmt, err := tx.PrepareContext(ctx, `
58
+ INSERT OR IGNORE INTO messages
59
+ (msg_id, owner_did, thread_id, direction, sender_did, receiver_did, group_id, group_did,
60
+ content_type, content, title, server_seq, sent_at, stored_at, is_e2ee, is_read,
61
+ sender_name, metadata, credential_name)
62
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
63
+ if err != nil {
64
+ _ = tx.Rollback()
65
+ return err
66
+ }
67
+ defer stmt.Close()
68
+ now := nowUTC()
69
+ for _, record := range batch {
70
+ if _, err := stmt.ExecContext(ctx,
71
+ record.MsgID,
72
+ normalizeOwnerDID(record.OwnerDID),
73
+ record.ThreadID,
74
+ record.Direction,
75
+ normalizeOptionalString(record.SenderDID),
76
+ normalizeOptionalString(record.ReceiverDID),
77
+ normalizeOptionalString(record.GroupID),
78
+ normalizeOptionalString(record.GroupDID),
79
+ defaultString(record.ContentType, "text"),
80
+ record.Content,
81
+ normalizeOptionalString(record.Title),
82
+ normalizeOptionalInt64(record.ServerSeq),
83
+ normalizeOptionalString(record.SentAt),
84
+ now,
85
+ boolToInt(record.IsE2EE),
86
+ boolToInt(record.IsRead),
87
+ normalizeOptionalString(record.SenderName),
88
+ normalizeMetadata(record.Metadata),
89
+ normalizeCredentialName(record.CredentialName),
90
+ ); err != nil {
91
+ _ = tx.Rollback()
92
+ return err
93
+ }
94
+ }
95
+ return tx.Commit()
96
+ }
97
+
98
+ func QueueE2EEOutbox(ctx context.Context, db *sql.DB, record E2EEOutboxRecord) (string, error) {
99
+ outboxID := defaultString(record.OutboxID, generateID())
100
+ now := nowUTC()
101
+ _, err := db.ExecContext(ctx, `
102
+ INSERT INTO e2ee_outbox
103
+ (outbox_id, owner_did, peer_did, session_id, original_type, plaintext, local_status,
104
+ attempt_count, sent_msg_id, sent_server_seq, last_error_code, retry_hint, failed_msg_id,
105
+ failed_server_seq, metadata, last_attempt_at, created_at, updated_at, credential_name)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
107
+ outboxID,
108
+ normalizeOwnerDID(record.OwnerDID),
109
+ record.PeerDID,
110
+ normalizeOptionalString(record.SessionID),
111
+ defaultString(record.OriginalType, "text"),
112
+ record.Plaintext,
113
+ defaultString(record.LocalStatus, "queued"),
114
+ record.AttemptCount,
115
+ normalizeOptionalString(record.SentMsgID),
116
+ normalizeOptionalInt64(record.SentServerSeq),
117
+ normalizeOptionalString(record.LastErrorCode),
118
+ normalizeOptionalString(record.RetryHint),
119
+ normalizeOptionalString(record.FailedMsgID),
120
+ normalizeOptionalInt64(record.FailedServerSeq),
121
+ normalizeMetadata(record.Metadata),
122
+ normalizeOptionalString(record.LastAttemptAt),
123
+ defaultString(record.CreatedAt, now),
124
+ defaultString(record.UpdatedAt, now),
125
+ normalizeCredentialName(record.CredentialName),
126
+ )
127
+ return outboxID, err
128
+ }
129
+
130
+ func MarkE2EEOutboxSent(ctx context.Context, db *sql.DB, outboxID string, ownerDID string, sessionID string, sentMsgID string, sentServerSeq *int64, metadata string) error {
131
+ _, err := db.ExecContext(ctx, `
132
+ UPDATE e2ee_outbox
133
+ SET session_id = COALESCE(?, session_id),
134
+ local_status = 'sent',
135
+ attempt_count = attempt_count + 1,
136
+ sent_msg_id = COALESCE(?, sent_msg_id),
137
+ sent_server_seq = COALESCE(?, sent_server_seq),
138
+ metadata = COALESCE(?, metadata),
139
+ last_attempt_at = ?,
140
+ updated_at = ?,
141
+ last_error_code = NULL,
142
+ retry_hint = NULL,
143
+ failed_msg_id = NULL,
144
+ failed_server_seq = NULL
145
+ WHERE outbox_id = ? AND owner_did = ?`,
146
+ normalizeOptionalString(sessionID),
147
+ normalizeOptionalString(sentMsgID),
148
+ normalizeOptionalInt64(sentServerSeq),
149
+ normalizeMetadata(metadata),
150
+ nowUTC(),
151
+ nowUTC(),
152
+ outboxID,
153
+ normalizeOwnerDID(ownerDID),
154
+ )
155
+ return err
156
+ }
157
+
158
+ func MarkE2EEOutboxFailed(ctx context.Context, db *sql.DB, outboxID string, ownerDID string, errorCode string, retryHint string, failedMsgID string, failedServerSeq *int64, metadata string) error {
159
+ _, err := db.ExecContext(ctx, `
160
+ UPDATE e2ee_outbox
161
+ SET local_status = 'failed',
162
+ last_error_code = ?,
163
+ retry_hint = COALESCE(?, retry_hint),
164
+ failed_msg_id = COALESCE(?, failed_msg_id),
165
+ failed_server_seq = COALESCE(?, failed_server_seq),
166
+ metadata = COALESCE(?, metadata),
167
+ updated_at = ?
168
+ WHERE outbox_id = ? AND owner_did = ?`,
169
+ errorCode,
170
+ normalizeOptionalString(retryHint),
171
+ normalizeOptionalString(failedMsgID),
172
+ normalizeOptionalInt64(failedServerSeq),
173
+ normalizeMetadata(metadata),
174
+ nowUTC(),
175
+ outboxID,
176
+ normalizeOwnerDID(ownerDID),
177
+ )
178
+ return err
179
+ }
180
+
181
+ func UpdateE2EEOutboxStatus(ctx context.Context, db *sql.DB, outboxID string, ownerDID string, credentialName string, status string) error {
182
+ if strings.TrimSpace(ownerDID) != "" {
183
+ _, err := db.ExecContext(ctx, `UPDATE e2ee_outbox SET local_status = ?, updated_at = ? WHERE outbox_id = ? AND owner_did = ?`,
184
+ status, nowUTC(), outboxID, normalizeOwnerDID(ownerDID))
185
+ return err
186
+ }
187
+ _, err := db.ExecContext(ctx, `UPDATE e2ee_outbox SET local_status = ?, updated_at = ? WHERE outbox_id = ? AND credential_name = ?`,
188
+ status, nowUTC(), outboxID, normalizeCredentialName(credentialName))
189
+ return err
190
+ }
191
+
192
+ func SetE2EEOutboxFailureByID(ctx context.Context, db *sql.DB, outboxID string, ownerDID string, credentialName string, errorCode string, retryHint string, metadata string) error {
193
+ if strings.TrimSpace(ownerDID) != "" {
194
+ _, err := db.ExecContext(ctx, `
195
+ UPDATE e2ee_outbox
196
+ SET local_status = 'failed',
197
+ last_error_code = ?,
198
+ retry_hint = COALESCE(?, retry_hint),
199
+ metadata = COALESCE(?, metadata),
200
+ updated_at = ?
201
+ WHERE outbox_id = ? AND owner_did = ?`,
202
+ errorCode, normalizeOptionalString(retryHint), normalizeMetadata(metadata), nowUTC(), outboxID, normalizeOwnerDID(ownerDID))
203
+ return err
204
+ }
205
+ _, err := db.ExecContext(ctx, `
206
+ UPDATE e2ee_outbox
207
+ SET local_status = 'failed',
208
+ last_error_code = ?,
209
+ retry_hint = COALESCE(?, retry_hint),
210
+ metadata = COALESCE(?, metadata),
211
+ updated_at = ?
212
+ WHERE outbox_id = ? AND credential_name = ?`,
213
+ errorCode, normalizeOptionalString(retryHint), normalizeMetadata(metadata), nowUTC(), outboxID, normalizeCredentialName(credentialName))
214
+ return err
215
+ }
216
+
217
+ func GetE2EEOutbox(ctx context.Context, db *sql.DB, outboxID string, ownerDID string, credentialName string) (map[string]any, error) {
218
+ if strings.TrimSpace(ownerDID) != "" {
219
+ return queryOneMap(ctx, db, `SELECT * FROM e2ee_outbox WHERE outbox_id = ? AND owner_did = ?`, outboxID, normalizeOwnerDID(ownerDID))
220
+ }
221
+ return queryOneMap(ctx, db, `SELECT * FROM e2ee_outbox WHERE outbox_id = ? AND credential_name = ?`, outboxID, normalizeCredentialName(credentialName))
222
+ }
223
+
224
+ func ListE2EEOutbox(ctx context.Context, db *sql.DB, ownerDID string, credentialName string, localStatus string) ([]map[string]any, error) {
225
+ switch {
226
+ case strings.TrimSpace(ownerDID) != "" && strings.TrimSpace(localStatus) != "":
227
+ return queryMaps(ctx, db, `SELECT * FROM e2ee_outbox WHERE owner_did = ? AND local_status = ? ORDER BY updated_at DESC`, normalizeOwnerDID(ownerDID), localStatus)
228
+ case strings.TrimSpace(ownerDID) != "":
229
+ return queryMaps(ctx, db, `SELECT * FROM e2ee_outbox WHERE owner_did = ? ORDER BY updated_at DESC`, normalizeOwnerDID(ownerDID))
230
+ case strings.TrimSpace(localStatus) != "":
231
+ return queryMaps(ctx, db, `SELECT * FROM e2ee_outbox WHERE credential_name = ? AND local_status = ? ORDER BY updated_at DESC`, normalizeCredentialName(credentialName), localStatus)
232
+ default:
233
+ return queryMaps(ctx, db, `SELECT * FROM e2ee_outbox WHERE credential_name = ? ORDER BY updated_at DESC`, normalizeCredentialName(credentialName))
234
+ }
235
+ }
236
+
237
+ func GetMessageByID(ctx context.Context, db *sql.DB, msgID string, ownerDID string, credentialName string) (map[string]any, error) {
238
+ if strings.TrimSpace(ownerDID) != "" {
239
+ return queryOneMap(ctx, db, `SELECT * FROM messages WHERE msg_id = ? AND owner_did = ?`, msgID, normalizeOwnerDID(ownerDID))
240
+ }
241
+ return queryOneMap(ctx, db, `SELECT * FROM messages WHERE msg_id = ? AND credential_name = ?`, msgID, normalizeCredentialName(credentialName))
242
+ }
243
+
244
+ func UpsertContact(ctx context.Context, db *sql.DB, record ContactRecord) error {
245
+ if strings.TrimSpace(record.DID) == "" {
246
+ return fmt.Errorf("contact did is required")
247
+ }
248
+ ownerDID := normalizeOwnerDID(record.OwnerDID)
249
+ existing, err := queryMaps(ctx, db, `SELECT did FROM contacts WHERE owner_did = ? AND did = ?`, ownerDID, record.DID)
250
+ if err != nil {
251
+ return err
252
+ }
253
+ now := nowUTC()
254
+ if len(existing) > 0 {
255
+ _, err = db.ExecContext(ctx, `
256
+ UPDATE contacts
257
+ SET name = COALESCE(?, name),
258
+ handle = COALESCE(?, handle),
259
+ nick_name = COALESCE(?, nick_name),
260
+ bio = COALESCE(?, bio),
261
+ profile_md = COALESCE(?, profile_md),
262
+ tags = COALESCE(?, tags),
263
+ relationship = COALESCE(?, relationship),
264
+ source_type = COALESCE(?, source_type),
265
+ source_name = COALESCE(?, source_name),
266
+ source_group_id = COALESCE(?, source_group_id),
267
+ connected_at = COALESCE(?, connected_at),
268
+ recommended_reason = COALESCE(?, recommended_reason),
269
+ followed = COALESCE(?, followed),
270
+ messaged = COALESCE(?, messaged),
271
+ note = COALESCE(?, note),
272
+ first_seen_at = COALESCE(?, first_seen_at),
273
+ last_seen_at = ?,
274
+ metadata = COALESCE(?, metadata)
275
+ WHERE owner_did = ? AND did = ?`,
276
+ normalizeOptionalString(record.Name),
277
+ normalizeOptionalString(record.Handle),
278
+ normalizeOptionalString(record.NickName),
279
+ normalizeOptionalString(record.Bio),
280
+ normalizeOptionalString(record.ProfileMD),
281
+ normalizeOptionalString(record.Tags),
282
+ normalizeOptionalString(record.Relationship),
283
+ normalizeOptionalString(record.SourceType),
284
+ normalizeOptionalString(record.SourceName),
285
+ normalizeOptionalString(record.SourceGroupID),
286
+ normalizeOptionalString(record.ConnectedAt),
287
+ normalizeOptionalString(record.RecommendedReason),
288
+ normalizeOptionalBool(record.Followed),
289
+ normalizeOptionalBool(record.Messaged),
290
+ normalizeOptionalString(record.Note),
291
+ normalizeOptionalString(record.FirstSeenAt),
292
+ now,
293
+ normalizeMetadata(record.Metadata),
294
+ ownerDID,
295
+ record.DID,
296
+ )
297
+ return err
298
+ }
299
+ _, err = db.ExecContext(ctx, `
300
+ INSERT INTO contacts
301
+ (owner_did, did, name, handle, nick_name, bio, profile_md, tags, relationship, source_type, source_name,
302
+ source_group_id, connected_at, recommended_reason, followed, messaged, note, first_seen_at, last_seen_at, metadata)
303
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
304
+ ownerDID, record.DID,
305
+ normalizeOptionalString(record.Name),
306
+ normalizeOptionalString(record.Handle),
307
+ normalizeOptionalString(record.NickName),
308
+ normalizeOptionalString(record.Bio),
309
+ normalizeOptionalString(record.ProfileMD),
310
+ normalizeOptionalString(record.Tags),
311
+ normalizeOptionalString(record.Relationship),
312
+ normalizeOptionalString(record.SourceType),
313
+ normalizeOptionalString(record.SourceName),
314
+ normalizeOptionalString(record.SourceGroupID),
315
+ normalizeOptionalString(record.ConnectedAt),
316
+ normalizeOptionalString(record.RecommendedReason),
317
+ defaultBoolValue(record.Followed),
318
+ defaultBoolValue(record.Messaged),
319
+ normalizeOptionalString(record.Note),
320
+ defaultString(record.FirstSeenAt, now),
321
+ defaultString(record.LastSeenAt, now),
322
+ normalizeMetadata(record.Metadata),
323
+ )
324
+ return err
325
+ }
326
+
327
+ func AppendRelationshipEvent(ctx context.Context, db *sql.DB, record RelationshipEventRecord) (string, error) {
328
+ eventID := defaultString(record.EventID, generateID())
329
+ now := nowUTC()
330
+ _, err := db.ExecContext(ctx, `
331
+ INSERT INTO relationship_events
332
+ (event_id, owner_did, target_did, target_handle, event_type, source_type, source_name, source_group_id,
333
+ reason, score, status, created_at, updated_at, metadata, credential_name)
334
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
335
+ eventID,
336
+ normalizeOwnerDID(record.OwnerDID),
337
+ record.TargetDID,
338
+ normalizeOptionalString(record.TargetHandle),
339
+ record.EventType,
340
+ normalizeOptionalString(record.SourceType),
341
+ normalizeOptionalString(record.SourceName),
342
+ normalizeOptionalString(record.SourceGroupID),
343
+ normalizeOptionalString(record.Reason),
344
+ normalizeOptionalFloat64(record.Score),
345
+ defaultString(record.Status, "pending"),
346
+ defaultString(record.CreatedAt, now),
347
+ defaultString(record.UpdatedAt, now),
348
+ normalizeMetadata(record.Metadata),
349
+ normalizeCredentialName(record.CredentialName),
350
+ )
351
+ return eventID, err
352
+ }
353
+
354
+ func UpsertGroup(ctx context.Context, db *sql.DB, record GroupRecord) error {
355
+ ownerDID := normalizeOwnerDID(record.OwnerDID)
356
+ if ownerDID == "" || strings.TrimSpace(record.GroupID) == "" {
357
+ return fmt.Errorf("owner_did and group_id are required")
358
+ }
359
+ now := nowUTC()
360
+ _, err := db.ExecContext(ctx, `
361
+ INSERT OR REPLACE INTO groups
362
+ (owner_did, group_id, group_did, name, group_mode, slug, description, goal, rules, message_prompt,
363
+ doc_url, group_owner_did, group_owner_handle, my_role, membership_status, join_enabled, join_code,
364
+ join_code_expires_at, member_count, last_synced_seq, last_read_seq, last_message_at, remote_created_at,
365
+ remote_updated_at, stored_at, metadata, credential_name)
366
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
367
+ ownerDID,
368
+ record.GroupID,
369
+ normalizeOptionalString(record.GroupDID),
370
+ normalizeOptionalString(record.Name),
371
+ defaultString(record.GroupMode, "general"),
372
+ normalizeOptionalString(record.Slug),
373
+ normalizeOptionalString(record.Description),
374
+ normalizeOptionalString(record.Goal),
375
+ normalizeOptionalString(record.Rules),
376
+ normalizeOptionalString(record.MessagePrompt),
377
+ normalizeOptionalString(record.DocURL),
378
+ normalizeOptionalString(record.GroupOwnerDID),
379
+ normalizeOptionalString(record.GroupOwnerHandle),
380
+ normalizeOptionalString(record.MyRole),
381
+ defaultString(record.MembershipStatus, "active"),
382
+ normalizeOptionalBool(record.JoinEnabled),
383
+ normalizeOptionalString(record.JoinCode),
384
+ normalizeOptionalString(record.JoinCodeExpiresAt),
385
+ normalizeOptionalInt64(record.MemberCount),
386
+ normalizeOptionalInt64(record.LastSyncedSeq),
387
+ normalizeOptionalInt64(record.LastReadSeq),
388
+ normalizeOptionalString(record.LastMessageAt),
389
+ normalizeOptionalString(record.RemoteCreatedAt),
390
+ normalizeOptionalString(record.RemoteUpdatedAt),
391
+ now,
392
+ normalizeMetadata(record.Metadata),
393
+ normalizeCredentialName(record.CredentialName),
394
+ )
395
+ return err
396
+ }
397
+
398
+ func ReplaceGroupMembers(ctx context.Context, db *sql.DB, ownerDID string, groupID string, members []GroupMemberRecord, credentialName string) error {
399
+ tx, err := db.BeginTx(ctx, nil)
400
+ if err != nil {
401
+ return err
402
+ }
403
+ if _, err := tx.ExecContext(ctx, `DELETE FROM group_members WHERE owner_did = ? AND group_id = ?`, normalizeOwnerDID(ownerDID), groupID); err != nil {
404
+ _ = tx.Rollback()
405
+ return err
406
+ }
407
+ stmt, err := tx.PrepareContext(ctx, `
408
+ INSERT INTO group_members
409
+ (owner_did, group_id, user_id, member_did, member_handle, profile_url, role, status,
410
+ joined_at, sent_message_count, last_synced_at, metadata, credential_name)
411
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
412
+ if err != nil {
413
+ _ = tx.Rollback()
414
+ return err
415
+ }
416
+ defer stmt.Close()
417
+ now := nowUTC()
418
+ zero := int64(0)
419
+ for _, member := range members {
420
+ if strings.TrimSpace(member.UserID) == "" {
421
+ continue
422
+ }
423
+ if _, err := stmt.ExecContext(ctx,
424
+ normalizeOwnerDID(ownerDID),
425
+ groupID,
426
+ member.UserID,
427
+ normalizeOptionalString(member.MemberDID),
428
+ normalizeOptionalString(member.MemberHandle),
429
+ normalizeOptionalString(member.ProfileURL),
430
+ normalizeOptionalString(member.Role),
431
+ defaultString(member.Status, "active"),
432
+ normalizeOptionalString(member.JoinedAt),
433
+ normalizeOptionalInt64(defaultInt64Ptr(member.SentMessageCount, &zero)),
434
+ now,
435
+ normalizeMetadata(member.Metadata),
436
+ normalizeCredentialName(defaultString(member.CredentialName, credentialName)),
437
+ ); err != nil {
438
+ _ = tx.Rollback()
439
+ return err
440
+ }
441
+ }
442
+ return tx.Commit()
443
+ }
444
+
445
+ func UpsertGroupMember(ctx context.Context, db *sql.DB, record GroupMemberRecord) error {
446
+ zero := int64(0)
447
+ _, err := db.ExecContext(ctx, `
448
+ INSERT OR REPLACE INTO group_members
449
+ (owner_did, group_id, user_id, member_did, member_handle, profile_url, role, status,
450
+ joined_at, sent_message_count, last_synced_at, metadata, credential_name)
451
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
452
+ normalizeOwnerDID(record.OwnerDID),
453
+ record.GroupID,
454
+ record.UserID,
455
+ normalizeOptionalString(record.MemberDID),
456
+ normalizeOptionalString(record.MemberHandle),
457
+ normalizeOptionalString(record.ProfileURL),
458
+ normalizeOptionalString(record.Role),
459
+ defaultString(record.Status, "active"),
460
+ normalizeOptionalString(record.JoinedAt),
461
+ normalizeOptionalInt64(defaultInt64Ptr(record.SentMessageCount, &zero)),
462
+ nowUTC(),
463
+ normalizeMetadata(record.Metadata),
464
+ normalizeCredentialName(record.CredentialName),
465
+ )
466
+ return err
467
+ }
468
+
469
+ func SyncGroupMemberFromSystemEvent(ctx context.Context, db *sql.DB, ownerDID string, groupID string, systemEvent map[string]any, credentialName string) (bool, error) {
470
+ subject, ok := systemEvent["subject"].(map[string]any)
471
+ if !ok {
472
+ return false, nil
473
+ }
474
+ userID, _ := subject["id"].(string)
475
+ if userID == "" {
476
+ return false, nil
477
+ }
478
+ kind, _ := systemEvent["kind"].(string)
479
+ statusByKind := map[string]string{"member_joined": "active", "member_left": "left", "member_kicked": "kicked"}
480
+ status, ok := statusByKind[kind]
481
+ if !ok {
482
+ return false, nil
483
+ }
484
+ if err := UpsertGroupMember(ctx, db, GroupMemberRecord{
485
+ OwnerDID: ownerDID,
486
+ GroupID: groupID,
487
+ UserID: userID,
488
+ MemberDID: stringFromAny(subject["did"]),
489
+ MemberHandle: stringFromAny(subject["handle"]),
490
+ ProfileURL: stringFromAny(subject["profile_url"]),
491
+ Role: "member",
492
+ Status: status,
493
+ Metadata: metadataFromAny(map[string]any{"system_event": systemEvent}),
494
+ CredentialName: credentialName,
495
+ }); err != nil {
496
+ return false, err
497
+ }
498
+ return true, nil
499
+ }
500
+
501
+ func DeleteGroupMembers(ctx context.Context, db *sql.DB, ownerDID string, groupID string, targetDID string, targetUserID string) (int64, error) {
502
+ var (
503
+ result sql.Result
504
+ err error
505
+ )
506
+ switch {
507
+ case strings.TrimSpace(targetDID) != "":
508
+ result, err = db.ExecContext(ctx, `DELETE FROM group_members WHERE owner_did = ? AND group_id = ? AND member_did = ?`, normalizeOwnerDID(ownerDID), groupID, targetDID)
509
+ case strings.TrimSpace(targetUserID) != "":
510
+ result, err = db.ExecContext(ctx, `DELETE FROM group_members WHERE owner_did = ? AND group_id = ? AND user_id = ?`, normalizeOwnerDID(ownerDID), groupID, targetUserID)
511
+ default:
512
+ result, err = db.ExecContext(ctx, `DELETE FROM group_members WHERE owner_did = ? AND group_id = ?`, normalizeOwnerDID(ownerDID), groupID)
513
+ }
514
+ if err != nil {
515
+ return 0, err
516
+ }
517
+ return result.RowsAffected()
518
+ }
519
+
520
+ func RebindOwnerDID(ctx context.Context, db *sql.DB, oldOwnerDID string, newOwnerDID string) (map[string]int64, error) {
521
+ oldOwnerDID = normalizeOwnerDID(oldOwnerDID)
522
+ newOwnerDID = normalizeOwnerDID(newOwnerDID)
523
+ result := map[string]int64{
524
+ "messages": 0,
525
+ "contacts": 0,
526
+ "relationship_events": 0,
527
+ "groups": 0,
528
+ "group_members": 0,
529
+ }
530
+ if oldOwnerDID == "" || newOwnerDID == "" || oldOwnerDID == newOwnerDID {
531
+ return result, nil
532
+ }
533
+ tx, err := db.BeginTx(ctx, nil)
534
+ if err != nil {
535
+ return nil, err
536
+ }
537
+ for _, table := range []string{"messages", "contacts", "relationship_events", "groups", "group_members"} {
538
+ var count int64
539
+ if err := tx.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE owner_did = ?", table), oldOwnerDID).Scan(&count); err != nil {
540
+ _ = tx.Rollback()
541
+ return nil, err
542
+ }
543
+ result[table] = count
544
+ if _, err := tx.ExecContext(ctx, fmt.Sprintf("UPDATE OR IGNORE %s SET owner_did = ? WHERE owner_did = ?", table), newOwnerDID, oldOwnerDID); err != nil {
545
+ _ = tx.Rollback()
546
+ return nil, err
547
+ }
548
+ }
549
+ if err := tx.Commit(); err != nil {
550
+ return nil, err
551
+ }
552
+ return result, nil
553
+ }
554
+
555
+ func ClearOwnerE2EEData(ctx context.Context, db *sql.DB, ownerDID string) (map[string]int64, error) {
556
+ ownerDID = normalizeOwnerDID(ownerDID)
557
+ result := map[string]int64{"e2ee_outbox": 0, "e2ee_sessions": 0}
558
+ if ownerDID == "" {
559
+ return result, nil
560
+ }
561
+ for _, table := range []string{"e2ee_outbox", "e2ee_sessions"} {
562
+ var count int64
563
+ if err := db.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE owner_did = ?", table), ownerDID).Scan(&count); err != nil {
564
+ return nil, err
565
+ }
566
+ result[table] = count
567
+ if _, err := db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE owner_did = ?", table), ownerDID); err != nil {
568
+ return nil, err
569
+ }
570
+ }
571
+ return result, nil
572
+ }
573
+
574
+ func ExecuteSQL(ctx context.Context, db *sql.DB, statement string, params ...any) ([]map[string]any, error) {
575
+ sqlText := strings.TrimSpace(strings.TrimSuffix(statement, ";"))
576
+ if sqlText == "" {
577
+ return nil, fmt.Errorf("%w: empty statement", ErrUnsafeSQL)
578
+ }
579
+ if strings.Contains(sqlText, ";") {
580
+ return nil, fmt.Errorf("%w: multiple statements are not allowed", ErrUnsafeSQL)
581
+ }
582
+ upper := strings.ToUpper(sqlText)
583
+ for _, pattern := range forbiddenPatterns {
584
+ if pattern.MatchString(upper) {
585
+ return nil, fmt.Errorf("%w: forbidden SQL operation", ErrUnsafeSQL)
586
+ }
587
+ }
588
+ if matched, _ := regexp.MatchString(`(?i)^\s*DELETE\b`, sqlText); matched {
589
+ if !regexp.MustCompile(`(?i)\bWHERE\b`).MatchString(sqlText) {
590
+ return nil, fmt.Errorf("%w: DELETE without WHERE clause is not allowed", ErrUnsafeSQL)
591
+ }
592
+ }
593
+ if strings.HasPrefix(upper, "SELECT") {
594
+ return queryMaps(ctx, db, sqlText, params...)
595
+ }
596
+ result, err := db.ExecContext(ctx, sqlText, params...)
597
+ if err != nil {
598
+ return nil, err
599
+ }
600
+ rowsAffected, _ := result.RowsAffected()
601
+ return []map[string]any{{"rows_affected": rowsAffected}}, nil
602
+ }
603
+
604
+ func boolToInt(value bool) int {
605
+ if value {
606
+ return 1
607
+ }
608
+ return 0
609
+ }
610
+
611
+ func defaultBoolValue(value *bool) int {
612
+ if value == nil {
613
+ return 0
614
+ }
615
+ return boolToInt(*value)
616
+ }
617
+
618
+ func defaultString(value string, fallback string) string {
619
+ if strings.TrimSpace(value) == "" {
620
+ return fallback
621
+ }
622
+ return value
623
+ }
624
+
625
+ func stringFromAny(value any) string {
626
+ text, _ := value.(string)
627
+ return text
628
+ }
629
+
630
+ func generateID() string {
631
+ return fmt.Sprintf("local-%d", time.Now().UnixNano())
632
+ }