@tpsdev-ai/flair 0.7.0 → 0.8.1

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.
@@ -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 the pairing body must be signed with the private key
101
- // corresponding to the publicKey in the body (proves key ownership).
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
- if (!verifyBodySignature(data, publicKey)) {
108
- return new Response(JSON.stringify({ error: "invalid signature — key ownership proof failed" }), {
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
- if (!verifyBodySignature(data, peer.publicKey)) {
220
- return new Response(JSON.stringify({ error: "invalid signature — request not from claimed peer" }), {
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
+ }
@@ -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 { scanContent, isStrictMode } from "./content-safety.js";
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). Table.search()
45
- // reads `target.conditions`; when that is unset, it iterates URLSearchParams entries
46
- // as conditions instead. We inject our scope condition into `.conditions`, which
47
- // means URL params (except agentId, which we always enforce) must also be translated
48
- // to conditions or they'll be silently dropped.
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
- existing.push(...query.conditions);
51
+ query.conditions = [agentIdCondition, ...query.conditions];
52
+ return withDetachedTxn(ctx, () => super.search(query));
53
53
  }
54
- else if (typeof query.entries === "function") {
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
- if (content.content) {
119
- const safety = scanContent(content.content);
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 = scanContent(content.content);
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 content is now clean
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 (handled by the resource)
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
- // ─── Signing ────────────────────────────────────────────────────────────────
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.7.0",
3
+ "version": "0.8.1",
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.1",
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
  }