@tpsdev-ai/flair 0.11.0 → 0.13.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 +247 -61
- 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 +8 -39
- 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 +30 -0
- package/dist/resources/Relationship.js +13 -7
- package/dist/resources/SemanticSearch.js +29 -42
- 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 +83 -55
- package/dist/resources/memory-consolidate-lib.js +72 -0
- package/dist/resources/scoring.js +52 -0
- package/package.json +1 -1
|
@@ -1,42 +1,13 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
2
3
|
import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
3
4
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
4
5
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
5
6
|
import { wrapUntrusted } from "./content-safety.js";
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
standard: 0.7,
|
|
11
|
-
ephemeral: 0.4,
|
|
12
|
-
};
|
|
13
|
-
// Half-life in days for exponential decay per durability level
|
|
14
|
-
const DECAY_HALF_LIFE_DAYS = {
|
|
15
|
-
permanent: Infinity, // never decays
|
|
16
|
-
persistent: 90,
|
|
17
|
-
standard: 30,
|
|
18
|
-
ephemeral: 7,
|
|
19
|
-
};
|
|
20
|
-
function recencyFactor(createdAt, durability) {
|
|
21
|
-
const halfLife = DECAY_HALF_LIFE_DAYS[durability] ?? 30;
|
|
22
|
-
if (halfLife === Infinity)
|
|
23
|
-
return 1.0;
|
|
24
|
-
const ageDays = (Date.now() - Date.parse(createdAt)) / (1000 * 60 * 60 * 24);
|
|
25
|
-
const lambda = Math.LN2 / halfLife;
|
|
26
|
-
return Math.exp(-lambda * ageDays);
|
|
27
|
-
}
|
|
28
|
-
function retrievalBoost(retrievalCount) {
|
|
29
|
-
if (!retrievalCount || retrievalCount <= 0)
|
|
30
|
-
return 1.0;
|
|
31
|
-
return 1.0 + 0.1 * Math.log2(retrievalCount); // gentle boost: 10 retrievals → ~1.33x
|
|
32
|
-
}
|
|
33
|
-
function compositeScore(semanticScore, record) {
|
|
34
|
-
const durability = record.durability ?? "standard";
|
|
35
|
-
const dWeight = DURABILITY_WEIGHTS[durability] ?? 0.7;
|
|
36
|
-
const rFactor = record.createdAt ? recencyFactor(record.createdAt, durability) : 1.0;
|
|
37
|
-
const rBoost = retrievalBoost(record.retrievalCount ?? 0);
|
|
38
|
-
return semanticScore * dWeight * rFactor * rBoost;
|
|
39
|
-
}
|
|
7
|
+
// Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
|
|
8
|
+
// relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
|
|
9
|
+
// unit-tested directly (see test/unit/temporal-scoring.test.ts).
|
|
10
|
+
import { compositeScore } from "./scoring.js";
|
|
40
11
|
// Convert HNSW cosine distance (1 - similarity) to similarity score
|
|
41
12
|
function distanceToSimilarity(distance) {
|
|
42
13
|
return 1 - distance;
|
|
@@ -45,6 +16,15 @@ function distanceToSimilarity(distance) {
|
|
|
45
16
|
// so composite re-ranking has enough headroom to reorder results.
|
|
46
17
|
const CANDIDATE_MULTIPLIER = 5;
|
|
47
18
|
export class SemanticSearch extends Resource {
|
|
19
|
+
// Self-authorize via the Ed25519 agent verify instead of relying on the auth
|
|
20
|
+
// gate's admin super_user elevation (removed in the auth reshape). Any
|
|
21
|
+
// cryptographically-verified agent may search; per-agent RESULT scoping is
|
|
22
|
+
// enforced in post() below (an agent only sees its own + office-visible +
|
|
23
|
+
// granted memories). Without this, Harper's default denies the POST for the
|
|
24
|
+
// least-privilege flair_agent role (AccessViolation 403).
|
|
25
|
+
async allowCreate() {
|
|
26
|
+
return allowVerified(this.getContext?.());
|
|
27
|
+
}
|
|
48
28
|
async post(data) {
|
|
49
29
|
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
|
|
50
30
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
@@ -52,11 +32,18 @@ export class SemanticSearch extends Resource {
|
|
|
52
32
|
// silently returned undefined and the defense-in-depth scope check below
|
|
53
33
|
// was bypassed, letting a non-admin agent read another agent's memories
|
|
54
34
|
// by putting the victim's id in the body.
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
35
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
36
|
+
// Anonymous HTTP must NOT search. Previously the no-auth path fell through to
|
|
37
|
+
// honoring the body-supplied agentId (line below), so an unauthenticated
|
|
38
|
+
// caller could read any agent's memories by putting that id in the body.
|
|
39
|
+
if (auth.kind === "anonymous") {
|
|
40
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
41
|
+
status: 401, headers: { "Content-Type": "application/json" },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const authenticatedAgent = auth.kind === "agent" ? auth.agentId : undefined;
|
|
45
|
+
const callerIsAdmin = auth.kind === "agent" && auth.isAdmin;
|
|
46
|
+
// Rate limiting — use authenticated agent ID (internal calls have none).
|
|
60
47
|
if (authenticatedAgent) {
|
|
61
48
|
const bucket = q && !queryEmbedding ? "embedding" : "general";
|
|
62
49
|
const rl = checkRateLimit(authenticatedAgent, bucket);
|
|
@@ -70,14 +57,14 @@ export class SemanticSearch extends Resource {
|
|
|
70
57
|
: null;
|
|
71
58
|
// Enforce agentId = authenticated agent for non-admins. A mismatched body
|
|
72
59
|
// agentId is a cross-agent read attempt — reject outright. Admins can query
|
|
73
|
-
// any agentId (
|
|
60
|
+
// any agentId (bootstrap / consolidation).
|
|
74
61
|
if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
|
|
75
62
|
return new Response(JSON.stringify({
|
|
76
63
|
error: "forbidden: agentId must match authenticated agent",
|
|
77
64
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
78
65
|
}
|
|
79
|
-
// Scope
|
|
80
|
-
//
|
|
66
|
+
// Scope: non-admin agent → own (+ granted). Admin agent or trusted internal
|
|
67
|
+
// call (no request) → honor the body-supplied agentId.
|
|
81
68
|
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
82
69
|
? authenticatedAgent
|
|
83
70
|
: bodyAgentId;
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
/**
|
|
6
|
+
* Deny anonymous; enforce per-agent write ownership for non-admin agents.
|
|
7
|
+
* The previous header-based check only fired when an agent WAS present (it read
|
|
8
|
+
* x-tps-agent), so an anonymous request — which carries no x-tps-agent — slipped
|
|
9
|
+
* through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
|
|
10
|
+
* distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
|
|
11
|
+
*/
|
|
12
|
+
async function enforceWriteAuth(self, data) {
|
|
13
|
+
const auth = await resolveAgentAuth(self.getContext?.());
|
|
14
|
+
if (auth.kind === "anonymous")
|
|
15
|
+
return UNAUTH();
|
|
16
|
+
if (auth.kind === "agent" && !auth.isAdmin && data?.agentId && data.agentId !== auth.agentId) {
|
|
17
|
+
return FORBIDDEN("forbidden: agentId must match authenticated agent");
|
|
9
18
|
}
|
|
10
19
|
return null;
|
|
11
20
|
}
|
|
12
21
|
export class Soul extends databases.flair.Soul {
|
|
13
22
|
async post(content, context) {
|
|
14
|
-
const denied =
|
|
23
|
+
const denied = await enforceWriteAuth(this, content);
|
|
15
24
|
if (denied)
|
|
16
25
|
return denied;
|
|
17
26
|
content.durability ||= "permanent";
|
|
@@ -20,7 +29,7 @@ export class Soul extends databases.flair.Soul {
|
|
|
20
29
|
return super.post(content, context);
|
|
21
30
|
}
|
|
22
31
|
async put(content, context) {
|
|
23
|
-
const denied =
|
|
32
|
+
const denied = await enforceWriteAuth(this, content);
|
|
24
33
|
if (denied)
|
|
25
34
|
return denied;
|
|
26
35
|
content.updatedAt = new Date().toISOString();
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Resource, databases } from '@harperfast/harper';
|
|
2
|
+
import { allowVerified } from './agent-auth.js';
|
|
2
3
|
export class FeedSouls extends Resource {
|
|
4
|
+
// Self-authorize the subscription via the Ed25519 agent verify (auth reshape
|
|
5
|
+
// removes the gate's admin elevation). FeedSouls extends Resource (not a Table),
|
|
6
|
+
// so getContext().request is reachable in allow* — same pattern as SemanticSearch.
|
|
7
|
+
async allowRead() {
|
|
8
|
+
return allowVerified(this.getContext?.());
|
|
9
|
+
}
|
|
3
10
|
async *connect(target, incomingMessages) {
|
|
4
11
|
const subscription = await databases.flair.Soul.subscribe(target);
|
|
5
12
|
if (!incomingMessages) {
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* Auth: requesting agent must match agentId in path (or be admin).
|
|
6
6
|
*/
|
|
7
7
|
import { Resource, databases } from "@harperfast/harper";
|
|
8
|
+
import { allowVerified } from "./agent-auth.js";
|
|
8
9
|
export class WorkspaceLatest extends Resource {
|
|
10
|
+
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
11
|
+
// admin elevation). Any verified agent may read; the path-vs-agent ownership
|
|
12
|
+
// check stays in get().
|
|
13
|
+
async allowRead() {
|
|
14
|
+
return allowVerified(this.getContext?.());
|
|
15
|
+
}
|
|
9
16
|
async get(pathInfo) {
|
|
10
17
|
const request = this.context?.request ?? this.request;
|
|
11
18
|
const callerAgent = request?.tpsAgent;
|
|
@@ -8,30 +8,30 @@
|
|
|
8
8
|
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "@harperfast/harper";
|
|
11
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
12
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
13
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
11
14
|
export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const ctx = this.getContext?.();
|
|
17
|
-
const request = ctx?.request ?? ctx;
|
|
18
|
-
return {
|
|
19
|
-
agentId: request?.tpsAgent,
|
|
20
|
-
isAdmin: request?.tpsAgentIsAdmin ?? false,
|
|
21
|
-
};
|
|
15
|
+
/** Auth verdict from the request context. internal = trusted in-process call;
|
|
16
|
+
* agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
|
|
17
|
+
_auth() {
|
|
18
|
+
return resolveAgentAuth(this.getContext?.());
|
|
22
19
|
}
|
|
23
20
|
/**
|
|
24
|
-
* Override search() to scope collection GETs to the authenticated agent's
|
|
25
|
-
*
|
|
21
|
+
* Override search() to scope collection GETs to the authenticated agent's own
|
|
22
|
+
* records. Internal calls + admin agents see all; anonymous is denied
|
|
23
|
+
* (previously `!authAgent` was treated as unfiltered — the anonymous-read leak).
|
|
26
24
|
*/
|
|
27
25
|
async search(query) {
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
26
|
+
const auth = await this._auth();
|
|
27
|
+
if (auth.kind === "anonymous")
|
|
28
|
+
return UNAUTH();
|
|
29
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
30
30
|
return super.search(query);
|
|
31
31
|
}
|
|
32
|
-
const agentIdCondition = { attribute: "agentId", comparator: "equals", value:
|
|
33
|
-
// Harper passes `query` as a request target object (
|
|
34
|
-
// Inject scope condition into its `.conditions` array
|
|
32
|
+
const agentIdCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
|
|
33
|
+
// Harper passes `query` as a request target object (pathname, id, isCollection…).
|
|
34
|
+
// Inject the scope condition into its `.conditions` array.
|
|
35
35
|
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
36
36
|
const existing = query.conditions ?? [];
|
|
37
37
|
query.conditions = Array.isArray(existing)
|
|
@@ -45,31 +45,41 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
45
45
|
return super.search(conditions);
|
|
46
46
|
}
|
|
47
47
|
async post(content) {
|
|
48
|
-
const
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
const auth = await this._auth();
|
|
49
|
+
// Anonymous must NOT write (previously the agentId check was skipped when
|
|
50
|
+
// there was no authenticated agent, so anonymous could write any record).
|
|
51
|
+
if (auth.kind === "anonymous")
|
|
52
|
+
return UNAUTH();
|
|
53
|
+
if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
|
|
54
|
+
return FORBIDDEN("forbidden: cannot write workspace state for another agent");
|
|
52
55
|
}
|
|
53
56
|
content.createdAt = new Date().toISOString();
|
|
54
57
|
content.timestamp ||= content.createdAt;
|
|
55
58
|
return super.post(content);
|
|
56
59
|
}
|
|
57
60
|
async put(content) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
return
|
|
61
|
+
const auth = await this._auth();
|
|
62
|
+
if (auth.kind === "anonymous")
|
|
63
|
+
return UNAUTH();
|
|
64
|
+
if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
|
|
65
|
+
return FORBIDDEN("forbidden: cannot write workspace state for another agent");
|
|
61
66
|
}
|
|
62
67
|
return super.put(content);
|
|
63
68
|
}
|
|
64
69
|
async delete(id) {
|
|
65
|
-
const
|
|
66
|
-
|
|
70
|
+
const auth = await this._auth();
|
|
71
|
+
// Anonymous must NOT delete (previously `!agentId → super.delete` let anonymous
|
|
72
|
+
// delete any record).
|
|
73
|
+
if (auth.kind === "anonymous")
|
|
74
|
+
return UNAUTH();
|
|
75
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
67
76
|
return super.delete(id);
|
|
77
|
+
}
|
|
68
78
|
const record = await this.get(id);
|
|
69
79
|
if (!record)
|
|
70
80
|
return super.delete(id);
|
|
71
|
-
if (
|
|
72
|
-
return
|
|
81
|
+
if (record.agentId !== auth.agentId) {
|
|
82
|
+
return FORBIDDEN("forbidden: cannot delete workspace state for another agent");
|
|
73
83
|
}
|
|
74
84
|
return super.delete(id);
|
|
75
85
|
}
|
package/dist/resources/XAA.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { allowAdmin } from "./agent-auth.js";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
5
|
/**
|
|
@@ -252,6 +253,13 @@ export async function handleJwtBearerGrant(data) {
|
|
|
252
253
|
* Admin-only access.
|
|
253
254
|
*/
|
|
254
255
|
export class IdpConfig extends databases.flair.IdpConfig {
|
|
256
|
+
// Admin-only on every path (the "Admin-only" comment was previously unenforced —
|
|
257
|
+
// a pure put() override with no auth check, so the non-rejecting gate let
|
|
258
|
+
// anonymous through to Harper's handler). allowAdmin permits admin + internal.
|
|
259
|
+
allowRead() { return allowAdmin(this.getContext?.()); }
|
|
260
|
+
allowCreate() { return allowAdmin(this.getContext?.()); }
|
|
261
|
+
allowUpdate() { return allowAdmin(this.getContext?.()); }
|
|
262
|
+
allowDelete() { return allowAdmin(this.getContext?.()); }
|
|
255
263
|
async put(content) {
|
|
256
264
|
const now = nowISO();
|
|
257
265
|
content.enabled ??= true;
|
|
@@ -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
|
+
}
|