@tpsdev-ai/flair 0.6.2 → 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.
@@ -0,0 +1,225 @@
1
+ // ─── Client Detection & Wiring ──────────────────────────────────────────────────────
2
+ //
3
+ // Detects locally installed MCP clients and wires them to Flair.
4
+ // Each client has:
5
+ // - detect(): boolean - returns true if client is installed
6
+ // - wire(options: { agentId: string; flairUrl: string }): { ok: boolean; message: string }
7
+ //
8
+ // ---- Detection helpers ----------------------------------------------------------
9
+ import { spawnSync } from "node:child_process";
10
+ import { accessSync, constants } from "node:fs";
11
+ /**
12
+ * Check if a command exists in PATH (cross-platform alternative to `which`).
13
+ * Does not spawn a child process — pure filesystem check.
14
+ */
15
+ function binInPath(name) {
16
+ try {
17
+ const sep = process.platform === "win32" ? ";" : ":";
18
+ const dirs = (process.env.PATH || "").split(sep);
19
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ".ps1"] : [];
20
+ for (const dir of dirs) {
21
+ if (!dir)
22
+ continue;
23
+ const base = `${dir}/${name}`;
24
+ try {
25
+ accessSync(base, constants.X_OK);
26
+ return true;
27
+ }
28
+ catch { /* not here */ }
29
+ for (const ext of exts) {
30
+ try {
31
+ accessSync(`${base}${ext}`, constants.X_OK);
32
+ return true;
33
+ }
34
+ catch { /* not here */ }
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ function claudeCodeDetect() {
44
+ try {
45
+ const result = spawnSync("npm", ["list", "-g", "@anthropic-ai/claude-code"], {
46
+ stdio: ["ignore", "ignore", "ignore"],
47
+ });
48
+ return result.status === 0;
49
+ }
50
+ catch (_e) {
51
+ return false;
52
+ }
53
+ }
54
+ function codexDetect() {
55
+ try {
56
+ if (binInPath("codex"))
57
+ return true;
58
+ const npmResult = spawnSync("npm", ["list", "-g", "@openai/codex"], {
59
+ stdio: ["ignore", "ignore", "ignore"],
60
+ });
61
+ return npmResult.status === 0;
62
+ }
63
+ catch (_e) {
64
+ return false;
65
+ }
66
+ }
67
+ function geminiDetect() {
68
+ try {
69
+ if (binInPath("gemini"))
70
+ return true;
71
+ const npmResult = spawnSync("npm", ["list", "-g", "@google/generative-ai"], {
72
+ stdio: ["ignore", "ignore", "ignore"],
73
+ });
74
+ return npmResult.status === 0;
75
+ }
76
+ catch (_e) {
77
+ return false;
78
+ }
79
+ }
80
+ function cursorDetect() {
81
+ try {
82
+ return binInPath("cursor");
83
+ }
84
+ catch (_e) {
85
+ return false;
86
+ }
87
+ }
88
+ // ---- Internal wiring functions --------------------------------------------------
89
+ function _wireClaudeCode(env) {
90
+ return {
91
+ ok: false,
92
+ message: `Manual wiring required for Claude Code:\n` +
93
+ `1. Locate Claude Code's settings file (usually ~/Library/Application Support/Claude/settings.json on macOS or %APPDATA%/Claude/settings.json on Windows)\n` +
94
+ `2. Add or update the "mcpServers" section:\n` +
95
+ ` {\n` +
96
+ ` "mcpServers": {\n` +
97
+ ` "flair": {\n` +
98
+ ` "command": "npx",\n` +
99
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
100
+ ` "env": {\n` +
101
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
102
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
103
+ ` }\n` +
104
+ ` }\n` +
105
+ ` }\n` +
106
+ ` }\n` +
107
+ `3. Restart Claude Code\n` +
108
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Claude Code's settings due to security restrictions.`,
109
+ };
110
+ }
111
+ function _wireCodex(env) {
112
+ return {
113
+ ok: false,
114
+ message: `Manual wiring required for Codex:\n` +
115
+ `1. Locate Codex's configuration (check ~/.codex/config or similar)\n` +
116
+ `2. Add the Flair MCP server configuration:\n` +
117
+ ` {\n` +
118
+ ` "mcpServers": {\n` +
119
+ ` "flair": {\n` +
120
+ ` "command": "npx",\n` +
121
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
122
+ ` "env": {\n` +
123
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
124
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
125
+ ` }\n` +
126
+ ` }\n` +
127
+ ` }\n` +
128
+ ` }\n` +
129
+ `3. Restart Codex\n` +
130
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Codex's configuration due to security restrictions.`,
131
+ };
132
+ }
133
+ function _wireGemini(env) {
134
+ return {
135
+ ok: false,
136
+ message: `Manual wiring required for Gemini:\n` +
137
+ `1. Locate Gemini's configuration (check ~/.gemini/config or similar)\n` +
138
+ `2. Add the Flair MCP server configuration:\n` +
139
+ ` {\n` +
140
+ ` "mcpServers": {\n` +
141
+ ` "flair": {\n` +
142
+ ` "command": "npx",\n` +
143
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
144
+ ` "env": {\n` +
145
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
146
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
147
+ ` }\n` +
148
+ ` }\n` +
149
+ ` }\n` +
150
+ ` }\n` +
151
+ `3. Restart Gemini\n` +
152
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Gemini's configuration due to security restrictions.`,
153
+ };
154
+ }
155
+ function _wireCursor(env) {
156
+ return {
157
+ ok: false,
158
+ message: `Manual wiring required for Cursor:\n` +
159
+ `1. Locate Cursor's settings file (usually ~/.cursor/settings.json)\n` +
160
+ `2. Add or update the "mcpServers" section:\n` +
161
+ ` {\n` +
162
+ ` "mcpServers": {\n` +
163
+ ` "flair": {\n` +
164
+ ` "command": "npx",\n` +
165
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
166
+ ` "env": {\n` +
167
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
168
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
169
+ ` }\n` +
170
+ ` }\n` +
171
+ ` }\n` +
172
+ ` }\n` +
173
+ `3. Restart Cursor\n` +
174
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Cursor's settings due to security restrictions.`,
175
+ };
176
+ }
177
+ // ---- Exported detection & wiring array ------------------------------------------
178
+ export const ALL_CLIENTS = [
179
+ {
180
+ id: "claude-code",
181
+ label: "Claude Code",
182
+ wire: _wireClaudeCode,
183
+ },
184
+ {
185
+ id: "codex",
186
+ label: "Codex",
187
+ wire: _wireCodex,
188
+ },
189
+ {
190
+ id: "gemini",
191
+ label: "Gemini",
192
+ wire: _wireGemini,
193
+ },
194
+ {
195
+ id: "cursor",
196
+ label: "Cursor",
197
+ wire: _wireCursor,
198
+ },
199
+ ];
200
+ export function detectClients() {
201
+ return ALL_CLIENTS.map((client) => ({
202
+ ...client,
203
+ detected: client.id === "claude-code"
204
+ ? claudeCodeDetect()
205
+ : client.id === "codex"
206
+ ? codexDetect()
207
+ : client.id === "gemini"
208
+ ? geminiDetect()
209
+ : client.id === "cursor"
210
+ ? cursorDetect()
211
+ : false,
212
+ }));
213
+ }
214
+ export function wireClaudeCode(env) {
215
+ return _wireClaudeCode(env);
216
+ }
217
+ export function wireCodex(env) {
218
+ return _wireCodex(env);
219
+ }
220
+ export function wireGemini(env) {
221
+ return _wireGemini(env);
222
+ }
223
+ export function wireCursor(env) {
224
+ return _wireCursor(env);
225
+ }
@@ -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.
@@ -82,6 +87,14 @@ export class FederationInstance extends Resource {
82
87
  * Requires a one-time pairing token generated by `flair federation token`.
83
88
  */
84
89
  export class FederationPair extends Resource {
90
+ // Pairing is a public endpoint by design — spokes have no Harper credentials
91
+ // on the hub yet. The handler self-validates via PairingToken + Ed25519
92
+ // body-signature. Bypassing Harper's role gate is required because Harper's
93
+ // auth layer (security/auth.js) claims any "Bearer X" header for itself,
94
+ // so we route the token through the body instead.
95
+ allowCreate(_user, _record, _context) {
96
+ return true;
97
+ }
85
98
  async post(data) {
86
99
  const { instanceId, publicKey, role, endpoint, signature, pairingToken } = data || {};
87
100
  if (!instanceId || !publicKey) {
@@ -89,15 +102,21 @@ export class FederationPair extends Resource {
89
102
  status: 400, headers: { "content-type": "application/json" },
90
103
  });
91
104
  }
92
- // Verify signature the pairing body must be signed with the private key
93
- // 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.
94
109
  if (!signature) {
95
110
  return new Response(JSON.stringify({ error: "signature required — request must be signed" }), {
96
111
  status: 401, headers: { "content-type": "application/json" },
97
112
  });
98
113
  }
99
- if (!verifyBodySignature(data, publicKey)) {
100
- 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}` }), {
101
120
  status: 401, headers: { "content-type": "application/json" },
102
121
  });
103
122
  }
@@ -119,36 +138,46 @@ export class FederationPair extends Resource {
119
138
  });
120
139
  }
121
140
  else {
122
- // New peer — require a valid pairing token
141
+ // New peer — require a valid pairing token in the request body.
142
+ // The handler self-validates because the endpoint is public at the
143
+ // Harper-auth and middleware layers (Bearer is not usable on Harper —
144
+ // its auth layer claims unknown Bearer values and 401s).
123
145
  if (!pairingToken) {
124
146
  return new Response(JSON.stringify({
125
- error: "pairingToken required generate one with 'flair federation token'",
147
+ error: "pairingToken required in body for first-time pair",
126
148
  }), { status: 401, headers: { "content-type": "application/json" } });
127
149
  }
128
- // Validate and consume the token
129
- let token = null;
150
+ // Fetch the token record for consumption
151
+ let tokenRecord = null;
130
152
  try {
131
- token = await databases.flair.PairingToken.get(pairingToken);
153
+ tokenRecord = await databases.flair.PairingToken.get(pairingToken);
132
154
  }
133
155
  catch { /* table may not exist */ }
134
- if (!token) {
135
- return new Response(JSON.stringify({ error: "invalid pairing token" }), {
156
+ if (!tokenRecord || tokenRecord.consumedBy) {
157
+ return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
136
158
  status: 401, headers: { "content-type": "application/json" },
137
159
  });
138
160
  }
139
- if (token.consumedBy) {
140
- return new Response(JSON.stringify({ error: "pairing token already used" }), {
161
+ if (tokenRecord.expiresAt && new Date(tokenRecord.expiresAt) < new Date()) {
162
+ return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
141
163
  status: 401, headers: { "content-type": "application/json" },
142
164
  });
143
165
  }
144
- if (token.expiresAt && new Date(token.expiresAt) < new Date()) {
145
- return new Response(JSON.stringify({ error: "pairing token expired" }), {
146
- status: 401, headers: { "content-type": "application/json" },
147
- });
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
+ }
148
177
  }
149
178
  // Consume the token
150
179
  await databases.flair.PairingToken.put({
151
- ...token,
180
+ ...tokenRecord,
152
181
  consumedBy: instanceId,
153
182
  consumedAt: new Date().toISOString(),
154
183
  });
@@ -163,6 +192,15 @@ export class FederationPair extends Resource {
163
192
  createdAt: new Date().toISOString(),
164
193
  updatedAt: new Date().toISOString(),
165
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
+ }
166
204
  }
167
205
  // Return our own identity for the peer to record
168
206
  let ourInstance = null;
@@ -190,6 +228,12 @@ export class FederationPair extends Resource {
190
228
  * The calling peer sends a batch of SyncRecords; we merge them.
191
229
  */
192
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
+ }
193
237
  async post(data) {
194
238
  const { instanceId, records, lamportClock, signature } = data || {};
195
239
  if (!instanceId || !Array.isArray(records)) {
@@ -204,14 +248,20 @@ export class FederationSync extends Resource {
204
248
  status: 403, headers: { "content-type": "application/json" },
205
249
  });
206
250
  }
207
- // 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.
208
254
  if (!signature) {
209
255
  return new Response(JSON.stringify({ error: "signature required — sync requests must be signed" }), {
210
256
  status: 401, headers: { "content-type": "application/json" },
211
257
  });
212
258
  }
213
- if (!verifyBodySignature(data, peer.publicKey)) {
214
- 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}` }), {
215
265
  status: 401, headers: { "content-type": "application/json" },
216
266
  });
217
267
  }
@@ -313,3 +363,16 @@ export class FederationPeers extends Resource {
313
363
  return { peers };
314
364
  }
315
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,28 +117,50 @@ server.http(async (request, nextLayer) => {
117
117
  url.pathname === "/AgentCard" ||
118
118
  url.pathname.startsWith("/A2AAdapter/") ||
119
119
  url.pathname.startsWith("/AgentCard/") ||
120
- // Federation endpoints handle their own auth via Ed25519 body signatures
121
- url.pathname === "/FederationPair" ||
120
+ // FederationSync uses Ed25519 body-signature auth with anti-replay, validated
121
+ // by the resource handler (allowCreate=true, same pattern as FederationPair).
122
122
  url.pathname === "/FederationSync" ||
123
+ // FederationPair uses one-time PairingToken in the request body, validated
124
+ // by the resource itself (allowCreate=true on the Resource lets anonymous
125
+ // POST through Harper's role gate). Bearer can't be used here because
126
+ // Harper's auth layer claims any "Bearer X" Authorization header for itself.
127
+ url.pathname === "/FederationPair" ||
123
128
  // OAuth 2.1 public endpoints (spec requires no pre-auth)
124
129
  url.pathname === "/OAuthRegister" ||
125
130
  url.pathname === "/OAuthAuthorize" ||
126
131
  url.pathname === "/OAuthToken" ||
127
132
  url.pathname === "/OAuthRevoke" ||
128
133
  url.pathname === "/.well-known/oauth-authorization-server" ||
129
- url.pathname === "/OAuthMetadata")
134
+ url.pathname === "/OAuthMetadata" ||
135
+ // ObservationCenter HTML shell is public — the page itself is just markup
136
+ // and inline JS, with no embedded data. The JS prompts for admin-pass and
137
+ // auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
138
+ // Without this allow-list entry, the HTML is 401-blocked on hosted Flair
139
+ // instances (rockit-local works only because authorizeLocal=true).
140
+ url.pathname === "/ObservationCenter")
130
141
  return nextLayer(request);
142
+ // If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
143
+ // trust Harper's auth decision and pass through without requiring additional headers.
144
+ if (request.user?.role?.permission?.super_user === true) {
145
+ return nextLayer(request);
146
+ }
131
147
  // Skip re-entry: if we already swapped auth to Basic, pass through
132
148
  if (request._tpsAuthVerified)
133
149
  return nextLayer(request);
134
150
  const header = request.headers.get("authorization") || request.headers?.asObject?.authorization || "";
135
- // ── Basic admin auth ──────────────────────────────────────────────────────
136
- // Allow Basic auth with the admin password for CLI operations (backup, etc.)
137
- // This is checked BEFORE Ed25519 so admin tools can use simple auth.
151
+ // ── Basic admin / super_user auth ──────────────────────────────────────────
152
+ // Allow Basic auth for CLI operations (backup, etc.). Two paths:
153
+ // 1. HDB_ADMIN_PASSWORD env-var fast-path (user must be "admin" with exact pass)
154
+ // 2. Harper super_user check — any user with super_user:true permission accepted
155
+ // Checked BEFORE Ed25519 so admin tools can use simple auth.
138
156
  if (header.startsWith("Basic ")) {
139
157
  try {
140
158
  const decoded = Buffer.from(header.slice(6), "base64").toString("utf-8");
141
- const [user, pass] = decoded.split(":");
159
+ const colonIdx = decoded.indexOf(":");
160
+ const user = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
161
+ const pass = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : "";
162
+ // Path 1: Env-var fast-path (back-compat). Only matches user==="admin"
163
+ // with exact HDB_ADMIN_PASSWORD. Non-match falls through to Path 2.
142
164
  const adminPass = getAdminPass();
143
165
  if (adminPass !== null && user === "admin" && pass === adminPass) {
144
166
  // Mark as verified and set Harper user directly
@@ -150,14 +172,47 @@ server.http(async (request, nextLayer) => {
150
172
  request.headers.set("x-tps-agent", "admin");
151
173
  if (request.headers.asObject)
152
174
  request.headers.asObject["x-tps-agent"] = "admin";
153
- // Basic admin auth IS admin — resource-level checks (SemanticSearch,
154
- // MemoryBootstrap, MemoryReflect, MemoryConsolidate) gate cross-agent
155
- // access on this flag. Prior to 0.5.5 the check was a no-op so this
156
- // was never needed; now it must be set.
157
175
  request.tpsAgent = "admin";
158
176
  request.tpsAgentIsAdmin = true;
159
177
  return nextLayer(request);
160
178
  }
179
+ // Path 2: Harper super_user check — any user with super_user:true
180
+ let harperUser = null;
181
+ try {
182
+ harperUser = await server.getUser(user, pass, request);
183
+ }
184
+ catch { /* fall through — invalid creds, non-existent user, etc. */ }
185
+ if (harperUser?.role?.permission?.super_user === true) {
186
+ request._tpsAuthVerified = true;
187
+ request.user = harperUser;
188
+ request.headers.set("x-tps-agent", user);
189
+ if (request.headers.asObject)
190
+ request.headers.asObject["x-tps-agent"] = user;
191
+ request.tpsAgent = user;
192
+ request.tpsAgentIsAdmin = true;
193
+ return nextLayer(request);
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
+ }
161
216
  }
162
217
  catch { /* fall through to Ed25519 check */ }
163
218
  return new Response(JSON.stringify({ error: "invalid_admin_credentials" }), { status: 401 });