@character-foundry/character-foundry 0.1.3 → 0.1.6
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/README.md +70 -0
- package/dist/app-framework.cjs +1859 -0
- package/dist/app-framework.cjs.map +1 -0
- package/dist/app-framework.d.cts +896 -0
- package/dist/app-framework.d.ts +896 -2
- package/dist/app-framework.js +1835 -1
- package/dist/app-framework.js.map +1 -1
- package/dist/charx.cjs +979 -0
- package/dist/charx.cjs.map +1 -0
- package/dist/charx.d.cts +640 -0
- package/dist/charx.d.ts +640 -2
- package/dist/charx.js +955 -1
- package/dist/charx.js.map +1 -1
- package/dist/core.cjs +755 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +404 -0
- package/dist/core.d.ts +404 -2
- package/dist/core.js +731 -1
- package/dist/core.js.map +1 -1
- package/dist/exporter.cjs +7619 -0
- package/dist/exporter.cjs.map +1 -0
- package/dist/exporter.d.cts +681 -0
- package/dist/exporter.d.ts +681 -2
- package/dist/exporter.js +7602 -1
- package/dist/exporter.js.map +1 -1
- package/dist/federation.cjs +3916 -0
- package/dist/federation.cjs.map +1 -0
- package/dist/federation.d.cts +2951 -0
- package/dist/federation.d.ts +2951 -2
- package/dist/federation.js +3892 -1
- package/dist/federation.js.map +1 -1
- package/dist/index.cjs +9213 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1119 -0
- package/dist/index.d.ts +1113 -20
- package/dist/index.js +9196 -26
- package/dist/index.js.map +1 -1
- package/dist/loader.cjs +8951 -0
- package/dist/loader.cjs.map +1 -0
- package/dist/loader.d.cts +1037 -0
- package/dist/loader.d.ts +1037 -2
- package/dist/loader.js +8934 -1
- package/dist/loader.js.map +1 -1
- package/dist/lorebook.cjs +866 -0
- package/dist/lorebook.cjs.map +1 -0
- package/dist/lorebook.d.cts +1008 -0
- package/dist/lorebook.d.ts +1008 -2
- package/dist/lorebook.js +842 -1
- package/dist/lorebook.js.map +1 -1
- package/dist/media.cjs +6661 -0
- package/dist/media.cjs.map +1 -0
- package/dist/media.d.cts +87 -0
- package/dist/media.d.ts +87 -2
- package/dist/media.js +6644 -1
- package/dist/media.js.map +1 -1
- package/dist/normalizer.cjs +503 -0
- package/dist/normalizer.cjs.map +1 -0
- package/dist/normalizer.d.cts +1217 -0
- package/dist/normalizer.d.ts +1217 -2
- package/dist/normalizer.js +479 -1
- package/dist/normalizer.js.map +1 -1
- package/dist/png.cjs +797 -0
- package/dist/png.cjs.map +1 -0
- package/dist/png.d.cts +786 -0
- package/dist/png.d.ts +786 -2
- package/dist/png.js +773 -1
- package/dist/png.js.map +1 -1
- package/dist/schemas.cjs +879 -0
- package/dist/schemas.cjs.map +1 -0
- package/dist/schemas.d.cts +2208 -0
- package/dist/schemas.d.ts +2208 -2
- package/dist/schemas.js +855 -1
- package/dist/schemas.js.map +1 -1
- package/dist/tokenizers.cjs +153 -0
- package/dist/tokenizers.cjs.map +1 -0
- package/dist/tokenizers.d.cts +155 -0
- package/dist/tokenizers.d.ts +155 -2
- package/dist/tokenizers.js +129 -1
- package/dist/tokenizers.js.map +1 -1
- package/dist/voxta.cjs +7907 -0
- package/dist/voxta.cjs.map +1 -0
- package/dist/voxta.d.cts +1349 -0
- package/dist/voxta.d.ts +1349 -2
- package/dist/voxta.js +7890 -1
- package/dist/voxta.js.map +1 -1
- package/package.json +177 -45
- package/dist/app-framework.d.ts.map +0 -1
- package/dist/charx.d.ts.map +0 -1
- package/dist/core.d.ts.map +0 -1
- package/dist/exporter.d.ts.map +0 -1
- package/dist/federation.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/loader.d.ts.map +0 -1
- package/dist/lorebook.d.ts.map +0 -1
- package/dist/media.d.ts.map +0 -1
- package/dist/normalizer.d.ts.map +0 -1
- package/dist/png.d.ts.map +0 -1
- package/dist/schemas.d.ts.map +0 -1
- package/dist/tokenizers.d.ts.map +0 -1
- package/dist/voxta.d.ts.map +0 -1
package/dist/federation.js
CHANGED
|
@@ -1,2 +1,3893 @@
|
|
|
1
|
-
|
|
1
|
+
// ../core/dist/index.js
|
|
2
|
+
var isNode = typeof process !== "undefined" && process.versions != null && process.versions.node != null;
|
|
3
|
+
var LARGE_BUFFER_THRESHOLD = 1024 * 1024;
|
|
4
|
+
var ENCODE_CHUNK_SIZE = 64 * 1024;
|
|
5
|
+
var FOUNDRY_ERROR_MARKER = /* @__PURE__ */ Symbol.for("@character-foundry/core:FoundryError");
|
|
6
|
+
var FoundryError = class extends Error {
|
|
7
|
+
constructor(message, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "FoundryError";
|
|
11
|
+
if (Error.captureStackTrace) {
|
|
12
|
+
Error.captureStackTrace(this, this.constructor);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** @internal Marker for cross-module identification */
|
|
16
|
+
[FOUNDRY_ERROR_MARKER] = true;
|
|
17
|
+
};
|
|
18
|
+
function formatUUID(bytes) {
|
|
19
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
20
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
21
|
+
}
|
|
22
|
+
function mathRandomUUID() {
|
|
23
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
24
|
+
const r = Math.random() * 16 | 0;
|
|
25
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
26
|
+
return v.toString(16);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function generateUUID() {
|
|
30
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
31
|
+
return crypto.randomUUID();
|
|
32
|
+
}
|
|
33
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
34
|
+
const bytes = new Uint8Array(16);
|
|
35
|
+
crypto.getRandomValues(bytes);
|
|
36
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
37
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
38
|
+
return formatUUID(bytes);
|
|
39
|
+
}
|
|
40
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "development") {
|
|
41
|
+
console.warn("[character-foundry/core] generateUUID: Using insecure Math.random() fallback");
|
|
42
|
+
}
|
|
43
|
+
return mathRandomUUID();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ../federation/dist/index.js
|
|
47
|
+
var ACTIVITY_CONTEXT = [
|
|
48
|
+
"https://www.w3.org/ns/activitystreams",
|
|
49
|
+
{
|
|
50
|
+
"character": "https://character-foundry.dev/ns#",
|
|
51
|
+
"character:version": { "@id": "character:version" },
|
|
52
|
+
"character:spec": { "@id": "character:spec" }
|
|
53
|
+
}
|
|
54
|
+
];
|
|
55
|
+
var FORK_ACTIVITY_CONTEXT = [
|
|
56
|
+
"https://www.w3.org/ns/activitystreams",
|
|
57
|
+
{
|
|
58
|
+
"character": "https://character-foundry.dev/ns#",
|
|
59
|
+
"character:version": { "@id": "character:version" },
|
|
60
|
+
"character:spec": { "@id": "character:spec" },
|
|
61
|
+
"Fork": "character:Fork",
|
|
62
|
+
"forkedFrom": { "@id": "character:forkedFrom", "@type": "@id" }
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
var INSTALL_ACTIVITY_CONTEXT = [
|
|
66
|
+
"https://www.w3.org/ns/activitystreams",
|
|
67
|
+
{
|
|
68
|
+
"character": "https://character-foundry.dev/ns#",
|
|
69
|
+
"Install": "character:Install"
|
|
70
|
+
}
|
|
71
|
+
];
|
|
72
|
+
function generateCardId(baseUrl, localId) {
|
|
73
|
+
const encodedId = encodeURIComponent(localId);
|
|
74
|
+
return `${baseUrl}/cards/${encodedId}`;
|
|
75
|
+
}
|
|
76
|
+
function generateActivityId(baseUrl) {
|
|
77
|
+
const timestamp = Date.now();
|
|
78
|
+
const random = generateUUID().split("-")[0];
|
|
79
|
+
return `${baseUrl}/activities/${timestamp}-${random}`;
|
|
80
|
+
}
|
|
81
|
+
function cardToActivityPub(card, options) {
|
|
82
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
83
|
+
const cardData = card.data;
|
|
84
|
+
const tags = cardData.tags?.map((tag) => ({
|
|
85
|
+
type: "Hashtag",
|
|
86
|
+
name: `#${tag.replace(/\s+/g, "_")}`
|
|
87
|
+
}));
|
|
88
|
+
const federatedCard = {
|
|
89
|
+
"@context": ACTIVITY_CONTEXT,
|
|
90
|
+
id: options.id,
|
|
91
|
+
type: "Note",
|
|
92
|
+
name: cardData.name,
|
|
93
|
+
summary: cardData.description?.substring(0, 500),
|
|
94
|
+
content: JSON.stringify(card),
|
|
95
|
+
mediaType: "application/json",
|
|
96
|
+
attributedTo: options.actorId,
|
|
97
|
+
published: options.published || now,
|
|
98
|
+
updated: options.updated,
|
|
99
|
+
tag: tags,
|
|
100
|
+
attachment: options.attachments,
|
|
101
|
+
"character:version": cardData.character_version,
|
|
102
|
+
"character:spec": card.spec_version
|
|
103
|
+
};
|
|
104
|
+
if (options.sourcePlatform && options.sourceId) {
|
|
105
|
+
federatedCard.source = {
|
|
106
|
+
platform: options.sourcePlatform,
|
|
107
|
+
id: options.sourceId,
|
|
108
|
+
url: options.sourceUrl
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return federatedCard;
|
|
112
|
+
}
|
|
113
|
+
function cardFromActivityPub(federatedCard) {
|
|
114
|
+
try {
|
|
115
|
+
const card = JSON.parse(federatedCard.content);
|
|
116
|
+
if (card.spec !== "chara_card_v3") {
|
|
117
|
+
throw new Error("Invalid card spec");
|
|
118
|
+
}
|
|
119
|
+
return card;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Failed to parse card from ActivityPub: ${err instanceof Error ? err.message : String(err)}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function createCreateActivity(card, actorId, baseUrl, recipients) {
|
|
127
|
+
return {
|
|
128
|
+
"@context": ACTIVITY_CONTEXT,
|
|
129
|
+
id: generateActivityId(baseUrl),
|
|
130
|
+
type: "Create",
|
|
131
|
+
actor: actorId,
|
|
132
|
+
object: card,
|
|
133
|
+
published: (/* @__PURE__ */ new Date()).toISOString(),
|
|
134
|
+
to: recipients?.to || ["https://www.w3.org/ns/activitystreams#Public"],
|
|
135
|
+
cc: recipients?.cc
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function createUpdateActivity(card, actorId, baseUrl, recipients) {
|
|
139
|
+
return {
|
|
140
|
+
"@context": ACTIVITY_CONTEXT,
|
|
141
|
+
id: generateActivityId(baseUrl),
|
|
142
|
+
type: "Update",
|
|
143
|
+
actor: actorId,
|
|
144
|
+
object: card,
|
|
145
|
+
published: (/* @__PURE__ */ new Date()).toISOString(),
|
|
146
|
+
to: recipients?.to || ["https://www.w3.org/ns/activitystreams#Public"],
|
|
147
|
+
cc: recipients?.cc
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function createDeleteActivity(cardId, actorId, baseUrl, recipients) {
|
|
151
|
+
return {
|
|
152
|
+
"@context": ACTIVITY_CONTEXT,
|
|
153
|
+
id: generateActivityId(baseUrl),
|
|
154
|
+
type: "Delete",
|
|
155
|
+
actor: actorId,
|
|
156
|
+
object: cardId,
|
|
157
|
+
published: (/* @__PURE__ */ new Date()).toISOString(),
|
|
158
|
+
to: recipients?.to || ["https://www.w3.org/ns/activitystreams#Public"],
|
|
159
|
+
cc: recipients?.cc
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function createAnnounceActivity(cardId, actorId, baseUrl, recipients) {
|
|
163
|
+
return {
|
|
164
|
+
"@context": ACTIVITY_CONTEXT,
|
|
165
|
+
id: generateActivityId(baseUrl),
|
|
166
|
+
type: "Announce",
|
|
167
|
+
actor: actorId,
|
|
168
|
+
object: cardId,
|
|
169
|
+
published: (/* @__PURE__ */ new Date()).toISOString(),
|
|
170
|
+
to: recipients?.to || ["https://www.w3.org/ns/activitystreams#Public"],
|
|
171
|
+
cc: recipients?.cc
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function createLikeActivity(cardId, actorId, baseUrl) {
|
|
175
|
+
return {
|
|
176
|
+
"@context": ACTIVITY_CONTEXT,
|
|
177
|
+
id: generateActivityId(baseUrl),
|
|
178
|
+
type: "Like",
|
|
179
|
+
actor: actorId,
|
|
180
|
+
object: cardId,
|
|
181
|
+
published: (/* @__PURE__ */ new Date()).toISOString()
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function createUndoActivity(originalActivityId, actorId, baseUrl) {
|
|
185
|
+
return {
|
|
186
|
+
"@context": ACTIVITY_CONTEXT,
|
|
187
|
+
id: generateActivityId(baseUrl),
|
|
188
|
+
type: "Undo",
|
|
189
|
+
actor: actorId,
|
|
190
|
+
object: originalActivityId,
|
|
191
|
+
published: (/* @__PURE__ */ new Date()).toISOString()
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function createForkActivity(sourceCardId, forkedCard, actorId, baseUrl, recipients) {
|
|
195
|
+
return {
|
|
196
|
+
"@context": FORK_ACTIVITY_CONTEXT,
|
|
197
|
+
id: generateActivityId(baseUrl),
|
|
198
|
+
type: "Fork",
|
|
199
|
+
actor: actorId,
|
|
200
|
+
object: sourceCardId,
|
|
201
|
+
result: forkedCard,
|
|
202
|
+
published: (/* @__PURE__ */ new Date()).toISOString(),
|
|
203
|
+
to: recipients?.to || ["https://www.w3.org/ns/activitystreams#Public"],
|
|
204
|
+
cc: recipients?.cc
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function parseForkActivity(activity) {
|
|
208
|
+
if (!activity || typeof activity !== "object") {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const act = activity;
|
|
212
|
+
if (act.type !== "Fork") {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (typeof act.actor !== "string" || typeof act.object !== "string" || typeof act.id !== "string" || !act.result || typeof act.result !== "object") {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const result = act.result;
|
|
219
|
+
if (typeof result.id !== "string" || typeof result.content !== "string" || typeof result.attributedTo !== "string") {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
sourceCardId: act.object,
|
|
224
|
+
forkedCard: result,
|
|
225
|
+
actor: act.actor,
|
|
226
|
+
activityId: act.id
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function createInstallActivity(cardId, actorId, baseUrl, platform, recipients) {
|
|
230
|
+
return {
|
|
231
|
+
"@context": INSTALL_ACTIVITY_CONTEXT,
|
|
232
|
+
id: generateActivityId(baseUrl),
|
|
233
|
+
type: "Install",
|
|
234
|
+
actor: actorId,
|
|
235
|
+
object: cardId,
|
|
236
|
+
target: {
|
|
237
|
+
type: "Application",
|
|
238
|
+
name: platform
|
|
239
|
+
},
|
|
240
|
+
published: (/* @__PURE__ */ new Date()).toISOString(),
|
|
241
|
+
to: recipients?.to,
|
|
242
|
+
cc: recipients?.cc
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function parseInstallActivity(activity) {
|
|
246
|
+
if (!activity || typeof activity !== "object") {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const act = activity;
|
|
250
|
+
if (act.type !== "Install") {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
if (typeof act.actor !== "string" || typeof act.object !== "string" || typeof act.id !== "string") {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
let platform = null;
|
|
257
|
+
if (act.target && typeof act.target === "object") {
|
|
258
|
+
const target = act.target;
|
|
259
|
+
if (target.type === "Application" && typeof target.name === "string") {
|
|
260
|
+
platform = target.name;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
cardId: act.object,
|
|
265
|
+
actor: act.actor,
|
|
266
|
+
platform,
|
|
267
|
+
activityId: act.id
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function createActor(options) {
|
|
271
|
+
const actor = {
|
|
272
|
+
id: options.id,
|
|
273
|
+
type: "Person",
|
|
274
|
+
name: options.displayName,
|
|
275
|
+
preferredUsername: options.username,
|
|
276
|
+
summary: options.summary,
|
|
277
|
+
icon: options.icon,
|
|
278
|
+
inbox: `${options.id}/inbox`,
|
|
279
|
+
outbox: `${options.id}/outbox`,
|
|
280
|
+
followers: `${options.id}/followers`,
|
|
281
|
+
following: `${options.id}/following`
|
|
282
|
+
};
|
|
283
|
+
if (options.publicKeyPem) {
|
|
284
|
+
actor.publicKey = {
|
|
285
|
+
id: `${options.id}#main-key`,
|
|
286
|
+
owner: options.id,
|
|
287
|
+
publicKeyPem: options.publicKeyPem
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return actor;
|
|
291
|
+
}
|
|
292
|
+
function parseActivity(data) {
|
|
293
|
+
if (!data || typeof data !== "object") {
|
|
294
|
+
throw new Error("Invalid activity: not an object");
|
|
295
|
+
}
|
|
296
|
+
const activity = data;
|
|
297
|
+
if (!activity.type || !activity.actor || !activity.object) {
|
|
298
|
+
throw new Error("Invalid activity: missing required fields");
|
|
299
|
+
}
|
|
300
|
+
return activity;
|
|
301
|
+
}
|
|
302
|
+
function hashCardFast(card) {
|
|
303
|
+
const content = JSON.stringify(card);
|
|
304
|
+
let hash = 0;
|
|
305
|
+
for (let i = 0; i < content.length; i++) {
|
|
306
|
+
const char = content.charCodeAt(i);
|
|
307
|
+
hash = (hash << 5) - hash + char;
|
|
308
|
+
hash = hash & hash;
|
|
309
|
+
}
|
|
310
|
+
return Math.abs(hash).toString(36);
|
|
311
|
+
}
|
|
312
|
+
async function hashCardSecure(card) {
|
|
313
|
+
const content = JSON.stringify(card);
|
|
314
|
+
const data = new TextEncoder().encode(content);
|
|
315
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
316
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
317
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
318
|
+
}
|
|
319
|
+
var SyncEngine = class {
|
|
320
|
+
platforms = /* @__PURE__ */ new Map();
|
|
321
|
+
stateStore;
|
|
322
|
+
baseUrl;
|
|
323
|
+
actorId;
|
|
324
|
+
listeners = /* @__PURE__ */ new Map();
|
|
325
|
+
autoSyncTimer;
|
|
326
|
+
secureHashing;
|
|
327
|
+
/**
|
|
328
|
+
* Mutex flag to prevent concurrent syncAll() executions.
|
|
329
|
+
* When autoSyncInterval triggers while a sync is in progress,
|
|
330
|
+
* the new sync is skipped to prevent race conditions.
|
|
331
|
+
*/
|
|
332
|
+
syncInProgress = false;
|
|
333
|
+
constructor(options) {
|
|
334
|
+
assertFederationEnabled("SyncEngine");
|
|
335
|
+
this.baseUrl = options.baseUrl;
|
|
336
|
+
this.actorId = options.actorId;
|
|
337
|
+
this.stateStore = options.stateStore;
|
|
338
|
+
this.secureHashing = options.secureHashing ?? false;
|
|
339
|
+
if (options.autoSyncInterval && options.autoSyncInterval > 0) {
|
|
340
|
+
this.autoSyncTimer = setInterval(
|
|
341
|
+
() => void this.syncAll(),
|
|
342
|
+
// void to handle Promise without blocking
|
|
343
|
+
options.autoSyncInterval
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Generate a hash for change detection.
|
|
349
|
+
* Uses SHA-256 if secureHashing is enabled, otherwise fast 32-bit hash.
|
|
350
|
+
*/
|
|
351
|
+
async hashCard(card) {
|
|
352
|
+
if (this.secureHashing) {
|
|
353
|
+
return hashCardSecure(card);
|
|
354
|
+
}
|
|
355
|
+
return hashCardFast(card);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Register a platform adapter
|
|
359
|
+
*/
|
|
360
|
+
registerPlatform(adapter) {
|
|
361
|
+
this.platforms.set(adapter.platform, adapter);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Unregister a platform adapter
|
|
365
|
+
*/
|
|
366
|
+
unregisterPlatform(platform) {
|
|
367
|
+
this.platforms.delete(platform);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Get registered platforms
|
|
371
|
+
*/
|
|
372
|
+
getPlatforms() {
|
|
373
|
+
return Array.from(this.platforms.keys());
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Add event listener
|
|
377
|
+
*/
|
|
378
|
+
on(event, listener) {
|
|
379
|
+
if (!this.listeners.has(event)) {
|
|
380
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
381
|
+
}
|
|
382
|
+
this.listeners.get(event).add(listener);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Remove event listener
|
|
386
|
+
*/
|
|
387
|
+
off(event, listener) {
|
|
388
|
+
this.listeners.get(event)?.delete(listener);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Emit an event
|
|
392
|
+
*/
|
|
393
|
+
emit(type, data) {
|
|
394
|
+
const event = {
|
|
395
|
+
type,
|
|
396
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
397
|
+
data
|
|
398
|
+
};
|
|
399
|
+
const listeners = this.listeners.get(type);
|
|
400
|
+
if (listeners) {
|
|
401
|
+
for (const listener of listeners) {
|
|
402
|
+
try {
|
|
403
|
+
listener(event);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.error(`Event listener error:`, err);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Push a card from one platform to another
|
|
412
|
+
*/
|
|
413
|
+
async pushCard(sourcePlatform, sourceId, targetPlatform) {
|
|
414
|
+
const operation = {
|
|
415
|
+
type: "push",
|
|
416
|
+
cardId: `${sourcePlatform}:${sourceId}`,
|
|
417
|
+
sourcePlatform,
|
|
418
|
+
targetPlatform,
|
|
419
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
420
|
+
};
|
|
421
|
+
try {
|
|
422
|
+
const sourceAdapter = this.platforms.get(sourcePlatform);
|
|
423
|
+
if (!sourceAdapter) {
|
|
424
|
+
throw new Error(`Platform not registered: ${sourcePlatform}`);
|
|
425
|
+
}
|
|
426
|
+
const targetAdapter = this.platforms.get(targetPlatform);
|
|
427
|
+
if (!targetAdapter) {
|
|
428
|
+
throw new Error(`Platform not registered: ${targetPlatform}`);
|
|
429
|
+
}
|
|
430
|
+
if (!await sourceAdapter.isAvailable()) {
|
|
431
|
+
throw new Error(`Source platform unavailable: ${sourcePlatform}`);
|
|
432
|
+
}
|
|
433
|
+
if (!await targetAdapter.isAvailable()) {
|
|
434
|
+
throw new Error(`Target platform unavailable: ${targetPlatform}`);
|
|
435
|
+
}
|
|
436
|
+
const card = await sourceAdapter.getCard(sourceId);
|
|
437
|
+
if (!card) {
|
|
438
|
+
throw new Error(`Card not found: ${sourceId}`);
|
|
439
|
+
}
|
|
440
|
+
const federatedId = generateCardId(this.baseUrl, `${sourcePlatform}-${sourceId}`);
|
|
441
|
+
let syncState = await this.stateStore.get(federatedId);
|
|
442
|
+
if (syncState?.platformIds[targetPlatform]) {
|
|
443
|
+
const existingCard = await targetAdapter.getCard(syncState.platformIds[targetPlatform]);
|
|
444
|
+
if (existingCard) {
|
|
445
|
+
const existingHash = await this.hashCard(existingCard);
|
|
446
|
+
const newHash2 = await this.hashCard(card);
|
|
447
|
+
if (existingHash !== syncState.versionHash && newHash2 !== syncState.versionHash) {
|
|
448
|
+
syncState.status = "conflict";
|
|
449
|
+
syncState.conflict = {
|
|
450
|
+
localVersion: newHash2,
|
|
451
|
+
remoteVersion: existingHash,
|
|
452
|
+
remotePlatform: targetPlatform
|
|
453
|
+
};
|
|
454
|
+
await this.stateStore.set(syncState);
|
|
455
|
+
this.emit("card:conflict", { syncState, sourcePlatform, targetPlatform });
|
|
456
|
+
return {
|
|
457
|
+
success: false,
|
|
458
|
+
operation,
|
|
459
|
+
newState: syncState,
|
|
460
|
+
error: "Sync conflict detected"
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const targetId = await targetAdapter.saveCard(
|
|
466
|
+
card,
|
|
467
|
+
syncState?.platformIds[targetPlatform]
|
|
468
|
+
);
|
|
469
|
+
const newHash = await this.hashCard(card);
|
|
470
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
471
|
+
const updatedState = syncState ?? {
|
|
472
|
+
localId: sourceId,
|
|
473
|
+
federatedId,
|
|
474
|
+
platformIds: {},
|
|
475
|
+
lastSync: {},
|
|
476
|
+
versionHash: newHash,
|
|
477
|
+
status: "synced"
|
|
478
|
+
};
|
|
479
|
+
updatedState.platformIds[sourcePlatform] = sourceId;
|
|
480
|
+
updatedState.platformIds[targetPlatform] = targetId;
|
|
481
|
+
updatedState.lastSync[sourcePlatform] = now;
|
|
482
|
+
updatedState.lastSync[targetPlatform] = now;
|
|
483
|
+
updatedState.versionHash = newHash;
|
|
484
|
+
updatedState.status = "synced";
|
|
485
|
+
updatedState.conflict = void 0;
|
|
486
|
+
await this.stateStore.set(updatedState);
|
|
487
|
+
this.emit("card:synced", { syncState: updatedState, sourcePlatform, targetPlatform });
|
|
488
|
+
return {
|
|
489
|
+
success: true,
|
|
490
|
+
operation,
|
|
491
|
+
newState: updatedState
|
|
492
|
+
};
|
|
493
|
+
} catch (err) {
|
|
494
|
+
this.emit("sync:failed", { operation, error: err });
|
|
495
|
+
return {
|
|
496
|
+
success: false,
|
|
497
|
+
operation,
|
|
498
|
+
error: err instanceof Error ? err.message : String(err)
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Pull a card from a remote platform to local
|
|
504
|
+
*/
|
|
505
|
+
async pullCard(remotePlatform, remoteId, localPlatform) {
|
|
506
|
+
return this.pushCard(remotePlatform, remoteId, localPlatform);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Sync a card across all registered platforms
|
|
510
|
+
*/
|
|
511
|
+
async syncCardToAll(sourcePlatform, sourceId) {
|
|
512
|
+
const results = [];
|
|
513
|
+
for (const [platform] of this.platforms) {
|
|
514
|
+
if (platform === sourcePlatform) continue;
|
|
515
|
+
const result = await this.pushCard(sourcePlatform, sourceId, platform);
|
|
516
|
+
results.push(result);
|
|
517
|
+
}
|
|
518
|
+
return results;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Sync all cards from one platform to another
|
|
522
|
+
*/
|
|
523
|
+
async syncPlatform(sourcePlatform, targetPlatform) {
|
|
524
|
+
const sourceAdapter = this.platforms.get(sourcePlatform);
|
|
525
|
+
if (!sourceAdapter) {
|
|
526
|
+
throw new Error(`Platform not registered: ${sourcePlatform}`);
|
|
527
|
+
}
|
|
528
|
+
this.emit("sync:started", { sourcePlatform, targetPlatform });
|
|
529
|
+
const cards = await sourceAdapter.listCards();
|
|
530
|
+
const results = [];
|
|
531
|
+
for (const { id } of cards) {
|
|
532
|
+
const result = await this.pushCard(sourcePlatform, id, targetPlatform);
|
|
533
|
+
results.push(result);
|
|
534
|
+
}
|
|
535
|
+
this.emit("sync:completed", {
|
|
536
|
+
sourcePlatform,
|
|
537
|
+
targetPlatform,
|
|
538
|
+
total: results.length,
|
|
539
|
+
successful: results.filter((r) => r.success).length,
|
|
540
|
+
failed: results.filter((r) => !r.success).length
|
|
541
|
+
});
|
|
542
|
+
return results;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Sync all platforms with each other.
|
|
546
|
+
*
|
|
547
|
+
* Includes mutex protection to prevent concurrent executions when
|
|
548
|
+
* triggered by autoSyncInterval. If a sync is already in progress,
|
|
549
|
+
* subsequent calls are skipped and emit a 'sync:skipped' event.
|
|
550
|
+
*/
|
|
551
|
+
async syncAll() {
|
|
552
|
+
if (this.syncInProgress) {
|
|
553
|
+
this.emit("sync:skipped", { reason: "already_in_progress", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
554
|
+
return /* @__PURE__ */ new Map();
|
|
555
|
+
}
|
|
556
|
+
this.syncInProgress = true;
|
|
557
|
+
try {
|
|
558
|
+
const results = /* @__PURE__ */ new Map();
|
|
559
|
+
const platforms = Array.from(this.platforms.keys());
|
|
560
|
+
for (let i = 0; i < platforms.length; i++) {
|
|
561
|
+
for (let j = i + 1; j < platforms.length; j++) {
|
|
562
|
+
const source = platforms[i];
|
|
563
|
+
const target = platforms[j];
|
|
564
|
+
const key = `${source}->${target}`;
|
|
565
|
+
results.set(key, await this.syncPlatform(source, target));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return results;
|
|
569
|
+
} finally {
|
|
570
|
+
this.syncInProgress = false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Get sync state for a card
|
|
575
|
+
*/
|
|
576
|
+
async getSyncState(federatedId) {
|
|
577
|
+
return this.stateStore.get(federatedId);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Find sync state by platform ID
|
|
581
|
+
*/
|
|
582
|
+
async findSyncState(platform, platformId) {
|
|
583
|
+
return this.stateStore.findByPlatformId(platform, platformId);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Resolve a sync conflict by choosing a version
|
|
587
|
+
*/
|
|
588
|
+
async resolveConflict(federatedId, resolution, mergedCard) {
|
|
589
|
+
const syncState = await this.stateStore.get(federatedId);
|
|
590
|
+
if (!syncState || syncState.status !== "conflict" || !syncState.conflict) {
|
|
591
|
+
throw new Error("No conflict to resolve");
|
|
592
|
+
}
|
|
593
|
+
const operation = {
|
|
594
|
+
type: "resolve",
|
|
595
|
+
cardId: federatedId,
|
|
596
|
+
sourcePlatform: "archive",
|
|
597
|
+
// Will be updated
|
|
598
|
+
targetPlatform: syncState.conflict.remotePlatform,
|
|
599
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
600
|
+
};
|
|
601
|
+
try {
|
|
602
|
+
let sourceCard = null;
|
|
603
|
+
let sourcePlatform = null;
|
|
604
|
+
if (resolution === "merge" && mergedCard) {
|
|
605
|
+
sourceCard = mergedCard;
|
|
606
|
+
} else if (resolution === "local") {
|
|
607
|
+
for (const [platform, id] of Object.entries(syncState.platformIds)) {
|
|
608
|
+
if (platform !== syncState.conflict.remotePlatform) {
|
|
609
|
+
const adapter = this.platforms.get(platform);
|
|
610
|
+
if (adapter) {
|
|
611
|
+
sourceCard = await adapter.getCard(id);
|
|
612
|
+
sourcePlatform = platform;
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} else if (resolution === "remote") {
|
|
618
|
+
const adapter = this.platforms.get(syncState.conflict.remotePlatform);
|
|
619
|
+
if (adapter) {
|
|
620
|
+
const remoteId = syncState.platformIds[syncState.conflict.remotePlatform];
|
|
621
|
+
if (remoteId) {
|
|
622
|
+
sourceCard = await adapter.getCard(remoteId);
|
|
623
|
+
sourcePlatform = syncState.conflict.remotePlatform;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!sourceCard) {
|
|
628
|
+
throw new Error("Could not resolve conflict: source card not found");
|
|
629
|
+
}
|
|
630
|
+
const newHash = await this.hashCard(sourceCard);
|
|
631
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
632
|
+
for (const [platform, id] of Object.entries(syncState.platformIds)) {
|
|
633
|
+
const adapter = this.platforms.get(platform);
|
|
634
|
+
if (adapter) {
|
|
635
|
+
await adapter.saveCard(sourceCard, id);
|
|
636
|
+
syncState.lastSync[platform] = now;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
syncState.versionHash = newHash;
|
|
640
|
+
syncState.status = "synced";
|
|
641
|
+
syncState.conflict = void 0;
|
|
642
|
+
await this.stateStore.set(syncState);
|
|
643
|
+
this.emit("card:synced", { syncState, resolution });
|
|
644
|
+
return {
|
|
645
|
+
success: true,
|
|
646
|
+
operation,
|
|
647
|
+
newState: syncState
|
|
648
|
+
};
|
|
649
|
+
} catch (err) {
|
|
650
|
+
return {
|
|
651
|
+
success: false,
|
|
652
|
+
operation,
|
|
653
|
+
error: err instanceof Error ? err.message : String(err)
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Fork a card from a remote source
|
|
659
|
+
*
|
|
660
|
+
* Creates a local copy of a remote card with fork metadata stored in
|
|
661
|
+
* the card's extensions and sync state. Optionally notifies the source
|
|
662
|
+
* instance about the fork.
|
|
663
|
+
*
|
|
664
|
+
* @param sourceFederatedId - Federated URI of the source card
|
|
665
|
+
* @param sourcePlatform - Platform where the source card resides
|
|
666
|
+
* @param targetPlatform - Platform to save the fork to
|
|
667
|
+
* @param options - Fork options
|
|
668
|
+
*/
|
|
669
|
+
async forkCard(sourceFederatedId, sourcePlatform, targetPlatform, options) {
|
|
670
|
+
const operation = {
|
|
671
|
+
type: "fork",
|
|
672
|
+
cardId: sourceFederatedId,
|
|
673
|
+
sourcePlatform,
|
|
674
|
+
targetPlatform,
|
|
675
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
676
|
+
};
|
|
677
|
+
try {
|
|
678
|
+
const sourceAdapter = this.platforms.get(sourcePlatform);
|
|
679
|
+
if (!sourceAdapter) {
|
|
680
|
+
throw new Error(`Platform not registered: ${sourcePlatform}`);
|
|
681
|
+
}
|
|
682
|
+
const targetAdapter = this.platforms.get(targetPlatform);
|
|
683
|
+
if (!targetAdapter) {
|
|
684
|
+
throw new Error(`Platform not registered: ${targetPlatform}`);
|
|
685
|
+
}
|
|
686
|
+
let sourceState = await this.stateStore.get(sourceFederatedId);
|
|
687
|
+
const sourceLocalId = sourceState?.platformIds[sourcePlatform];
|
|
688
|
+
if (!sourceLocalId) {
|
|
689
|
+
throw new Error(`Cannot find source card: ${sourceFederatedId}`);
|
|
690
|
+
}
|
|
691
|
+
const sourceCard = await sourceAdapter.getCard(sourceLocalId);
|
|
692
|
+
if (!sourceCard) {
|
|
693
|
+
throw new Error(`Source card not found: ${sourceLocalId}`);
|
|
694
|
+
}
|
|
695
|
+
const forkedCard = this.createForkedCard(
|
|
696
|
+
sourceCard,
|
|
697
|
+
sourceFederatedId,
|
|
698
|
+
sourcePlatform,
|
|
699
|
+
sourceState?.versionHash,
|
|
700
|
+
options?.modifications
|
|
701
|
+
);
|
|
702
|
+
const forkLocalId = await targetAdapter.saveCard(forkedCard);
|
|
703
|
+
const forkFederatedId = generateCardId(this.baseUrl, `${targetPlatform}-${forkLocalId}`);
|
|
704
|
+
const forkHash = await this.hashCard(forkedCard);
|
|
705
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
706
|
+
const forkReference = {
|
|
707
|
+
federatedId: sourceFederatedId,
|
|
708
|
+
platform: sourcePlatform,
|
|
709
|
+
forkedAt: now,
|
|
710
|
+
sourceVersionHash: sourceState?.versionHash
|
|
711
|
+
};
|
|
712
|
+
const forkState = {
|
|
713
|
+
localId: forkLocalId,
|
|
714
|
+
federatedId: forkFederatedId,
|
|
715
|
+
platformIds: { [targetPlatform]: forkLocalId },
|
|
716
|
+
lastSync: { [targetPlatform]: now },
|
|
717
|
+
versionHash: forkHash,
|
|
718
|
+
status: "synced",
|
|
719
|
+
forkedFrom: forkReference
|
|
720
|
+
};
|
|
721
|
+
await this.stateStore.set(forkState);
|
|
722
|
+
this.emit("card:forked", {
|
|
723
|
+
forkState,
|
|
724
|
+
sourceFederatedId,
|
|
725
|
+
sourcePlatform,
|
|
726
|
+
targetPlatform
|
|
727
|
+
});
|
|
728
|
+
return {
|
|
729
|
+
success: true,
|
|
730
|
+
operation,
|
|
731
|
+
forkState,
|
|
732
|
+
sourceFederatedId,
|
|
733
|
+
forkFederatedId
|
|
734
|
+
};
|
|
735
|
+
} catch (err) {
|
|
736
|
+
this.emit("sync:failed", { operation, error: err });
|
|
737
|
+
return {
|
|
738
|
+
success: false,
|
|
739
|
+
operation,
|
|
740
|
+
error: err instanceof Error ? err.message : String(err)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Create a forked card with fork metadata in extensions
|
|
746
|
+
*/
|
|
747
|
+
createForkedCard(source, sourceFederatedId, sourcePlatform, sourceVersionHash, modifications) {
|
|
748
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
749
|
+
const forked = JSON.parse(JSON.stringify(source));
|
|
750
|
+
if (modifications) {
|
|
751
|
+
Object.assign(forked.data, modifications);
|
|
752
|
+
}
|
|
753
|
+
if (!forked.data.extensions) {
|
|
754
|
+
forked.data.extensions = {};
|
|
755
|
+
}
|
|
756
|
+
const cfExtension = forked.data.extensions["character-foundry"] || {};
|
|
757
|
+
cfExtension.forkedFrom = {
|
|
758
|
+
federatedId: sourceFederatedId,
|
|
759
|
+
platform: sourcePlatform,
|
|
760
|
+
forkedAt: now,
|
|
761
|
+
sourceVersionHash
|
|
762
|
+
};
|
|
763
|
+
forked.data.extensions["character-foundry"] = cfExtension;
|
|
764
|
+
forked.data.character_version = generateUUID();
|
|
765
|
+
return forked;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Handle incoming fork notification
|
|
769
|
+
*
|
|
770
|
+
* Called when another instance notifies us that they forked one of our cards.
|
|
771
|
+
* Increments the fork count and stores the notification.
|
|
772
|
+
*/
|
|
773
|
+
async handleForkNotification(activity) {
|
|
774
|
+
const parsed = parseForkActivity(activity);
|
|
775
|
+
if (!parsed) {
|
|
776
|
+
throw new Error("Invalid fork activity");
|
|
777
|
+
}
|
|
778
|
+
const { sourceCardId, actor } = parsed;
|
|
779
|
+
const sourceState = await this.stateStore.get(sourceCardId);
|
|
780
|
+
if (!sourceState) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
let forkPlatform = "custom";
|
|
784
|
+
if (actor.includes("archive")) forkPlatform = "archive";
|
|
785
|
+
else if (actor.includes("hub")) forkPlatform = "hub";
|
|
786
|
+
else if (actor.includes("editor")) forkPlatform = "editor";
|
|
787
|
+
else if (actor.includes("chub")) forkPlatform = "chub";
|
|
788
|
+
else if (actor.includes("risu")) forkPlatform = "risu";
|
|
789
|
+
const notification = {
|
|
790
|
+
forkId: parsed.forkedCard.id,
|
|
791
|
+
actorId: actor,
|
|
792
|
+
platform: forkPlatform,
|
|
793
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
794
|
+
};
|
|
795
|
+
const notifications = sourceState.forkNotifications || [];
|
|
796
|
+
if (notifications.length < 100) {
|
|
797
|
+
notifications.push(notification);
|
|
798
|
+
}
|
|
799
|
+
sourceState.forksCount = (sourceState.forksCount || 0) + 1;
|
|
800
|
+
sourceState.forkNotifications = notifications;
|
|
801
|
+
await this.stateStore.set(sourceState);
|
|
802
|
+
this.emit("card:fork-received", {
|
|
803
|
+
sourceCardId,
|
|
804
|
+
notification,
|
|
805
|
+
newForkCount: sourceState.forksCount
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Handle incoming install notification
|
|
810
|
+
*
|
|
811
|
+
* Called when a consumer (SillyTavern, Voxta) notifies us that they installed one of our cards.
|
|
812
|
+
* Increments the install count and stores the notification.
|
|
813
|
+
*/
|
|
814
|
+
async handleInstallNotification(activity) {
|
|
815
|
+
const parsed = parseInstallActivity(activity);
|
|
816
|
+
if (!parsed) {
|
|
817
|
+
throw new Error("Invalid install activity");
|
|
818
|
+
}
|
|
819
|
+
const { cardId, actor, platform } = parsed;
|
|
820
|
+
const cardState = await this.stateStore.get(cardId);
|
|
821
|
+
if (!cardState) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const installPlatform = platform || "custom";
|
|
825
|
+
if (!cardState.stats) {
|
|
826
|
+
cardState.stats = {
|
|
827
|
+
installCount: 0,
|
|
828
|
+
installsByPlatform: {},
|
|
829
|
+
forkCount: cardState.forksCount || 0,
|
|
830
|
+
likeCount: 0,
|
|
831
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
cardState.stats.installCount++;
|
|
835
|
+
cardState.stats.installsByPlatform[installPlatform] = (cardState.stats.installsByPlatform[installPlatform] || 0) + 1;
|
|
836
|
+
cardState.stats.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
|
|
837
|
+
await this.stateStore.set(cardState);
|
|
838
|
+
this.emit("card:install-received", {
|
|
839
|
+
cardId,
|
|
840
|
+
platform: installPlatform,
|
|
841
|
+
actorId: actor,
|
|
842
|
+
newInstallCount: cardState.stats.installCount
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Get stats for a card
|
|
847
|
+
*/
|
|
848
|
+
async getCardStats(federatedId) {
|
|
849
|
+
const state = await this.stateStore.get(federatedId);
|
|
850
|
+
return state?.stats || null;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Get fork count for a card
|
|
854
|
+
*/
|
|
855
|
+
async getForkCount(federatedId) {
|
|
856
|
+
const state = await this.stateStore.get(federatedId);
|
|
857
|
+
return state?.forksCount || 0;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Get install count for a card
|
|
861
|
+
*/
|
|
862
|
+
async getInstallCount(federatedId) {
|
|
863
|
+
const state = await this.stateStore.get(federatedId);
|
|
864
|
+
return state?.stats?.installCount || 0;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Find all local forks of a source card
|
|
868
|
+
*/
|
|
869
|
+
async findForks(sourceFederatedId) {
|
|
870
|
+
const allStates = await this.stateStore.list();
|
|
871
|
+
return allStates.filter(
|
|
872
|
+
(state) => state.forkedFrom?.federatedId === sourceFederatedId
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Stop the sync engine
|
|
877
|
+
*/
|
|
878
|
+
dispose() {
|
|
879
|
+
if (this.autoSyncTimer) {
|
|
880
|
+
clearInterval(this.autoSyncTimer);
|
|
881
|
+
}
|
|
882
|
+
this.listeners.clear();
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
var MemorySyncStateStore = class {
|
|
886
|
+
states = /* @__PURE__ */ new Map();
|
|
887
|
+
async get(federatedId) {
|
|
888
|
+
return this.states.get(federatedId) || null;
|
|
889
|
+
}
|
|
890
|
+
async set(state) {
|
|
891
|
+
this.states.set(state.federatedId, { ...state });
|
|
892
|
+
}
|
|
893
|
+
async delete(federatedId) {
|
|
894
|
+
this.states.delete(federatedId);
|
|
895
|
+
}
|
|
896
|
+
async list() {
|
|
897
|
+
return Array.from(this.states.values());
|
|
898
|
+
}
|
|
899
|
+
async findByPlatformId(platform, platformId) {
|
|
900
|
+
for (const state of this.states.values()) {
|
|
901
|
+
if (state.platformIds[platform] === platformId) {
|
|
902
|
+
return state;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Clear all states (for testing)
|
|
909
|
+
*/
|
|
910
|
+
clear() {
|
|
911
|
+
this.states.clear();
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Increment fork count and add notification
|
|
915
|
+
*/
|
|
916
|
+
async incrementForkCount(federatedId, notification) {
|
|
917
|
+
const state = this.states.get(federatedId);
|
|
918
|
+
if (!state) return;
|
|
919
|
+
const notifications = state.forkNotifications || [];
|
|
920
|
+
if (notifications.length < 100) {
|
|
921
|
+
notifications.push(notification);
|
|
922
|
+
}
|
|
923
|
+
state.forksCount = (state.forksCount || 0) + 1;
|
|
924
|
+
state.forkNotifications = notifications;
|
|
925
|
+
this.states.set(federatedId, state);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Get fork count for a card
|
|
929
|
+
*/
|
|
930
|
+
async getForkCount(federatedId) {
|
|
931
|
+
return this.states.get(federatedId)?.forksCount || 0;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Find all cards that are forks of a given source card
|
|
935
|
+
*/
|
|
936
|
+
async findForks(sourceFederatedId) {
|
|
937
|
+
const forks = [];
|
|
938
|
+
for (const state of this.states.values()) {
|
|
939
|
+
if (state.forkedFrom?.federatedId === sourceFederatedId) {
|
|
940
|
+
forks.push(state);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return forks;
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
var FileSyncStateStore = class {
|
|
947
|
+
states = /* @__PURE__ */ new Map();
|
|
948
|
+
filePath;
|
|
949
|
+
saveDebounce;
|
|
950
|
+
fs;
|
|
951
|
+
constructor(filePath, fs) {
|
|
952
|
+
this.filePath = filePath;
|
|
953
|
+
this.fs = fs;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Load state from file
|
|
957
|
+
*/
|
|
958
|
+
async load() {
|
|
959
|
+
try {
|
|
960
|
+
const content = await this.fs.readFile(this.filePath, "utf-8");
|
|
961
|
+
const data = JSON.parse(content);
|
|
962
|
+
this.states.clear();
|
|
963
|
+
for (const state of data) {
|
|
964
|
+
this.states.set(state.federatedId, state);
|
|
965
|
+
}
|
|
966
|
+
} catch (err) {
|
|
967
|
+
this.states.clear();
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Save state to file (debounced)
|
|
972
|
+
*/
|
|
973
|
+
scheduleSave() {
|
|
974
|
+
if (this.saveDebounce) {
|
|
975
|
+
clearTimeout(this.saveDebounce);
|
|
976
|
+
}
|
|
977
|
+
this.saveDebounce = setTimeout(() => this.saveNow(), 1e3);
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Save state to file immediately
|
|
981
|
+
*/
|
|
982
|
+
async saveNow() {
|
|
983
|
+
const data = Array.from(this.states.values());
|
|
984
|
+
const json = JSON.stringify(data, null, 2);
|
|
985
|
+
const dir = this.filePath.substring(0, this.filePath.lastIndexOf("/"));
|
|
986
|
+
if (dir) {
|
|
987
|
+
await this.fs.mkdir(dir, { recursive: true });
|
|
988
|
+
}
|
|
989
|
+
await this.fs.writeFile(this.filePath, json);
|
|
990
|
+
}
|
|
991
|
+
async get(federatedId) {
|
|
992
|
+
return this.states.get(federatedId) || null;
|
|
993
|
+
}
|
|
994
|
+
async set(state) {
|
|
995
|
+
this.states.set(state.federatedId, { ...state });
|
|
996
|
+
this.scheduleSave();
|
|
997
|
+
}
|
|
998
|
+
async delete(federatedId) {
|
|
999
|
+
this.states.delete(federatedId);
|
|
1000
|
+
this.scheduleSave();
|
|
1001
|
+
}
|
|
1002
|
+
async list() {
|
|
1003
|
+
return Array.from(this.states.values());
|
|
1004
|
+
}
|
|
1005
|
+
async findByPlatformId(platform, platformId) {
|
|
1006
|
+
for (const state of this.states.values()) {
|
|
1007
|
+
if (state.platformIds[platform] === platformId) {
|
|
1008
|
+
return state;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Increment fork count and add notification
|
|
1015
|
+
*/
|
|
1016
|
+
async incrementForkCount(federatedId, notification) {
|
|
1017
|
+
const state = this.states.get(federatedId);
|
|
1018
|
+
if (!state) return;
|
|
1019
|
+
const notifications = state.forkNotifications || [];
|
|
1020
|
+
if (notifications.length < 100) {
|
|
1021
|
+
notifications.push(notification);
|
|
1022
|
+
}
|
|
1023
|
+
state.forksCount = (state.forksCount || 0) + 1;
|
|
1024
|
+
state.forkNotifications = notifications;
|
|
1025
|
+
this.states.set(federatedId, state);
|
|
1026
|
+
this.scheduleSave();
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Get fork count for a card
|
|
1030
|
+
*/
|
|
1031
|
+
async getForkCount(federatedId) {
|
|
1032
|
+
return this.states.get(federatedId)?.forksCount || 0;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Find all cards that are forks of a given source card
|
|
1036
|
+
*/
|
|
1037
|
+
async findForks(sourceFederatedId) {
|
|
1038
|
+
const forks = [];
|
|
1039
|
+
for (const state of this.states.values()) {
|
|
1040
|
+
if (state.forkedFrom?.federatedId === sourceFederatedId) {
|
|
1041
|
+
forks.push(state);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return forks;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
function createLocalStorageStore(key, storage) {
|
|
1048
|
+
return {
|
|
1049
|
+
async get(federatedId) {
|
|
1050
|
+
const data = storage.getItem(`${key}:${federatedId}`);
|
|
1051
|
+
return data ? JSON.parse(data) : null;
|
|
1052
|
+
},
|
|
1053
|
+
async set(state) {
|
|
1054
|
+
storage.setItem(`${key}:${state.federatedId}`, JSON.stringify(state));
|
|
1055
|
+
const indexKey = `${key}:__index__`;
|
|
1056
|
+
const index = JSON.parse(storage.getItem(indexKey) || "[]");
|
|
1057
|
+
if (!index.includes(state.federatedId)) {
|
|
1058
|
+
index.push(state.federatedId);
|
|
1059
|
+
storage.setItem(indexKey, JSON.stringify(index));
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
async delete(federatedId) {
|
|
1063
|
+
storage.removeItem(`${key}:${federatedId}`);
|
|
1064
|
+
const indexKey = `${key}:__index__`;
|
|
1065
|
+
const index = JSON.parse(storage.getItem(indexKey) || "[]");
|
|
1066
|
+
const newIndex = index.filter((id) => id !== federatedId);
|
|
1067
|
+
storage.setItem(indexKey, JSON.stringify(newIndex));
|
|
1068
|
+
},
|
|
1069
|
+
async list() {
|
|
1070
|
+
const indexKey = `${key}:__index__`;
|
|
1071
|
+
const index = JSON.parse(storage.getItem(indexKey) || "[]");
|
|
1072
|
+
const states = [];
|
|
1073
|
+
for (const id of index) {
|
|
1074
|
+
const data = storage.getItem(`${key}:${id}`);
|
|
1075
|
+
if (data) {
|
|
1076
|
+
states.push(JSON.parse(data));
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return states;
|
|
1080
|
+
},
|
|
1081
|
+
async findByPlatformId(platform, platformId) {
|
|
1082
|
+
const states = await this.list();
|
|
1083
|
+
return states.find((s) => s.platformIds[platform] === platformId) || null;
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
function validateTableName(name) {
|
|
1088
|
+
const validPattern = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
1089
|
+
if (!validPattern.test(name)) {
|
|
1090
|
+
throw new Error(
|
|
1091
|
+
`Invalid table name "${name}": must start with a letter and contain only alphanumeric characters and underscores`
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
if (name.length > 64) {
|
|
1095
|
+
throw new Error(`Invalid table name "${name}": must be 64 characters or less`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
var D1SyncStateStore = class {
|
|
1099
|
+
db;
|
|
1100
|
+
tableName;
|
|
1101
|
+
/**
|
|
1102
|
+
* Create a new D1SyncStateStore
|
|
1103
|
+
*
|
|
1104
|
+
* @param db - D1Database instance (from env.DB in Workers)
|
|
1105
|
+
* @param tableName - Table name for storing sync state (default: 'federation_sync_state')
|
|
1106
|
+
* @throws If tableName contains invalid characters (SQL injection prevention)
|
|
1107
|
+
*/
|
|
1108
|
+
constructor(db, tableName = "federation_sync_state") {
|
|
1109
|
+
validateTableName(tableName);
|
|
1110
|
+
this.db = db;
|
|
1111
|
+
this.tableName = tableName;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Initialize the database table
|
|
1115
|
+
*
|
|
1116
|
+
* Creates the sync state table if it doesn't exist.
|
|
1117
|
+
* Safe to call multiple times (idempotent).
|
|
1118
|
+
*/
|
|
1119
|
+
async init() {
|
|
1120
|
+
await this.db.exec(`
|
|
1121
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
1122
|
+
federated_id TEXT PRIMARY KEY,
|
|
1123
|
+
local_id TEXT NOT NULL,
|
|
1124
|
+
platform_ids TEXT NOT NULL,
|
|
1125
|
+
last_sync TEXT NOT NULL,
|
|
1126
|
+
version_hash TEXT NOT NULL,
|
|
1127
|
+
status TEXT NOT NULL CHECK (status IN ('synced', 'pending', 'conflict', 'error')),
|
|
1128
|
+
conflict TEXT,
|
|
1129
|
+
forked_from TEXT,
|
|
1130
|
+
forks_count INTEGER DEFAULT 0,
|
|
1131
|
+
fork_notifications TEXT,
|
|
1132
|
+
stats TEXT,
|
|
1133
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
1134
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
1135
|
+
)
|
|
1136
|
+
`);
|
|
1137
|
+
await this.db.exec(`
|
|
1138
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_local_id ON ${this.tableName}(local_id)
|
|
1139
|
+
`);
|
|
1140
|
+
await this.db.exec(`
|
|
1141
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_forked_from
|
|
1142
|
+
ON ${this.tableName}(json_extract(forked_from, '$.federatedId'))
|
|
1143
|
+
`);
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Get sync state for a federated card ID
|
|
1147
|
+
*/
|
|
1148
|
+
async get(federatedId) {
|
|
1149
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.tableName} WHERE federated_id = ?`).bind(federatedId).first();
|
|
1150
|
+
return row ? this.rowToState(row) : null;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Save or update sync state
|
|
1154
|
+
*/
|
|
1155
|
+
async set(state) {
|
|
1156
|
+
const platformIds = JSON.stringify(state.platformIds);
|
|
1157
|
+
const lastSync = JSON.stringify(state.lastSync);
|
|
1158
|
+
const conflict = state.conflict ? JSON.stringify(state.conflict) : null;
|
|
1159
|
+
const forkedFrom = state.forkedFrom ? JSON.stringify(state.forkedFrom) : null;
|
|
1160
|
+
const forkNotifications = state.forkNotifications ? JSON.stringify(state.forkNotifications) : null;
|
|
1161
|
+
const stats = state.stats ? JSON.stringify(state.stats) : null;
|
|
1162
|
+
await this.db.prepare(
|
|
1163
|
+
`INSERT INTO ${this.tableName} (federated_id, local_id, platform_ids, last_sync, version_hash, status, conflict, forked_from, forks_count, fork_notifications, stats, updated_at)
|
|
1164
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())
|
|
1165
|
+
ON CONFLICT(federated_id) DO UPDATE SET
|
|
1166
|
+
local_id = excluded.local_id,
|
|
1167
|
+
platform_ids = excluded.platform_ids,
|
|
1168
|
+
last_sync = excluded.last_sync,
|
|
1169
|
+
version_hash = excluded.version_hash,
|
|
1170
|
+
status = excluded.status,
|
|
1171
|
+
conflict = excluded.conflict,
|
|
1172
|
+
forked_from = excluded.forked_from,
|
|
1173
|
+
forks_count = excluded.forks_count,
|
|
1174
|
+
fork_notifications = excluded.fork_notifications,
|
|
1175
|
+
stats = excluded.stats,
|
|
1176
|
+
updated_at = unixepoch()`
|
|
1177
|
+
).bind(
|
|
1178
|
+
state.federatedId,
|
|
1179
|
+
state.localId,
|
|
1180
|
+
platformIds,
|
|
1181
|
+
lastSync,
|
|
1182
|
+
state.versionHash,
|
|
1183
|
+
state.status,
|
|
1184
|
+
conflict,
|
|
1185
|
+
forkedFrom,
|
|
1186
|
+
state.forksCount ?? 0,
|
|
1187
|
+
forkNotifications,
|
|
1188
|
+
stats
|
|
1189
|
+
).run();
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Delete sync state for a federated card ID
|
|
1193
|
+
*/
|
|
1194
|
+
async delete(federatedId) {
|
|
1195
|
+
await this.db.prepare(`DELETE FROM ${this.tableName} WHERE federated_id = ?`).bind(federatedId).run();
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* List all sync states
|
|
1199
|
+
*/
|
|
1200
|
+
async list() {
|
|
1201
|
+
const result = await this.db.prepare(`SELECT * FROM ${this.tableName} ORDER BY updated_at DESC`).all();
|
|
1202
|
+
return result.results.map((row) => this.rowToState(row));
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Find sync state by platform-specific ID
|
|
1206
|
+
*
|
|
1207
|
+
* @param platform - Platform identifier
|
|
1208
|
+
* @param platformId - Platform-specific card ID
|
|
1209
|
+
* @returns Sync state if found, null otherwise
|
|
1210
|
+
*/
|
|
1211
|
+
async findByPlatformId(platform, platformId) {
|
|
1212
|
+
const result = await this.db.prepare(
|
|
1213
|
+
`SELECT * FROM ${this.tableName}
|
|
1214
|
+
WHERE json_extract(platform_ids, ?) = ?`
|
|
1215
|
+
).bind(`$.${platform}`, platformId).first();
|
|
1216
|
+
return result ? this.rowToState(result) : null;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Find sync state by local ID
|
|
1220
|
+
*
|
|
1221
|
+
* @param localId - Local card ID
|
|
1222
|
+
* @returns Sync state if found, null otherwise
|
|
1223
|
+
*/
|
|
1224
|
+
async findByLocalId(localId) {
|
|
1225
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.tableName} WHERE local_id = ?`).bind(localId).first();
|
|
1226
|
+
return row ? this.rowToState(row) : null;
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Get count of all sync states
|
|
1230
|
+
*/
|
|
1231
|
+
async count() {
|
|
1232
|
+
const result = await this.db.prepare(`SELECT COUNT(*) as count FROM ${this.tableName}`).first();
|
|
1233
|
+
return result?.count ?? 0;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* List sync states by status
|
|
1237
|
+
*
|
|
1238
|
+
* @param status - Status to filter by
|
|
1239
|
+
* @returns Array of sync states with matching status
|
|
1240
|
+
*/
|
|
1241
|
+
async listByStatus(status) {
|
|
1242
|
+
const result = await this.db.prepare(
|
|
1243
|
+
`SELECT * FROM ${this.tableName} WHERE status = ? ORDER BY updated_at DESC`
|
|
1244
|
+
).bind(status).all();
|
|
1245
|
+
return result.results.map((row) => this.rowToState(row));
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Clear all sync states (for testing)
|
|
1249
|
+
*
|
|
1250
|
+
* ⚠️ Use with caution - this deletes all data
|
|
1251
|
+
*/
|
|
1252
|
+
async clear() {
|
|
1253
|
+
await this.db.prepare(`DELETE FROM ${this.tableName}`).run();
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Increment fork count and add notification for a source card
|
|
1257
|
+
*
|
|
1258
|
+
* Used when receiving a Fork activity to track that someone forked this card.
|
|
1259
|
+
* Notifications are capped at 100 to prevent unbounded growth.
|
|
1260
|
+
*/
|
|
1261
|
+
async incrementForkCount(federatedId, notification) {
|
|
1262
|
+
const state = await this.get(federatedId);
|
|
1263
|
+
if (!state) {
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const notifications = state.forkNotifications || [];
|
|
1267
|
+
if (notifications.length < 100) {
|
|
1268
|
+
notifications.push(notification);
|
|
1269
|
+
}
|
|
1270
|
+
state.forksCount = (state.forksCount || 0) + 1;
|
|
1271
|
+
state.forkNotifications = notifications;
|
|
1272
|
+
await this.set(state);
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Get fork count for a card
|
|
1276
|
+
*/
|
|
1277
|
+
async getForkCount(federatedId) {
|
|
1278
|
+
const result = await this.db.prepare(`SELECT forks_count FROM ${this.tableName} WHERE federated_id = ?`).bind(federatedId).first();
|
|
1279
|
+
return result?.forks_count ?? 0;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Find all cards that are forks of a given source card
|
|
1283
|
+
*/
|
|
1284
|
+
async findForks(sourceFederatedId) {
|
|
1285
|
+
const result = await this.db.prepare(
|
|
1286
|
+
`SELECT * FROM ${this.tableName}
|
|
1287
|
+
WHERE json_extract(forked_from, '$.federatedId') = ?
|
|
1288
|
+
ORDER BY updated_at DESC`
|
|
1289
|
+
).bind(sourceFederatedId).all();
|
|
1290
|
+
return result.results.map((row) => this.rowToState(row));
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Convert database row to CardSyncState
|
|
1294
|
+
*/
|
|
1295
|
+
rowToState(row) {
|
|
1296
|
+
const state = {
|
|
1297
|
+
federatedId: row.federated_id,
|
|
1298
|
+
localId: row.local_id,
|
|
1299
|
+
platformIds: JSON.parse(row.platform_ids),
|
|
1300
|
+
lastSync: JSON.parse(row.last_sync),
|
|
1301
|
+
versionHash: row.version_hash,
|
|
1302
|
+
status: row.status,
|
|
1303
|
+
conflict: row.conflict ? JSON.parse(row.conflict) : void 0
|
|
1304
|
+
};
|
|
1305
|
+
if (row.forked_from) {
|
|
1306
|
+
state.forkedFrom = JSON.parse(row.forked_from);
|
|
1307
|
+
}
|
|
1308
|
+
if (row.forks_count > 0) {
|
|
1309
|
+
state.forksCount = row.forks_count;
|
|
1310
|
+
}
|
|
1311
|
+
if (row.fork_notifications) {
|
|
1312
|
+
state.forkNotifications = JSON.parse(row.fork_notifications);
|
|
1313
|
+
}
|
|
1314
|
+
if (row.stats) {
|
|
1315
|
+
state.stats = JSON.parse(row.stats);
|
|
1316
|
+
}
|
|
1317
|
+
return state;
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
var BasePlatformAdapter = class {
|
|
1321
|
+
/**
|
|
1322
|
+
* Generate a new local ID using crypto-grade randomness
|
|
1323
|
+
*/
|
|
1324
|
+
generateId() {
|
|
1325
|
+
const timestamp = Date.now();
|
|
1326
|
+
const random = generateUUID().split("-")[0];
|
|
1327
|
+
return `${timestamp}-${random}`;
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
var MemoryPlatformAdapter = class extends BasePlatformAdapter {
|
|
1331
|
+
platform;
|
|
1332
|
+
displayName;
|
|
1333
|
+
cards = /* @__PURE__ */ new Map();
|
|
1334
|
+
assets = /* @__PURE__ */ new Map();
|
|
1335
|
+
constructor(platform = "custom", displayName = "Memory Store") {
|
|
1336
|
+
super();
|
|
1337
|
+
this.platform = platform;
|
|
1338
|
+
this.displayName = displayName;
|
|
1339
|
+
}
|
|
1340
|
+
async isAvailable() {
|
|
1341
|
+
return true;
|
|
1342
|
+
}
|
|
1343
|
+
async getCard(localId) {
|
|
1344
|
+
const entry = this.cards.get(localId);
|
|
1345
|
+
return entry?.card || null;
|
|
1346
|
+
}
|
|
1347
|
+
async listCards(options) {
|
|
1348
|
+
let cards = Array.from(this.cards.values());
|
|
1349
|
+
if (options?.since) {
|
|
1350
|
+
const sinceDate = new Date(options.since);
|
|
1351
|
+
cards = cards.filter((c) => new Date(c.updatedAt) > sinceDate);
|
|
1352
|
+
}
|
|
1353
|
+
cards.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
1354
|
+
const offset = options?.offset || 0;
|
|
1355
|
+
const limit = options?.limit || cards.length;
|
|
1356
|
+
cards = cards.slice(offset, offset + limit);
|
|
1357
|
+
return cards;
|
|
1358
|
+
}
|
|
1359
|
+
async saveCard(card, localId) {
|
|
1360
|
+
const id = localId || this.generateId();
|
|
1361
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1362
|
+
const existing = this.cards.get(id);
|
|
1363
|
+
this.cards.set(id, {
|
|
1364
|
+
id,
|
|
1365
|
+
card,
|
|
1366
|
+
updatedAt: now,
|
|
1367
|
+
createdAt: existing?.createdAt || now
|
|
1368
|
+
});
|
|
1369
|
+
return id;
|
|
1370
|
+
}
|
|
1371
|
+
async deleteCard(localId) {
|
|
1372
|
+
const existed = this.cards.has(localId);
|
|
1373
|
+
this.cards.delete(localId);
|
|
1374
|
+
this.assets.delete(localId);
|
|
1375
|
+
return existed;
|
|
1376
|
+
}
|
|
1377
|
+
async getAssets(localId) {
|
|
1378
|
+
return this.assets.get(localId) || [];
|
|
1379
|
+
}
|
|
1380
|
+
async getLastModified(localId) {
|
|
1381
|
+
const entry = this.cards.get(localId);
|
|
1382
|
+
return entry?.updatedAt || null;
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Set assets for a card (for testing)
|
|
1386
|
+
*/
|
|
1387
|
+
setAssets(localId, assets) {
|
|
1388
|
+
this.assets.set(localId, assets);
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Clear all data (for testing)
|
|
1392
|
+
*/
|
|
1393
|
+
clear() {
|
|
1394
|
+
this.cards.clear();
|
|
1395
|
+
this.assets.clear();
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Get card count
|
|
1399
|
+
*/
|
|
1400
|
+
count() {
|
|
1401
|
+
return this.cards.size;
|
|
1402
|
+
}
|
|
1403
|
+
};
|
|
1404
|
+
var InvalidResourceIdError = class extends Error {
|
|
1405
|
+
constructor(id, reason) {
|
|
1406
|
+
super(`Invalid resource ID "${id}": ${reason}`);
|
|
1407
|
+
this.id = id;
|
|
1408
|
+
this.reason = reason;
|
|
1409
|
+
this.name = "InvalidResourceIdError";
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
function validateAndEncodeId(id) {
|
|
1413
|
+
let decoded;
|
|
1414
|
+
try {
|
|
1415
|
+
decoded = decodeURIComponent(id);
|
|
1416
|
+
} catch {
|
|
1417
|
+
throw new InvalidResourceIdError(id, "invalid URL encoding");
|
|
1418
|
+
}
|
|
1419
|
+
if (decoded.includes("..")) {
|
|
1420
|
+
throw new InvalidResourceIdError(id, "path traversal detected");
|
|
1421
|
+
}
|
|
1422
|
+
if (decoded.startsWith("/")) {
|
|
1423
|
+
throw new InvalidResourceIdError(id, "absolute path not allowed");
|
|
1424
|
+
}
|
|
1425
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) {
|
|
1426
|
+
throw new InvalidResourceIdError(id, "protocol injection detected");
|
|
1427
|
+
}
|
|
1428
|
+
if (decoded.includes("\\")) {
|
|
1429
|
+
throw new InvalidResourceIdError(id, "backslash not allowed");
|
|
1430
|
+
}
|
|
1431
|
+
return encodeURIComponent(id);
|
|
1432
|
+
}
|
|
1433
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1434
|
+
var HttpPlatformAdapter = class extends BasePlatformAdapter {
|
|
1435
|
+
platform;
|
|
1436
|
+
displayName;
|
|
1437
|
+
config;
|
|
1438
|
+
fetchFn;
|
|
1439
|
+
timeoutMs;
|
|
1440
|
+
constructor(config) {
|
|
1441
|
+
super();
|
|
1442
|
+
this.platform = config.platform;
|
|
1443
|
+
this.displayName = config.displayName;
|
|
1444
|
+
this.config = config;
|
|
1445
|
+
this.fetchFn = config.fetch || globalThis.fetch.bind(globalThis);
|
|
1446
|
+
this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Execute fetch with timeout using AbortController
|
|
1450
|
+
*
|
|
1451
|
+
* @security Prevents hanging connections that could cause resource exhaustion
|
|
1452
|
+
*/
|
|
1453
|
+
async fetchWithTimeout(url, init) {
|
|
1454
|
+
const controller = new AbortController();
|
|
1455
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
1456
|
+
try {
|
|
1457
|
+
const response = await this.fetchFn(url, {
|
|
1458
|
+
...init,
|
|
1459
|
+
signal: controller.signal
|
|
1460
|
+
});
|
|
1461
|
+
return response;
|
|
1462
|
+
} finally {
|
|
1463
|
+
clearTimeout(timeoutId);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Build headers for requests
|
|
1468
|
+
*/
|
|
1469
|
+
buildHeaders(contentType) {
|
|
1470
|
+
const headers = {};
|
|
1471
|
+
if (contentType) {
|
|
1472
|
+
headers["Content-Type"] = contentType;
|
|
1473
|
+
}
|
|
1474
|
+
if (this.config.auth) {
|
|
1475
|
+
switch (this.config.auth.type) {
|
|
1476
|
+
case "bearer":
|
|
1477
|
+
headers["Authorization"] = `Bearer ${this.config.auth.token}`;
|
|
1478
|
+
break;
|
|
1479
|
+
case "api-key":
|
|
1480
|
+
headers[this.config.auth.header || "X-API-Key"] = this.config.auth.token;
|
|
1481
|
+
break;
|
|
1482
|
+
case "basic":
|
|
1483
|
+
headers["Authorization"] = `Basic ${this.config.auth.token}`;
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return headers;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Build full URL with optional resource ID.
|
|
1491
|
+
*
|
|
1492
|
+
* @security ID is validated and encoded to prevent SSRF/path traversal.
|
|
1493
|
+
* @throws InvalidResourceIdError if ID contains malicious patterns
|
|
1494
|
+
*/
|
|
1495
|
+
buildUrl(endpoint, id) {
|
|
1496
|
+
let url = `${this.config.baseUrl}${endpoint}`;
|
|
1497
|
+
if (id) {
|
|
1498
|
+
const safeId = validateAndEncodeId(id);
|
|
1499
|
+
url = `${url}/${safeId}`;
|
|
1500
|
+
}
|
|
1501
|
+
return url;
|
|
1502
|
+
}
|
|
1503
|
+
async isAvailable() {
|
|
1504
|
+
try {
|
|
1505
|
+
const endpoint = this.config.endpoints.health || this.config.endpoints.list;
|
|
1506
|
+
const response = await this.fetchWithTimeout(
|
|
1507
|
+
this.buildUrl(endpoint),
|
|
1508
|
+
{
|
|
1509
|
+
method: "GET",
|
|
1510
|
+
headers: this.buildHeaders()
|
|
1511
|
+
}
|
|
1512
|
+
);
|
|
1513
|
+
return response.ok;
|
|
1514
|
+
} catch {
|
|
1515
|
+
return false;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
async getCard(localId) {
|
|
1519
|
+
try {
|
|
1520
|
+
const response = await this.fetchWithTimeout(
|
|
1521
|
+
this.buildUrl(this.config.endpoints.get, localId),
|
|
1522
|
+
{
|
|
1523
|
+
method: "GET",
|
|
1524
|
+
headers: this.buildHeaders()
|
|
1525
|
+
}
|
|
1526
|
+
);
|
|
1527
|
+
if (!response.ok) {
|
|
1528
|
+
if (response.status === 404) return null;
|
|
1529
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1530
|
+
}
|
|
1531
|
+
const data = await response.json();
|
|
1532
|
+
return this.config.transformers?.get ? this.config.transformers.get(data) : data;
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
console.error(`Failed to get card ${localId}:`, err);
|
|
1535
|
+
return null;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
async listCards(options) {
|
|
1539
|
+
const url = new URL(this.buildUrl(this.config.endpoints.list));
|
|
1540
|
+
if (options?.limit) {
|
|
1541
|
+
url.searchParams.set("limit", String(options.limit));
|
|
1542
|
+
}
|
|
1543
|
+
if (options?.offset) {
|
|
1544
|
+
url.searchParams.set("offset", String(options.offset));
|
|
1545
|
+
}
|
|
1546
|
+
if (options?.since) {
|
|
1547
|
+
url.searchParams.set("since", options.since);
|
|
1548
|
+
}
|
|
1549
|
+
const response = await this.fetchWithTimeout(
|
|
1550
|
+
url.toString(),
|
|
1551
|
+
{
|
|
1552
|
+
method: "GET",
|
|
1553
|
+
headers: this.buildHeaders()
|
|
1554
|
+
}
|
|
1555
|
+
);
|
|
1556
|
+
if (!response.ok) {
|
|
1557
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1558
|
+
}
|
|
1559
|
+
const data = await response.json();
|
|
1560
|
+
return this.config.transformers?.list ? this.config.transformers.list(data) : data;
|
|
1561
|
+
}
|
|
1562
|
+
async saveCard(card, localId) {
|
|
1563
|
+
if (localId) {
|
|
1564
|
+
const body = this.config.transformers?.update ? this.config.transformers.update(card) : card;
|
|
1565
|
+
const response = await this.fetchWithTimeout(
|
|
1566
|
+
this.buildUrl(this.config.endpoints.update, localId),
|
|
1567
|
+
{
|
|
1568
|
+
method: "PUT",
|
|
1569
|
+
headers: this.buildHeaders("application/json"),
|
|
1570
|
+
body: JSON.stringify(body)
|
|
1571
|
+
}
|
|
1572
|
+
);
|
|
1573
|
+
if (!response.ok) {
|
|
1574
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1575
|
+
}
|
|
1576
|
+
return localId;
|
|
1577
|
+
} else {
|
|
1578
|
+
const body = this.config.transformers?.create ? this.config.transformers.create(card) : card;
|
|
1579
|
+
const response = await this.fetchWithTimeout(
|
|
1580
|
+
this.buildUrl(this.config.endpoints.create),
|
|
1581
|
+
{
|
|
1582
|
+
method: "POST",
|
|
1583
|
+
headers: this.buildHeaders("application/json"),
|
|
1584
|
+
body: JSON.stringify(body)
|
|
1585
|
+
}
|
|
1586
|
+
);
|
|
1587
|
+
if (!response.ok) {
|
|
1588
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1589
|
+
}
|
|
1590
|
+
const data = await response.json();
|
|
1591
|
+
return this.config.transformers?.extractId ? this.config.transformers.extractId(data) : data.id;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
async deleteCard(localId) {
|
|
1595
|
+
const response = await this.fetchWithTimeout(
|
|
1596
|
+
this.buildUrl(this.config.endpoints.delete, localId),
|
|
1597
|
+
{
|
|
1598
|
+
method: "DELETE",
|
|
1599
|
+
headers: this.buildHeaders()
|
|
1600
|
+
}
|
|
1601
|
+
);
|
|
1602
|
+
return response.ok;
|
|
1603
|
+
}
|
|
1604
|
+
async getAssets(localId) {
|
|
1605
|
+
if (!this.config.endpoints.assets) {
|
|
1606
|
+
return [];
|
|
1607
|
+
}
|
|
1608
|
+
try {
|
|
1609
|
+
const response = await this.fetchWithTimeout(
|
|
1610
|
+
this.buildUrl(this.config.endpoints.assets, localId),
|
|
1611
|
+
{
|
|
1612
|
+
method: "GET",
|
|
1613
|
+
headers: this.buildHeaders()
|
|
1614
|
+
}
|
|
1615
|
+
);
|
|
1616
|
+
if (!response.ok) {
|
|
1617
|
+
return [];
|
|
1618
|
+
}
|
|
1619
|
+
return await response.json();
|
|
1620
|
+
} catch {
|
|
1621
|
+
return [];
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
async getLastModified(localId) {
|
|
1625
|
+
try {
|
|
1626
|
+
const response = await this.fetchWithTimeout(
|
|
1627
|
+
this.buildUrl(this.config.endpoints.get, localId),
|
|
1628
|
+
{
|
|
1629
|
+
method: "HEAD",
|
|
1630
|
+
headers: this.buildHeaders()
|
|
1631
|
+
}
|
|
1632
|
+
);
|
|
1633
|
+
if (!response.ok) {
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
return response.headers.get("Last-Modified");
|
|
1637
|
+
} catch {
|
|
1638
|
+
return null;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
function createArchiveAdapter(baseUrl, apiKey) {
|
|
1643
|
+
return new HttpPlatformAdapter({
|
|
1644
|
+
platform: "archive",
|
|
1645
|
+
displayName: "Character Archive",
|
|
1646
|
+
baseUrl,
|
|
1647
|
+
endpoints: {
|
|
1648
|
+
list: "/api/characters",
|
|
1649
|
+
get: "/api/characters",
|
|
1650
|
+
create: "/api/characters",
|
|
1651
|
+
update: "/api/characters",
|
|
1652
|
+
delete: "/api/characters",
|
|
1653
|
+
assets: "/api/characters/assets",
|
|
1654
|
+
health: "/api/health"
|
|
1655
|
+
},
|
|
1656
|
+
auth: apiKey ? { type: "api-key", token: apiKey } : void 0,
|
|
1657
|
+
transformers: {
|
|
1658
|
+
list: (data) => {
|
|
1659
|
+
const response = data;
|
|
1660
|
+
return response.characters.map((c) => ({
|
|
1661
|
+
id: c.id,
|
|
1662
|
+
card: c.data,
|
|
1663
|
+
updatedAt: c.updatedAt
|
|
1664
|
+
}));
|
|
1665
|
+
},
|
|
1666
|
+
get: (data) => data.data,
|
|
1667
|
+
extractId: (data) => data.id
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
function createHubAdapter(baseUrl, apiKey) {
|
|
1672
|
+
return new HttpPlatformAdapter({
|
|
1673
|
+
platform: "hub",
|
|
1674
|
+
displayName: "CardsHub",
|
|
1675
|
+
baseUrl,
|
|
1676
|
+
endpoints: {
|
|
1677
|
+
list: "/api/cards",
|
|
1678
|
+
get: "/api/cards",
|
|
1679
|
+
create: "/api/cards",
|
|
1680
|
+
update: "/api/cards",
|
|
1681
|
+
delete: "/api/cards",
|
|
1682
|
+
assets: "/api/cards/assets",
|
|
1683
|
+
health: "/api/health"
|
|
1684
|
+
},
|
|
1685
|
+
auth: apiKey ? { type: "bearer", token: apiKey } : void 0
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
function stCharacterToCCv3(st) {
|
|
1689
|
+
return {
|
|
1690
|
+
spec: "chara_card_v3",
|
|
1691
|
+
spec_version: "3.0",
|
|
1692
|
+
data: {
|
|
1693
|
+
name: st.data.name,
|
|
1694
|
+
description: st.data.description,
|
|
1695
|
+
personality: st.data.personality,
|
|
1696
|
+
scenario: st.data.scenario,
|
|
1697
|
+
first_mes: st.data.first_mes,
|
|
1698
|
+
mes_example: st.data.mes_example,
|
|
1699
|
+
creator_notes: st.data.creator_notes || "",
|
|
1700
|
+
system_prompt: st.data.system_prompt || "",
|
|
1701
|
+
post_history_instructions: st.data.post_history_instructions || "",
|
|
1702
|
+
alternate_greetings: st.data.alternate_greetings || [],
|
|
1703
|
+
group_only_greetings: [],
|
|
1704
|
+
tags: st.data.tags || [],
|
|
1705
|
+
creator: st.data.creator || "",
|
|
1706
|
+
character_version: st.data.character_version || "",
|
|
1707
|
+
character_book: st.data.character_book,
|
|
1708
|
+
extensions: st.data.extensions || {}
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
function ccv3ToSTCharacter(card, filename) {
|
|
1713
|
+
const data = card.data;
|
|
1714
|
+
const name = filename || data.name.replace(/[^a-zA-Z0-9]/g, "_");
|
|
1715
|
+
return {
|
|
1716
|
+
name,
|
|
1717
|
+
avatar: `${name}.png`,
|
|
1718
|
+
data: {
|
|
1719
|
+
name: data.name,
|
|
1720
|
+
description: data.description,
|
|
1721
|
+
personality: data.personality ?? "",
|
|
1722
|
+
// Coerce null to empty string
|
|
1723
|
+
scenario: data.scenario,
|
|
1724
|
+
first_mes: data.first_mes,
|
|
1725
|
+
mes_example: data.mes_example ?? "",
|
|
1726
|
+
// Coerce null to empty string
|
|
1727
|
+
creator_notes: data.creator_notes,
|
|
1728
|
+
system_prompt: data.system_prompt,
|
|
1729
|
+
post_history_instructions: data.post_history_instructions,
|
|
1730
|
+
alternate_greetings: data.alternate_greetings,
|
|
1731
|
+
// Cast character_book - ST format is compatible but types differ slightly
|
|
1732
|
+
character_book: data.character_book,
|
|
1733
|
+
tags: data.tags,
|
|
1734
|
+
creator: data.creator,
|
|
1735
|
+
character_version: data.character_version,
|
|
1736
|
+
extensions: data.extensions
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
var SillyTavernAdapter = class extends BasePlatformAdapter {
|
|
1741
|
+
platform = "sillytavern";
|
|
1742
|
+
displayName = "SillyTavern";
|
|
1743
|
+
bridge;
|
|
1744
|
+
constructor(bridge) {
|
|
1745
|
+
super();
|
|
1746
|
+
this.bridge = bridge;
|
|
1747
|
+
}
|
|
1748
|
+
async isAvailable() {
|
|
1749
|
+
return this.bridge.isConnected();
|
|
1750
|
+
}
|
|
1751
|
+
async getCard(localId) {
|
|
1752
|
+
const st = await this.bridge.getCharacter(localId);
|
|
1753
|
+
if (!st) return null;
|
|
1754
|
+
return stCharacterToCCv3(st);
|
|
1755
|
+
}
|
|
1756
|
+
async listCards(options) {
|
|
1757
|
+
const characters = await this.bridge.getCharacters();
|
|
1758
|
+
let cards = characters.map((st) => ({
|
|
1759
|
+
id: st.name,
|
|
1760
|
+
card: stCharacterToCCv3(st),
|
|
1761
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1762
|
+
// ST doesn't track this
|
|
1763
|
+
}));
|
|
1764
|
+
const offset = options?.offset || 0;
|
|
1765
|
+
const limit = options?.limit || cards.length;
|
|
1766
|
+
cards = cards.slice(offset, offset + limit);
|
|
1767
|
+
return cards;
|
|
1768
|
+
}
|
|
1769
|
+
async saveCard(card, localId) {
|
|
1770
|
+
const stChar = ccv3ToSTCharacter(card, localId);
|
|
1771
|
+
return this.bridge.saveCharacter(stChar);
|
|
1772
|
+
}
|
|
1773
|
+
async deleteCard(localId) {
|
|
1774
|
+
return this.bridge.deleteCharacter(localId);
|
|
1775
|
+
}
|
|
1776
|
+
async getAssets(localId) {
|
|
1777
|
+
const avatar = await this.bridge.getAvatar(localId);
|
|
1778
|
+
if (!avatar) return [];
|
|
1779
|
+
return [{
|
|
1780
|
+
name: "avatar",
|
|
1781
|
+
type: "icon",
|
|
1782
|
+
data: avatar,
|
|
1783
|
+
mimeType: "image/png"
|
|
1784
|
+
}];
|
|
1785
|
+
}
|
|
1786
|
+
async getLastModified(localId) {
|
|
1787
|
+
const char = await this.bridge.getCharacter(localId);
|
|
1788
|
+
return char ? (/* @__PURE__ */ new Date()).toISOString() : null;
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Get stats for a character (if bridge supports it)
|
|
1792
|
+
*/
|
|
1793
|
+
async getStats(localId) {
|
|
1794
|
+
if (!this.bridge.getCharacterStats) {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
return this.bridge.getCharacterStats(localId);
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Get stats for all characters (if bridge supports it)
|
|
1801
|
+
*/
|
|
1802
|
+
async getAllStats() {
|
|
1803
|
+
if (!this.bridge.getAllStats) {
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
1806
|
+
return this.bridge.getAllStats();
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Notify hub about installation (if bridge supports it)
|
|
1810
|
+
*/
|
|
1811
|
+
async notifyInstall(federatedId, hubInbox) {
|
|
1812
|
+
if (!this.bridge.notifyInstall) {
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
await this.bridge.notifyInstall(federatedId, hubInbox);
|
|
1816
|
+
return true;
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
function createMockSTBridge() {
|
|
1820
|
+
const characters = /* @__PURE__ */ new Map();
|
|
1821
|
+
const avatars = /* @__PURE__ */ new Map();
|
|
1822
|
+
const stats = /* @__PURE__ */ new Map();
|
|
1823
|
+
const installNotifications = [];
|
|
1824
|
+
return {
|
|
1825
|
+
characters,
|
|
1826
|
+
avatars,
|
|
1827
|
+
stats,
|
|
1828
|
+
installNotifications,
|
|
1829
|
+
async getCharacters() {
|
|
1830
|
+
return Array.from(characters.values());
|
|
1831
|
+
},
|
|
1832
|
+
async getCharacter(name) {
|
|
1833
|
+
return characters.get(name) || null;
|
|
1834
|
+
},
|
|
1835
|
+
async saveCharacter(character) {
|
|
1836
|
+
characters.set(character.name, character);
|
|
1837
|
+
if (!stats.has(character.name)) {
|
|
1838
|
+
stats.set(character.name, {
|
|
1839
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1840
|
+
chatCount: 0,
|
|
1841
|
+
messageCount: 0
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
return character.name;
|
|
1845
|
+
},
|
|
1846
|
+
async deleteCharacter(name) {
|
|
1847
|
+
const existed = characters.has(name);
|
|
1848
|
+
characters.delete(name);
|
|
1849
|
+
avatars.delete(name);
|
|
1850
|
+
stats.delete(name);
|
|
1851
|
+
return existed;
|
|
1852
|
+
},
|
|
1853
|
+
async getAvatar(name) {
|
|
1854
|
+
return avatars.get(name) || null;
|
|
1855
|
+
},
|
|
1856
|
+
async isConnected() {
|
|
1857
|
+
return true;
|
|
1858
|
+
},
|
|
1859
|
+
async getCharacterStats(name) {
|
|
1860
|
+
return stats.get(name) || null;
|
|
1861
|
+
},
|
|
1862
|
+
async getAllStats() {
|
|
1863
|
+
return new Map(stats);
|
|
1864
|
+
},
|
|
1865
|
+
async notifyInstall(federatedId, hubInbox) {
|
|
1866
|
+
installNotifications.push({ federatedId, hubInbox });
|
|
1867
|
+
}
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
function handleWebFinger(resource, config) {
|
|
1871
|
+
assertFederationEnabled("handleWebFinger");
|
|
1872
|
+
if (!resource) return null;
|
|
1873
|
+
const { actor } = config;
|
|
1874
|
+
let domain;
|
|
1875
|
+
try {
|
|
1876
|
+
domain = new URL(actor.id).host;
|
|
1877
|
+
} catch {
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
const validResources = [
|
|
1881
|
+
`acct:${actor.preferredUsername}@${domain}`,
|
|
1882
|
+
actor.id
|
|
1883
|
+
];
|
|
1884
|
+
if (!validResources.includes(resource)) {
|
|
1885
|
+
return null;
|
|
1886
|
+
}
|
|
1887
|
+
return {
|
|
1888
|
+
subject: `acct:${actor.preferredUsername}@${domain}`,
|
|
1889
|
+
aliases: [actor.id],
|
|
1890
|
+
links: [
|
|
1891
|
+
{
|
|
1892
|
+
rel: "self",
|
|
1893
|
+
type: "application/activity+json",
|
|
1894
|
+
href: actor.id
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
rel: "http://webfinger.net/rel/profile-page",
|
|
1898
|
+
type: "text/html",
|
|
1899
|
+
href: actor.id
|
|
1900
|
+
// Often the actor ID resolves to a profile page in browsers
|
|
1901
|
+
}
|
|
1902
|
+
]
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
function handleNodeInfoDiscovery(baseUrl) {
|
|
1906
|
+
assertFederationEnabled("handleNodeInfoDiscovery");
|
|
1907
|
+
const origin = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
1908
|
+
return {
|
|
1909
|
+
links: [
|
|
1910
|
+
{
|
|
1911
|
+
rel: "http://nodeinfo.diaspora.foundation/ns/schema/2.0",
|
|
1912
|
+
href: `${origin}/api/federation/nodeinfo/2.0`
|
|
1913
|
+
},
|
|
1914
|
+
{
|
|
1915
|
+
rel: "http://nodeinfo.diaspora.foundation/ns/schema/2.1",
|
|
1916
|
+
href: `${origin}/api/federation/nodeinfo/2.1`
|
|
1917
|
+
}
|
|
1918
|
+
]
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
function handleNodeInfo(config, version = "2.1") {
|
|
1922
|
+
assertFederationEnabled("handleNodeInfo");
|
|
1923
|
+
return {
|
|
1924
|
+
version,
|
|
1925
|
+
software: {
|
|
1926
|
+
name: "character-foundry",
|
|
1927
|
+
version: "0.1.0"
|
|
1928
|
+
},
|
|
1929
|
+
protocols: [
|
|
1930
|
+
"activitypub"
|
|
1931
|
+
],
|
|
1932
|
+
services: {
|
|
1933
|
+
inbound: [],
|
|
1934
|
+
outbound: []
|
|
1935
|
+
},
|
|
1936
|
+
openRegistrations: false,
|
|
1937
|
+
usage: {
|
|
1938
|
+
users: {
|
|
1939
|
+
total: 1
|
|
1940
|
+
// TODO: Count actual users if multi-tenant
|
|
1941
|
+
}
|
|
1942
|
+
},
|
|
1943
|
+
metadata: {
|
|
1944
|
+
nodeName: config.actor.name,
|
|
1945
|
+
nodeDescription: config.actor.summary
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function handleActor(config) {
|
|
1950
|
+
assertFederationEnabled("handleActor");
|
|
1951
|
+
return {
|
|
1952
|
+
...config.actor,
|
|
1953
|
+
"@context": config.actor["@context"] || [
|
|
1954
|
+
"https://www.w3.org/ns/activitystreams",
|
|
1955
|
+
"https://w3id.org/security/v1"
|
|
1956
|
+
]
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
var MODERATION_ACTIVITY_CONTEXT = [
|
|
1960
|
+
"https://www.w3.org/ns/activitystreams",
|
|
1961
|
+
{
|
|
1962
|
+
moderation: "https://character-foundry.dev/ns/moderation#",
|
|
1963
|
+
category: { "@id": "moderation:category" }
|
|
1964
|
+
}
|
|
1965
|
+
];
|
|
1966
|
+
function createFlagActivity(reporterActorId, targetIds, baseUrl, options) {
|
|
1967
|
+
const targets = Array.isArray(targetIds) ? targetIds : [targetIds];
|
|
1968
|
+
return {
|
|
1969
|
+
"@context": [...MODERATION_ACTIVITY_CONTEXT],
|
|
1970
|
+
id: generateActivityId(baseUrl),
|
|
1971
|
+
type: "Flag",
|
|
1972
|
+
actor: reporterActorId,
|
|
1973
|
+
object: targets.length === 1 ? targets[0] : targets,
|
|
1974
|
+
content: options?.content,
|
|
1975
|
+
category: options?.category,
|
|
1976
|
+
published: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1977
|
+
to: options?.to
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
function parseFlagActivity(activity) {
|
|
1981
|
+
if (!activity || typeof activity !== "object") return null;
|
|
1982
|
+
const act = activity;
|
|
1983
|
+
if (act.type !== "Flag") return null;
|
|
1984
|
+
if (typeof act.actor !== "string") return null;
|
|
1985
|
+
if (typeof act.id !== "string") return null;
|
|
1986
|
+
if (!act.object) return null;
|
|
1987
|
+
const targets = Array.isArray(act.object) ? act.object.filter((t) => typeof t === "string") : typeof act.object === "string" ? [act.object] : [];
|
|
1988
|
+
if (targets.length === 0) return null;
|
|
1989
|
+
return {
|
|
1990
|
+
actorId: act.actor,
|
|
1991
|
+
targetIds: targets,
|
|
1992
|
+
content: typeof act.content === "string" ? act.content : void 0,
|
|
1993
|
+
category: typeof act.category === "string" ? act.category : void 0,
|
|
1994
|
+
activityId: act.id
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
function validateFlagActivity(activity) {
|
|
1998
|
+
const parsed = parseFlagActivity(activity);
|
|
1999
|
+
if (!parsed) {
|
|
2000
|
+
return {
|
|
2001
|
+
valid: false,
|
|
2002
|
+
error: "Invalid flag activity: missing required fields"
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
for (const target of parsed.targetIds) {
|
|
2006
|
+
try {
|
|
2007
|
+
new URL(target);
|
|
2008
|
+
} catch {
|
|
2009
|
+
return {
|
|
2010
|
+
valid: false,
|
|
2011
|
+
error: `Invalid flag activity: target "${target}" is not a valid URI`
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
if (parsed.content && parsed.content.length > 5e3) {
|
|
2016
|
+
return {
|
|
2017
|
+
valid: false,
|
|
2018
|
+
error: "Flag content too long (max 5000 chars)"
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
return { valid: true };
|
|
2022
|
+
}
|
|
2023
|
+
function createBlockActivity(adminActorId, blockedTarget, baseUrl, options) {
|
|
2024
|
+
return {
|
|
2025
|
+
"@context": [...MODERATION_ACTIVITY_CONTEXT],
|
|
2026
|
+
id: generateActivityId(baseUrl),
|
|
2027
|
+
type: "Block",
|
|
2028
|
+
actor: adminActorId,
|
|
2029
|
+
object: blockedTarget,
|
|
2030
|
+
summary: options?.summary,
|
|
2031
|
+
published: (/* @__PURE__ */ new Date()).toISOString()
|
|
2032
|
+
};
|
|
2033
|
+
}
|
|
2034
|
+
function parseBlockActivity(activity) {
|
|
2035
|
+
if (!activity || typeof activity !== "object") return null;
|
|
2036
|
+
const act = activity;
|
|
2037
|
+
if (act.type !== "Block") return null;
|
|
2038
|
+
if (typeof act.actor !== "string") return null;
|
|
2039
|
+
if (typeof act.object !== "string") return null;
|
|
2040
|
+
if (typeof act.id !== "string") return null;
|
|
2041
|
+
return {
|
|
2042
|
+
actorId: act.actor,
|
|
2043
|
+
targetId: act.object,
|
|
2044
|
+
summary: typeof act.summary === "string" ? act.summary : void 0,
|
|
2045
|
+
activityId: act.id
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
function validateBlockActivity(activity) {
|
|
2049
|
+
const parsed = parseBlockActivity(activity);
|
|
2050
|
+
if (!parsed) {
|
|
2051
|
+
return {
|
|
2052
|
+
valid: false,
|
|
2053
|
+
error: "Invalid block activity: missing required fields"
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
const target = parsed.targetId;
|
|
2057
|
+
const isDomain = /^[a-zA-Z0-9][a-zA-Z0-9-]*(\.[a-zA-Z0-9][a-zA-Z0-9-]*)+$/.test(target);
|
|
2058
|
+
let isValidUri = false;
|
|
2059
|
+
try {
|
|
2060
|
+
new URL(target);
|
|
2061
|
+
isValidUri = true;
|
|
2062
|
+
} catch {
|
|
2063
|
+
}
|
|
2064
|
+
if (!isDomain && !isValidUri) {
|
|
2065
|
+
return {
|
|
2066
|
+
valid: false,
|
|
2067
|
+
error: "Invalid block activity: target must be a domain or valid URI"
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
return { valid: true };
|
|
2071
|
+
}
|
|
2072
|
+
var REQUIRED_SIGNED_HEADERS = ["(request-target)", "host", "date"];
|
|
2073
|
+
function parseSignatureHeader(header) {
|
|
2074
|
+
const params = {};
|
|
2075
|
+
const regex = /(\w+)="([^"]+)"/g;
|
|
2076
|
+
let match;
|
|
2077
|
+
while ((match = regex.exec(header)) !== null) {
|
|
2078
|
+
params[match[1]] = match[2];
|
|
2079
|
+
}
|
|
2080
|
+
if (!params.keyId || !params.signature) {
|
|
2081
|
+
return null;
|
|
2082
|
+
}
|
|
2083
|
+
return {
|
|
2084
|
+
keyId: params.keyId,
|
|
2085
|
+
algorithm: params.algorithm || "rsa-sha256",
|
|
2086
|
+
headers: (params.headers || "(request-target) host date").split(" "),
|
|
2087
|
+
signature: params.signature
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
function buildSigningString(method, path, headers, headerNames) {
|
|
2091
|
+
const result = buildSigningStringStrict(method, path, headers, headerNames);
|
|
2092
|
+
if (!result.success) {
|
|
2093
|
+
console.warn(`[federation] Signature verification may fail: ${result.error}`);
|
|
2094
|
+
const lines = [];
|
|
2095
|
+
for (const name of headerNames) {
|
|
2096
|
+
if (name === "(request-target)") {
|
|
2097
|
+
lines.push(`(request-target): ${method.toLowerCase()} ${path}`);
|
|
2098
|
+
} else if (name === "(created)" || name === "(expires)") {
|
|
2099
|
+
} else {
|
|
2100
|
+
const value = headers.get(name);
|
|
2101
|
+
if (value !== null) {
|
|
2102
|
+
lines.push(`${name.toLowerCase()}: ${value}`);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
return lines.join("\n");
|
|
2107
|
+
}
|
|
2108
|
+
return result.signingString;
|
|
2109
|
+
}
|
|
2110
|
+
function buildSigningStringStrict(method, path, headers, headerNames) {
|
|
2111
|
+
const lines = [];
|
|
2112
|
+
const missingHeaders = [];
|
|
2113
|
+
const syntheticHeaders = /* @__PURE__ */ new Set(["(request-target)", "(created)", "(expires)"]);
|
|
2114
|
+
for (const name of headerNames) {
|
|
2115
|
+
if (name === "(request-target)") {
|
|
2116
|
+
lines.push(`(request-target): ${method.toLowerCase()} ${path}`);
|
|
2117
|
+
} else if (name === "(created)") {
|
|
2118
|
+
} else if (name === "(expires)") {
|
|
2119
|
+
} else {
|
|
2120
|
+
const value = headers.get(name);
|
|
2121
|
+
if (value !== null) {
|
|
2122
|
+
lines.push(`${name.toLowerCase()}: ${value}`);
|
|
2123
|
+
} else if (!syntheticHeaders.has(name)) {
|
|
2124
|
+
missingHeaders.push(name);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
if (missingHeaders.length > 0) {
|
|
2129
|
+
return {
|
|
2130
|
+
success: false,
|
|
2131
|
+
error: `Signature claims to sign headers that are missing from request: ${missingHeaders.join(", ")}`,
|
|
2132
|
+
missingHeaders
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
return {
|
|
2136
|
+
success: true,
|
|
2137
|
+
signingString: lines.join("\n")
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
async function verifyHttpSignature(parsed, publicKeyPem, method, path, headers, options = {}) {
|
|
2141
|
+
try {
|
|
2142
|
+
if (options.strictHeaders) {
|
|
2143
|
+
const strictResult = buildSigningStringStrict(method, path, headers, parsed.headers);
|
|
2144
|
+
if (!strictResult.success) {
|
|
2145
|
+
console.warn(`[federation] Strict header verification failed: ${strictResult.error}`);
|
|
2146
|
+
return false;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
const signingString = buildSigningString(method, path, headers, parsed.headers);
|
|
2150
|
+
const publicKey = await importPublicKey(publicKeyPem);
|
|
2151
|
+
if (!publicKey) {
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
const signatureBytes = base64ToArrayBuffer(parsed.signature);
|
|
2155
|
+
const encoder = new TextEncoder();
|
|
2156
|
+
const data = encoder.encode(signingString);
|
|
2157
|
+
const algorithm = parsed.algorithm === "hs2019" ? "RSASSA-PKCS1-v1_5" : "RSASSA-PKCS1-v1_5";
|
|
2158
|
+
return await crypto.subtle.verify(
|
|
2159
|
+
{ name: algorithm, hash: "SHA-256" },
|
|
2160
|
+
publicKey,
|
|
2161
|
+
signatureBytes,
|
|
2162
|
+
data
|
|
2163
|
+
);
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
console.error("Signature verification failed:", error);
|
|
2166
|
+
return false;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
async function validateActivitySignature(activity, headers, options) {
|
|
2170
|
+
const signatureHeader = headers.get("Signature");
|
|
2171
|
+
if (!signatureHeader) {
|
|
2172
|
+
return { valid: false, error: "Missing Signature header" };
|
|
2173
|
+
}
|
|
2174
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
2175
|
+
if (!parsed) {
|
|
2176
|
+
return { valid: false, error: "Invalid Signature header format" };
|
|
2177
|
+
}
|
|
2178
|
+
if (options.strictMode) {
|
|
2179
|
+
const missingHeaders = REQUIRED_SIGNED_HEADERS.filter(
|
|
2180
|
+
(h) => !parsed.headers.includes(h)
|
|
2181
|
+
);
|
|
2182
|
+
if (missingHeaders.length > 0) {
|
|
2183
|
+
return {
|
|
2184
|
+
valid: false,
|
|
2185
|
+
error: `Strict mode: signature missing required headers: ${missingHeaders.join(", ")}`
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
if (!headers.get("Date")) {
|
|
2189
|
+
return { valid: false, error: "Strict mode: Date header required" };
|
|
2190
|
+
}
|
|
2191
|
+
if (!headers.get("host") && !headers.get("Host")) {
|
|
2192
|
+
return { valid: false, error: "Strict mode: host header required" };
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
const dateHeader = headers.get("Date");
|
|
2196
|
+
if (dateHeader) {
|
|
2197
|
+
const requestDate = new Date(dateHeader);
|
|
2198
|
+
const maxAge = options.maxAge ?? 300;
|
|
2199
|
+
const now = Date.now();
|
|
2200
|
+
const requestTime = requestDate.getTime();
|
|
2201
|
+
if (isNaN(requestTime)) {
|
|
2202
|
+
return { valid: false, error: "Invalid Date header" };
|
|
2203
|
+
}
|
|
2204
|
+
if (Math.abs(now - requestTime) > maxAge * 1e3) {
|
|
2205
|
+
return { valid: false, error: "Request too old or in future" };
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
let keyIdBase;
|
|
2209
|
+
let actorBase;
|
|
2210
|
+
try {
|
|
2211
|
+
const keyIdUrl = new URL(parsed.keyId);
|
|
2212
|
+
const actorUrl = new URL(activity.actor);
|
|
2213
|
+
keyIdBase = `${keyIdUrl.origin}${keyIdUrl.pathname}`;
|
|
2214
|
+
actorBase = `${actorUrl.origin}${actorUrl.pathname}`;
|
|
2215
|
+
} catch {
|
|
2216
|
+
return { valid: false, error: "Invalid key ID or actor URL" };
|
|
2217
|
+
}
|
|
2218
|
+
if (keyIdBase !== actorBase) {
|
|
2219
|
+
return { valid: false, error: "Key ID does not match activity actor" };
|
|
2220
|
+
}
|
|
2221
|
+
const actorId = keyIdBase;
|
|
2222
|
+
const actor = await options.fetchActor(actorId);
|
|
2223
|
+
if (!actor) {
|
|
2224
|
+
return { valid: false, error: "Could not fetch actor" };
|
|
2225
|
+
}
|
|
2226
|
+
if (!actor.publicKey?.publicKeyPem) {
|
|
2227
|
+
return { valid: false, error: "Actor has no public key" };
|
|
2228
|
+
}
|
|
2229
|
+
if (actor.publicKey.id !== parsed.keyId) {
|
|
2230
|
+
return { valid: false, error: "Key ID mismatch" };
|
|
2231
|
+
}
|
|
2232
|
+
const valid = await verifyHttpSignature(
|
|
2233
|
+
parsed,
|
|
2234
|
+
actor.publicKey.publicKeyPem,
|
|
2235
|
+
options.method,
|
|
2236
|
+
options.path,
|
|
2237
|
+
headers,
|
|
2238
|
+
{ strictHeaders: options.strictMode }
|
|
2239
|
+
);
|
|
2240
|
+
if (!valid) {
|
|
2241
|
+
return { valid: false, error: "Invalid signature" };
|
|
2242
|
+
}
|
|
2243
|
+
return { valid: true, actor, keyId: parsed.keyId };
|
|
2244
|
+
}
|
|
2245
|
+
async function signRequest(options) {
|
|
2246
|
+
if (!options.host) {
|
|
2247
|
+
throw new Error("signRequest requires options.host - cannot generate valid signature without it");
|
|
2248
|
+
}
|
|
2249
|
+
const now = /* @__PURE__ */ new Date();
|
|
2250
|
+
const dateString = now.toUTCString();
|
|
2251
|
+
const headersToSign = ["(request-target)", "host", "date"];
|
|
2252
|
+
const headerValues = new Headers();
|
|
2253
|
+
headerValues.set("date", dateString);
|
|
2254
|
+
headerValues.set("host", options.host);
|
|
2255
|
+
if (options.digest) {
|
|
2256
|
+
headersToSign.push("digest");
|
|
2257
|
+
headerValues.set("digest", options.digest);
|
|
2258
|
+
}
|
|
2259
|
+
if (options.contentType) {
|
|
2260
|
+
headersToSign.push("content-type");
|
|
2261
|
+
headerValues.set("content-type", options.contentType);
|
|
2262
|
+
}
|
|
2263
|
+
const signingString = buildSigningString(
|
|
2264
|
+
options.method,
|
|
2265
|
+
options.path,
|
|
2266
|
+
headerValues,
|
|
2267
|
+
headersToSign
|
|
2268
|
+
);
|
|
2269
|
+
const privateKey = await importPrivateKey(options.privateKeyPem);
|
|
2270
|
+
if (!privateKey) {
|
|
2271
|
+
throw new Error("Failed to import private key");
|
|
2272
|
+
}
|
|
2273
|
+
const encoder = new TextEncoder();
|
|
2274
|
+
const data = encoder.encode(signingString);
|
|
2275
|
+
const signatureBuffer = await crypto.subtle.sign(
|
|
2276
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
2277
|
+
privateKey,
|
|
2278
|
+
data
|
|
2279
|
+
);
|
|
2280
|
+
const signatureBase64 = arrayBufferToBase64(signatureBuffer);
|
|
2281
|
+
const signatureHeader = `keyId="${options.keyId}",algorithm="rsa-sha256",headers="${headersToSign.join(" ")}",signature="${signatureBase64}"`;
|
|
2282
|
+
return { signature: signatureHeader, date: dateString, host: options.host };
|
|
2283
|
+
}
|
|
2284
|
+
async function calculateDigest(body) {
|
|
2285
|
+
const encoder = new TextEncoder();
|
|
2286
|
+
const data = typeof body === "string" ? encoder.encode(body) : body;
|
|
2287
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
2288
|
+
const hashBase64 = arrayBufferToBase64(hashBuffer);
|
|
2289
|
+
return `SHA-256=${hashBase64}`;
|
|
2290
|
+
}
|
|
2291
|
+
async function importPublicKey(pem) {
|
|
2292
|
+
try {
|
|
2293
|
+
const pemContents = pem.replace(/-----BEGIN PUBLIC KEY-----/, "").replace(/-----END PUBLIC KEY-----/, "").replace(/-----BEGIN RSA PUBLIC KEY-----/, "").replace(/-----END RSA PUBLIC KEY-----/, "").replace(/\s/g, "");
|
|
2294
|
+
const binaryDer = base64ToArrayBuffer(pemContents);
|
|
2295
|
+
return await crypto.subtle.importKey(
|
|
2296
|
+
"spki",
|
|
2297
|
+
binaryDer,
|
|
2298
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
2299
|
+
false,
|
|
2300
|
+
["verify"]
|
|
2301
|
+
);
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
console.error("Failed to import public key:", error);
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
async function importPrivateKey(pem) {
|
|
2308
|
+
try {
|
|
2309
|
+
const pemContents = pem.replace(/-----BEGIN PRIVATE KEY-----/, "").replace(/-----END PRIVATE KEY-----/, "").replace(/-----BEGIN RSA PRIVATE KEY-----/, "").replace(/-----END RSA PRIVATE KEY-----/, "").replace(/\s/g, "");
|
|
2310
|
+
const binaryDer = base64ToArrayBuffer(pemContents);
|
|
2311
|
+
return await crypto.subtle.importKey(
|
|
2312
|
+
"pkcs8",
|
|
2313
|
+
binaryDer,
|
|
2314
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
2315
|
+
false,
|
|
2316
|
+
["sign"]
|
|
2317
|
+
);
|
|
2318
|
+
} catch (error) {
|
|
2319
|
+
console.error("Failed to import private key:", error);
|
|
2320
|
+
return null;
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
function base64ToArrayBuffer(base64) {
|
|
2324
|
+
const normalized = base64.replace(/-/g, "+").replace(/_/g, "/");
|
|
2325
|
+
const binary = atob(normalized);
|
|
2326
|
+
const bytes = new Uint8Array(binary.length);
|
|
2327
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2328
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2329
|
+
}
|
|
2330
|
+
return bytes.buffer;
|
|
2331
|
+
}
|
|
2332
|
+
function arrayBufferToBase64(buffer) {
|
|
2333
|
+
const bytes = new Uint8Array(buffer);
|
|
2334
|
+
let binary = "";
|
|
2335
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2336
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2337
|
+
}
|
|
2338
|
+
return btoa(binary);
|
|
2339
|
+
}
|
|
2340
|
+
function extractHostFromActorId(actorId) {
|
|
2341
|
+
try {
|
|
2342
|
+
const url = new URL(actorId);
|
|
2343
|
+
return url.host;
|
|
2344
|
+
} catch {
|
|
2345
|
+
return null;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
async function handleInbox(body, headers, options) {
|
|
2349
|
+
assertFederationEnabled("handleInbox");
|
|
2350
|
+
try {
|
|
2351
|
+
if (options.moderationStore) {
|
|
2352
|
+
const actorId = typeof body === "object" && body !== null && "actor" in body ? String(body.actor) : null;
|
|
2353
|
+
if (actorId) {
|
|
2354
|
+
const host = extractHostFromActorId(actorId);
|
|
2355
|
+
if (host && await options.moderationStore.isInstanceBlocked(host)) {
|
|
2356
|
+
return {
|
|
2357
|
+
accepted: false,
|
|
2358
|
+
error: `Instance ${host} is blocked`
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
let activity;
|
|
2364
|
+
try {
|
|
2365
|
+
activity = parseActivity(body);
|
|
2366
|
+
} catch (err) {
|
|
2367
|
+
return {
|
|
2368
|
+
accepted: false,
|
|
2369
|
+
error: `Invalid activity: ${err instanceof Error ? err.message : String(err)}`
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
if (options.strictMode) {
|
|
2373
|
+
const signatureHeader = headers instanceof Headers ? headers.get("signature") : headers["signature"] || headers["Signature"];
|
|
2374
|
+
if (!signatureHeader) {
|
|
2375
|
+
return {
|
|
2376
|
+
accepted: false,
|
|
2377
|
+
error: "Missing Signature header (required in strict mode)"
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
const parsedSig = parseSignatureHeader(signatureHeader);
|
|
2381
|
+
if (!parsedSig) {
|
|
2382
|
+
return {
|
|
2383
|
+
accepted: false,
|
|
2384
|
+
error: "Invalid Signature header format"
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
const missingSignedHeaders = REQUIRED_SIGNED_HEADERS.filter(
|
|
2388
|
+
(h) => !parsedSig.headers.includes(h)
|
|
2389
|
+
);
|
|
2390
|
+
if (missingSignedHeaders.length > 0) {
|
|
2391
|
+
return {
|
|
2392
|
+
accepted: false,
|
|
2393
|
+
error: `Strict mode: signature missing required headers: ${missingSignedHeaders.join(", ")}`
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
const dateHeader = headers instanceof Headers ? headers.get("date") : headers["date"] || headers["Date"];
|
|
2397
|
+
if (!dateHeader) {
|
|
2398
|
+
return {
|
|
2399
|
+
accepted: false,
|
|
2400
|
+
error: "Strict mode: Date header required"
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
const hostHeader = headers instanceof Headers ? headers.get("host") : headers["host"] || headers["Host"];
|
|
2404
|
+
if (!hostHeader) {
|
|
2405
|
+
return {
|
|
2406
|
+
accepted: false,
|
|
2407
|
+
error: "Strict mode: host header required"
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
const actor = await options.fetchActor(activity.actor);
|
|
2411
|
+
if (!actor) {
|
|
2412
|
+
return {
|
|
2413
|
+
accepted: false,
|
|
2414
|
+
error: `Unknown actor: ${activity.actor}`
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
if (!actor.publicKey?.publicKeyPem) {
|
|
2418
|
+
return {
|
|
2419
|
+
accepted: false,
|
|
2420
|
+
error: `Actor ${activity.actor} has no public key`
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
try {
|
|
2424
|
+
const keyIdUrl = new URL(parsedSig.keyId);
|
|
2425
|
+
const actorUrl = new URL(activity.actor);
|
|
2426
|
+
const keyIdBase = `${keyIdUrl.origin}${keyIdUrl.pathname}`;
|
|
2427
|
+
const actorBase = `${actorUrl.origin}${actorUrl.pathname}`;
|
|
2428
|
+
if (keyIdBase !== actorBase) {
|
|
2429
|
+
return {
|
|
2430
|
+
accepted: false,
|
|
2431
|
+
error: `Key ID ${parsedSig.keyId} does not match actor ${activity.actor}`
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
if (actor.publicKey.id !== parsedSig.keyId) {
|
|
2435
|
+
return {
|
|
2436
|
+
accepted: false,
|
|
2437
|
+
error: `Actor's key ID ${actor.publicKey.id} does not match signature key ID ${parsedSig.keyId}`
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
} catch {
|
|
2441
|
+
return {
|
|
2442
|
+
accepted: false,
|
|
2443
|
+
error: `Invalid key ID or actor URL`
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
const digestHeader = headers instanceof Headers ? headers.get("digest") : headers["digest"] || headers["Digest"];
|
|
2447
|
+
if (digestHeader) {
|
|
2448
|
+
if (!options.rawBody) {
|
|
2449
|
+
return {
|
|
2450
|
+
accepted: false,
|
|
2451
|
+
error: "Digest header present but rawBody not provided for verification"
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
const digestMatch = digestHeader.match(/^(SHA-256|sha-256)=(.+)$/i);
|
|
2455
|
+
if (!digestMatch) {
|
|
2456
|
+
return {
|
|
2457
|
+
accepted: false,
|
|
2458
|
+
error: "Invalid Digest header format (expected SHA-256=...)"
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
const expectedDigest = await calculateDigest(options.rawBody);
|
|
2462
|
+
const actualDigest = `SHA-256=${digestMatch[2]}`;
|
|
2463
|
+
if (expectedDigest !== actualDigest) {
|
|
2464
|
+
return {
|
|
2465
|
+
accepted: false,
|
|
2466
|
+
error: "Digest mismatch - body may have been tampered with"
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
} else if (parsedSig.headers.includes("digest")) {
|
|
2470
|
+
return {
|
|
2471
|
+
accepted: false,
|
|
2472
|
+
error: "Signature includes digest but no Digest header present"
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
const method = options.method || "POST";
|
|
2476
|
+
const path = options.path || "/inbox";
|
|
2477
|
+
const normalizedHeaders = headers instanceof Headers ? headers : new Headers(headers);
|
|
2478
|
+
const isValid = await verifyHttpSignature(
|
|
2479
|
+
parsedSig,
|
|
2480
|
+
actor.publicKey.publicKeyPem,
|
|
2481
|
+
method,
|
|
2482
|
+
path,
|
|
2483
|
+
normalizedHeaders
|
|
2484
|
+
);
|
|
2485
|
+
if (!isValid) {
|
|
2486
|
+
return {
|
|
2487
|
+
accepted: false,
|
|
2488
|
+
error: "Invalid HTTP signature"
|
|
2489
|
+
};
|
|
2490
|
+
}
|
|
2491
|
+
const effectiveMaxAge = options.maxAge ?? 300;
|
|
2492
|
+
const requestDate = new Date(dateHeader);
|
|
2493
|
+
const now = /* @__PURE__ */ new Date();
|
|
2494
|
+
const ageMs = Math.abs(now.getTime() - requestDate.getTime());
|
|
2495
|
+
if (isNaN(requestDate.getTime())) {
|
|
2496
|
+
return {
|
|
2497
|
+
accepted: false,
|
|
2498
|
+
error: "Invalid Date header format"
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2501
|
+
if (ageMs > effectiveMaxAge * 1e3) {
|
|
2502
|
+
return {
|
|
2503
|
+
accepted: false,
|
|
2504
|
+
error: `Request too old or in future (${Math.round(ageMs / 1e3)}s, max ${effectiveMaxAge}s)`
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
const activityType = activity.type;
|
|
2509
|
+
switch (activityType) {
|
|
2510
|
+
case "Fork": {
|
|
2511
|
+
if (options.onFork) {
|
|
2512
|
+
const forkActivity = activity;
|
|
2513
|
+
await options.onFork(forkActivity);
|
|
2514
|
+
}
|
|
2515
|
+
return { accepted: true, activityType: "Fork" };
|
|
2516
|
+
}
|
|
2517
|
+
case "Install": {
|
|
2518
|
+
if (options.onInstall) {
|
|
2519
|
+
const installActivity = activity;
|
|
2520
|
+
await options.onInstall(installActivity);
|
|
2521
|
+
}
|
|
2522
|
+
return { accepted: true, activityType: "Install" };
|
|
2523
|
+
}
|
|
2524
|
+
case "Create": {
|
|
2525
|
+
if (options.onCreate) {
|
|
2526
|
+
await options.onCreate(activity);
|
|
2527
|
+
}
|
|
2528
|
+
return { accepted: true, activityType: "Create" };
|
|
2529
|
+
}
|
|
2530
|
+
case "Update": {
|
|
2531
|
+
if (options.onUpdate) {
|
|
2532
|
+
await options.onUpdate(activity);
|
|
2533
|
+
}
|
|
2534
|
+
return { accepted: true, activityType: "Update" };
|
|
2535
|
+
}
|
|
2536
|
+
case "Delete": {
|
|
2537
|
+
if (options.onDelete) {
|
|
2538
|
+
await options.onDelete(activity);
|
|
2539
|
+
}
|
|
2540
|
+
return { accepted: true, activityType: "Delete" };
|
|
2541
|
+
}
|
|
2542
|
+
case "Like": {
|
|
2543
|
+
if (options.onLike) {
|
|
2544
|
+
await options.onLike(activity);
|
|
2545
|
+
}
|
|
2546
|
+
return { accepted: true, activityType: "Like" };
|
|
2547
|
+
}
|
|
2548
|
+
case "Announce": {
|
|
2549
|
+
if (options.onAnnounce) {
|
|
2550
|
+
await options.onAnnounce(activity);
|
|
2551
|
+
}
|
|
2552
|
+
return { accepted: true, activityType: "Announce" };
|
|
2553
|
+
}
|
|
2554
|
+
case "Undo": {
|
|
2555
|
+
if (options.onUndo) {
|
|
2556
|
+
await options.onUndo(activity);
|
|
2557
|
+
}
|
|
2558
|
+
return { accepted: true, activityType: "Undo" };
|
|
2559
|
+
}
|
|
2560
|
+
case "Flag": {
|
|
2561
|
+
const parsed = parseFlagActivity(activity);
|
|
2562
|
+
if (options.onFlag && parsed) {
|
|
2563
|
+
await options.onFlag(activity, parsed);
|
|
2564
|
+
}
|
|
2565
|
+
return { accepted: true, activityType: "Flag" };
|
|
2566
|
+
}
|
|
2567
|
+
case "Block": {
|
|
2568
|
+
const parsed = parseBlockActivity(activity);
|
|
2569
|
+
if (options.onBlock && parsed) {
|
|
2570
|
+
await options.onBlock(activity, parsed);
|
|
2571
|
+
}
|
|
2572
|
+
return { accepted: true, activityType: "Block" };
|
|
2573
|
+
}
|
|
2574
|
+
default: {
|
|
2575
|
+
return {
|
|
2576
|
+
accepted: true,
|
|
2577
|
+
activityType
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
} catch (err) {
|
|
2582
|
+
return {
|
|
2583
|
+
accepted: false,
|
|
2584
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
function validateForkActivity(activity) {
|
|
2589
|
+
const parsed = parseForkActivity(activity);
|
|
2590
|
+
if (!parsed) {
|
|
2591
|
+
return {
|
|
2592
|
+
valid: false,
|
|
2593
|
+
error: "Invalid fork activity: missing required fields"
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
try {
|
|
2597
|
+
JSON.parse(parsed.forkedCard.content);
|
|
2598
|
+
} catch {
|
|
2599
|
+
return {
|
|
2600
|
+
valid: false,
|
|
2601
|
+
error: "Invalid fork activity: forked card content is not valid JSON"
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
return { valid: true };
|
|
2605
|
+
}
|
|
2606
|
+
function validateInstallActivity(activity) {
|
|
2607
|
+
const parsed = parseInstallActivity(activity);
|
|
2608
|
+
if (!parsed) {
|
|
2609
|
+
return {
|
|
2610
|
+
valid: false,
|
|
2611
|
+
error: "Invalid install activity: missing required fields"
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
try {
|
|
2615
|
+
new URL(parsed.cardId);
|
|
2616
|
+
} catch {
|
|
2617
|
+
return {
|
|
2618
|
+
valid: false,
|
|
2619
|
+
error: "Invalid install activity: cardId is not a valid URI"
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
return { valid: true };
|
|
2623
|
+
}
|
|
2624
|
+
var MemoryModerationStore = class {
|
|
2625
|
+
reports = /* @__PURE__ */ new Map();
|
|
2626
|
+
actions = /* @__PURE__ */ new Map();
|
|
2627
|
+
blocks = /* @__PURE__ */ new Map();
|
|
2628
|
+
policies = /* @__PURE__ */ new Map();
|
|
2629
|
+
rateLimits = /* @__PURE__ */ new Map();
|
|
2630
|
+
// ============ Report Operations ============
|
|
2631
|
+
async createReport(report) {
|
|
2632
|
+
const id = generateUUID();
|
|
2633
|
+
const full = { ...report, id };
|
|
2634
|
+
this.reports.set(id, full);
|
|
2635
|
+
return full;
|
|
2636
|
+
}
|
|
2637
|
+
async getReport(id) {
|
|
2638
|
+
return this.reports.get(id) || null;
|
|
2639
|
+
}
|
|
2640
|
+
async updateReport(id, updates) {
|
|
2641
|
+
const existing = this.reports.get(id);
|
|
2642
|
+
if (existing) {
|
|
2643
|
+
this.reports.set(id, {
|
|
2644
|
+
...existing,
|
|
2645
|
+
...updates,
|
|
2646
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
async listReports(filters) {
|
|
2651
|
+
let results = Array.from(this.reports.values());
|
|
2652
|
+
if (filters?.status) {
|
|
2653
|
+
results = results.filter((r) => r.status === filters.status);
|
|
2654
|
+
}
|
|
2655
|
+
if (filters?.category) {
|
|
2656
|
+
results = results.filter((r) => r.category === filters.category);
|
|
2657
|
+
}
|
|
2658
|
+
if (filters?.targetId) {
|
|
2659
|
+
results = results.filter((r) => r.targetIds.includes(filters.targetId));
|
|
2660
|
+
}
|
|
2661
|
+
if (filters?.reporterActorId) {
|
|
2662
|
+
results = results.filter((r) => r.reporterActorId === filters.reporterActorId);
|
|
2663
|
+
}
|
|
2664
|
+
if (filters?.since) {
|
|
2665
|
+
results = results.filter((r) => r.createdAt >= filters.since);
|
|
2666
|
+
}
|
|
2667
|
+
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
2668
|
+
const offset = filters?.offset || 0;
|
|
2669
|
+
const limit = filters?.limit || 100;
|
|
2670
|
+
return results.slice(offset, offset + limit);
|
|
2671
|
+
}
|
|
2672
|
+
async countReports(filters) {
|
|
2673
|
+
let count = 0;
|
|
2674
|
+
for (const report of this.reports.values()) {
|
|
2675
|
+
if (!filters?.status || report.status === filters.status) {
|
|
2676
|
+
count++;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
return count;
|
|
2680
|
+
}
|
|
2681
|
+
// ============ Action Operations ============
|
|
2682
|
+
async createAction(action) {
|
|
2683
|
+
const id = generateUUID();
|
|
2684
|
+
const full = { ...action, id };
|
|
2685
|
+
this.actions.set(id, full);
|
|
2686
|
+
return full;
|
|
2687
|
+
}
|
|
2688
|
+
async getAction(id) {
|
|
2689
|
+
return this.actions.get(id) || null;
|
|
2690
|
+
}
|
|
2691
|
+
async listActions(filters) {
|
|
2692
|
+
let results = Array.from(this.actions.values());
|
|
2693
|
+
if (filters?.targetId) {
|
|
2694
|
+
results = results.filter((a) => a.targetId === filters.targetId);
|
|
2695
|
+
}
|
|
2696
|
+
if (filters?.moderatorActorId) {
|
|
2697
|
+
results = results.filter((a) => a.moderatorActorId === filters.moderatorActorId);
|
|
2698
|
+
}
|
|
2699
|
+
if (filters?.actionType) {
|
|
2700
|
+
results = results.filter((a) => a.actionType === filters.actionType);
|
|
2701
|
+
}
|
|
2702
|
+
if (filters?.active !== void 0) {
|
|
2703
|
+
results = results.filter((a) => a.active === filters.active);
|
|
2704
|
+
}
|
|
2705
|
+
if (filters?.since) {
|
|
2706
|
+
results = results.filter((a) => a.timestamp >= filters.since);
|
|
2707
|
+
}
|
|
2708
|
+
results.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
2709
|
+
return results.slice(0, filters?.limit || 100);
|
|
2710
|
+
}
|
|
2711
|
+
async deactivateAction(id) {
|
|
2712
|
+
const action = this.actions.get(id);
|
|
2713
|
+
if (action) {
|
|
2714
|
+
this.actions.set(id, { ...action, active: false });
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
// ============ Instance Block Operations ============
|
|
2718
|
+
async createBlock(block) {
|
|
2719
|
+
const id = generateUUID();
|
|
2720
|
+
const full = { ...block, id };
|
|
2721
|
+
this.blocks.set(id, full);
|
|
2722
|
+
return full;
|
|
2723
|
+
}
|
|
2724
|
+
async getBlock(id) {
|
|
2725
|
+
return this.blocks.get(id) || null;
|
|
2726
|
+
}
|
|
2727
|
+
async getBlockByDomain(domain) {
|
|
2728
|
+
const normalized = domain.toLowerCase();
|
|
2729
|
+
for (const block of this.blocks.values()) {
|
|
2730
|
+
if (block.blockedDomain.toLowerCase() === normalized && block.active) {
|
|
2731
|
+
return block;
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
return null;
|
|
2735
|
+
}
|
|
2736
|
+
async listBlocks(filters) {
|
|
2737
|
+
let results = Array.from(this.blocks.values());
|
|
2738
|
+
if (filters?.active !== void 0) {
|
|
2739
|
+
results = results.filter((b) => b.active === filters.active);
|
|
2740
|
+
}
|
|
2741
|
+
results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
2742
|
+
return results;
|
|
2743
|
+
}
|
|
2744
|
+
async updateBlock(id, updates) {
|
|
2745
|
+
const existing = this.blocks.get(id);
|
|
2746
|
+
if (existing) {
|
|
2747
|
+
this.blocks.set(id, { ...existing, ...updates });
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
async isInstanceBlocked(domain) {
|
|
2751
|
+
const block = await this.getBlockByDomain(domain);
|
|
2752
|
+
return block !== null;
|
|
2753
|
+
}
|
|
2754
|
+
// ============ Content Policy Operations ============
|
|
2755
|
+
async createPolicy(policy) {
|
|
2756
|
+
const id = generateUUID();
|
|
2757
|
+
const full = { ...policy, id };
|
|
2758
|
+
this.policies.set(id, full);
|
|
2759
|
+
return full;
|
|
2760
|
+
}
|
|
2761
|
+
async getPolicy(id) {
|
|
2762
|
+
return this.policies.get(id) || null;
|
|
2763
|
+
}
|
|
2764
|
+
async listPolicies(filters) {
|
|
2765
|
+
let results = Array.from(this.policies.values());
|
|
2766
|
+
if (filters?.enabled !== void 0) {
|
|
2767
|
+
results = results.filter((p) => p.enabled === filters.enabled);
|
|
2768
|
+
}
|
|
2769
|
+
return results;
|
|
2770
|
+
}
|
|
2771
|
+
async updatePolicy(id, updates) {
|
|
2772
|
+
const existing = this.policies.get(id);
|
|
2773
|
+
if (existing) {
|
|
2774
|
+
this.policies.set(id, {
|
|
2775
|
+
...existing,
|
|
2776
|
+
...updates,
|
|
2777
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
async deletePolicy(id) {
|
|
2782
|
+
this.policies.delete(id);
|
|
2783
|
+
}
|
|
2784
|
+
// ============ Rate Limit Operations ============
|
|
2785
|
+
async getRateLimitBucket(actorId) {
|
|
2786
|
+
return this.rateLimits.get(actorId) || null;
|
|
2787
|
+
}
|
|
2788
|
+
async updateRateLimitBucket(bucket) {
|
|
2789
|
+
this.rateLimits.set(bucket.actorId, bucket);
|
|
2790
|
+
}
|
|
2791
|
+
// ============ Audit ============
|
|
2792
|
+
async getAuditLog(filters) {
|
|
2793
|
+
let results = await this.listActions({
|
|
2794
|
+
targetId: filters?.targetId,
|
|
2795
|
+
moderatorActorId: filters?.actorId,
|
|
2796
|
+
since: filters?.since,
|
|
2797
|
+
limit: filters?.limit
|
|
2798
|
+
});
|
|
2799
|
+
if (filters?.until) {
|
|
2800
|
+
results = results.filter((a) => a.timestamp <= filters.until);
|
|
2801
|
+
}
|
|
2802
|
+
return results;
|
|
2803
|
+
}
|
|
2804
|
+
// ============ Utility Methods ============
|
|
2805
|
+
/**
|
|
2806
|
+
* Clear all data (useful for testing)
|
|
2807
|
+
*/
|
|
2808
|
+
clear() {
|
|
2809
|
+
this.reports.clear();
|
|
2810
|
+
this.actions.clear();
|
|
2811
|
+
this.blocks.clear();
|
|
2812
|
+
this.policies.clear();
|
|
2813
|
+
this.rateLimits.clear();
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Get counts for dashboard/stats
|
|
2817
|
+
*/
|
|
2818
|
+
async getStats() {
|
|
2819
|
+
const pendingReports = await this.countReports({ status: "pending" });
|
|
2820
|
+
const activeActions = (await this.listActions({ active: true })).length;
|
|
2821
|
+
const activeBlocks = (await this.listBlocks({ active: true })).length;
|
|
2822
|
+
const enabledPolicies = (await this.listPolicies({ enabled: true })).length;
|
|
2823
|
+
return {
|
|
2824
|
+
reports: {
|
|
2825
|
+
total: this.reports.size,
|
|
2826
|
+
pending: pendingReports
|
|
2827
|
+
},
|
|
2828
|
+
actions: {
|
|
2829
|
+
total: this.actions.size,
|
|
2830
|
+
active: activeActions
|
|
2831
|
+
},
|
|
2832
|
+
blocks: {
|
|
2833
|
+
total: this.blocks.size,
|
|
2834
|
+
active: activeBlocks
|
|
2835
|
+
},
|
|
2836
|
+
policies: {
|
|
2837
|
+
total: this.policies.size,
|
|
2838
|
+
enabled: enabledPolicies
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
function validateTablePrefix(prefix) {
|
|
2844
|
+
const validPattern = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
2845
|
+
if (!validPattern.test(prefix)) {
|
|
2846
|
+
throw new Error(
|
|
2847
|
+
`Invalid table prefix "${prefix}": must start with a letter and contain only alphanumeric characters and underscores`
|
|
2848
|
+
);
|
|
2849
|
+
}
|
|
2850
|
+
if (prefix.length > 32) {
|
|
2851
|
+
throw new Error(`Invalid table prefix "${prefix}": must be 32 characters or less`);
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
var D1ModerationStore = class {
|
|
2855
|
+
db;
|
|
2856
|
+
prefix;
|
|
2857
|
+
constructor(db, tablePrefix = "moderation") {
|
|
2858
|
+
validateTablePrefix(tablePrefix);
|
|
2859
|
+
this.db = db;
|
|
2860
|
+
this.prefix = tablePrefix;
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Initialize all moderation tables
|
|
2864
|
+
*/
|
|
2865
|
+
async init() {
|
|
2866
|
+
await this.db.exec(`
|
|
2867
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_reports (
|
|
2868
|
+
id TEXT PRIMARY KEY,
|
|
2869
|
+
reporter_actor_id TEXT NOT NULL,
|
|
2870
|
+
reporter_instance TEXT NOT NULL,
|
|
2871
|
+
target_ids TEXT NOT NULL,
|
|
2872
|
+
category TEXT NOT NULL,
|
|
2873
|
+
description TEXT NOT NULL,
|
|
2874
|
+
status TEXT NOT NULL,
|
|
2875
|
+
activity_id TEXT NOT NULL,
|
|
2876
|
+
created_at TEXT NOT NULL,
|
|
2877
|
+
updated_at TEXT NOT NULL,
|
|
2878
|
+
receiving_instance TEXT NOT NULL,
|
|
2879
|
+
federated_to_target INTEGER NOT NULL DEFAULT 0,
|
|
2880
|
+
metadata TEXT
|
|
2881
|
+
)
|
|
2882
|
+
`);
|
|
2883
|
+
await this.db.exec(`
|
|
2884
|
+
CREATE INDEX IF NOT EXISTS idx_${this.prefix}_reports_status
|
|
2885
|
+
ON ${this.prefix}_reports(status)
|
|
2886
|
+
`);
|
|
2887
|
+
await this.db.exec(`
|
|
2888
|
+
CREATE INDEX IF NOT EXISTS idx_${this.prefix}_reports_reporter
|
|
2889
|
+
ON ${this.prefix}_reports(reporter_actor_id)
|
|
2890
|
+
`);
|
|
2891
|
+
await this.db.exec(`
|
|
2892
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_actions (
|
|
2893
|
+
id TEXT PRIMARY KEY,
|
|
2894
|
+
report_id TEXT,
|
|
2895
|
+
moderator_actor_id TEXT NOT NULL,
|
|
2896
|
+
target_id TEXT NOT NULL,
|
|
2897
|
+
action_type TEXT NOT NULL,
|
|
2898
|
+
reason TEXT NOT NULL,
|
|
2899
|
+
timestamp TEXT NOT NULL,
|
|
2900
|
+
expires_at TEXT,
|
|
2901
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
2902
|
+
reverses_action_id TEXT,
|
|
2903
|
+
approved_by TEXT,
|
|
2904
|
+
metadata TEXT
|
|
2905
|
+
)
|
|
2906
|
+
`);
|
|
2907
|
+
await this.db.exec(`
|
|
2908
|
+
CREATE INDEX IF NOT EXISTS idx_${this.prefix}_actions_target
|
|
2909
|
+
ON ${this.prefix}_actions(target_id)
|
|
2910
|
+
`);
|
|
2911
|
+
await this.db.exec(`
|
|
2912
|
+
CREATE INDEX IF NOT EXISTS idx_${this.prefix}_actions_active
|
|
2913
|
+
ON ${this.prefix}_actions(active)
|
|
2914
|
+
`);
|
|
2915
|
+
await this.db.exec(`
|
|
2916
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_blocks (
|
|
2917
|
+
id TEXT PRIMARY KEY,
|
|
2918
|
+
blocked_domain TEXT NOT NULL UNIQUE,
|
|
2919
|
+
level TEXT NOT NULL CHECK (level IN ('suspend', 'silence', 'reject_media')),
|
|
2920
|
+
reason TEXT NOT NULL,
|
|
2921
|
+
created_by TEXT NOT NULL,
|
|
2922
|
+
created_at TEXT NOT NULL,
|
|
2923
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
2924
|
+
public_comment TEXT,
|
|
2925
|
+
federate INTEGER NOT NULL DEFAULT 0
|
|
2926
|
+
)
|
|
2927
|
+
`);
|
|
2928
|
+
await this.db.exec(`
|
|
2929
|
+
CREATE INDEX IF NOT EXISTS idx_${this.prefix}_blocks_domain
|
|
2930
|
+
ON ${this.prefix}_blocks(blocked_domain)
|
|
2931
|
+
`);
|
|
2932
|
+
await this.db.exec(`
|
|
2933
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_policies (
|
|
2934
|
+
id TEXT PRIMARY KEY,
|
|
2935
|
+
name TEXT NOT NULL UNIQUE,
|
|
2936
|
+
description TEXT NOT NULL,
|
|
2937
|
+
rules TEXT NOT NULL,
|
|
2938
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
2939
|
+
default_action TEXT NOT NULL,
|
|
2940
|
+
updated_at TEXT NOT NULL
|
|
2941
|
+
)
|
|
2942
|
+
`);
|
|
2943
|
+
await this.db.exec(`
|
|
2944
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_rate_limits (
|
|
2945
|
+
actor_id TEXT PRIMARY KEY,
|
|
2946
|
+
tokens REAL NOT NULL,
|
|
2947
|
+
max_tokens INTEGER NOT NULL,
|
|
2948
|
+
last_refill TEXT NOT NULL,
|
|
2949
|
+
refill_rate REAL NOT NULL
|
|
2950
|
+
)
|
|
2951
|
+
`);
|
|
2952
|
+
}
|
|
2953
|
+
// ============ Report Operations ============
|
|
2954
|
+
async createReport(report) {
|
|
2955
|
+
const id = crypto.randomUUID();
|
|
2956
|
+
const full = { ...report, id };
|
|
2957
|
+
await this.db.prepare(
|
|
2958
|
+
`INSERT INTO ${this.prefix}_reports
|
|
2959
|
+
(id, reporter_actor_id, reporter_instance, target_ids, category, description, status,
|
|
2960
|
+
activity_id, created_at, updated_at, receiving_instance, federated_to_target, metadata)
|
|
2961
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2962
|
+
).bind(
|
|
2963
|
+
id,
|
|
2964
|
+
report.reporterActorId,
|
|
2965
|
+
report.reporterInstance,
|
|
2966
|
+
JSON.stringify(report.targetIds),
|
|
2967
|
+
report.category,
|
|
2968
|
+
report.description,
|
|
2969
|
+
report.status,
|
|
2970
|
+
report.activityId,
|
|
2971
|
+
report.createdAt,
|
|
2972
|
+
report.updatedAt,
|
|
2973
|
+
report.receivingInstance,
|
|
2974
|
+
report.federatedToTarget ? 1 : 0,
|
|
2975
|
+
report.metadata ? JSON.stringify(report.metadata) : null
|
|
2976
|
+
).run();
|
|
2977
|
+
return full;
|
|
2978
|
+
}
|
|
2979
|
+
async getReport(id) {
|
|
2980
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.prefix}_reports WHERE id = ?`).bind(id).first();
|
|
2981
|
+
return row ? this.rowToReport(row) : null;
|
|
2982
|
+
}
|
|
2983
|
+
async updateReport(id, updates) {
|
|
2984
|
+
const existing = await this.getReport(id);
|
|
2985
|
+
if (!existing) return;
|
|
2986
|
+
const merged = { ...existing, ...updates, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2987
|
+
await this.db.prepare(
|
|
2988
|
+
`UPDATE ${this.prefix}_reports SET
|
|
2989
|
+
reporter_actor_id = ?, reporter_instance = ?, target_ids = ?, category = ?,
|
|
2990
|
+
description = ?, status = ?, activity_id = ?, updated_at = ?,
|
|
2991
|
+
receiving_instance = ?, federated_to_target = ?, metadata = ?
|
|
2992
|
+
WHERE id = ?`
|
|
2993
|
+
).bind(
|
|
2994
|
+
merged.reporterActorId,
|
|
2995
|
+
merged.reporterInstance,
|
|
2996
|
+
JSON.stringify(merged.targetIds),
|
|
2997
|
+
merged.category,
|
|
2998
|
+
merged.description,
|
|
2999
|
+
merged.status,
|
|
3000
|
+
merged.activityId,
|
|
3001
|
+
merged.updatedAt,
|
|
3002
|
+
merged.receivingInstance,
|
|
3003
|
+
merged.federatedToTarget ? 1 : 0,
|
|
3004
|
+
merged.metadata ? JSON.stringify(merged.metadata) : null,
|
|
3005
|
+
id
|
|
3006
|
+
).run();
|
|
3007
|
+
}
|
|
3008
|
+
async listReports(filters) {
|
|
3009
|
+
const conditions = ["1=1"];
|
|
3010
|
+
const bindings = [];
|
|
3011
|
+
if (filters?.status) {
|
|
3012
|
+
conditions.push("status = ?");
|
|
3013
|
+
bindings.push(filters.status);
|
|
3014
|
+
}
|
|
3015
|
+
if (filters?.category) {
|
|
3016
|
+
conditions.push("category = ?");
|
|
3017
|
+
bindings.push(filters.category);
|
|
3018
|
+
}
|
|
3019
|
+
if (filters?.targetId) {
|
|
3020
|
+
conditions.push("target_ids LIKE ?");
|
|
3021
|
+
bindings.push(`%${filters.targetId}%`);
|
|
3022
|
+
}
|
|
3023
|
+
if (filters?.reporterActorId) {
|
|
3024
|
+
conditions.push("reporter_actor_id = ?");
|
|
3025
|
+
bindings.push(filters.reporterActorId);
|
|
3026
|
+
}
|
|
3027
|
+
if (filters?.since) {
|
|
3028
|
+
conditions.push("created_at >= ?");
|
|
3029
|
+
bindings.push(filters.since);
|
|
3030
|
+
}
|
|
3031
|
+
const limit = filters?.limit ?? 100;
|
|
3032
|
+
const offset = filters?.offset ?? 0;
|
|
3033
|
+
let stmt = this.db.prepare(
|
|
3034
|
+
`SELECT * FROM ${this.prefix}_reports
|
|
3035
|
+
WHERE ${conditions.join(" AND ")}
|
|
3036
|
+
ORDER BY created_at DESC
|
|
3037
|
+
LIMIT ? OFFSET ?`
|
|
3038
|
+
);
|
|
3039
|
+
for (const binding of bindings) {
|
|
3040
|
+
stmt = stmt.bind(binding);
|
|
3041
|
+
}
|
|
3042
|
+
stmt = stmt.bind(limit, offset);
|
|
3043
|
+
const result = await stmt.all();
|
|
3044
|
+
return result.results.map((row) => this.rowToReport(row));
|
|
3045
|
+
}
|
|
3046
|
+
async countReports(filters) {
|
|
3047
|
+
let query = `SELECT COUNT(*) as count FROM ${this.prefix}_reports`;
|
|
3048
|
+
const bindings = [];
|
|
3049
|
+
if (filters?.status) {
|
|
3050
|
+
query += " WHERE status = ?";
|
|
3051
|
+
bindings.push(filters.status);
|
|
3052
|
+
}
|
|
3053
|
+
let stmt = this.db.prepare(query);
|
|
3054
|
+
for (const binding of bindings) {
|
|
3055
|
+
stmt = stmt.bind(binding);
|
|
3056
|
+
}
|
|
3057
|
+
const result = await stmt.first();
|
|
3058
|
+
return result?.count ?? 0;
|
|
3059
|
+
}
|
|
3060
|
+
// ============ Action Operations ============
|
|
3061
|
+
async createAction(action) {
|
|
3062
|
+
const id = crypto.randomUUID();
|
|
3063
|
+
const full = { ...action, id };
|
|
3064
|
+
await this.db.prepare(
|
|
3065
|
+
`INSERT INTO ${this.prefix}_actions
|
|
3066
|
+
(id, report_id, moderator_actor_id, target_id, action_type, reason, timestamp,
|
|
3067
|
+
expires_at, active, reverses_action_id, approved_by, metadata)
|
|
3068
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3069
|
+
).bind(
|
|
3070
|
+
id,
|
|
3071
|
+
action.reportId ?? null,
|
|
3072
|
+
action.moderatorActorId,
|
|
3073
|
+
action.targetId,
|
|
3074
|
+
action.actionType,
|
|
3075
|
+
action.reason,
|
|
3076
|
+
action.timestamp,
|
|
3077
|
+
action.expiresAt ?? null,
|
|
3078
|
+
action.active ? 1 : 0,
|
|
3079
|
+
action.reversesActionId ?? null,
|
|
3080
|
+
action.approvedBy ? JSON.stringify(action.approvedBy) : null,
|
|
3081
|
+
action.metadata ? JSON.stringify(action.metadata) : null
|
|
3082
|
+
).run();
|
|
3083
|
+
return full;
|
|
3084
|
+
}
|
|
3085
|
+
async getAction(id) {
|
|
3086
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.prefix}_actions WHERE id = ?`).bind(id).first();
|
|
3087
|
+
return row ? this.rowToAction(row) : null;
|
|
3088
|
+
}
|
|
3089
|
+
async listActions(filters) {
|
|
3090
|
+
const conditions = ["1=1"];
|
|
3091
|
+
const bindings = [];
|
|
3092
|
+
if (filters?.targetId) {
|
|
3093
|
+
conditions.push("target_id = ?");
|
|
3094
|
+
bindings.push(filters.targetId);
|
|
3095
|
+
}
|
|
3096
|
+
if (filters?.moderatorActorId) {
|
|
3097
|
+
conditions.push("moderator_actor_id = ?");
|
|
3098
|
+
bindings.push(filters.moderatorActorId);
|
|
3099
|
+
}
|
|
3100
|
+
if (filters?.actionType) {
|
|
3101
|
+
conditions.push("action_type = ?");
|
|
3102
|
+
bindings.push(filters.actionType);
|
|
3103
|
+
}
|
|
3104
|
+
if (filters?.active !== void 0) {
|
|
3105
|
+
conditions.push("active = ?");
|
|
3106
|
+
bindings.push(filters.active ? 1 : 0);
|
|
3107
|
+
}
|
|
3108
|
+
if (filters?.since) {
|
|
3109
|
+
conditions.push("timestamp >= ?");
|
|
3110
|
+
bindings.push(filters.since);
|
|
3111
|
+
}
|
|
3112
|
+
const limit = filters?.limit ?? 100;
|
|
3113
|
+
let stmt = this.db.prepare(
|
|
3114
|
+
`SELECT * FROM ${this.prefix}_actions
|
|
3115
|
+
WHERE ${conditions.join(" AND ")}
|
|
3116
|
+
ORDER BY timestamp DESC
|
|
3117
|
+
LIMIT ?`
|
|
3118
|
+
);
|
|
3119
|
+
for (const binding of bindings) {
|
|
3120
|
+
stmt = stmt.bind(binding);
|
|
3121
|
+
}
|
|
3122
|
+
stmt = stmt.bind(limit);
|
|
3123
|
+
const result = await stmt.all();
|
|
3124
|
+
return result.results.map((row) => this.rowToAction(row));
|
|
3125
|
+
}
|
|
3126
|
+
async deactivateAction(id) {
|
|
3127
|
+
await this.db.prepare(`UPDATE ${this.prefix}_actions SET active = 0 WHERE id = ?`).bind(id).run();
|
|
3128
|
+
}
|
|
3129
|
+
// ============ Instance Block Operations ============
|
|
3130
|
+
async createBlock(block) {
|
|
3131
|
+
const id = crypto.randomUUID();
|
|
3132
|
+
const full = { ...block, id };
|
|
3133
|
+
await this.db.prepare(
|
|
3134
|
+
`INSERT INTO ${this.prefix}_blocks
|
|
3135
|
+
(id, blocked_domain, level, reason, created_by, created_at, active, public_comment, federate)
|
|
3136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3137
|
+
).bind(
|
|
3138
|
+
id,
|
|
3139
|
+
block.blockedDomain.toLowerCase(),
|
|
3140
|
+
block.level,
|
|
3141
|
+
block.reason,
|
|
3142
|
+
block.createdBy,
|
|
3143
|
+
block.createdAt,
|
|
3144
|
+
block.active ? 1 : 0,
|
|
3145
|
+
block.publicComment ?? null,
|
|
3146
|
+
block.federate ? 1 : 0
|
|
3147
|
+
).run();
|
|
3148
|
+
return full;
|
|
3149
|
+
}
|
|
3150
|
+
async getBlock(id) {
|
|
3151
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.prefix}_blocks WHERE id = ?`).bind(id).first();
|
|
3152
|
+
return row ? this.rowToBlock(row) : null;
|
|
3153
|
+
}
|
|
3154
|
+
async getBlockByDomain(domain) {
|
|
3155
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.prefix}_blocks WHERE blocked_domain = ? AND active = 1`).bind(domain.toLowerCase()).first();
|
|
3156
|
+
return row ? this.rowToBlock(row) : null;
|
|
3157
|
+
}
|
|
3158
|
+
async listBlocks(filters) {
|
|
3159
|
+
let query = `SELECT * FROM ${this.prefix}_blocks`;
|
|
3160
|
+
const bindings = [];
|
|
3161
|
+
if (filters?.active !== void 0) {
|
|
3162
|
+
query += " WHERE active = ?";
|
|
3163
|
+
bindings.push(filters.active ? 1 : 0);
|
|
3164
|
+
}
|
|
3165
|
+
query += " ORDER BY created_at DESC";
|
|
3166
|
+
let stmt = this.db.prepare(query);
|
|
3167
|
+
for (const binding of bindings) {
|
|
3168
|
+
stmt = stmt.bind(binding);
|
|
3169
|
+
}
|
|
3170
|
+
const result = await stmt.all();
|
|
3171
|
+
return result.results.map((row) => this.rowToBlock(row));
|
|
3172
|
+
}
|
|
3173
|
+
async updateBlock(id, updates) {
|
|
3174
|
+
const existing = await this.getBlock(id);
|
|
3175
|
+
if (!existing) return;
|
|
3176
|
+
const merged = { ...existing, ...updates };
|
|
3177
|
+
await this.db.prepare(
|
|
3178
|
+
`UPDATE ${this.prefix}_blocks SET
|
|
3179
|
+
blocked_domain = ?, level = ?, reason = ?, active = ?, public_comment = ?, federate = ?
|
|
3180
|
+
WHERE id = ?`
|
|
3181
|
+
).bind(
|
|
3182
|
+
merged.blockedDomain.toLowerCase(),
|
|
3183
|
+
merged.level,
|
|
3184
|
+
merged.reason,
|
|
3185
|
+
merged.active ? 1 : 0,
|
|
3186
|
+
merged.publicComment ?? null,
|
|
3187
|
+
merged.federate ? 1 : 0,
|
|
3188
|
+
id
|
|
3189
|
+
).run();
|
|
3190
|
+
}
|
|
3191
|
+
async isInstanceBlocked(domain) {
|
|
3192
|
+
const block = await this.getBlockByDomain(domain);
|
|
3193
|
+
return block !== null;
|
|
3194
|
+
}
|
|
3195
|
+
// ============ Content Policy Operations ============
|
|
3196
|
+
async createPolicy(policy) {
|
|
3197
|
+
const id = crypto.randomUUID();
|
|
3198
|
+
const full = { ...policy, id };
|
|
3199
|
+
await this.db.prepare(
|
|
3200
|
+
`INSERT INTO ${this.prefix}_policies
|
|
3201
|
+
(id, name, description, rules, enabled, default_action, updated_at)
|
|
3202
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
3203
|
+
).bind(
|
|
3204
|
+
id,
|
|
3205
|
+
policy.name,
|
|
3206
|
+
policy.description,
|
|
3207
|
+
JSON.stringify(policy.rules),
|
|
3208
|
+
policy.enabled ? 1 : 0,
|
|
3209
|
+
policy.defaultAction,
|
|
3210
|
+
policy.updatedAt
|
|
3211
|
+
).run();
|
|
3212
|
+
return full;
|
|
3213
|
+
}
|
|
3214
|
+
async getPolicy(id) {
|
|
3215
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.prefix}_policies WHERE id = ?`).bind(id).first();
|
|
3216
|
+
return row ? this.rowToPolicy(row) : null;
|
|
3217
|
+
}
|
|
3218
|
+
async listPolicies(filters) {
|
|
3219
|
+
let query = `SELECT * FROM ${this.prefix}_policies`;
|
|
3220
|
+
const bindings = [];
|
|
3221
|
+
if (filters?.enabled !== void 0) {
|
|
3222
|
+
query += " WHERE enabled = ?";
|
|
3223
|
+
bindings.push(filters.enabled ? 1 : 0);
|
|
3224
|
+
}
|
|
3225
|
+
query += " ORDER BY name ASC";
|
|
3226
|
+
let stmt = this.db.prepare(query);
|
|
3227
|
+
for (const binding of bindings) {
|
|
3228
|
+
stmt = stmt.bind(binding);
|
|
3229
|
+
}
|
|
3230
|
+
const result = await stmt.all();
|
|
3231
|
+
return result.results.map((row) => this.rowToPolicy(row));
|
|
3232
|
+
}
|
|
3233
|
+
async updatePolicy(id, updates) {
|
|
3234
|
+
const existing = await this.getPolicy(id);
|
|
3235
|
+
if (!existing) return;
|
|
3236
|
+
const merged = { ...existing, ...updates, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3237
|
+
await this.db.prepare(
|
|
3238
|
+
`UPDATE ${this.prefix}_policies SET
|
|
3239
|
+
name = ?, description = ?, rules = ?, enabled = ?, default_action = ?, updated_at = ?
|
|
3240
|
+
WHERE id = ?`
|
|
3241
|
+
).bind(
|
|
3242
|
+
merged.name,
|
|
3243
|
+
merged.description,
|
|
3244
|
+
JSON.stringify(merged.rules),
|
|
3245
|
+
merged.enabled ? 1 : 0,
|
|
3246
|
+
merged.defaultAction,
|
|
3247
|
+
merged.updatedAt,
|
|
3248
|
+
id
|
|
3249
|
+
).run();
|
|
3250
|
+
}
|
|
3251
|
+
async deletePolicy(id) {
|
|
3252
|
+
await this.db.prepare(`DELETE FROM ${this.prefix}_policies WHERE id = ?`).bind(id).run();
|
|
3253
|
+
}
|
|
3254
|
+
// ============ Rate Limit Operations ============
|
|
3255
|
+
async getRateLimitBucket(actorId) {
|
|
3256
|
+
const row = await this.db.prepare(`SELECT * FROM ${this.prefix}_rate_limits WHERE actor_id = ?`).bind(actorId).first();
|
|
3257
|
+
return row ? this.rowToRateLimit(row) : null;
|
|
3258
|
+
}
|
|
3259
|
+
async updateRateLimitBucket(bucket) {
|
|
3260
|
+
await this.db.prepare(
|
|
3261
|
+
`INSERT INTO ${this.prefix}_rate_limits (actor_id, tokens, max_tokens, last_refill, refill_rate)
|
|
3262
|
+
VALUES (?, ?, ?, ?, ?)
|
|
3263
|
+
ON CONFLICT(actor_id) DO UPDATE SET
|
|
3264
|
+
tokens = excluded.tokens,
|
|
3265
|
+
max_tokens = excluded.max_tokens,
|
|
3266
|
+
last_refill = excluded.last_refill,
|
|
3267
|
+
refill_rate = excluded.refill_rate`
|
|
3268
|
+
).bind(bucket.actorId, bucket.tokens, bucket.maxTokens, bucket.lastRefill, bucket.refillRate).run();
|
|
3269
|
+
}
|
|
3270
|
+
// ============ Audit ============
|
|
3271
|
+
async getAuditLog(filters) {
|
|
3272
|
+
const conditions = ["1=1"];
|
|
3273
|
+
const bindings = [];
|
|
3274
|
+
if (filters?.targetId) {
|
|
3275
|
+
conditions.push("target_id = ?");
|
|
3276
|
+
bindings.push(filters.targetId);
|
|
3277
|
+
}
|
|
3278
|
+
if (filters?.actorId) {
|
|
3279
|
+
conditions.push("moderator_actor_id = ?");
|
|
3280
|
+
bindings.push(filters.actorId);
|
|
3281
|
+
}
|
|
3282
|
+
if (filters?.since) {
|
|
3283
|
+
conditions.push("timestamp >= ?");
|
|
3284
|
+
bindings.push(filters.since);
|
|
3285
|
+
}
|
|
3286
|
+
if (filters?.until) {
|
|
3287
|
+
conditions.push("timestamp <= ?");
|
|
3288
|
+
bindings.push(filters.until);
|
|
3289
|
+
}
|
|
3290
|
+
const limit = filters?.limit ?? 100;
|
|
3291
|
+
let stmt = this.db.prepare(
|
|
3292
|
+
`SELECT * FROM ${this.prefix}_actions
|
|
3293
|
+
WHERE ${conditions.join(" AND ")}
|
|
3294
|
+
ORDER BY timestamp DESC
|
|
3295
|
+
LIMIT ?`
|
|
3296
|
+
);
|
|
3297
|
+
for (const binding of bindings) {
|
|
3298
|
+
stmt = stmt.bind(binding);
|
|
3299
|
+
}
|
|
3300
|
+
stmt = stmt.bind(limit);
|
|
3301
|
+
const result = await stmt.all();
|
|
3302
|
+
return result.results.map((row) => this.rowToAction(row));
|
|
3303
|
+
}
|
|
3304
|
+
// ============ Utility Methods ============
|
|
3305
|
+
/**
|
|
3306
|
+
* Clear all moderation data (for testing)
|
|
3307
|
+
*/
|
|
3308
|
+
async clear() {
|
|
3309
|
+
await this.db.prepare(`DELETE FROM ${this.prefix}_reports`).run();
|
|
3310
|
+
await this.db.prepare(`DELETE FROM ${this.prefix}_actions`).run();
|
|
3311
|
+
await this.db.prepare(`DELETE FROM ${this.prefix}_blocks`).run();
|
|
3312
|
+
await this.db.prepare(`DELETE FROM ${this.prefix}_policies`).run();
|
|
3313
|
+
await this.db.prepare(`DELETE FROM ${this.prefix}_rate_limits`).run();
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3316
|
+
* Get stats for dashboard
|
|
3317
|
+
*/
|
|
3318
|
+
async getStats() {
|
|
3319
|
+
const [reportsTotal, reportsPending, actionsTotal, actionsActive, blocksTotal, blocksActive, policiesTotal, policiesEnabled] = await Promise.all([
|
|
3320
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_reports`).first(),
|
|
3321
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_reports WHERE status = 'pending'`).first(),
|
|
3322
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_actions`).first(),
|
|
3323
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_actions WHERE active = 1`).first(),
|
|
3324
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_blocks`).first(),
|
|
3325
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_blocks WHERE active = 1`).first(),
|
|
3326
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_policies`).first(),
|
|
3327
|
+
this.db.prepare(`SELECT COUNT(*) as count FROM ${this.prefix}_policies WHERE enabled = 1`).first()
|
|
3328
|
+
]);
|
|
3329
|
+
return {
|
|
3330
|
+
reports: { total: reportsTotal?.count ?? 0, pending: reportsPending?.count ?? 0 },
|
|
3331
|
+
actions: { total: actionsTotal?.count ?? 0, active: actionsActive?.count ?? 0 },
|
|
3332
|
+
blocks: { total: blocksTotal?.count ?? 0, active: blocksActive?.count ?? 0 },
|
|
3333
|
+
policies: { total: policiesTotal?.count ?? 0, enabled: policiesEnabled?.count ?? 0 }
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
// ============ Row Converters ============
|
|
3337
|
+
rowToReport(row) {
|
|
3338
|
+
return {
|
|
3339
|
+
id: row.id,
|
|
3340
|
+
reporterActorId: row.reporter_actor_id,
|
|
3341
|
+
reporterInstance: row.reporter_instance,
|
|
3342
|
+
targetIds: JSON.parse(row.target_ids),
|
|
3343
|
+
category: row.category,
|
|
3344
|
+
description: row.description,
|
|
3345
|
+
status: row.status,
|
|
3346
|
+
activityId: row.activity_id,
|
|
3347
|
+
createdAt: row.created_at,
|
|
3348
|
+
updatedAt: row.updated_at,
|
|
3349
|
+
receivingInstance: row.receiving_instance,
|
|
3350
|
+
federatedToTarget: row.federated_to_target === 1,
|
|
3351
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : void 0
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
3354
|
+
rowToAction(row) {
|
|
3355
|
+
return {
|
|
3356
|
+
id: row.id,
|
|
3357
|
+
reportId: row.report_id ?? void 0,
|
|
3358
|
+
moderatorActorId: row.moderator_actor_id,
|
|
3359
|
+
targetId: row.target_id,
|
|
3360
|
+
actionType: row.action_type,
|
|
3361
|
+
reason: row.reason,
|
|
3362
|
+
timestamp: row.timestamp,
|
|
3363
|
+
expiresAt: row.expires_at ?? void 0,
|
|
3364
|
+
active: row.active === 1,
|
|
3365
|
+
reversesActionId: row.reverses_action_id ?? void 0,
|
|
3366
|
+
approvedBy: row.approved_by ? JSON.parse(row.approved_by) : void 0,
|
|
3367
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : void 0
|
|
3368
|
+
};
|
|
3369
|
+
}
|
|
3370
|
+
rowToBlock(row) {
|
|
3371
|
+
return {
|
|
3372
|
+
id: row.id,
|
|
3373
|
+
blockedDomain: row.blocked_domain,
|
|
3374
|
+
level: row.level,
|
|
3375
|
+
reason: row.reason,
|
|
3376
|
+
createdBy: row.created_by,
|
|
3377
|
+
createdAt: row.created_at,
|
|
3378
|
+
active: row.active === 1,
|
|
3379
|
+
publicComment: row.public_comment ?? void 0,
|
|
3380
|
+
federate: row.federate === 1
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
rowToPolicy(row) {
|
|
3384
|
+
return {
|
|
3385
|
+
id: row.id,
|
|
3386
|
+
name: row.name,
|
|
3387
|
+
description: row.description,
|
|
3388
|
+
rules: JSON.parse(row.rules),
|
|
3389
|
+
enabled: row.enabled === 1,
|
|
3390
|
+
defaultAction: row.default_action,
|
|
3391
|
+
updatedAt: row.updated_at
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
rowToRateLimit(row) {
|
|
3395
|
+
return {
|
|
3396
|
+
actorId: row.actor_id,
|
|
3397
|
+
tokens: row.tokens,
|
|
3398
|
+
maxTokens: row.max_tokens,
|
|
3399
|
+
lastRefill: row.last_refill,
|
|
3400
|
+
refillRate: row.refill_rate
|
|
3401
|
+
};
|
|
3402
|
+
}
|
|
3403
|
+
};
|
|
3404
|
+
var DEFAULT_TARGET_FIELDS = ["name", "description", "personality", "scenario", "first_mes"];
|
|
3405
|
+
var MAX_REGEX_INPUT_LENGTH = 1e5;
|
|
3406
|
+
var MAX_REGEX_PATTERN_LENGTH = 1e3;
|
|
3407
|
+
var REDOS_WARNING_PATTERNS = [
|
|
3408
|
+
/\(\.\*\)\+/,
|
|
3409
|
+
// (.*)+
|
|
3410
|
+
/\(\.\+\)\+/,
|
|
3411
|
+
// (.+)+
|
|
3412
|
+
/\([^)]*\+[^)]*\)\+/,
|
|
3413
|
+
// (a+)+ style nested quantifiers
|
|
3414
|
+
/\([^)]*\*[^)]*\)\*/,
|
|
3415
|
+
// (a*)* style nested quantifiers
|
|
3416
|
+
/\(\[.*?\]\+\)\+/
|
|
3417
|
+
// ([a-z]+)+ character class with nested quantifiers
|
|
3418
|
+
];
|
|
3419
|
+
function checkRegexSafety(pattern) {
|
|
3420
|
+
if (pattern.length > MAX_REGEX_PATTERN_LENGTH) {
|
|
3421
|
+
return `Pattern too long (${pattern.length} chars, max ${MAX_REGEX_PATTERN_LENGTH})`;
|
|
3422
|
+
}
|
|
3423
|
+
for (const dangerousPattern of REDOS_WARNING_PATTERNS) {
|
|
3424
|
+
if (dangerousPattern.test(pattern)) {
|
|
3425
|
+
return `Pattern may be vulnerable to ReDoS (catastrophic backtracking): matches ${dangerousPattern}`;
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
return null;
|
|
3429
|
+
}
|
|
3430
|
+
var PolicyEngine = class {
|
|
3431
|
+
store;
|
|
3432
|
+
compiledRegexCache = /* @__PURE__ */ new Map();
|
|
3433
|
+
constructor(store) {
|
|
3434
|
+
this.store = store;
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* Evaluate a card against all active policies
|
|
3438
|
+
*
|
|
3439
|
+
* @param card - The CCv3 card to evaluate
|
|
3440
|
+
* @param sourceInstance - Optional source instance domain for instance rules
|
|
3441
|
+
* @returns Evaluation result with action and matched rules
|
|
3442
|
+
*/
|
|
3443
|
+
async evaluateCard(card, sourceInstance) {
|
|
3444
|
+
const policies = await this.store.listPolicies({ enabled: true });
|
|
3445
|
+
const allMatches = [];
|
|
3446
|
+
let finalAction = "allow";
|
|
3447
|
+
let lowestPriority = Infinity;
|
|
3448
|
+
for (const policy of policies) {
|
|
3449
|
+
const result = this.evaluateAgainstPolicy(card, policy, sourceInstance);
|
|
3450
|
+
allMatches.push(...result.matchedRules);
|
|
3451
|
+
for (const match of result.matchedRules) {
|
|
3452
|
+
const rule = policy.rules.find((r) => r.id === match.ruleId);
|
|
3453
|
+
if (rule && rule.priority < lowestPriority) {
|
|
3454
|
+
lowestPriority = rule.priority;
|
|
3455
|
+
finalAction = rule.action;
|
|
3456
|
+
if (rule.action === "allow") {
|
|
3457
|
+
return {
|
|
3458
|
+
action: "allow",
|
|
3459
|
+
matchedRules: allMatches,
|
|
3460
|
+
hasMatch: true
|
|
3461
|
+
};
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
if (allMatches.length === 0) {
|
|
3467
|
+
const defaultAction = policies.length > 0 ? policies[0].defaultAction : "allow";
|
|
3468
|
+
return {
|
|
3469
|
+
action: defaultAction,
|
|
3470
|
+
matchedRules: [],
|
|
3471
|
+
hasMatch: false
|
|
3472
|
+
};
|
|
3473
|
+
}
|
|
3474
|
+
return {
|
|
3475
|
+
action: finalAction,
|
|
3476
|
+
matchedRules: allMatches,
|
|
3477
|
+
hasMatch: true
|
|
3478
|
+
};
|
|
3479
|
+
}
|
|
3480
|
+
/**
|
|
3481
|
+
* Evaluate card against a single policy
|
|
3482
|
+
*/
|
|
3483
|
+
evaluateAgainstPolicy(card, policy, sourceInstance) {
|
|
3484
|
+
const matches = [];
|
|
3485
|
+
const enabledRules = policy.rules.filter((r) => r.enabled).sort((a, b) => a.priority - b.priority);
|
|
3486
|
+
for (const rule of enabledRules) {
|
|
3487
|
+
const match = this.evaluateRule(card, rule, sourceInstance);
|
|
3488
|
+
if (match) {
|
|
3489
|
+
matches.push({
|
|
3490
|
+
ruleId: rule.id,
|
|
3491
|
+
ruleName: rule.name,
|
|
3492
|
+
matchedField: match.field,
|
|
3493
|
+
matchedValue: match.value
|
|
3494
|
+
});
|
|
3495
|
+
if (rule.action === "allow") {
|
|
3496
|
+
return { action: "allow", matchedRules: matches, hasMatch: true };
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
return {
|
|
3501
|
+
action: matches.length > 0 ? enabledRules.find((r) => matches.some((m) => m.ruleId === r.id))?.action ?? policy.defaultAction : policy.defaultAction,
|
|
3502
|
+
matchedRules: matches,
|
|
3503
|
+
hasMatch: matches.length > 0
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
/**
|
|
3507
|
+
* Evaluate a single rule against a card
|
|
3508
|
+
*/
|
|
3509
|
+
evaluateRule(card, rule, sourceInstance) {
|
|
3510
|
+
switch (rule.type) {
|
|
3511
|
+
case "keyword":
|
|
3512
|
+
return this.evaluateKeywordRule(card, rule);
|
|
3513
|
+
case "regex":
|
|
3514
|
+
return this.evaluateRegexRule(card, rule);
|
|
3515
|
+
case "tag":
|
|
3516
|
+
return this.evaluateTagRule(card, rule);
|
|
3517
|
+
case "creator":
|
|
3518
|
+
return this.evaluateCreatorRule(card, rule);
|
|
3519
|
+
case "instance":
|
|
3520
|
+
return this.evaluateInstanceRule(sourceInstance, rule);
|
|
3521
|
+
default:
|
|
3522
|
+
return null;
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
/**
|
|
3526
|
+
* Keyword rule - case-insensitive text search
|
|
3527
|
+
*/
|
|
3528
|
+
evaluateKeywordRule(card, rule) {
|
|
3529
|
+
const keyword = rule.pattern.toLowerCase();
|
|
3530
|
+
const fields = rule.targetFields || DEFAULT_TARGET_FIELDS;
|
|
3531
|
+
for (const field of fields) {
|
|
3532
|
+
const value = this.getCardField(card, field);
|
|
3533
|
+
if (typeof value === "string" && value.toLowerCase().includes(keyword)) {
|
|
3534
|
+
return { field, value: keyword };
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
return null;
|
|
3538
|
+
}
|
|
3539
|
+
/**
|
|
3540
|
+
* Regex rule - pattern matching with compiled cache
|
|
3541
|
+
*
|
|
3542
|
+
* @security Input is truncated to MAX_REGEX_INPUT_LENGTH to prevent ReDoS.
|
|
3543
|
+
* Patterns should be admin-audited. Use checkRegexSafety() when creating rules.
|
|
3544
|
+
*/
|
|
3545
|
+
evaluateRegexRule(card, rule) {
|
|
3546
|
+
let regex = this.compiledRegexCache.get(rule.id);
|
|
3547
|
+
if (!regex) {
|
|
3548
|
+
const safetyWarning = checkRegexSafety(rule.pattern);
|
|
3549
|
+
if (safetyWarning) {
|
|
3550
|
+
console.warn(`[moderation] Rule "${rule.name}": ${safetyWarning}`);
|
|
3551
|
+
}
|
|
3552
|
+
try {
|
|
3553
|
+
regex = new RegExp(rule.pattern, "i");
|
|
3554
|
+
this.compiledRegexCache.set(rule.id, regex);
|
|
3555
|
+
} catch {
|
|
3556
|
+
return null;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
const fields = rule.targetFields || DEFAULT_TARGET_FIELDS;
|
|
3560
|
+
for (const field of fields) {
|
|
3561
|
+
const value = this.getCardField(card, field);
|
|
3562
|
+
if (typeof value === "string") {
|
|
3563
|
+
const truncatedValue = value.length > MAX_REGEX_INPUT_LENGTH ? value.slice(0, MAX_REGEX_INPUT_LENGTH) : value;
|
|
3564
|
+
const match = truncatedValue.match(regex);
|
|
3565
|
+
if (match) {
|
|
3566
|
+
return { field, value: match[0] };
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
return null;
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* Tag rule - check card tags array
|
|
3574
|
+
*/
|
|
3575
|
+
evaluateTagRule(card, rule) {
|
|
3576
|
+
const tags = card.data.tags || [];
|
|
3577
|
+
const targetTag = rule.pattern.toLowerCase();
|
|
3578
|
+
const found = tags.find((t) => t.toLowerCase() === targetTag);
|
|
3579
|
+
if (found) {
|
|
3580
|
+
return { field: "tags", value: found };
|
|
3581
|
+
}
|
|
3582
|
+
return null;
|
|
3583
|
+
}
|
|
3584
|
+
/**
|
|
3585
|
+
* Creator rule - match card creator
|
|
3586
|
+
*/
|
|
3587
|
+
evaluateCreatorRule(card, rule) {
|
|
3588
|
+
const creator = card.data.creator?.toLowerCase() || "";
|
|
3589
|
+
if (creator === rule.pattern.toLowerCase()) {
|
|
3590
|
+
return { field: "creator", value: creator };
|
|
3591
|
+
}
|
|
3592
|
+
return null;
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Instance rule - match source domain
|
|
3596
|
+
* Supports wildcard patterns like "*.evil.com"
|
|
3597
|
+
*/
|
|
3598
|
+
evaluateInstanceRule(sourceInstance, rule) {
|
|
3599
|
+
if (!sourceInstance) return null;
|
|
3600
|
+
const domain = sourceInstance.toLowerCase();
|
|
3601
|
+
const pattern = rule.pattern.toLowerCase();
|
|
3602
|
+
if (pattern.startsWith("*.")) {
|
|
3603
|
+
const suffix = pattern.slice(2);
|
|
3604
|
+
if (domain.endsWith(suffix) || domain === suffix) {
|
|
3605
|
+
return { field: "instance", value: domain };
|
|
3606
|
+
}
|
|
3607
|
+
} else if (domain === pattern) {
|
|
3608
|
+
return { field: "instance", value: domain };
|
|
3609
|
+
}
|
|
3610
|
+
return null;
|
|
3611
|
+
}
|
|
3612
|
+
/**
|
|
3613
|
+
* Get a field value from card data
|
|
3614
|
+
*/
|
|
3615
|
+
getCardField(card, field) {
|
|
3616
|
+
if (field.includes(".")) {
|
|
3617
|
+
const parts = field.split(".");
|
|
3618
|
+
let value = card.data;
|
|
3619
|
+
for (const part of parts) {
|
|
3620
|
+
if (value && typeof value === "object" && part in value) {
|
|
3621
|
+
value = value[part];
|
|
3622
|
+
} else {
|
|
3623
|
+
return void 0;
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
return value;
|
|
3627
|
+
}
|
|
3628
|
+
return card.data[field];
|
|
3629
|
+
}
|
|
3630
|
+
/**
|
|
3631
|
+
* Clear regex cache (call when rules change)
|
|
3632
|
+
*/
|
|
3633
|
+
clearCache() {
|
|
3634
|
+
this.compiledRegexCache.clear();
|
|
3635
|
+
}
|
|
3636
|
+
/**
|
|
3637
|
+
* Pre-compile regexes for a policy (optimization)
|
|
3638
|
+
*
|
|
3639
|
+
* @returns errors for invalid patterns, warnings for potentially unsafe patterns
|
|
3640
|
+
*/
|
|
3641
|
+
precompilePolicy(policy) {
|
|
3642
|
+
const errors = [];
|
|
3643
|
+
const warnings = [];
|
|
3644
|
+
for (const rule of policy.rules) {
|
|
3645
|
+
if (rule.type === "regex" && rule.enabled) {
|
|
3646
|
+
const safetyWarning = checkRegexSafety(rule.pattern);
|
|
3647
|
+
if (safetyWarning) {
|
|
3648
|
+
warnings.push(`Rule "${rule.name}": ${safetyWarning}`);
|
|
3649
|
+
}
|
|
3650
|
+
try {
|
|
3651
|
+
const regex = new RegExp(rule.pattern, "i");
|
|
3652
|
+
this.compiledRegexCache.set(rule.id, regex);
|
|
3653
|
+
} catch (err) {
|
|
3654
|
+
errors.push(`Rule "${rule.name}" has invalid regex: ${err instanceof Error ? err.message : String(err)}`);
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
return { errors, warnings };
|
|
3659
|
+
}
|
|
3660
|
+
};
|
|
3661
|
+
var DEFAULT_CONFIG = {
|
|
3662
|
+
maxTokens: 5,
|
|
3663
|
+
refillRate: 1
|
|
3664
|
+
};
|
|
3665
|
+
var RateLimiter = class {
|
|
3666
|
+
store;
|
|
3667
|
+
config;
|
|
3668
|
+
constructor(store, config) {
|
|
3669
|
+
this.store = store;
|
|
3670
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
3671
|
+
}
|
|
3672
|
+
/**
|
|
3673
|
+
* Check if action is allowed and consume a token if so
|
|
3674
|
+
*
|
|
3675
|
+
* @param actorId - Actor URI to check
|
|
3676
|
+
* @returns Rate limit result with remaining tokens and reset time
|
|
3677
|
+
*/
|
|
3678
|
+
async checkAndConsume(actorId) {
|
|
3679
|
+
let bucket = await this.store.getRateLimitBucket(actorId);
|
|
3680
|
+
if (!bucket) {
|
|
3681
|
+
bucket = {
|
|
3682
|
+
actorId,
|
|
3683
|
+
tokens: this.config.maxTokens,
|
|
3684
|
+
maxTokens: this.config.maxTokens,
|
|
3685
|
+
lastRefill: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3686
|
+
refillRate: this.config.refillRate
|
|
3687
|
+
};
|
|
3688
|
+
}
|
|
3689
|
+
bucket = this.refillBucket(bucket);
|
|
3690
|
+
if (bucket.tokens < 1) {
|
|
3691
|
+
const resetAt = this.calculateResetTime(bucket);
|
|
3692
|
+
const retryAfter = Math.ceil((new Date(resetAt).getTime() - Date.now()) / 1e3);
|
|
3693
|
+
await this.store.updateRateLimitBucket(bucket);
|
|
3694
|
+
return {
|
|
3695
|
+
allowed: false,
|
|
3696
|
+
remaining: 0,
|
|
3697
|
+
resetAt,
|
|
3698
|
+
retryAfter: Math.max(0, retryAfter)
|
|
3699
|
+
};
|
|
3700
|
+
}
|
|
3701
|
+
bucket.tokens -= 1;
|
|
3702
|
+
await this.store.updateRateLimitBucket(bucket);
|
|
3703
|
+
return {
|
|
3704
|
+
allowed: true,
|
|
3705
|
+
remaining: Math.floor(bucket.tokens),
|
|
3706
|
+
resetAt: this.calculateResetTime(bucket)
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
/**
|
|
3710
|
+
* Check remaining tokens without consuming
|
|
3711
|
+
*
|
|
3712
|
+
* @param actorId - Actor URI to check
|
|
3713
|
+
* @returns Current rate limit status
|
|
3714
|
+
*/
|
|
3715
|
+
async check(actorId) {
|
|
3716
|
+
let bucket = await this.store.getRateLimitBucket(actorId);
|
|
3717
|
+
if (!bucket) {
|
|
3718
|
+
return {
|
|
3719
|
+
allowed: true,
|
|
3720
|
+
remaining: this.config.maxTokens,
|
|
3721
|
+
resetAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
bucket = this.refillBucket(bucket);
|
|
3725
|
+
return {
|
|
3726
|
+
allowed: bucket.tokens >= 1,
|
|
3727
|
+
remaining: Math.floor(bucket.tokens),
|
|
3728
|
+
resetAt: this.calculateResetTime(bucket),
|
|
3729
|
+
retryAfter: bucket.tokens < 1 ? Math.ceil((new Date(this.calculateResetTime(bucket)).getTime() - Date.now()) / 1e3) : void 0
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
/**
|
|
3733
|
+
* Reset rate limit for an actor (admin function)
|
|
3734
|
+
*
|
|
3735
|
+
* @param actorId - Actor URI to reset
|
|
3736
|
+
*/
|
|
3737
|
+
async reset(actorId) {
|
|
3738
|
+
const bucket = {
|
|
3739
|
+
actorId,
|
|
3740
|
+
tokens: this.config.maxTokens,
|
|
3741
|
+
maxTokens: this.config.maxTokens,
|
|
3742
|
+
lastRefill: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3743
|
+
refillRate: this.config.refillRate
|
|
3744
|
+
};
|
|
3745
|
+
await this.store.updateRateLimitBucket(bucket);
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* Refill tokens based on time elapsed since last refill
|
|
3749
|
+
*/
|
|
3750
|
+
refillBucket(bucket) {
|
|
3751
|
+
const now = /* @__PURE__ */ new Date();
|
|
3752
|
+
const lastRefill = new Date(bucket.lastRefill);
|
|
3753
|
+
const hoursSinceRefill = (now.getTime() - lastRefill.getTime()) / (1e3 * 60 * 60);
|
|
3754
|
+
const tokensToAdd = hoursSinceRefill * bucket.refillRate;
|
|
3755
|
+
if (tokensToAdd >= 1) {
|
|
3756
|
+
bucket.tokens = Math.min(bucket.maxTokens, bucket.tokens + Math.floor(tokensToAdd));
|
|
3757
|
+
bucket.lastRefill = now.toISOString();
|
|
3758
|
+
}
|
|
3759
|
+
return bucket;
|
|
3760
|
+
}
|
|
3761
|
+
/**
|
|
3762
|
+
* Calculate when bucket will have at least 1 token
|
|
3763
|
+
*/
|
|
3764
|
+
calculateResetTime(bucket) {
|
|
3765
|
+
if (bucket.tokens >= bucket.maxTokens) {
|
|
3766
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
3767
|
+
}
|
|
3768
|
+
const tokensNeeded = Math.max(0, 1 - bucket.tokens);
|
|
3769
|
+
const hoursUntilToken = tokensNeeded / bucket.refillRate;
|
|
3770
|
+
const msUntilToken = hoursUntilToken * 60 * 60 * 1e3;
|
|
3771
|
+
const lastRefill = new Date(bucket.lastRefill);
|
|
3772
|
+
return new Date(lastRefill.getTime() + msUntilToken).toISOString();
|
|
3773
|
+
}
|
|
3774
|
+
/**
|
|
3775
|
+
* Get current configuration
|
|
3776
|
+
*/
|
|
3777
|
+
getConfig() {
|
|
3778
|
+
return { ...this.config };
|
|
3779
|
+
}
|
|
3780
|
+
};
|
|
3781
|
+
var explicitlyEnabled = false;
|
|
3782
|
+
var envCheckSkipped = false;
|
|
3783
|
+
function getEnvVar(name) {
|
|
3784
|
+
if (typeof process !== "undefined" && process?.env) {
|
|
3785
|
+
return process.env[name];
|
|
3786
|
+
}
|
|
3787
|
+
return void 0;
|
|
3788
|
+
}
|
|
3789
|
+
function enableFederation(options) {
|
|
3790
|
+
explicitlyEnabled = true;
|
|
3791
|
+
envCheckSkipped = options?.skipEnvCheck ?? false;
|
|
3792
|
+
const nodeEnv = getEnvVar("NODE_ENV");
|
|
3793
|
+
if (nodeEnv === "development" || nodeEnv === "test") {
|
|
3794
|
+
console.warn(
|
|
3795
|
+
"[character-foundry/federation] Federation enabled. WARNING: Verify HTTP signatures in production. Do NOT use in production with untrusted inputs without signature validation."
|
|
3796
|
+
);
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
function isFederationEnabled() {
|
|
3800
|
+
if (!explicitlyEnabled) {
|
|
3801
|
+
return false;
|
|
3802
|
+
}
|
|
3803
|
+
if (envCheckSkipped) {
|
|
3804
|
+
return true;
|
|
3805
|
+
}
|
|
3806
|
+
const envEnabled = getEnvVar("FEDERATION_ENABLED") === "true";
|
|
3807
|
+
return envEnabled;
|
|
3808
|
+
}
|
|
3809
|
+
function assertFederationEnabled(feature) {
|
|
3810
|
+
if (!explicitlyEnabled) {
|
|
3811
|
+
const hasProcess = typeof process !== "undefined" && process?.env;
|
|
3812
|
+
const envHint = hasProcess ? "You must BOTH call enableFederation() AND set FEDERATION_ENABLED=true." : "You must call enableFederation({ skipEnvCheck: true }) in browser/Workers environments.";
|
|
3813
|
+
throw new Error(
|
|
3814
|
+
`Federation is not enabled. ${feature} requires federation to be explicitly enabled. ${envHint} WARNING: Federation security features are incomplete.`
|
|
3815
|
+
);
|
|
3816
|
+
}
|
|
3817
|
+
if (envCheckSkipped) {
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
const envEnabled = getEnvVar("FEDERATION_ENABLED") === "true";
|
|
3821
|
+
if (!envEnabled) {
|
|
3822
|
+
throw new Error(
|
|
3823
|
+
`FEDERATION_ENABLED environment variable not set. ${feature} requires FEDERATION_ENABLED=true. Code opt-in alone is not sufficient (dual opt-in required). In browser/Workers, use enableFederation({ skipEnvCheck: true }) instead.`
|
|
3824
|
+
);
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
export {
|
|
3828
|
+
ACTIVITY_CONTEXT,
|
|
3829
|
+
BasePlatformAdapter,
|
|
3830
|
+
D1ModerationStore,
|
|
3831
|
+
D1SyncStateStore,
|
|
3832
|
+
FORK_ACTIVITY_CONTEXT,
|
|
3833
|
+
FileSyncStateStore,
|
|
3834
|
+
HttpPlatformAdapter,
|
|
3835
|
+
INSTALL_ACTIVITY_CONTEXT,
|
|
3836
|
+
InvalidResourceIdError,
|
|
3837
|
+
MODERATION_ACTIVITY_CONTEXT,
|
|
3838
|
+
MemoryModerationStore,
|
|
3839
|
+
MemoryPlatformAdapter,
|
|
3840
|
+
MemorySyncStateStore,
|
|
3841
|
+
PolicyEngine,
|
|
3842
|
+
REQUIRED_SIGNED_HEADERS,
|
|
3843
|
+
RateLimiter,
|
|
3844
|
+
SillyTavernAdapter,
|
|
3845
|
+
SyncEngine,
|
|
3846
|
+
assertFederationEnabled,
|
|
3847
|
+
buildSigningString,
|
|
3848
|
+
buildSigningStringStrict,
|
|
3849
|
+
calculateDigest,
|
|
3850
|
+
cardFromActivityPub,
|
|
3851
|
+
cardToActivityPub,
|
|
3852
|
+
ccv3ToSTCharacter,
|
|
3853
|
+
checkRegexSafety,
|
|
3854
|
+
createActor,
|
|
3855
|
+
createAnnounceActivity,
|
|
3856
|
+
createArchiveAdapter,
|
|
3857
|
+
createBlockActivity,
|
|
3858
|
+
createCreateActivity,
|
|
3859
|
+
createDeleteActivity,
|
|
3860
|
+
createFlagActivity,
|
|
3861
|
+
createForkActivity,
|
|
3862
|
+
createHubAdapter,
|
|
3863
|
+
createInstallActivity,
|
|
3864
|
+
createLikeActivity,
|
|
3865
|
+
createLocalStorageStore,
|
|
3866
|
+
createMockSTBridge,
|
|
3867
|
+
createUndoActivity,
|
|
3868
|
+
createUpdateActivity,
|
|
3869
|
+
enableFederation,
|
|
3870
|
+
generateActivityId,
|
|
3871
|
+
generateCardId,
|
|
3872
|
+
handleActor,
|
|
3873
|
+
handleInbox,
|
|
3874
|
+
handleNodeInfo,
|
|
3875
|
+
handleNodeInfoDiscovery,
|
|
3876
|
+
handleWebFinger,
|
|
3877
|
+
isFederationEnabled,
|
|
3878
|
+
parseActivity,
|
|
3879
|
+
parseBlockActivity,
|
|
3880
|
+
parseFlagActivity,
|
|
3881
|
+
parseForkActivity,
|
|
3882
|
+
parseInstallActivity,
|
|
3883
|
+
parseSignatureHeader,
|
|
3884
|
+
signRequest,
|
|
3885
|
+
stCharacterToCCv3,
|
|
3886
|
+
validateBlockActivity as validateBlockActivityFields,
|
|
3887
|
+
validateFlagActivity as validateFlagActivityFields,
|
|
3888
|
+
validateForkActivity,
|
|
3889
|
+
validateActivitySignature as validateHttpSignature,
|
|
3890
|
+
validateInstallActivity,
|
|
3891
|
+
verifyHttpSignature
|
|
3892
|
+
};
|
|
2
3893
|
//# sourceMappingURL=federation.js.map
|