@tpsdev-ai/flair 0.10.1 → 0.12.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/dist/cli.js +318 -34
- package/dist/resources/Agent.js +20 -7
- package/dist/resources/AgentCard.js +6 -0
- package/dist/resources/AgentSeed.js +7 -1
- package/dist/resources/Credential.js +24 -13
- package/dist/resources/IngestEvents.js +7 -0
- package/dist/resources/Instance.js +13 -0
- package/dist/resources/Integration.js +64 -0
- package/dist/resources/Memory.js +53 -8
- package/dist/resources/MemoryBootstrap.js +8 -0
- package/dist/resources/MemoryConsolidate.js +7 -1
- package/dist/resources/MemoryFeed.js +8 -0
- package/dist/resources/MemoryGrant.js +79 -0
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +7 -1
- package/dist/resources/MemoryReindex.js +7 -1
- package/dist/resources/OAuthClient.js +15 -0
- package/dist/resources/ObsAgentSnapshot.js +13 -0
- package/dist/resources/ObsEventFeed.js +13 -0
- package/dist/resources/ObsOffice.js +19 -0
- package/dist/resources/OrgEvent.js +37 -21
- package/dist/resources/OrgEventCatchup.js +8 -0
- package/dist/resources/OrgEventMaintenance.js +7 -0
- package/dist/resources/PairingToken.js +14 -0
- package/dist/resources/Peer.js +15 -0
- package/dist/resources/Presence.js +300 -0
- package/dist/resources/Relationship.js +13 -7
- package/dist/resources/SemanticSearch.js +25 -8
- package/dist/resources/Soul.js +18 -9
- package/dist/resources/SoulFeed.js +7 -0
- package/dist/resources/WorkspaceLatest.js +7 -0
- package/dist/resources/WorkspaceState.js +38 -28
- package/dist/resources/XAA.js +8 -0
- package/dist/resources/agent-auth.js +209 -0
- package/dist/resources/auth-middleware.js +89 -57
- package/package.json +1 -1
- package/schemas/schema.graphql +9 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flair-agent-auth — Ed25519 agent authentication.
|
|
3
|
+
*
|
|
4
|
+
* The verification logic that used to live in the instance-wide `server.http`
|
|
5
|
+
* auth-middleware, extracted into a per-resource helper. Resources call
|
|
6
|
+
* `verifyAgentRequest()` from their `allow*()` methods to identify + authorize
|
|
7
|
+
* the calling agent. This keeps flair's auth PER-RESOURCE (Harper-native) so it
|
|
8
|
+
* composes on a multi-component hub instead of a global gate that 401s siblings.
|
|
9
|
+
*
|
|
10
|
+
* Plugin-shaped on purpose (config via env today, extractable to a standalone
|
|
11
|
+
* @tps/agent-auth Harper plugin later — the "agent auth as its own plugin" path).
|
|
12
|
+
*
|
|
13
|
+
* Auth model: an agent presents `Authorization: TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`.
|
|
14
|
+
* The signature covers `<id>:<ts>:<nonce>:<METHOD>:<pathname><search>` and is
|
|
15
|
+
* verified against the agent's stored Ed25519 public key. Replay is bounded by a
|
|
16
|
+
* 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
|
|
17
|
+
*/
|
|
18
|
+
import { databases } from "@harperfast/harper";
|
|
19
|
+
const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
20
|
+
/**
|
|
21
|
+
* Shared Harper user that verified Ed25519 agents resolve to (least-privilege
|
|
22
|
+
* `flair_agent` role), replacing the old admin super_user elevation. Single
|
|
23
|
+
* source of truth — the auth gate resolves agents to this user and the CLI
|
|
24
|
+
* provisions it (ensureFlairAgentUser); they MUST agree on the name.
|
|
25
|
+
*/
|
|
26
|
+
export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
27
|
+
// Replay protection: remember recently-seen (agent:nonce), pruned by the window.
|
|
28
|
+
const nonceSeen = new Map();
|
|
29
|
+
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
30
|
+
function b64ToArrayBuffer(b64) {
|
|
31
|
+
// Handle both standard and URL-safe base64.
|
|
32
|
+
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
33
|
+
const bin = atob(std);
|
|
34
|
+
const buf = new ArrayBuffer(bin.length);
|
|
35
|
+
const view = new Uint8Array(buf);
|
|
36
|
+
for (let i = 0; i < bin.length; i++)
|
|
37
|
+
view[i] = bin.charCodeAt(i);
|
|
38
|
+
return buf;
|
|
39
|
+
}
|
|
40
|
+
const keyCache = new Map();
|
|
41
|
+
async function importEd25519Key(publicKeyStr) {
|
|
42
|
+
const cached = keyCache.get(publicKeyStr);
|
|
43
|
+
if (cached)
|
|
44
|
+
return cached;
|
|
45
|
+
// Accept hex (64-char) or base64 (44-char) encoded 32-byte Ed25519 public key.
|
|
46
|
+
let raw;
|
|
47
|
+
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
48
|
+
const bytes = new Uint8Array(32);
|
|
49
|
+
for (let i = 0; i < 32; i++)
|
|
50
|
+
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
51
|
+
raw = bytes.buffer;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
raw = b64ToArrayBuffer(publicKeyStr);
|
|
55
|
+
}
|
|
56
|
+
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
57
|
+
keyCache.set(publicKeyStr, key);
|
|
58
|
+
return key;
|
|
59
|
+
}
|
|
60
|
+
// ─── Admin resolution ─────────────────────────────────────────────────────────
|
|
61
|
+
// Admin agents come from FLAIR_ADMIN_AGENTS (comma-separated) OR Agent records
|
|
62
|
+
// with role === "admin". OR-combined, cached 60s. Distinct from Harper's
|
|
63
|
+
// super_user — admin here gates flair-policy decisions (promotions, raw ops).
|
|
64
|
+
let adminCacheExpiry = 0;
|
|
65
|
+
let adminCache = new Set();
|
|
66
|
+
async function getAdminAgents() {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
if (now < adminCacheExpiry)
|
|
69
|
+
return adminCache;
|
|
70
|
+
const fromEnv = (process.env.FLAIR_ADMIN_AGENTS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
71
|
+
const fromDb = [];
|
|
72
|
+
try {
|
|
73
|
+
const results = await databases.flair.Agent.search([{ attribute: "role", value: "admin", condition: "equals" }]);
|
|
74
|
+
for await (const row of results)
|
|
75
|
+
if (row?.id)
|
|
76
|
+
fromDb.push(row.id);
|
|
77
|
+
}
|
|
78
|
+
catch { /* Agent table may be empty */ }
|
|
79
|
+
adminCache = new Set([...fromEnv, ...fromDb]);
|
|
80
|
+
adminCacheExpiry = now + 60_000;
|
|
81
|
+
return adminCache;
|
|
82
|
+
}
|
|
83
|
+
export async function isAdmin(agentId) {
|
|
84
|
+
return (await getAdminAgents()).has(agentId);
|
|
85
|
+
}
|
|
86
|
+
const HEADER_RE = /^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/;
|
|
87
|
+
async function doVerify(request) {
|
|
88
|
+
const header = request?.headers?.get?.("authorization") ??
|
|
89
|
+
request?.headers?.asObject?.authorization ??
|
|
90
|
+
"";
|
|
91
|
+
const m = HEADER_RE.exec(header);
|
|
92
|
+
if (!m)
|
|
93
|
+
return null;
|
|
94
|
+
const [, agentId, tsRaw, nonce, signatureB64] = m;
|
|
95
|
+
const ts = Number(tsRaw);
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
98
|
+
return null;
|
|
99
|
+
// Prune expired nonces, then reject replays within the window.
|
|
100
|
+
for (const [k, t] of nonceSeen)
|
|
101
|
+
if (now - t > WINDOW_MS)
|
|
102
|
+
nonceSeen.delete(k);
|
|
103
|
+
const nonceKey = `${agentId}:${nonce}`;
|
|
104
|
+
if (nonceSeen.has(nonceKey))
|
|
105
|
+
return null;
|
|
106
|
+
const agent = await databases.flair.Agent.get(agentId).catch(() => null);
|
|
107
|
+
if (!agent?.publicKey)
|
|
108
|
+
return null;
|
|
109
|
+
// Canonical signed payload: id:ts:nonce:METHOD:pathname+search (must match the
|
|
110
|
+
// TPS CLI signer exactly — changing this breaks every agent's auth).
|
|
111
|
+
const url = new URL(request.url, "http://localhost");
|
|
112
|
+
const payload = `${agentId}:${tsRaw}:${nonce}:${request.method}:${url.pathname}${url.search}`;
|
|
113
|
+
try {
|
|
114
|
+
const key = await importEd25519Key(String(agent.publicKey));
|
|
115
|
+
const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, b64ToArrayBuffer(signatureB64), new TextEncoder().encode(payload));
|
|
116
|
+
if (!ok)
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
nonceSeen.set(nonceKey, ts);
|
|
123
|
+
return { agentId, isAdmin: await isAdmin(agentId) };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Verify a TPS-Ed25519 signed request → the authenticated agent, or null.
|
|
127
|
+
*
|
|
128
|
+
* MEMOIZED per request object: `allow*` may run several times for one request,
|
|
129
|
+
* and re-running doVerify would (a) waste a crypto verify and (b) double-consume
|
|
130
|
+
* the nonce (the 2nd call would see a replay and fail). We verify once and cache
|
|
131
|
+
* the result (including null) on the request.
|
|
132
|
+
*/
|
|
133
|
+
export async function verifyAgentRequest(request) {
|
|
134
|
+
if (!request)
|
|
135
|
+
return null;
|
|
136
|
+
if (request._flairAgentAuth !== undefined)
|
|
137
|
+
return request._flairAgentAuth;
|
|
138
|
+
const result = await doVerify(request);
|
|
139
|
+
try {
|
|
140
|
+
request._flairAgentAuth = result;
|
|
141
|
+
}
|
|
142
|
+
catch { /* frozen request — fine, just no cache */ }
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Three-way auth verdict for a resource — the safe replacement for the old
|
|
147
|
+
* `if (!authAgent) → unfiltered/trusted` pattern that leaked to anonymous callers
|
|
148
|
+
* once the gate stopped rejecting them. Distinguishes:
|
|
149
|
+
* - **internal**: no HTTP request at all → a programmatic/in-process call
|
|
150
|
+
* (maintenance, consolidation). Trusted; callers may run unfiltered.
|
|
151
|
+
* - **agent**: a verified agent (Ed25519). Carries agentId + isAdmin.
|
|
152
|
+
* - **anonymous**: an HTTP request with NO valid agent → MUST be denied.
|
|
153
|
+
*
|
|
154
|
+
* Pass the RESOURCE CONTEXT (`getContext()`), not `getContext().request`:
|
|
155
|
+
* `getContext().request` is inconsistently populated across resource methods
|
|
156
|
+
* (present for search/GET, undefined for PUT/POST in Table resources), so we read
|
|
157
|
+
* the gate's annotations off `context.request ?? context` — exactly how the
|
|
158
|
+
* pre-reshape resources read `request.tpsAgent`. Resolution order:
|
|
159
|
+
* 1. explicit anonymous marker (`tpsAnonymous`, set by the non-rejecting gate)
|
|
160
|
+
* 2. gate's verified-agent annotation (`tpsAgent` / `tpsAgentIsAdmin`)
|
|
161
|
+
* 3. per-agent identity on `context.user` (the de-elevated user; username=agentId)
|
|
162
|
+
* 4. header verify (custom-resource allow*, where the raw request is present)
|
|
163
|
+
* — and if a request object IS present but yields no agent → anonymous
|
|
164
|
+
* 5. nothing at all → trusted internal call
|
|
165
|
+
*/
|
|
166
|
+
/**
|
|
167
|
+
* allow* for AGENT-FACING resources: permit verified agents, admins/super_user,
|
|
168
|
+
* and trusted internal calls; deny anonymous HTTP. Pass getContext(). Per-record
|
|
169
|
+
* scoping/ownership is still enforced in the handler. Replaces the
|
|
170
|
+
* `!!verifyAgentRequest(request)` pattern, which wrongly denied Basic-admin/
|
|
171
|
+
* super_user (no TPS header) — breaking the CLI/consolidation path.
|
|
172
|
+
*/
|
|
173
|
+
export async function allowVerified(context) {
|
|
174
|
+
return (await resolveAgentAuth(context)).kind !== "anonymous";
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* allow* for ADMIN-ONLY resources: permit admin agents + super_user + trusted
|
|
178
|
+
* internal calls; deny non-admin agents and anonymous.
|
|
179
|
+
*/
|
|
180
|
+
export async function allowAdmin(context) {
|
|
181
|
+
const a = await resolveAgentAuth(context);
|
|
182
|
+
return a.kind === "internal" || (a.kind === "agent" && a.isAdmin);
|
|
183
|
+
}
|
|
184
|
+
export async function resolveAgentAuth(context) {
|
|
185
|
+
const c = context?.request ?? context;
|
|
186
|
+
if (!c)
|
|
187
|
+
return { kind: "internal" };
|
|
188
|
+
if (c.tpsAnonymous === true)
|
|
189
|
+
return { kind: "anonymous" };
|
|
190
|
+
if (c.tpsAgent) {
|
|
191
|
+
return { kind: "agent", agentId: String(c.tpsAgent), isAdmin: c.tpsAgentIsAdmin === true };
|
|
192
|
+
}
|
|
193
|
+
const user = context?.user ?? c.user;
|
|
194
|
+
if (user?.role?.permission?.super_user === true) {
|
|
195
|
+
return { kind: "agent", agentId: String(user.username ?? "admin"), isAdmin: true };
|
|
196
|
+
}
|
|
197
|
+
if (user?.username && user.username !== FLAIR_AGENT_USERNAME) {
|
|
198
|
+
return { kind: "agent", agentId: String(user.username), isAdmin: false };
|
|
199
|
+
}
|
|
200
|
+
// A raw request with headers is present → verify it; an HTTP request that
|
|
201
|
+
// yields no agent is anonymous (NOT internal).
|
|
202
|
+
if (c.headers?.get || c.headers?.asObject) {
|
|
203
|
+
const auth = await verifyAgentRequest(c);
|
|
204
|
+
if (auth)
|
|
205
|
+
return { kind: "agent", agentId: auth.agentId, isAdmin: auth.isAdmin };
|
|
206
|
+
return { kind: "anonymous" };
|
|
207
|
+
}
|
|
208
|
+
return { kind: "internal" };
|
|
209
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { patchRecord } from "./table-helpers.js";
|
|
2
2
|
import { server, databases } from "@harperfast/harper";
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
|
+
import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
4
5
|
// --- Admin credentials ---
|
|
5
6
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
6
7
|
// (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
|
|
@@ -31,34 +32,10 @@ function getAdminPass() {
|
|
|
31
32
|
const WINDOW_MS = 30_000;
|
|
32
33
|
const nonceSeen = new Map();
|
|
33
34
|
// ─── Admin resolution ─────────────────────────────────────────────────────────
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
let adminCache = new Set();
|
|
39
|
-
async function getAdminAgents() {
|
|
40
|
-
const now = Date.now();
|
|
41
|
-
if (now < adminCacheExpiry)
|
|
42
|
-
return adminCache;
|
|
43
|
-
const from_env = (process.env.FLAIR_ADMIN_AGENTS ?? "")
|
|
44
|
-
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
45
|
-
let from_db = [];
|
|
46
|
-
try {
|
|
47
|
-
const results = await databases.flair.Agent.search([{ attribute: "role", value: "admin", condition: "equals" }]);
|
|
48
|
-
for await (const row of results) {
|
|
49
|
-
if (row?.id)
|
|
50
|
-
from_db.push(row.id);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
catch { /* Agent table might not be populated yet */ }
|
|
54
|
-
adminCache = new Set([...from_env, ...from_db]);
|
|
55
|
-
adminCacheExpiry = now + 60_000;
|
|
56
|
-
return adminCache;
|
|
57
|
-
}
|
|
58
|
-
export async function isAdmin(agentId) {
|
|
59
|
-
const admins = await getAdminAgents();
|
|
60
|
-
return admins.has(agentId);
|
|
61
|
-
}
|
|
35
|
+
// `isAdmin` (FLAIR_ADMIN_AGENTS env + Agent role==="admin", 60s-cached) now lives
|
|
36
|
+
// in agent-auth.ts as the single source of truth, imported above. During the
|
|
37
|
+
// auth reshape this gate and the per-resource allow* helpers must agree on who's
|
|
38
|
+
// an admin — one implementation guarantees they can't diverge.
|
|
62
39
|
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
63
40
|
function b64ToArrayBuffer(b64) {
|
|
64
41
|
// Handle both standard and URL-safe base64
|
|
@@ -142,12 +119,27 @@ server.http(async (request, nextLayer) => {
|
|
|
142
119
|
// and inline JS, with no embedded data. The JS prompts for admin-pass and
|
|
143
120
|
// auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
|
|
144
121
|
// Without this allow-list entry, the HTML is 401-blocked on hosted Flair
|
|
145
|
-
// instances (
|
|
146
|
-
url.pathname === "/ObservationCenter"
|
|
122
|
+
// instances (rockit-local works only because authorizeLocal=true).
|
|
123
|
+
url.pathname === "/ObservationCenter" ||
|
|
124
|
+
// Presence roster is public-safe (field-allowlisted); GET serves the
|
|
125
|
+
// Office Space renderer without auth. POST handles Ed25519 auth internally
|
|
126
|
+
// (allowCreate=true on the Resource).
|
|
127
|
+
url.pathname === "/Presence")
|
|
147
128
|
return nextLayer(request);
|
|
148
|
-
// If Harper has already authorized this request (e.g.
|
|
149
|
-
// trust Harper's auth decision and pass
|
|
129
|
+
// If Harper has already authorized this request (e.g. Basic admin, or
|
|
130
|
+
// authorizeLocal=true on localhost), trust Harper's auth decision and pass
|
|
131
|
+
// through. Annotate the admin identity so resources' resolveAgentAuth recognizes
|
|
132
|
+
// this as an ADMIN caller (not anonymous) — otherwise a Basic-admin request,
|
|
133
|
+
// which carries no TPS-Ed25519 header, gets classified anonymous and denied.
|
|
150
134
|
if (request.user?.role?.permission?.super_user === true) {
|
|
135
|
+
request.tpsAgent = request.user.username ?? "admin";
|
|
136
|
+
request.tpsAgentIsAdmin = true;
|
|
137
|
+
try {
|
|
138
|
+
request.headers.set("x-tps-agent", request.tpsAgent);
|
|
139
|
+
if (request.headers.asObject)
|
|
140
|
+
request.headers.asObject["x-tps-agent"] = request.tpsAgent;
|
|
141
|
+
}
|
|
142
|
+
catch { /* frozen headers — annotation on request object still applies */ }
|
|
151
143
|
return nextLayer(request);
|
|
152
144
|
}
|
|
153
145
|
// Skip re-entry: if we already swapped auth to Basic, pass through
|
|
@@ -220,8 +212,19 @@ server.http(async (request, nextLayer) => {
|
|
|
220
212
|
}
|
|
221
213
|
}
|
|
222
214
|
}
|
|
223
|
-
catch { /* fall through to
|
|
224
|
-
|
|
215
|
+
catch { /* fall through to anonymous */ }
|
|
216
|
+
// NON-REJECTING (auth-rbac flip): a Basic header that matched no admin/super_user/
|
|
217
|
+
// pair path → annotate anonymous + pass through (don't 401 — a sibling component's
|
|
218
|
+
// Basic auth on a shared Harper must not be rejected by flair's gate). Resource
|
|
219
|
+
// allow* denies if the path is flair-protected.
|
|
220
|
+
request.tpsAnonymous = true;
|
|
221
|
+
try {
|
|
222
|
+
request.headers.set("x-tps-anonymous", "1");
|
|
223
|
+
if (request.headers.asObject)
|
|
224
|
+
request.headers.asObject["x-tps-anonymous"] = "1";
|
|
225
|
+
}
|
|
226
|
+
catch { /* frozen headers */ }
|
|
227
|
+
return nextLayer(request);
|
|
225
228
|
}
|
|
226
229
|
// ── Ed25519 agent auth ────────────────────────────────────────────────────
|
|
227
230
|
const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
|
|
@@ -240,7 +243,19 @@ server.http(async (request, nextLayer) => {
|
|
|
240
243
|
},
|
|
241
244
|
});
|
|
242
245
|
}
|
|
243
|
-
|
|
246
|
+
// NON-REJECTING GATE (auth-rbac flip): no valid agent → annotate anonymous and
|
|
247
|
+
// pass through. Per-resource allow* (resolveAgentAuth → anonymous → deny) is the
|
|
248
|
+
// enforcement; the gate no longer 401s instance-wide, which was breaking sibling
|
|
249
|
+
// components on a shared Harper / composite hub. Anonymous reaches only public
|
|
250
|
+
// allow-listed paths + resources whose allow* permit it.
|
|
251
|
+
request.tpsAnonymous = true;
|
|
252
|
+
try {
|
|
253
|
+
request.headers.set("x-tps-anonymous", "1");
|
|
254
|
+
if (request.headers.asObject)
|
|
255
|
+
request.headers.asObject["x-tps-anonymous"] = "1";
|
|
256
|
+
}
|
|
257
|
+
catch { /* frozen headers — annotation on the request object still applies */ }
|
|
258
|
+
return nextLayer(request);
|
|
244
259
|
}
|
|
245
260
|
const [, agentId, tsRaw, nonce, signatureB64] = m;
|
|
246
261
|
const ts = Number(tsRaw);
|
|
@@ -273,33 +288,50 @@ server.http(async (request, nextLayer) => {
|
|
|
273
288
|
request._tpsAuthVerified = true;
|
|
274
289
|
request.tpsAgentIsAdmin = await isAdmin(agentId);
|
|
275
290
|
// Grant Harper-level permissions for the cryptographically-verified agent by
|
|
276
|
-
// setting request.user directly
|
|
291
|
+
// setting request.user directly. Setting request.user is the supported
|
|
292
|
+
// extension path (and the only one that works post-5.0.9: Harper resolves
|
|
293
|
+
// request.user from the Authorization header BEFORE this middleware runs, and
|
|
294
|
+
// a TPS-Ed25519 header matches no Basic/Bearer strategy, so request.user
|
|
295
|
+
// arrives null — see #456). getUser(name, null) looks up the record WITHOUT
|
|
296
|
+
// password validation, safe here because the Ed25519 signature already proved
|
|
297
|
+
// identity cryptographically.
|
|
277
298
|
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
// as a generic "Login failed" 401. (Broke at the 2026-05-27 daemon restart,
|
|
287
|
-
// which loaded 5.0.9 fresh; the prior long-running process held an older
|
|
288
|
-
// in-memory Harper where the late header read still worked.)
|
|
299
|
+
// RESHAPE (auth-rbac) — THE FLIP: per-agent DE-ELEVATION. A cryptographically-
|
|
300
|
+
// verified NON-admin agent resolves to the least-privilege `flair-agent` user,
|
|
301
|
+
// NOT admin super_user. The flair_agent role grants exactly the table CRUD agents
|
|
302
|
+
// need; with no operations grant, /sql + /graphql are natively 403 (the hand-
|
|
303
|
+
// rolled raw-query block below becomes belt-and-suspenders). Admins still resolve
|
|
304
|
+
// to admin. getUser(name, null) looks up WITHOUT password validation — safe
|
|
305
|
+
// because the Ed25519 signature already proved identity. Row-level ownership stays
|
|
306
|
+
// enforced via x-tps-agent / resolveAgentAuth, independent of request.user.
|
|
289
307
|
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// because the Ed25519 signature verified above already proves agent identity
|
|
294
|
-
// cryptographically; no Basic credential is presented. This preserves the
|
|
295
|
-
// prior privilege model (verified agents act with admin perms; per-agent data
|
|
296
|
-
// isolation is still enforced downstream via the x-tps-agent header below).
|
|
308
|
+
// GRACEFUL FALLBACK: if the flair-agent user isn't provisioned on this instance
|
|
309
|
+
// yet (pre-migration — ensureFlairAgentUser hasn't run), fall back to admin so
|
|
310
|
+
// agents keep working. De-elevation activates per-instance once the user exists.
|
|
297
311
|
try {
|
|
298
|
-
|
|
312
|
+
if (request.tpsAgentIsAdmin) {
|
|
313
|
+
request.user = await server.getUser("admin", null, request);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
let deElevated = null;
|
|
317
|
+
try {
|
|
318
|
+
deElevated = await server.getUser(FLAIR_AGENT_USERNAME, null, request);
|
|
319
|
+
}
|
|
320
|
+
catch { /* not provisioned */ }
|
|
321
|
+
// getUser(name, null) returns a ROLE-LESS phantom `{ username }` (not null,
|
|
322
|
+
// not a throw) for a nonexistent user — harper security/user.js
|
|
323
|
+
// findAndValidateUser: `if (!userTmp) { if (!validatePassword) return { username } }`.
|
|
324
|
+
// A phantom is truthy, so `deElevated ?? admin` would keep it and the request
|
|
325
|
+
// would carry NO role → 403 AccessViolation. Require a real role to use the
|
|
326
|
+
// de-elevated user; otherwise fall back to admin (pre-migration instances).
|
|
327
|
+
request.user = (deElevated && deElevated.role)
|
|
328
|
+
? deElevated
|
|
329
|
+
: await server.getUser("admin", null, request);
|
|
330
|
+
}
|
|
299
331
|
}
|
|
300
332
|
catch {
|
|
301
|
-
//
|
|
302
|
-
//
|
|
333
|
+
// No usable user record — request proceeds as the verified tpsAgent without
|
|
334
|
+
// elevated perms; resource-level scoping (x-tps-agent) still applies.
|
|
303
335
|
}
|
|
304
336
|
// Propagate authenticated agent to downstream resources via header.
|
|
305
337
|
// Resources can read this to enforce agent-level scoping.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
package/schemas/schema.graphql
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
|
|
2
|
+
# ─── Presence table ───────────────────────────────────────────────────────────
|
|
3
|
+
|
|
4
|
+
type Presence @table(database: "flair") @export {
|
|
5
|
+
agentId: ID @primaryKey
|
|
6
|
+
lastHeartbeatAt: BigInt # unix ms timestamp
|
|
7
|
+
currentTask: String # short human string, e.g. "Reviewing flair#467"
|
|
8
|
+
activity: String @indexed # "coding" | "reviewing" | "planning" | "idle"
|
|
9
|
+
}
|
|
10
|
+
|
|
2
11
|
# ─── Observatory tables ───────────────────────────────────────────────────────
|
|
3
12
|
|
|
4
13
|
type ObsOffice @table(database: "flair") @export {
|