chainlesschain 0.47.7 → 0.47.9

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.
@@ -0,0 +1,623 @@
1
+ /**
2
+ * ActivityPub C2S Bridge — actors, outbox/inbox, follow graph.
3
+ *
4
+ * In-memory + SQLite persistence. Implements the W3C ActivityPub Client-to-Server
5
+ * surface: actors publish activities to their outbox, receive in their inbox.
6
+ * Network delivery is simulated via `deliverToInbox` (no real HTTP signatures here).
7
+ *
8
+ * Activity vocabulary covered:
9
+ * Create(Note) · Follow · Accept · Undo(Follow) · Like · Announce
10
+ *
11
+ * Actor / activity / object IDs follow ActivityStreams 2.0 convention:
12
+ * https://<origin>/users/<username>
13
+ * https://<origin>/activities/<uuid>
14
+ * https://<origin>/notes/<uuid>
15
+ */
16
+
17
+ import crypto from "crypto";
18
+
19
+ const PUBLIC_AUDIENCE = "https://www.w3.org/ns/activitystreams#Public";
20
+ const CONTEXT = "https://www.w3.org/ns/activitystreams";
21
+ const DEFAULT_ORIGIN = "https://local.chainlesschain";
22
+
23
+ const _actors = new Map(); // id → actor
24
+ const _activities = new Map(); // id → activity record { direction, ownerId, activity }
25
+ const _follows = new Map(); // `${follower}|${followee}` → { state, createdAt }
26
+
27
+ /* ── Schema ────────────────────────────────────────────────── */
28
+
29
+ export function ensureActivityPubTables(db) {
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS ap_actors (
32
+ id TEXT PRIMARY KEY,
33
+ username TEXT,
34
+ name TEXT,
35
+ summary TEXT,
36
+ inbox_url TEXT,
37
+ outbox_url TEXT,
38
+ is_local INTEGER DEFAULT 1,
39
+ created_at TEXT DEFAULT (datetime('now'))
40
+ )
41
+ `);
42
+ db.exec(`
43
+ CREATE TABLE IF NOT EXISTS ap_activities (
44
+ id TEXT PRIMARY KEY,
45
+ actor_id TEXT,
46
+ owner_id TEXT,
47
+ type TEXT,
48
+ direction TEXT,
49
+ object_json TEXT,
50
+ published TEXT,
51
+ created_at TEXT DEFAULT (datetime('now'))
52
+ )
53
+ `);
54
+ db.exec(`
55
+ CREATE TABLE IF NOT EXISTS ap_follows (
56
+ follower_id TEXT NOT NULL,
57
+ followee_id TEXT NOT NULL,
58
+ state TEXT DEFAULT 'pending',
59
+ created_at TEXT DEFAULT (datetime('now')),
60
+ PRIMARY KEY (follower_id, followee_id)
61
+ )
62
+ `);
63
+ }
64
+
65
+ /* ── Helpers ───────────────────────────────────────────────── */
66
+
67
+ function _now() {
68
+ return new Date().toISOString();
69
+ }
70
+
71
+ function _actorUrl(origin, username) {
72
+ return `${origin.replace(/\/$/, "")}/users/${username}`;
73
+ }
74
+
75
+ function _resolveActorId(ref) {
76
+ if (!ref) return null;
77
+ if (ref.startsWith("http://") || ref.startsWith("https://")) return ref;
78
+ // bare username — look up a local actor
79
+ for (const actor of _actors.values()) {
80
+ if (actor.isLocal && actor.username === ref) return actor.id;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function _requireActor(ref) {
86
+ const id = _resolveActorId(ref);
87
+ if (!id) throw new Error(`Actor not found: ${ref}`);
88
+ const actor = _actors.get(id);
89
+ if (!actor) throw new Error(`Actor not found: ${ref}`);
90
+ return actor;
91
+ }
92
+
93
+ function _persistActivity(db, record) {
94
+ db.prepare(
95
+ `INSERT INTO ap_activities (id, actor_id, owner_id, type, direction, object_json, published, created_at)
96
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
97
+ ).run(
98
+ record.activity.id,
99
+ record.activity.actor,
100
+ record.ownerId,
101
+ record.activity.type,
102
+ record.direction,
103
+ JSON.stringify(record.activity),
104
+ record.activity.published,
105
+ _now(),
106
+ );
107
+ }
108
+
109
+ /* ── Actors ────────────────────────────────────────────────── */
110
+
111
+ export function createActor(
112
+ db,
113
+ { username, name, summary, origin = DEFAULT_ORIGIN, remoteId } = {},
114
+ ) {
115
+ if (!remoteId && !username) throw new Error("username is required");
116
+ const isLocal = !remoteId;
117
+ const id = remoteId || _actorUrl(origin, username);
118
+
119
+ if (_actors.has(id)) {
120
+ return { success: true, actor: _actors.get(id), existed: true };
121
+ }
122
+
123
+ const actor = {
124
+ id,
125
+ type: "Person",
126
+ username: username || null,
127
+ preferredUsername: username || null,
128
+ name: name || username || null,
129
+ summary: summary || null,
130
+ inbox: `${id}/inbox`,
131
+ outbox: `${id}/outbox`,
132
+ followers: `${id}/followers`,
133
+ following: `${id}/following`,
134
+ isLocal,
135
+ createdAt: _now(),
136
+ };
137
+
138
+ _actors.set(id, actor);
139
+
140
+ db.prepare(
141
+ `INSERT INTO ap_actors (id, username, name, summary, inbox_url, outbox_url, is_local, created_at)
142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
143
+ ).run(
144
+ actor.id,
145
+ actor.username,
146
+ actor.name,
147
+ actor.summary,
148
+ actor.inbox,
149
+ actor.outbox,
150
+ actor.isLocal ? 1 : 0,
151
+ actor.createdAt,
152
+ );
153
+
154
+ return { success: true, actor, existed: false };
155
+ }
156
+
157
+ export function listActors({ local } = {}) {
158
+ let actors = [..._actors.values()];
159
+ if (local === true) actors = actors.filter((a) => a.isLocal);
160
+ if (local === false) actors = actors.filter((a) => !a.isLocal);
161
+ return actors;
162
+ }
163
+
164
+ export function getActor(ref) {
165
+ const id = _resolveActorId(ref);
166
+ return id ? _actors.get(id) || null : null;
167
+ }
168
+
169
+ /* ── Publishing (outbox) ───────────────────────────────────── */
170
+
171
+ export function publishNote(
172
+ db,
173
+ { actor: actorRef, content, to, cc, inReplyTo } = {},
174
+ ) {
175
+ if (!actorRef) throw new Error("actor is required");
176
+ if (content === undefined || content === null || content === "") {
177
+ throw new Error("content is required");
178
+ }
179
+ const actor = _requireActor(actorRef);
180
+ const published = _now();
181
+ const origin = actor.id.split("/users/")[0];
182
+ const noteId = `${origin}/notes/${crypto.randomUUID()}`;
183
+ const activityId = `${origin}/activities/${crypto.randomUUID()}`;
184
+
185
+ const audienceTo =
186
+ Array.isArray(to) && to.length > 0 ? to : [PUBLIC_AUDIENCE];
187
+ const audienceCc =
188
+ Array.isArray(cc) && cc.length > 0 ? cc : [actor.followers];
189
+
190
+ const note = {
191
+ id: noteId,
192
+ type: "Note",
193
+ attributedTo: actor.id,
194
+ content,
195
+ to: audienceTo,
196
+ cc: audienceCc,
197
+ published,
198
+ ...(inReplyTo ? { inReplyTo } : {}),
199
+ };
200
+
201
+ const activity = {
202
+ "@context": CONTEXT,
203
+ id: activityId,
204
+ type: "Create",
205
+ actor: actor.id,
206
+ object: note,
207
+ to: audienceTo,
208
+ cc: audienceCc,
209
+ published,
210
+ };
211
+
212
+ const record = { direction: "outbox", ownerId: actor.id, activity };
213
+ _activities.set(activity.id, record);
214
+ _persistActivity(db, record);
215
+
216
+ return { success: true, activity };
217
+ }
218
+
219
+ export function follow(db, { actor: actorRef, target } = {}) {
220
+ if (!actorRef) throw new Error("actor is required");
221
+ if (!target) throw new Error("target is required");
222
+ const actor = _requireActor(actorRef);
223
+ const targetId = target.startsWith("http") ? target : _resolveActorId(target);
224
+ if (!targetId) throw new Error(`Target actor not found: ${target}`);
225
+ const published = _now();
226
+ const origin = actor.id.split("/users/")[0];
227
+ const activityId = `${origin}/activities/${crypto.randomUUID()}`;
228
+
229
+ const activity = {
230
+ "@context": CONTEXT,
231
+ id: activityId,
232
+ type: "Follow",
233
+ actor: actor.id,
234
+ object: targetId,
235
+ published,
236
+ };
237
+
238
+ const record = { direction: "outbox", ownerId: actor.id, activity };
239
+ _activities.set(activity.id, record);
240
+ _persistActivity(db, record);
241
+
242
+ const key = `${actor.id}|${targetId}`;
243
+ _follows.set(key, { state: "pending", createdAt: published });
244
+ db.prepare(
245
+ `INSERT OR REPLACE INTO ap_follows (follower_id, followee_id, state, created_at)
246
+ VALUES (?, ?, ?, ?)`,
247
+ ).run(actor.id, targetId, "pending", published);
248
+
249
+ return { success: true, activity };
250
+ }
251
+
252
+ export function acceptFollow(db, { actor: actorRef, followActivityId } = {}) {
253
+ if (!actorRef) throw new Error("actor is required");
254
+ if (!followActivityId) throw new Error("followActivityId is required");
255
+ const actor = _requireActor(actorRef);
256
+ const followRec = _activities.get(followActivityId);
257
+ if (!followRec || followRec.activity.type !== "Follow") {
258
+ throw new Error(`Follow activity not found: ${followActivityId}`);
259
+ }
260
+ if (followRec.activity.object !== actor.id) {
261
+ throw new Error("Accept can only be issued by the Follow target");
262
+ }
263
+ const published = _now();
264
+ const origin = actor.id.split("/users/")[0];
265
+ const activityId = `${origin}/activities/${crypto.randomUUID()}`;
266
+
267
+ const activity = {
268
+ "@context": CONTEXT,
269
+ id: activityId,
270
+ type: "Accept",
271
+ actor: actor.id,
272
+ object: followRec.activity,
273
+ published,
274
+ };
275
+
276
+ const record = { direction: "outbox", ownerId: actor.id, activity };
277
+ _activities.set(activity.id, record);
278
+ _persistActivity(db, record);
279
+
280
+ const key = `${followRec.activity.actor}|${actor.id}`;
281
+ const state = {
282
+ state: "accepted",
283
+ createdAt: _follows.get(key)?.createdAt || published,
284
+ };
285
+ _follows.set(key, state);
286
+ db.prepare(
287
+ `UPDATE ap_follows SET state = ? WHERE follower_id = ? AND followee_id = ?`,
288
+ ).run("accepted", followRec.activity.actor, actor.id);
289
+
290
+ return { success: true, activity };
291
+ }
292
+
293
+ export function undoFollow(db, { actor: actorRef, target } = {}) {
294
+ if (!actorRef) throw new Error("actor is required");
295
+ if (!target) throw new Error("target is required");
296
+ const actor = _requireActor(actorRef);
297
+ const targetId = target.startsWith("http") ? target : _resolveActorId(target);
298
+ if (!targetId) throw new Error(`Target actor not found: ${target}`);
299
+
300
+ // Find the most recent Follow activity for this pair
301
+ let followActivity = null;
302
+ for (const rec of _activities.values()) {
303
+ if (
304
+ rec.direction === "outbox" &&
305
+ rec.activity.type === "Follow" &&
306
+ rec.activity.actor === actor.id &&
307
+ rec.activity.object === targetId
308
+ ) {
309
+ followActivity = rec.activity;
310
+ }
311
+ }
312
+ if (!followActivity) {
313
+ throw new Error(`No Follow activity found for ${actor.id} → ${targetId}`);
314
+ }
315
+
316
+ const published = _now();
317
+ const origin = actor.id.split("/users/")[0];
318
+ const activityId = `${origin}/activities/${crypto.randomUUID()}`;
319
+
320
+ const activity = {
321
+ "@context": CONTEXT,
322
+ id: activityId,
323
+ type: "Undo",
324
+ actor: actor.id,
325
+ object: followActivity,
326
+ published,
327
+ };
328
+
329
+ const record = { direction: "outbox", ownerId: actor.id, activity };
330
+ _activities.set(activity.id, record);
331
+ _persistActivity(db, record);
332
+
333
+ const key = `${actor.id}|${targetId}`;
334
+ _follows.delete(key);
335
+ db.prepare(
336
+ `DELETE FROM ap_follows WHERE follower_id = ? AND followee_id = ?`,
337
+ ).run(actor.id, targetId);
338
+
339
+ return { success: true, activity };
340
+ }
341
+
342
+ export function like(db, { actor: actorRef, object } = {}) {
343
+ if (!actorRef) throw new Error("actor is required");
344
+ if (!object) throw new Error("object is required");
345
+ const actor = _requireActor(actorRef);
346
+ const published = _now();
347
+ const origin = actor.id.split("/users/")[0];
348
+ const activityId = `${origin}/activities/${crypto.randomUUID()}`;
349
+
350
+ const activity = {
351
+ "@context": CONTEXT,
352
+ id: activityId,
353
+ type: "Like",
354
+ actor: actor.id,
355
+ object,
356
+ published,
357
+ };
358
+
359
+ const record = { direction: "outbox", ownerId: actor.id, activity };
360
+ _activities.set(activity.id, record);
361
+ _persistActivity(db, record);
362
+ return { success: true, activity };
363
+ }
364
+
365
+ export function announce(db, { actor: actorRef, object } = {}) {
366
+ if (!actorRef) throw new Error("actor is required");
367
+ if (!object) throw new Error("object is required");
368
+ const actor = _requireActor(actorRef);
369
+ const published = _now();
370
+ const origin = actor.id.split("/users/")[0];
371
+ const activityId = `${origin}/activities/${crypto.randomUUID()}`;
372
+
373
+ const activity = {
374
+ "@context": CONTEXT,
375
+ id: activityId,
376
+ type: "Announce",
377
+ actor: actor.id,
378
+ object,
379
+ to: [PUBLIC_AUDIENCE],
380
+ cc: [actor.followers],
381
+ published,
382
+ };
383
+
384
+ const record = { direction: "outbox", ownerId: actor.id, activity };
385
+ _activities.set(activity.id, record);
386
+ _persistActivity(db, record);
387
+ return { success: true, activity };
388
+ }
389
+
390
+ /* ── Inbox (C2S simulation) ────────────────────────────────── */
391
+
392
+ /**
393
+ * Deliver an activity into a local actor's inbox. In real ActivityPub this
394
+ * would be an HTTP POST with an HTTP Signature header; here we just record it.
395
+ *
396
+ * Side effects on accepted activity types:
397
+ * - Follow → create pending ap_follows row (mirror of sender's side)
398
+ * - Accept(Follow) → mark ap_follows row as accepted
399
+ * - Undo(Follow) → delete ap_follows row
400
+ */
401
+ export function deliverToInbox(db, { actor: actorRef, activity } = {}) {
402
+ if (!actorRef) throw new Error("actor is required");
403
+ if (!activity || !activity.type || !activity.actor) {
404
+ throw new Error("activity with type and actor is required");
405
+ }
406
+ const owner = _requireActor(actorRef);
407
+ const delivered = {
408
+ ...activity,
409
+ id:
410
+ activity.id ||
411
+ `${activity.actor.split("/users/")[0]}/activities/${crypto.randomUUID()}`,
412
+ published: activity.published || _now(),
413
+ };
414
+
415
+ const record = {
416
+ direction: "inbox",
417
+ ownerId: owner.id,
418
+ activity: delivered,
419
+ };
420
+ _activities.set(delivered.id, record);
421
+ _persistActivity(db, record);
422
+
423
+ // Side-effects on follow graph
424
+ if (delivered.type === "Follow" && delivered.object === owner.id) {
425
+ const key = `${delivered.actor}|${owner.id}`;
426
+ _follows.set(key, { state: "pending", createdAt: delivered.published });
427
+ db.prepare(
428
+ `INSERT OR REPLACE INTO ap_follows (follower_id, followee_id, state, created_at)
429
+ VALUES (?, ?, ?, ?)`,
430
+ ).run(delivered.actor, owner.id, "pending", delivered.published);
431
+ } else if (
432
+ delivered.type === "Accept" &&
433
+ typeof delivered.object === "object" &&
434
+ delivered.object?.type === "Follow"
435
+ ) {
436
+ const followActor = delivered.object.actor;
437
+ const followTarget = delivered.object.object;
438
+ if (followActor === owner.id) {
439
+ const key = `${followActor}|${followTarget}`;
440
+ const existing = _follows.get(key);
441
+ _follows.set(key, {
442
+ state: "accepted",
443
+ createdAt: existing?.createdAt || delivered.published,
444
+ });
445
+ db.prepare(
446
+ `UPDATE ap_follows SET state = ? WHERE follower_id = ? AND followee_id = ?`,
447
+ ).run("accepted", followActor, followTarget);
448
+ }
449
+ } else if (
450
+ delivered.type === "Undo" &&
451
+ typeof delivered.object === "object" &&
452
+ delivered.object?.type === "Follow" &&
453
+ delivered.object?.object === owner.id
454
+ ) {
455
+ const key = `${delivered.object.actor}|${owner.id}`;
456
+ _follows.delete(key);
457
+ db.prepare(
458
+ `DELETE FROM ap_follows WHERE follower_id = ? AND followee_id = ?`,
459
+ ).run(delivered.object.actor, owner.id);
460
+ }
461
+
462
+ return { success: true, activity: delivered };
463
+ }
464
+
465
+ /* ── Reads ─────────────────────────────────────────────────── */
466
+
467
+ export function getOutbox(actorRef, { limit = 50, types } = {}) {
468
+ const actor = _requireActor(actorRef);
469
+ const items = [..._activities.values()]
470
+ .filter((r) => r.direction === "outbox" && r.ownerId === actor.id)
471
+ .map((r) => r.activity);
472
+ const filtered = types ? items.filter((a) => types.includes(a.type)) : items;
473
+ return filtered.slice(0, limit);
474
+ }
475
+
476
+ export function getInbox(actorRef, { limit = 50, types } = {}) {
477
+ const actor = _requireActor(actorRef);
478
+ const items = [..._activities.values()]
479
+ .filter((r) => r.direction === "inbox" && r.ownerId === actor.id)
480
+ .map((r) => r.activity);
481
+ const filtered = types ? items.filter((a) => types.includes(a.type)) : items;
482
+ return filtered.slice(0, limit);
483
+ }
484
+
485
+ export function listFollowers(actorRef, { state } = {}) {
486
+ const actor = _requireActor(actorRef);
487
+ const rows = [];
488
+ for (const [key, value] of _follows.entries()) {
489
+ const [followerId, followeeId] = key.split("|");
490
+ if (followeeId === actor.id) {
491
+ if (state && value.state !== state) continue;
492
+ rows.push({ id: followerId, ...value });
493
+ }
494
+ }
495
+ return rows;
496
+ }
497
+
498
+ export function listFollowing(actorRef, { state } = {}) {
499
+ const actor = _requireActor(actorRef);
500
+ const rows = [];
501
+ for (const [key, value] of _follows.entries()) {
502
+ const [followerId, followeeId] = key.split("|");
503
+ if (followerId === actor.id) {
504
+ if (state && value.state !== state) continue;
505
+ rows.push({ id: followeeId, ...value });
506
+ }
507
+ }
508
+ return rows;
509
+ }
510
+
511
+ /* ── Fediverse search ──────────────────────────────────────── */
512
+
513
+ function _tokenize(text) {
514
+ if (!text) return [];
515
+ return text
516
+ .toLowerCase()
517
+ .split(/[^\p{L}\p{N}_]+/u)
518
+ .filter(Boolean);
519
+ }
520
+
521
+ function _scoreMatch(haystack, needleTokens) {
522
+ if (!haystack) return 0;
523
+ const lower = haystack.toLowerCase();
524
+ let score = 0;
525
+ for (const token of needleTokens) {
526
+ if (!token) continue;
527
+ if (lower === token) score += 5;
528
+ else if (lower.startsWith(token)) score += 3;
529
+ else if (lower.includes(token)) score += 1;
530
+ }
531
+ return score;
532
+ }
533
+
534
+ /**
535
+ * Search actors by preferredUsername / name / summary.
536
+ * Scope: 'local' | 'remote' | 'all' (default 'all').
537
+ */
538
+ export function searchActors(query, { limit = 20, scope = "all" } = {}) {
539
+ const tokens = _tokenize(query || "");
540
+ if (tokens.length === 0) return [];
541
+ let pool = [..._actors.values()];
542
+ if (scope === "local") pool = pool.filter((a) => a.isLocal);
543
+ else if (scope === "remote") pool = pool.filter((a) => !a.isLocal);
544
+
545
+ const scored = pool
546
+ .map((actor) => {
547
+ const score =
548
+ _scoreMatch(actor.username, tokens) * 2 +
549
+ _scoreMatch(actor.name, tokens) +
550
+ _scoreMatch(actor.summary, tokens);
551
+ return { actor, score };
552
+ })
553
+ .filter((entry) => entry.score > 0)
554
+ .sort((a, b) => b.score - a.score)
555
+ .slice(0, limit);
556
+
557
+ return scored.map(({ actor, score }) => ({ ...actor, score }));
558
+ }
559
+
560
+ /**
561
+ * Search Create(Note) activities by content.
562
+ * Filters: authorId (full actor URL or local username), since/until (ISO), scope.
563
+ */
564
+ export function searchNotes(
565
+ query,
566
+ { limit = 20, author, since, until, scope = "all" } = {},
567
+ ) {
568
+ const tokens = _tokenize(query || "");
569
+ if (tokens.length === 0) return [];
570
+
571
+ const authorId = author ? _resolveActorId(author) : null;
572
+
573
+ const sinceTs = since ? new Date(since).getTime() : null;
574
+ const untilTs = until ? new Date(until).getTime() : null;
575
+
576
+ const entries = [];
577
+ for (const rec of _activities.values()) {
578
+ const activity = rec.activity;
579
+ if (activity.type !== "Create") continue;
580
+ const note = activity.object;
581
+ if (!note || note.type !== "Note") continue;
582
+ if (authorId && activity.actor !== authorId) continue;
583
+
584
+ if (scope !== "all") {
585
+ const authorActor = _actors.get(activity.actor);
586
+ const isLocal = authorActor ? authorActor.isLocal : false;
587
+ if (scope === "local" && !isLocal) continue;
588
+ if (scope === "remote" && isLocal) continue;
589
+ }
590
+
591
+ if (sinceTs || untilTs) {
592
+ const ts = activity.published
593
+ ? new Date(activity.published).getTime()
594
+ : null;
595
+ if (sinceTs && (!ts || ts < sinceTs)) continue;
596
+ if (untilTs && (!ts || ts > untilTs)) continue;
597
+ }
598
+
599
+ const score = _scoreMatch(note.content, tokens);
600
+ if (score > 0) {
601
+ entries.push({
602
+ activityId: activity.id,
603
+ noteId: note.id,
604
+ actor: activity.actor,
605
+ content: note.content,
606
+ published: activity.published,
607
+ score,
608
+ });
609
+ }
610
+ }
611
+
612
+ return entries.sort((a, b) => b.score - a.score).slice(0, limit);
613
+ }
614
+
615
+ /* ── Reset (for testing) ───────────────────────────────────── */
616
+
617
+ export function _resetState() {
618
+ _actors.clear();
619
+ _activities.clear();
620
+ _follows.clear();
621
+ }
622
+
623
+ export const _constants = { PUBLIC_AUDIENCE, CONTEXT, DEFAULT_ORIGIN };