@tpsdev-ai/flair 0.7.0 → 0.8.0
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 +12 -1
- package/dist/bridges/builtins/index.js +28 -11
- package/dist/bridges/builtins/markdown.js +66 -0
- package/dist/bridges/runtime/context.js +2 -1
- package/dist/bridges/runtime/formats.js +97 -9
- package/dist/bridges/runtime/load-bridge.js +15 -5
- package/dist/cli.js +1445 -103
- package/dist/resources/Federation.js +67 -10
- package/dist/resources/Memory.js +17 -24
- package/dist/resources/MemoryBootstrap.js +11 -2
- package/dist/resources/auth-middleware.js +23 -1
- package/dist/resources/content-safety.js +21 -0
- package/dist/resources/federation-cleanup.js +161 -0
- package/dist/resources/federation-crypto.js +100 -1
- package/package.json +6 -6
- package/schemas/memory.graphql +28 -0
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { Resource, databases } from "@harperfast/harper";
|
|
1
|
+
import { Resource, databases, server } from "@harperfast/harper";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
|
-
import { canonicalize, signBody, verifyBodySignature } from "./federation-crypto.js";
|
|
4
|
+
import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, createNonceStore, generateNonce, } from "./federation-crypto.js";
|
|
5
|
+
import { initFederationCleanup } from "./federation-cleanup.js";
|
|
6
|
+
// Module-level nonce store for federation anti-replay.
|
|
7
|
+
// Shared across FederationPair + FederationSync — nonces are globally unique
|
|
8
|
+
// (generated by signBodyFresh per request with 128-bit random nonces).
|
|
9
|
+
const federationNonceStore = createNonceStore();
|
|
5
10
|
// Re-export for consumers that import from Federation.ts
|
|
6
|
-
export { canonicalize, signBody, verifyBodySignature };
|
|
11
|
+
export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce };
|
|
7
12
|
// ─── Conflict resolution ─────────────────────────────────────────────────────
|
|
8
13
|
/**
|
|
9
14
|
* Field-level Last-Write-Wins merge.
|
|
@@ -97,15 +102,21 @@ export class FederationPair extends Resource {
|
|
|
97
102
|
status: 400, headers: { "content-type": "application/json" },
|
|
98
103
|
});
|
|
99
104
|
}
|
|
100
|
-
// Verify signature
|
|
101
|
-
//
|
|
105
|
+
// Verify request signature against the public key (proves key ownership).
|
|
106
|
+
// Uses fresh verification with anti-replay — even though pair has implicit
|
|
107
|
+
// replay protection via single-use pairingToken, explicit anti-replay is
|
|
108
|
+
// defense-in-depth and keeps signature semantics consistent across endpoints.
|
|
102
109
|
if (!signature) {
|
|
103
110
|
return new Response(JSON.stringify({ error: "signature required — request must be signed" }), {
|
|
104
111
|
status: 401, headers: { "content-type": "application/json" },
|
|
105
112
|
});
|
|
106
113
|
}
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
const verifyResult = verifyBodySignatureFresh(data, publicKey, {
|
|
115
|
+
windowMs: 30_000,
|
|
116
|
+
nonceStore: federationNonceStore,
|
|
117
|
+
});
|
|
118
|
+
if (!verifyResult.ok) {
|
|
119
|
+
return new Response(JSON.stringify({ error: `invalid signature — ${verifyResult.reason}` }), {
|
|
109
120
|
status: 401, headers: { "content-type": "application/json" },
|
|
110
121
|
});
|
|
111
122
|
}
|
|
@@ -152,6 +163,18 @@ export class FederationPair extends Resource {
|
|
|
152
163
|
status: 401, headers: { "content-type": "application/json" },
|
|
153
164
|
});
|
|
154
165
|
}
|
|
166
|
+
// PR-4: bind authenticated bootstrap user to specific pairing token
|
|
167
|
+
const ctx = this.getContext?.() ?? {};
|
|
168
|
+
const authedUser = ctx.request?.tpsAgent;
|
|
169
|
+
const isAdmin = ctx.request?.tpsAgentIsAdmin === true;
|
|
170
|
+
if (!isAdmin && authedUser) {
|
|
171
|
+
const expectedBootstrap = `pair-bootstrap-${pairingToken.slice(0, 8)}`;
|
|
172
|
+
if (authedUser !== expectedBootstrap) {
|
|
173
|
+
return new Response(JSON.stringify({
|
|
174
|
+
error: "bootstrap_user_token_mismatch",
|
|
175
|
+
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
155
178
|
// Consume the token
|
|
156
179
|
await databases.flair.PairingToken.put({
|
|
157
180
|
...tokenRecord,
|
|
@@ -169,6 +192,15 @@ export class FederationPair extends Resource {
|
|
|
169
192
|
createdAt: new Date().toISOString(),
|
|
170
193
|
updatedAt: new Date().toISOString(),
|
|
171
194
|
});
|
|
195
|
+
// Drop the bootstrap user that was created alongside this pairing token.
|
|
196
|
+
// Failure here is non-fatal: the cleanup sweep (PR-5) will catch stragglers.
|
|
197
|
+
try {
|
|
198
|
+
const bootstrapUsername = `pair-bootstrap-${pairingToken.slice(0, 8)}`;
|
|
199
|
+
await server.operation({ operation: "drop_user", username: bootstrapUsername }, ctx, false);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
console.warn(`[federation] drop_user failed: ${err?.message} — cleanup sweep will retry`);
|
|
203
|
+
}
|
|
172
204
|
}
|
|
173
205
|
// Return our own identity for the peer to record
|
|
174
206
|
let ourInstance = null;
|
|
@@ -196,6 +228,12 @@ export class FederationPair extends Resource {
|
|
|
196
228
|
* The calling peer sends a batch of SyncRecords; we merge them.
|
|
197
229
|
*/
|
|
198
230
|
export class FederationSync extends Resource {
|
|
231
|
+
// Public endpoint by design: spokes have no Harper credentials. Body signature
|
|
232
|
+
// (with timestamp + nonce, via verifyBodySignatureFresh) is the auth boundary.
|
|
233
|
+
// Same pattern as FederationPair.
|
|
234
|
+
allowCreate(_user, _record, _context) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
199
237
|
async post(data) {
|
|
200
238
|
const { instanceId, records, lamportClock, signature } = data || {};
|
|
201
239
|
if (!instanceId || !Array.isArray(records)) {
|
|
@@ -210,14 +248,20 @@ export class FederationSync extends Resource {
|
|
|
210
248
|
status: 403, headers: { "content-type": "application/json" },
|
|
211
249
|
});
|
|
212
250
|
}
|
|
213
|
-
// Verify request signature against peer's pinned public key
|
|
251
|
+
// Verify request signature against peer's pinned public key.
|
|
252
|
+
// Fresh verification with anti-replay — prevents captured signatures from
|
|
253
|
+
// being replayed across the timestamp window.
|
|
214
254
|
if (!signature) {
|
|
215
255
|
return new Response(JSON.stringify({ error: "signature required — sync requests must be signed" }), {
|
|
216
256
|
status: 401, headers: { "content-type": "application/json" },
|
|
217
257
|
});
|
|
218
258
|
}
|
|
219
|
-
|
|
220
|
-
|
|
259
|
+
const verifyResult = verifyBodySignatureFresh(data, peer.publicKey, {
|
|
260
|
+
windowMs: 30_000,
|
|
261
|
+
nonceStore: federationNonceStore,
|
|
262
|
+
});
|
|
263
|
+
if (!verifyResult.ok) {
|
|
264
|
+
return new Response(JSON.stringify({ error: `invalid signature — ${verifyResult.reason}` }), {
|
|
221
265
|
status: 401, headers: { "content-type": "application/json" },
|
|
222
266
|
});
|
|
223
267
|
}
|
|
@@ -319,3 +363,16 @@ export class FederationPeers extends Resource {
|
|
|
319
363
|
return { peers };
|
|
320
364
|
}
|
|
321
365
|
}
|
|
366
|
+
// ── Module initialisation ────────────────────────────────────────────────────
|
|
367
|
+
// Deferred: the cleanup sweep (PR-5) starts after the module graph resolves.
|
|
368
|
+
// In unit test environments (no Harper runtime), setTimeout never fires
|
|
369
|
+
// synchronously and import is safe. In the live Harper process, the sweep
|
|
370
|
+
// will start on the next event loop tick and runs only on hub instances.
|
|
371
|
+
if (typeof setTimeout !== "undefined") {
|
|
372
|
+
setTimeout(() => {
|
|
373
|
+
initFederationCleanup().catch((err) => {
|
|
374
|
+
// Swallow — in test/resource-env the Harper databases may not be bound.
|
|
375
|
+
// In production, initFederationCleanup handles its own error paths.
|
|
376
|
+
});
|
|
377
|
+
}, 0);
|
|
378
|
+
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -2,7 +2,7 @@ import { databases } from "@harperfast/harper";
|
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
3
|
import { isAdmin } from "./auth-middleware.js";
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
|
-
import {
|
|
5
|
+
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
6
6
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
7
7
|
export class Memory extends databases.flair.Memory {
|
|
8
8
|
/**
|
|
@@ -41,25 +41,17 @@ export class Memory extends databases.flair.Memory {
|
|
|
41
41
|
const agentIdCondition = allowedOwners.length === 1
|
|
42
42
|
? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
|
|
43
43
|
: { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
|
|
44
|
-
// Harper passes `query` as a RequestTarget (extends URLSearchParams)
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
44
|
+
// Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
|
|
45
|
+
// conditions array. For URL-based GET /Memory?... calls, URL params are no
|
|
46
|
+
// longer translated to conditions here — callers should use
|
|
47
|
+
// POST /Memory/search_by_conditions with an explicit conditions array.
|
|
48
|
+
// For programmatic calls with a conditions array, we wrap with the agentId scope.
|
|
49
49
|
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
50
|
-
const existing = [];
|
|
51
50
|
if (Array.isArray(query.conditions) && query.conditions.length > 0) {
|
|
52
|
-
|
|
51
|
+
query.conditions = [agentIdCondition, ...query.conditions];
|
|
52
|
+
return withDetachedTxn(ctx, () => super.search(query));
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
for (const [k, v] of query.entries()) {
|
|
56
|
-
if (k === "agentId")
|
|
57
|
-
continue;
|
|
58
|
-
existing.push({ attribute: k, comparator: "equals", value: v });
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
query.conditions = [agentIdCondition, ...existing];
|
|
62
|
-
return withDetachedTxn(ctx, () => super.search(query));
|
|
54
|
+
// Fallback: no conditions array present — just scope and pass through
|
|
63
55
|
}
|
|
64
56
|
// Fallback: plain array or no query (internal calls)
|
|
65
57
|
const conditions = Array.isArray(query) && query.length > 0
|
|
@@ -114,9 +106,10 @@ export class Memory extends databases.flair.Memory {
|
|
|
114
106
|
const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
|
|
115
107
|
content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
|
|
116
108
|
}
|
|
117
|
-
// Content safety scan
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
// Content safety scan — covers content + summary (defense-in-depth for
|
|
110
|
+
// agent-set summaries, ops-i2jb).
|
|
111
|
+
if (content.content || content.summary) {
|
|
112
|
+
const safety = scanFields(content, ["content", "summary"]);
|
|
120
113
|
if (!safety.safe) {
|
|
121
114
|
if (isStrictMode()) {
|
|
122
115
|
return new Response(JSON.stringify({
|
|
@@ -163,9 +156,9 @@ export class Memory extends databases.flair.Memory {
|
|
|
163
156
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
164
157
|
content.archived = content.archived ?? false;
|
|
165
158
|
content.createdAt = content.createdAt ?? now;
|
|
166
|
-
// Content safety scan on updated content
|
|
167
|
-
if (content.content) {
|
|
168
|
-
const safety =
|
|
159
|
+
// Content safety scan on updated content + summary (ops-i2jb).
|
|
160
|
+
if (content.content || content.summary) {
|
|
161
|
+
const safety = scanFields(content, ["content", "summary"]);
|
|
169
162
|
if (!safety.safe) {
|
|
170
163
|
if (isStrictMode()) {
|
|
171
164
|
return new Response(JSON.stringify({
|
|
@@ -177,7 +170,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
177
170
|
content._safetyFlags = safety.flags;
|
|
178
171
|
}
|
|
179
172
|
else {
|
|
180
|
-
// Clear previous flags if
|
|
173
|
+
// Clear previous flags if both fields are now clean
|
|
181
174
|
content._safetyFlags = null;
|
|
182
175
|
}
|
|
183
176
|
}
|
|
@@ -82,6 +82,7 @@ export class BootstrapMemories extends Resource {
|
|
|
82
82
|
let tokenBudget = maxTokens;
|
|
83
83
|
let memoriesIncluded = 0;
|
|
84
84
|
let memoriesAvailable = 0;
|
|
85
|
+
let memoriesTruncated = 0;
|
|
85
86
|
// --- 1. Soul records (budgeted — prioritized by key importance) ---
|
|
86
87
|
// Soul is who you are, but we still need to respect token budgets.
|
|
87
88
|
// Workspace files (SOUL.md, AGENTS.md) can be massive — they're already
|
|
@@ -189,6 +190,9 @@ export class BootstrapMemories extends Resource {
|
|
|
189
190
|
tokenBudget -= cost;
|
|
190
191
|
memoriesIncluded++;
|
|
191
192
|
}
|
|
193
|
+
else {
|
|
194
|
+
memoriesTruncated++;
|
|
195
|
+
}
|
|
192
196
|
}
|
|
193
197
|
// --- 3. Recent memories (adaptive window) ---
|
|
194
198
|
// Start with 48h. If nothing found, widen to 7d, then 30d.
|
|
@@ -217,8 +221,10 @@ export class BootstrapMemories extends Resource {
|
|
|
217
221
|
for (const m of recent) {
|
|
218
222
|
const line = formatMemory(m);
|
|
219
223
|
const cost = estimateTokens(line);
|
|
220
|
-
if (recentSpent + cost > recentBudget)
|
|
224
|
+
if (recentSpent + cost > recentBudget) {
|
|
225
|
+
memoriesTruncated++;
|
|
221
226
|
continue;
|
|
227
|
+
}
|
|
222
228
|
sections.recent.push(line);
|
|
223
229
|
recentSpent += cost;
|
|
224
230
|
tokenBudget -= cost;
|
|
@@ -249,8 +255,10 @@ export class BootstrapMemories extends Resource {
|
|
|
249
255
|
for (const m of subjectMemories) {
|
|
250
256
|
const line = formatMemory(m);
|
|
251
257
|
const cost = estimateTokens(line);
|
|
252
|
-
if (predictedSpent + cost > predictedBudget)
|
|
258
|
+
if (predictedSpent + cost > predictedBudget) {
|
|
259
|
+
memoriesTruncated++;
|
|
253
260
|
continue;
|
|
261
|
+
}
|
|
254
262
|
sections.predicted.push(line);
|
|
255
263
|
predictedSpent += cost;
|
|
256
264
|
tokenBudget -= cost;
|
|
@@ -401,6 +409,7 @@ export class BootstrapMemories extends Resource {
|
|
|
401
409
|
memoryTokens,
|
|
402
410
|
memoriesIncluded,
|
|
403
411
|
memoriesAvailable,
|
|
412
|
+
memoriesTruncated,
|
|
404
413
|
};
|
|
405
414
|
}
|
|
406
415
|
}
|
|
@@ -117,7 +117,8 @@ server.http(async (request, nextLayer) => {
|
|
|
117
117
|
url.pathname === "/AgentCard" ||
|
|
118
118
|
url.pathname.startsWith("/A2AAdapter/") ||
|
|
119
119
|
url.pathname.startsWith("/AgentCard/") ||
|
|
120
|
-
// FederationSync uses Ed25519 body-signature auth
|
|
120
|
+
// FederationSync uses Ed25519 body-signature auth with anti-replay, validated
|
|
121
|
+
// by the resource handler (allowCreate=true, same pattern as FederationPair).
|
|
121
122
|
url.pathname === "/FederationSync" ||
|
|
122
123
|
// FederationPair uses one-time PairingToken in the request body, validated
|
|
123
124
|
// by the resource itself (allowCreate=true on the Resource lets anonymous
|
|
@@ -191,6 +192,27 @@ server.http(async (request, nextLayer) => {
|
|
|
191
192
|
request.tpsAgentIsAdmin = true;
|
|
192
193
|
return nextLayer(request);
|
|
193
194
|
}
|
|
195
|
+
// Path 3: flair_pair_initiator — restricted to /FederationPair only.
|
|
196
|
+
// Bootstrap credentials (pair-bootstrap-<id>) may only be used on this
|
|
197
|
+
// one endpoint. Any other path must fall through to 401.
|
|
198
|
+
if (url.pathname === "/FederationPair" && user.startsWith("pair-bootstrap-")) {
|
|
199
|
+
let pairUser = null;
|
|
200
|
+
try {
|
|
201
|
+
pairUser = await server.getUser(user, pass, request);
|
|
202
|
+
}
|
|
203
|
+
catch { /* fall through */ }
|
|
204
|
+
if (pairUser?.role?.role === "flair_pair_initiator" &&
|
|
205
|
+
pairUser?.active === true) {
|
|
206
|
+
request._tpsAuthVerified = true;
|
|
207
|
+
request.user = pairUser;
|
|
208
|
+
request.headers.set("x-tps-agent", user);
|
|
209
|
+
if (request.headers.asObject)
|
|
210
|
+
request.headers.asObject["x-tps-agent"] = user;
|
|
211
|
+
request.tpsAgent = user;
|
|
212
|
+
request.tpsAgentIsAdmin = false;
|
|
213
|
+
return nextLayer(request);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
194
216
|
}
|
|
195
217
|
catch { /* fall through to Ed25519 check */ }
|
|
196
218
|
return new Response(JSON.stringify({ error: "invalid_admin_credentials" }), { status: 401 });
|
|
@@ -47,6 +47,27 @@ export function scanContent(text) {
|
|
|
47
47
|
}
|
|
48
48
|
return { safe: flags.length === 0, flags };
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Scan multiple string fields on a record, returning a merged SafetyResult.
|
|
52
|
+
* Non-string / empty fields are skipped. Flags are de-duplicated across fields.
|
|
53
|
+
*/
|
|
54
|
+
export function scanFields(record, fields) {
|
|
55
|
+
const flags = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
for (const field of fields) {
|
|
58
|
+
const value = record[field];
|
|
59
|
+
if (typeof value !== "string" || value.length === 0)
|
|
60
|
+
continue;
|
|
61
|
+
const result = scanContent(value);
|
|
62
|
+
for (const flag of result.flags) {
|
|
63
|
+
if (!seen.has(flag)) {
|
|
64
|
+
flags.push(flag);
|
|
65
|
+
seen.add(flag);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { safe: flags.length === 0, flags };
|
|
70
|
+
}
|
|
50
71
|
/**
|
|
51
72
|
* Check if strict mode is enabled (rejects flagged writes).
|
|
52
73
|
*/
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes
|
|
2
|
+
let cleanupTimer = null;
|
|
3
|
+
/**
|
|
4
|
+
* Initialise the federation cleanup sweep.
|
|
5
|
+
*
|
|
6
|
+
* Only active on hub instances — reads the Instance table to determine
|
|
7
|
+
* role. On spokes this is a no-op.
|
|
8
|
+
*
|
|
9
|
+
* On hubs: ensures a 5-minute setInterval that sweeps PairingToken records
|
|
10
|
+
* for consumed or expired tokens, drops the corresponding bootstrap users,
|
|
11
|
+
* and performs housekeeping on expired/unconsumed token records.
|
|
12
|
+
*
|
|
13
|
+
* In test environments, callers pass mock serverOp/db via `opts` so this
|
|
14
|
+
* module never imports @harperfast/harper at the top level (which would
|
|
15
|
+
* crash when STORAGE_PATH isn't set).
|
|
16
|
+
*/
|
|
17
|
+
export async function initFederationCleanup(opts) {
|
|
18
|
+
const immediate = opts?.immediateTick ?? true;
|
|
19
|
+
// Resolve server / databases: use caller-supplied mocks when available,
|
|
20
|
+
// otherwise lazy-import @harperfast/harper at call time.
|
|
21
|
+
let svr;
|
|
22
|
+
let db;
|
|
23
|
+
if (opts?.serverOp && opts?.db) {
|
|
24
|
+
svr = opts.serverOp;
|
|
25
|
+
db = opts.db;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
try {
|
|
29
|
+
const harper = await import("@harperfast/harper");
|
|
30
|
+
svr = opts?.serverOp ?? harper.server.operation;
|
|
31
|
+
db = opts?.db ?? harper.databases;
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error("[federation-cleanup] failed to load @harperfast/harper:", err?.message ?? err);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const role = opts?.instanceRole !== undefined
|
|
39
|
+
? opts.instanceRole
|
|
40
|
+
: await getInstanceRole(db);
|
|
41
|
+
if (role !== "hub") {
|
|
42
|
+
console.log("[federation-cleanup] not a hub instance — cleanup disabled");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.log("[federation-cleanup] starting cleanup sweep (5-min cadence)");
|
|
46
|
+
if (cleanupTimer)
|
|
47
|
+
clearInterval(cleanupTimer);
|
|
48
|
+
cleanupTimer = setInterval(() => {
|
|
49
|
+
runCleanupTick({ serverOp: svr, db }).catch((err) => {
|
|
50
|
+
console.error("[federation-cleanup] tick error:", err?.message ?? err);
|
|
51
|
+
});
|
|
52
|
+
}, CLEANUP_INTERVAL_MS);
|
|
53
|
+
if (immediate) {
|
|
54
|
+
// Run an immediate first tick after role detection
|
|
55
|
+
runCleanupTick({ serverOp: svr, db }).catch((err) => {
|
|
56
|
+
console.error("[federation-cleanup] initial tick error:", err?.message ?? err);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Look up the instance role from the Instance table.
|
|
62
|
+
* Returns null if the table doesn't exist or no record is present.
|
|
63
|
+
*/
|
|
64
|
+
async function getInstanceRole(db) {
|
|
65
|
+
try {
|
|
66
|
+
for await (const inst of db.flair.Instance.search()) {
|
|
67
|
+
return inst.role ?? null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* table may not exist yet */
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Core cleanup logic — exposed for unit testing.
|
|
77
|
+
*
|
|
78
|
+
* - Finds PairingToken records that are:
|
|
79
|
+
* - consumedBy is non-null (pair succeeded, bootstrap user no longer needed)
|
|
80
|
+
* - OR expiresAt < now (token expired without successful pair)
|
|
81
|
+
* - Drops the bootstrap user via the Harper ops API (idempotent: 404 is
|
|
82
|
+
* swallowed).
|
|
83
|
+
* - Deletes the PairingToken record if expired AND not consumed
|
|
84
|
+
* (housekeeping). Consumed records are kept for audit.
|
|
85
|
+
*
|
|
86
|
+
* Logging emits token-id prefix only (NEVER the full username, NEVER a
|
|
87
|
+
* password).
|
|
88
|
+
*/
|
|
89
|
+
export async function runCleanupTick(opts = {}) {
|
|
90
|
+
let svr;
|
|
91
|
+
let db;
|
|
92
|
+
if (opts.serverOp && opts.db) {
|
|
93
|
+
svr = opts.serverOp;
|
|
94
|
+
db = opts.db;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const harper = await import("@harperfast/harper");
|
|
98
|
+
svr = opts.serverOp ?? harper.server.operation;
|
|
99
|
+
db = opts.db ?? harper.databases;
|
|
100
|
+
}
|
|
101
|
+
const now = opts.now ?? new Date();
|
|
102
|
+
// ── Query candidates ──────────────────────────────────────────────────
|
|
103
|
+
const candidates = [];
|
|
104
|
+
try {
|
|
105
|
+
for await (const token of db.flair.PairingToken.search()) {
|
|
106
|
+
const consumed = !!token.consumedBy;
|
|
107
|
+
const expired = token.expiresAt && new Date(token.expiresAt) < now;
|
|
108
|
+
if (consumed || expired) {
|
|
109
|
+
candidates.push(token);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.error("[federation-cleanup] failed to query PairingToken records:", err?.message ?? err);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// ── Process each candidate ────────────────────────────────────────────
|
|
118
|
+
for (const token of candidates) {
|
|
119
|
+
const tokenId = token.id;
|
|
120
|
+
const consumed = !!token.consumedBy;
|
|
121
|
+
const expired = token.expiresAt && new Date(token.expiresAt) < now;
|
|
122
|
+
const bootstrapUsername = `pair-bootstrap-${tokenId.slice(0, 8)}`;
|
|
123
|
+
// Drop the bootstrap user
|
|
124
|
+
try {
|
|
125
|
+
await svr({ operation: "drop_user", username: bootstrapUsername }, { user: null }, false);
|
|
126
|
+
console.log("[federation-cleanup] dropped user", { tid: tokenId.slice(0, 8) });
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const msg = err?.message ?? "";
|
|
130
|
+
const isNotFound = err?.statusCode === 404 ||
|
|
131
|
+
msg.toLowerCase().includes("not exist") ||
|
|
132
|
+
msg.toLowerCase().includes("not found");
|
|
133
|
+
if (isNotFound) {
|
|
134
|
+
// Idempotent — user already gone, no action needed
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
console.error("[federation-cleanup] drop_user error", { tid: tokenId.slice(0, 8), err: String(err?.message ?? err) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// ── Housekeeping ────────────────────────────────────────────────────
|
|
141
|
+
if (expired && !consumed) {
|
|
142
|
+
// Delete the expired, unconsumed token record itself
|
|
143
|
+
try {
|
|
144
|
+
await svr({
|
|
145
|
+
operation: "delete",
|
|
146
|
+
database: "flair",
|
|
147
|
+
table: "PairingToken",
|
|
148
|
+
hash_value: tokenId,
|
|
149
|
+
}, { user: null }, false);
|
|
150
|
+
console.log("[federation-cleanup] deleted expired token", { tid: tokenId.slice(0, 8) });
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
console.error("[federation-cleanup] delete token error", { tid: tokenId.slice(0, 8), err: String(err?.message ?? err) });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Consumed tokens: keep record for audit trail
|
|
157
|
+
if (consumed) {
|
|
158
|
+
console.log("[federation-cleanup] keeping audit record", { tid: tokenId.slice(0, 8), consumedBy: token.consumedBy });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -22,10 +22,106 @@ function sortKeys(val) {
|
|
|
22
22
|
}
|
|
23
23
|
return sorted;
|
|
24
24
|
}
|
|
25
|
-
// ───
|
|
25
|
+
// ─── Nonce generation ───────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Generate a random nonce for anti-replay protection.
|
|
28
|
+
* 16 random bytes → base64url (22 chars, no padding). 128 bits of entropy
|
|
29
|
+
* is sufficient for collision-resistance over the signing window.
|
|
30
|
+
*/
|
|
31
|
+
export function generateNonce() {
|
|
32
|
+
return Buffer.from(nacl.randomBytes(16)).toString("base64url");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Default in-memory nonce store backed by a Map.
|
|
36
|
+
* Safe for module-level singleton use (e.g. federationNonceStore).
|
|
37
|
+
*/
|
|
38
|
+
export function createNonceStore() {
|
|
39
|
+
const store = new Map();
|
|
40
|
+
return {
|
|
41
|
+
has(key) { return store.has(key); },
|
|
42
|
+
set(key, value) { store.set(key, value); },
|
|
43
|
+
evict(olderThan) {
|
|
44
|
+
for (const [k, ts] of store.entries()) {
|
|
45
|
+
if (ts < olderThan)
|
|
46
|
+
store.delete(k);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Sign a request body with embedded timestamp and nonce for anti-replay.
|
|
53
|
+
*
|
|
54
|
+
* Adds `_ts` and `_nonce` fields to the body, then signs the canonical form
|
|
55
|
+
* (including those fields) using the existing `signBody`. Returns the body
|
|
56
|
+
* with `_ts`, `_nonce`, and `signature` fields set.
|
|
57
|
+
*
|
|
58
|
+
* The caller sends the returned body as the JSON payload. The receiver
|
|
59
|
+
* uses `verifyBodySignatureFresh` to validate it.
|
|
60
|
+
*/
|
|
61
|
+
export function signBodyFresh(body, secretKey, opts) {
|
|
62
|
+
const tsBody = {
|
|
63
|
+
...body,
|
|
64
|
+
_ts: opts?.ts ?? Date.now(),
|
|
65
|
+
_nonce: opts?.nonce ?? generateNonce(),
|
|
66
|
+
};
|
|
67
|
+
const sig = signBody(tsBody, secretKey);
|
|
68
|
+
return { ...tsBody, signature: sig };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Verify a signed request body with anti-replay protection.
|
|
72
|
+
*
|
|
73
|
+
* 1. Validates the Ed25519 signature over the canonical form (including
|
|
74
|
+
* `_ts`, `_nonce`, and all other fields EXCEPT `signature`).
|
|
75
|
+
* 2. Checks that the embedded `_ts` is within `opts.windowMs` of now.
|
|
76
|
+
* 3. Checks that the embedded `_nonce` has not been seen before (replay).
|
|
77
|
+
* 4. Records the nonce on success.
|
|
78
|
+
*
|
|
79
|
+
* Returns `{ ok: true }` on success, or `{ ok: false, reason: "..." }`.
|
|
80
|
+
*/
|
|
81
|
+
export function verifyBodySignatureFresh(body, publicKeyB64url, opts = {}) {
|
|
82
|
+
const windowMs = opts.windowMs ?? 30_000;
|
|
83
|
+
const nonceStore = opts.nonceStore;
|
|
84
|
+
const { signature, _ts, _nonce, ...rest } = body;
|
|
85
|
+
// ── Field presence ───────────────────────────────────────────────────
|
|
86
|
+
if (!signature)
|
|
87
|
+
return { ok: false, reason: "invalid_signature" };
|
|
88
|
+
if (_ts == null || !Number.isFinite(_ts))
|
|
89
|
+
return { ok: false, reason: "invalid_signature" };
|
|
90
|
+
if (!_nonce || typeof _nonce !== "string")
|
|
91
|
+
return { ok: false, reason: "invalid_signature" };
|
|
92
|
+
// ── Timestamp check ──────────────────────────────────────────────────
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const delta = now - _ts;
|
|
95
|
+
if (delta > windowMs)
|
|
96
|
+
return { ok: false, reason: "stale" };
|
|
97
|
+
if (delta < -windowMs)
|
|
98
|
+
return { ok: false, reason: "future" };
|
|
99
|
+
// ── Nonce replay check ───────────────────────────────────────────────
|
|
100
|
+
if (nonceStore) {
|
|
101
|
+
// Evict entries older than 2x window — keeps the store bounded
|
|
102
|
+
nonceStore.evict(now - 2 * windowMs);
|
|
103
|
+
if (nonceStore.has(_nonce))
|
|
104
|
+
return { ok: false, reason: "replay" };
|
|
105
|
+
}
|
|
106
|
+
// ── Signature verification — canonical form includes _ts, _nonce ─────
|
|
107
|
+
const verificationBody = { _ts, _nonce, ...rest, signature };
|
|
108
|
+
if (!verifyBodySignature(verificationBody, publicKeyB64url)) {
|
|
109
|
+
return { ok: false, reason: "invalid_signature" };
|
|
110
|
+
}
|
|
111
|
+
// ── Record nonce ─────────────────────────────────────────────────────
|
|
112
|
+
if (nonceStore) {
|
|
113
|
+
nonceStore.set(_nonce, now);
|
|
114
|
+
}
|
|
115
|
+
return { ok: true };
|
|
116
|
+
}
|
|
117
|
+
// ─── Legacy signing (without anti-replay) ────────────────────────────────────
|
|
118
|
+
// Kept as implementation detail for signBodyFresh / verifyBodySignatureFresh.
|
|
119
|
+
// Callers should use the fresh variants for replay-safe federation operations.
|
|
26
120
|
/**
|
|
27
121
|
* Create a detached Ed25519 signature over the canonical form of a body.
|
|
28
122
|
* Returns base64url-encoded signature.
|
|
123
|
+
*
|
|
124
|
+
* NOTE: Prefer `signBodyFresh()` which includes anti-replay metadata (_ts, _nonce).
|
|
29
125
|
*/
|
|
30
126
|
export function signBody(body, secretKey) {
|
|
31
127
|
const message = new TextEncoder().encode(canonicalize(body));
|
|
@@ -35,6 +131,9 @@ export function signBody(body, secretKey) {
|
|
|
35
131
|
/**
|
|
36
132
|
* Verify a signature field on a request body.
|
|
37
133
|
* The canonical form is the body WITHOUT the `signature` field.
|
|
134
|
+
*
|
|
135
|
+
* NOTE: Prefer `verifyBodySignatureFresh()` which adds timestamp and nonce
|
|
136
|
+
* replay protection. This function performs ONLY signature validation.
|
|
38
137
|
*/
|
|
39
138
|
export function verifyBodySignature(body, publicKeyB64url) {
|
|
40
139
|
const { signature, ...rest } = body;
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/tpsdev-ai/flair.git"
|
|
9
|
+
"url": "git+https://github.com/tpsdev-ai/flair.git"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://github.com/tpsdev-ai/flair",
|
|
12
12
|
"bugs": {
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"prepublishOnly": "npm run build && npm run build:cli",
|
|
45
45
|
"test": "bun test",
|
|
46
46
|
"test:e2e": "playwright test",
|
|
47
|
-
"release": "./scripts/release.sh"
|
|
47
|
+
"release": "./scripts/release.sh",
|
|
48
|
+
"postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');const p='dist/cli.js';if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}\""
|
|
48
49
|
},
|
|
49
50
|
"publishConfig": {
|
|
50
51
|
"access": "public"
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
"node": ">=22"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
|
56
|
-
"@harperfast/harper": "5.0.
|
|
57
|
+
"@harperfast/harper": "5.0.9",
|
|
57
58
|
"@types/js-yaml": "^4.0.9",
|
|
58
59
|
"commander": "14.0.3",
|
|
59
60
|
"harper-fabric-embeddings": "0.2.3",
|
|
@@ -73,7 +74,6 @@
|
|
|
73
74
|
"harper-fabric-embeddings"
|
|
74
75
|
],
|
|
75
76
|
"workspaces": [
|
|
76
|
-
"packages/*"
|
|
77
|
-
"plugins/*"
|
|
77
|
+
"packages/*"
|
|
78
78
|
]
|
|
79
79
|
}
|