@tpsdev-ai/flair 0.4.15 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/cli.js +971 -102
- package/dist/keystore.js +116 -0
- package/dist/resources/AdminConnectors.js +54 -0
- package/dist/resources/AdminDashboard.js +68 -0
- package/dist/resources/AdminIdp.js +57 -0
- package/dist/resources/AdminInstance.js +64 -0
- package/dist/resources/AdminMemory.js +92 -0
- package/dist/resources/AdminPrincipals.js +71 -0
- package/dist/resources/Agent.js +49 -2
- package/dist/resources/Credential.js +105 -0
- package/dist/resources/Federation.js +315 -0
- package/dist/resources/Memory.js +11 -0
- package/dist/resources/MemoryBootstrap.js +91 -4
- package/dist/resources/OAuth.js +381 -0
- package/dist/resources/Relationship.js +96 -0
- package/dist/resources/SemanticSearch.js +20 -4
- package/dist/resources/XAA.js +264 -0
- package/dist/resources/admin-layout.js +94 -0
- package/dist/resources/auth-middleware.js +39 -21
- package/dist/resources/embeddings-provider.js +33 -30
- package/dist/resources/federation-crypto.js +52 -0
- package/dist/resources/health.js +67 -1
- package/dist/src/keystore.js +116 -0
- package/package.json +5 -3
- package/schemas/agent.graphql +51 -2
- package/schemas/federation.graphql +51 -0
- package/schemas/memory.graphql +20 -0
- package/schemas/oauth.graphql +72 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { handleJwtBearerGrant } from "./XAA.js";
|
|
4
|
+
/**
|
|
5
|
+
* OAuth 2.1 Authorization Server for Flair.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints (all mapped via Harper's resource routing):
|
|
8
|
+
* GET /OAuthMetadata → /.well-known/oauth-authorization-server
|
|
9
|
+
* POST /OAuthRegister → /oauth/register (DCR)
|
|
10
|
+
* GET /OAuthAuthorize → /oauth/authorize (consent screen)
|
|
11
|
+
* POST /OAuthToken → /oauth/token (token exchange)
|
|
12
|
+
* POST /OAuthRevoke → /oauth/revoke
|
|
13
|
+
*
|
|
14
|
+
* 1.0 constraints (per FLAIR-PRINCIPALS § 2):
|
|
15
|
+
* - Only https://claude.com/api/mcp/auth_callback permitted as redirect URI
|
|
16
|
+
* - PKCE required (S256 only)
|
|
17
|
+
* - Access tokens: max 1 hour
|
|
18
|
+
* - Refresh token rotation on each use
|
|
19
|
+
* - No "remember this decision" — each authorize is explicit
|
|
20
|
+
*/
|
|
21
|
+
const ALLOWED_REDIRECT_URI = "https://claude.com/api/mcp/auth_callback";
|
|
22
|
+
const ACCESS_TOKEN_TTL_MS = 3600_000; // 1 hour
|
|
23
|
+
const REFRESH_TOKEN_TTL_MS = 7 * 86400_000; // 7 days
|
|
24
|
+
const AUTH_CODE_TTL_MS = 600_000; // 10 minutes
|
|
25
|
+
function sha256(input) {
|
|
26
|
+
return createHash("sha256").update(input).digest("hex");
|
|
27
|
+
}
|
|
28
|
+
function randomToken(prefix) {
|
|
29
|
+
return `${prefix}${randomBytes(32).toString("base64url")}`;
|
|
30
|
+
}
|
|
31
|
+
function nowISO() {
|
|
32
|
+
return new Date().toISOString();
|
|
33
|
+
}
|
|
34
|
+
function futureISO(ms) {
|
|
35
|
+
return new Date(Date.now() + ms).toISOString();
|
|
36
|
+
}
|
|
37
|
+
// ─── Discovery metadata ──────────────────────────────────────────────────────
|
|
38
|
+
export class OAuthMetadata extends Resource {
|
|
39
|
+
async get() {
|
|
40
|
+
const baseUrl = process.env.FLAIR_PUBLIC_URL || `http://127.0.0.1:${process.env.HTTP_PORT || 19926}`;
|
|
41
|
+
return {
|
|
42
|
+
issuer: baseUrl,
|
|
43
|
+
authorization_endpoint: `${baseUrl}/OAuthAuthorize`,
|
|
44
|
+
token_endpoint: `${baseUrl}/OAuthToken`,
|
|
45
|
+
registration_endpoint: `${baseUrl}/OAuthRegister`,
|
|
46
|
+
revocation_endpoint: `${baseUrl}/OAuthRevoke`,
|
|
47
|
+
response_types_supported: ["code"],
|
|
48
|
+
grant_types_supported: [
|
|
49
|
+
"authorization_code",
|
|
50
|
+
"refresh_token",
|
|
51
|
+
"urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
52
|
+
],
|
|
53
|
+
token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
|
|
54
|
+
code_challenge_methods_supported: ["S256"],
|
|
55
|
+
scopes_supported: [
|
|
56
|
+
"memory:read", "memory:write", "memory:admin",
|
|
57
|
+
"principal:read", "principal:admin",
|
|
58
|
+
"connector:read", "connector:admin",
|
|
59
|
+
],
|
|
60
|
+
extensions_supported: [
|
|
61
|
+
"io.modelcontextprotocol/enterprise-managed-authorization",
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
|
|
67
|
+
export class OAuthRegister extends Resource {
|
|
68
|
+
async post(data) {
|
|
69
|
+
const redirectUris = data?.redirect_uris ?? [];
|
|
70
|
+
const clientName = data?.client_name ?? "Unknown Client";
|
|
71
|
+
// 1.0: only claude.com redirect URI permitted
|
|
72
|
+
for (const uri of redirectUris) {
|
|
73
|
+
if (uri !== ALLOWED_REDIRECT_URI) {
|
|
74
|
+
return new Response(JSON.stringify({
|
|
75
|
+
error: "invalid_redirect_uri",
|
|
76
|
+
error_description: `Only ${ALLOWED_REDIRECT_URI} is permitted in 1.0`,
|
|
77
|
+
}), { status: 400, headers: { "content-type": "application/json" } });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (redirectUris.length === 0) {
|
|
81
|
+
redirectUris.push(ALLOWED_REDIRECT_URI);
|
|
82
|
+
}
|
|
83
|
+
const clientId = `flair_cl_${randomBytes(16).toString("base64url")}`;
|
|
84
|
+
const now = nowISO();
|
|
85
|
+
await databases.flair.OAuthClient.put({
|
|
86
|
+
id: clientId,
|
|
87
|
+
name: clientName,
|
|
88
|
+
redirectUris,
|
|
89
|
+
grantTypes: ["authorization_code", "refresh_token"],
|
|
90
|
+
scope: data?.scope ?? "memory:read memory:write",
|
|
91
|
+
registeredBy: "dcr",
|
|
92
|
+
createdAt: now,
|
|
93
|
+
updatedAt: now,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
client_id: clientId,
|
|
97
|
+
client_name: clientName,
|
|
98
|
+
redirect_uris: redirectUris,
|
|
99
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
100
|
+
token_endpoint_auth_method: "none",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// ─── Authorization endpoint ──────────────────────────────────────────────────
|
|
105
|
+
export class OAuthAuthorize extends Resource {
|
|
106
|
+
async get() {
|
|
107
|
+
// In 1.0, this returns a simple HTML consent page.
|
|
108
|
+
// The user (Nathan) approves or denies, which POSTs back.
|
|
109
|
+
const request = this.request;
|
|
110
|
+
const url = new URL(request?.url ?? "http://localhost", "http://localhost");
|
|
111
|
+
const clientId = url.searchParams.get("client_id") ?? "";
|
|
112
|
+
const redirectUri = url.searchParams.get("redirect_uri") ?? "";
|
|
113
|
+
const responseType = url.searchParams.get("response_type") ?? "";
|
|
114
|
+
const scope = url.searchParams.get("scope") ?? "memory:read";
|
|
115
|
+
const state = url.searchParams.get("state") ?? "";
|
|
116
|
+
const codeChallenge = url.searchParams.get("code_challenge") ?? "";
|
|
117
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "";
|
|
118
|
+
if (responseType !== "code") {
|
|
119
|
+
return new Response(JSON.stringify({ error: "unsupported_response_type" }), {
|
|
120
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (redirectUri && redirectUri !== ALLOWED_REDIRECT_URI) {
|
|
124
|
+
return new Response(JSON.stringify({ error: "invalid_redirect_uri" }), {
|
|
125
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// Verify client exists
|
|
129
|
+
const client = await databases.flair.OAuthClient.get(clientId);
|
|
130
|
+
if (!client) {
|
|
131
|
+
return new Response(JSON.stringify({ error: "invalid_client" }), {
|
|
132
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// Server-rendered consent page (minimal HTML, no JS frameworks)
|
|
136
|
+
const html = `<!DOCTYPE html>
|
|
137
|
+
<html><head><title>Flair — Authorize</title>
|
|
138
|
+
<style>body{font-family:system-ui;max-width:480px;margin:60px auto;padding:0 20px}
|
|
139
|
+
h1{font-size:1.4em}button{padding:10px 24px;font-size:1em;border:none;border-radius:6px;cursor:pointer;margin-right:8px}
|
|
140
|
+
.approve{background:#2563eb;color:#fff}.deny{background:#e5e7eb;color:#333}
|
|
141
|
+
.scope{background:#f3f4f6;padding:8px 12px;border-radius:4px;margin:4px 0;font-family:monospace}</style></head>
|
|
142
|
+
<body>
|
|
143
|
+
<h1>Authorize ${client.name || clientId}</h1>
|
|
144
|
+
<p>This application wants to access your Flair memories:</p>
|
|
145
|
+
${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
146
|
+
<form method="POST" action="/OAuthAuthorize" style="margin-top:24px">
|
|
147
|
+
<input type="hidden" name="client_id" value="${clientId}">
|
|
148
|
+
<input type="hidden" name="redirect_uri" value="${redirectUri || ALLOWED_REDIRECT_URI}">
|
|
149
|
+
<input type="hidden" name="scope" value="${scope}">
|
|
150
|
+
<input type="hidden" name="state" value="${state}">
|
|
151
|
+
<input type="hidden" name="code_challenge" value="${codeChallenge}">
|
|
152
|
+
<input type="hidden" name="code_challenge_method" value="${codeChallengeMethod}">
|
|
153
|
+
<button type="submit" name="action" value="approve" class="approve">Approve</button>
|
|
154
|
+
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
|
155
|
+
</form></body></html>`;
|
|
156
|
+
return new Response(html, {
|
|
157
|
+
status: 200,
|
|
158
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async post(data) {
|
|
162
|
+
const action = data?.action;
|
|
163
|
+
const clientId = data?.client_id ?? "";
|
|
164
|
+
const redirectUri = data?.redirect_uri || ALLOWED_REDIRECT_URI;
|
|
165
|
+
const scope = data?.scope ?? "memory:read";
|
|
166
|
+
const state = data?.state ?? "";
|
|
167
|
+
const codeChallenge = data?.code_challenge ?? "";
|
|
168
|
+
const codeChallengeMethod = data?.code_challenge_method ?? "S256";
|
|
169
|
+
// Sherlock 2026-04-11: validate redirect_uri in POST handler too.
|
|
170
|
+
// Without this, an attacker can CSRF the approval form with an arbitrary
|
|
171
|
+
// redirect_uri, stealing the authorization code via open redirect.
|
|
172
|
+
if (redirectUri !== ALLOWED_REDIRECT_URI) {
|
|
173
|
+
return new Response(JSON.stringify({ error: "invalid_redirect_uri" }), {
|
|
174
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (action === "deny") {
|
|
178
|
+
const params = new URLSearchParams({ error: "access_denied", state });
|
|
179
|
+
return Response.redirect(`${redirectUri}?${params}`, 302);
|
|
180
|
+
}
|
|
181
|
+
// Determine authenticated principal
|
|
182
|
+
const request = this.request;
|
|
183
|
+
const principalId = request?.tpsAgent ?? "admin";
|
|
184
|
+
// Generate authorization code
|
|
185
|
+
const code = randomBytes(32).toString("base64url");
|
|
186
|
+
const now = nowISO();
|
|
187
|
+
await databases.flair.OAuthAuthCode.put({
|
|
188
|
+
id: code,
|
|
189
|
+
clientId,
|
|
190
|
+
principalId,
|
|
191
|
+
redirectUri,
|
|
192
|
+
scope,
|
|
193
|
+
codeChallenge,
|
|
194
|
+
codeChallengeMethod,
|
|
195
|
+
expiresAt: futureISO(AUTH_CODE_TTL_MS),
|
|
196
|
+
used: false,
|
|
197
|
+
createdAt: now,
|
|
198
|
+
});
|
|
199
|
+
const params = new URLSearchParams({ code, state });
|
|
200
|
+
return Response.redirect(`${redirectUri}?${params}`, 302);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// ─── Token endpoint ──────────────────────────────────────────────────────────
|
|
204
|
+
export class OAuthToken extends Resource {
|
|
205
|
+
async post(data) {
|
|
206
|
+
const grantType = data?.grant_type;
|
|
207
|
+
if (grantType === "authorization_code") {
|
|
208
|
+
return this.handleAuthorizationCode(data);
|
|
209
|
+
}
|
|
210
|
+
else if (grantType === "refresh_token") {
|
|
211
|
+
return this.handleRefreshToken(data);
|
|
212
|
+
}
|
|
213
|
+
else if (grantType === "urn:ietf:params:oauth:grant-type:jwt-bearer") {
|
|
214
|
+
return handleJwtBearerGrant(data);
|
|
215
|
+
}
|
|
216
|
+
return new Response(JSON.stringify({ error: "unsupported_grant_type" }), {
|
|
217
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async handleAuthorizationCode(data) {
|
|
221
|
+
const code = data?.code;
|
|
222
|
+
const clientId = data?.client_id;
|
|
223
|
+
const redirectUri = data?.redirect_uri;
|
|
224
|
+
const codeVerifier = data?.code_verifier;
|
|
225
|
+
if (!code || !clientId) {
|
|
226
|
+
return new Response(JSON.stringify({ error: "invalid_request" }), {
|
|
227
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// Look up auth code
|
|
231
|
+
const authCode = await databases.flair.OAuthAuthCode.get(code);
|
|
232
|
+
if (!authCode) {
|
|
233
|
+
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
234
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// Validate
|
|
238
|
+
if (authCode.used) {
|
|
239
|
+
return new Response(JSON.stringify({ error: "invalid_grant", error_description: "code already used" }), {
|
|
240
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (authCode.clientId !== clientId) {
|
|
244
|
+
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
245
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (new Date(authCode.expiresAt) < new Date()) {
|
|
249
|
+
return new Response(JSON.stringify({ error: "invalid_grant", error_description: "code expired" }), {
|
|
250
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (redirectUri && authCode.redirectUri !== redirectUri) {
|
|
254
|
+
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
255
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// PKCE verification
|
|
259
|
+
if (authCode.codeChallenge) {
|
|
260
|
+
if (!codeVerifier) {
|
|
261
|
+
return new Response(JSON.stringify({ error: "invalid_grant", error_description: "code_verifier required" }), {
|
|
262
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const expectedChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
266
|
+
if (expectedChallenge !== authCode.codeChallenge) {
|
|
267
|
+
return new Response(JSON.stringify({ error: "invalid_grant", error_description: "PKCE verification failed" }), {
|
|
268
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Mark code as used
|
|
273
|
+
await databases.flair.OAuthAuthCode.put({ ...authCode, used: true });
|
|
274
|
+
// Issue tokens
|
|
275
|
+
return this.issueTokenPair(authCode.clientId, authCode.principalId, authCode.scope);
|
|
276
|
+
}
|
|
277
|
+
async handleRefreshToken(data) {
|
|
278
|
+
const refreshTokenRaw = data?.refresh_token;
|
|
279
|
+
const clientId = data?.client_id;
|
|
280
|
+
if (!refreshTokenRaw) {
|
|
281
|
+
return new Response(JSON.stringify({ error: "invalid_request" }), {
|
|
282
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const tokenHash = sha256(refreshTokenRaw);
|
|
286
|
+
// Find refresh token by hash
|
|
287
|
+
let refreshRecord = null;
|
|
288
|
+
for await (const t of databases.flair.OAuthToken.search({
|
|
289
|
+
conditions: [
|
|
290
|
+
{ attribute: "tokenHash", comparator: "equals", value: tokenHash },
|
|
291
|
+
{ attribute: "tokenType", comparator: "equals", value: "refresh" },
|
|
292
|
+
],
|
|
293
|
+
})) {
|
|
294
|
+
refreshRecord = t;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
if (!refreshRecord) {
|
|
298
|
+
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
299
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (refreshRecord.revokedAt) {
|
|
303
|
+
return new Response(JSON.stringify({ error: "invalid_grant", error_description: "token revoked" }), {
|
|
304
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (new Date(refreshRecord.expiresAt) < new Date()) {
|
|
308
|
+
return new Response(JSON.stringify({ error: "invalid_grant", error_description: "refresh token expired" }), {
|
|
309
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (clientId && refreshRecord.clientId !== clientId) {
|
|
313
|
+
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
|
314
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
// Rotate: revoke old refresh token, issue new pair
|
|
318
|
+
await databases.flair.OAuthToken.put({
|
|
319
|
+
...refreshRecord,
|
|
320
|
+
revokedAt: nowISO(),
|
|
321
|
+
});
|
|
322
|
+
return this.issueTokenPair(refreshRecord.clientId, refreshRecord.principalId, refreshRecord.scope);
|
|
323
|
+
}
|
|
324
|
+
async issueTokenPair(clientId, principalId, scope) {
|
|
325
|
+
const now = nowISO();
|
|
326
|
+
const accessTokenRaw = randomToken("flair_at_");
|
|
327
|
+
const refreshTokenRaw = randomToken("flair_rt_");
|
|
328
|
+
const accessId = `at_${randomBytes(8).toString("hex")}`;
|
|
329
|
+
const refreshId = `rt_${randomBytes(8).toString("hex")}`;
|
|
330
|
+
await databases.flair.OAuthToken.put({
|
|
331
|
+
id: accessId,
|
|
332
|
+
tokenHash: sha256(accessTokenRaw),
|
|
333
|
+
tokenType: "access",
|
|
334
|
+
clientId,
|
|
335
|
+
principalId,
|
|
336
|
+
scope,
|
|
337
|
+
expiresAt: futureISO(ACCESS_TOKEN_TTL_MS),
|
|
338
|
+
createdAt: now,
|
|
339
|
+
});
|
|
340
|
+
await databases.flair.OAuthToken.put({
|
|
341
|
+
id: refreshId,
|
|
342
|
+
tokenHash: sha256(refreshTokenRaw),
|
|
343
|
+
tokenType: "refresh",
|
|
344
|
+
clientId,
|
|
345
|
+
principalId,
|
|
346
|
+
scope,
|
|
347
|
+
expiresAt: futureISO(REFRESH_TOKEN_TTL_MS),
|
|
348
|
+
parentTokenId: accessId,
|
|
349
|
+
createdAt: now,
|
|
350
|
+
});
|
|
351
|
+
return {
|
|
352
|
+
access_token: accessTokenRaw,
|
|
353
|
+
token_type: "Bearer",
|
|
354
|
+
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
|
|
355
|
+
refresh_token: refreshTokenRaw,
|
|
356
|
+
scope,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// ─── Revocation endpoint ─────────────────────────────────────────────────────
|
|
361
|
+
export class OAuthRevoke extends Resource {
|
|
362
|
+
async post(data) {
|
|
363
|
+
const token = data?.token;
|
|
364
|
+
if (!token) {
|
|
365
|
+
return new Response(JSON.stringify({ error: "invalid_request" }), {
|
|
366
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
const tokenHash = sha256(token);
|
|
370
|
+
for await (const t of databases.flair.OAuthToken.search({
|
|
371
|
+
conditions: [{ attribute: "tokenHash", comparator: "equals", value: tokenHash }],
|
|
372
|
+
})) {
|
|
373
|
+
await databases.flair.OAuthToken.put({
|
|
374
|
+
...t,
|
|
375
|
+
revokedAt: nowISO(),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
// RFC 7009: always return 200 regardless of whether token was found
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
3
|
+
/**
|
|
4
|
+
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
5
|
+
*
|
|
6
|
+
* Enables knowledge graph queries like:
|
|
7
|
+
* - "Who manages project X?" (active relationships)
|
|
8
|
+
* - "Who was team lead in Q1?" (historical, validFrom/validTo bounded)
|
|
9
|
+
* - "What changed about Nathan's role?" (all relationships for a subject, ordered by time)
|
|
10
|
+
*
|
|
11
|
+
* Relationships are scoped by agentId for multi-agent isolation.
|
|
12
|
+
* Admin agents can query across all agents.
|
|
13
|
+
*/
|
|
14
|
+
export class Relationship extends databases.flair.Relationship {
|
|
15
|
+
async search(query) {
|
|
16
|
+
const ctx = this.getContext?.();
|
|
17
|
+
const request = ctx?.request ?? ctx;
|
|
18
|
+
const authAgent = request?.tpsAgent;
|
|
19
|
+
const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
|
|
20
|
+
if (!authAgent || isAdminAgent) {
|
|
21
|
+
return super.search(query);
|
|
22
|
+
}
|
|
23
|
+
// Non-admin: scope to own relationships
|
|
24
|
+
const agentCondition = { attribute: "agentId", comparator: "equals", value: authAgent };
|
|
25
|
+
if (!query?.conditions) {
|
|
26
|
+
return super.search({ conditions: [agentCondition], ...(query || {}) });
|
|
27
|
+
}
|
|
28
|
+
return super.search({
|
|
29
|
+
...query,
|
|
30
|
+
conditions: [agentCondition, { conditions: query.conditions, operator: query.operator || "and" }],
|
|
31
|
+
operator: "and",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async put(content) {
|
|
35
|
+
const ctx = this.getContext?.();
|
|
36
|
+
const request = ctx?.request ?? ctx;
|
|
37
|
+
const authAgent = request?.tpsAgent;
|
|
38
|
+
if (!authAgent) {
|
|
39
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
40
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const rl = checkRateLimit(authAgent);
|
|
44
|
+
if (!rl.allowed)
|
|
45
|
+
return rateLimitResponse(rl.retryAfterMs, "relationship");
|
|
46
|
+
// Validate required fields
|
|
47
|
+
if (!content.subject || typeof content.subject !== "string") {
|
|
48
|
+
return new Response(JSON.stringify({ error: "subject is required (string)" }), {
|
|
49
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (!content.predicate || typeof content.predicate !== "string") {
|
|
53
|
+
return new Response(JSON.stringify({ error: "predicate is required (string)" }), {
|
|
54
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (!content.object || typeof content.object !== "string") {
|
|
58
|
+
return new Response(JSON.stringify({ error: "object is required (string)" }), {
|
|
59
|
+
status: 400, headers: { "content-type": "application/json" },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// Normalize
|
|
63
|
+
const now = new Date().toISOString();
|
|
64
|
+
content.agentId = authAgent;
|
|
65
|
+
content.subject = content.subject.toLowerCase();
|
|
66
|
+
content.predicate = content.predicate.toLowerCase();
|
|
67
|
+
content.object = content.object.toLowerCase();
|
|
68
|
+
content.createdAt = content.createdAt || now;
|
|
69
|
+
content.updatedAt = now;
|
|
70
|
+
content.validFrom = content.validFrom || now;
|
|
71
|
+
// validTo left as null/undefined for active relationships
|
|
72
|
+
content.confidence = content.confidence ?? 1.0;
|
|
73
|
+
return super.put(content);
|
|
74
|
+
}
|
|
75
|
+
async delete(_) {
|
|
76
|
+
const ctx = this.getContext?.();
|
|
77
|
+
const request = ctx?.request ?? ctx;
|
|
78
|
+
const authAgent = request?.tpsAgent;
|
|
79
|
+
const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
|
|
80
|
+
if (!authAgent) {
|
|
81
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
82
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
// Non-admin: verify ownership before delete
|
|
86
|
+
if (!isAdminAgent) {
|
|
87
|
+
const existing = await super.get();
|
|
88
|
+
if (existing?.agentId && existing.agentId !== authAgent) {
|
|
89
|
+
return new Response(JSON.stringify({ error: "cannot delete another agent's relationship" }), {
|
|
90
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return super.delete(_);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
|
|
|
2
2
|
import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
3
3
|
import { patchRecord } from "./table-helpers.js";
|
|
4
4
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
5
|
+
import { wrapUntrusted } from "./content-safety.js";
|
|
5
6
|
// ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
|
|
6
7
|
const DURABILITY_WEIGHTS = {
|
|
7
8
|
permanent: 1.0,
|
|
@@ -45,7 +46,7 @@ function distanceToSimilarity(distance) {
|
|
|
45
46
|
const CANDIDATE_MULTIPLIER = 5;
|
|
46
47
|
export class SemanticSearch extends Resource {
|
|
47
48
|
async post(data) {
|
|
48
|
-
const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since } = data || {};
|
|
49
|
+
const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
|
|
49
50
|
// Rate limiting — use authenticated agent ID from request context, not client-supplied body
|
|
50
51
|
const rateLimitAgent = this.request?.headers?.get?.("x-tps-agent")
|
|
51
52
|
?? this.request?.tpsAgent;
|
|
@@ -171,7 +172,7 @@ export class SemanticSearch extends Resource {
|
|
|
171
172
|
"source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
|
|
172
173
|
"promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
|
|
173
174
|
"parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
|
|
174
|
-
"$distance"],
|
|
175
|
+
"validFrom", "validTo", "_safetyFlags", "$distance"],
|
|
175
176
|
limit: candidateLimit,
|
|
176
177
|
};
|
|
177
178
|
if (conditions.length > 0) {
|
|
@@ -182,6 +183,11 @@ export class SemanticSearch extends Resource {
|
|
|
182
183
|
continue;
|
|
183
184
|
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
184
185
|
continue;
|
|
186
|
+
// Temporal validity: if asOf is specified, only include memories valid at that point
|
|
187
|
+
if (asOf && record.validFrom && record.validFrom > asOf)
|
|
188
|
+
continue;
|
|
189
|
+
if (asOf && record.validTo && record.validTo <= asOf)
|
|
190
|
+
continue;
|
|
185
191
|
const semanticScore = distanceToSimilarity(record.$distance ?? 1);
|
|
186
192
|
let keywordHit = false;
|
|
187
193
|
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
@@ -192,11 +198,14 @@ export class SemanticSearch extends Resource {
|
|
|
192
198
|
if (temporalBoost > 1.0)
|
|
193
199
|
finalScore *= temporalBoost;
|
|
194
200
|
const { $distance, ...rest } = record;
|
|
201
|
+
const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
|
|
202
|
+
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
195
203
|
results.push({
|
|
196
204
|
...rest,
|
|
205
|
+
content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
|
|
197
206
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
198
207
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
199
|
-
_source:
|
|
208
|
+
_source: source,
|
|
200
209
|
});
|
|
201
210
|
}
|
|
202
211
|
}
|
|
@@ -210,6 +219,10 @@ export class SemanticSearch extends Resource {
|
|
|
210
219
|
continue;
|
|
211
220
|
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
212
221
|
continue;
|
|
222
|
+
if (asOf && record.validFrom && record.validFrom > asOf)
|
|
223
|
+
continue;
|
|
224
|
+
if (asOf && record.validTo && record.validTo <= asOf)
|
|
225
|
+
continue;
|
|
213
226
|
let keywordHit = false;
|
|
214
227
|
if (q && String(record.content || "").toLowerCase().includes(String(q).toLowerCase())) {
|
|
215
228
|
keywordHit = true;
|
|
@@ -221,11 +234,14 @@ export class SemanticSearch extends Resource {
|
|
|
221
234
|
let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, rest);
|
|
222
235
|
if (temporalBoost > 1.0)
|
|
223
236
|
finalScore *= temporalBoost;
|
|
237
|
+
const isFlagged = rest._safetyFlags && Array.isArray(rest._safetyFlags) && rest._safetyFlags.length > 0;
|
|
238
|
+
const source = record.agentId !== agentId ? record.agentId : undefined;
|
|
224
239
|
results.push({
|
|
225
240
|
...rest,
|
|
241
|
+
content: isFlagged ? wrapUntrusted(rest.content, source) : rest.content,
|
|
226
242
|
_score: Math.round(finalScore * 1000) / 1000,
|
|
227
243
|
_rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
|
|
228
|
-
_source:
|
|
244
|
+
_source: source,
|
|
229
245
|
});
|
|
230
246
|
}
|
|
231
247
|
}
|