@tpsdev-ai/flair 0.4.16 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/cli.js +1149 -89
- 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 +7 -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,264 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
|
+
/**
|
|
5
|
+
* XAA (Enterprise-Managed Authorization) — ID-JAG validation for Flair.
|
|
6
|
+
*
|
|
7
|
+
* Validates ID-JAG tokens from enterprise IdPs (Google Workspace, Azure AD,
|
|
8
|
+
* Okta) and issues Flair access tokens. Implements the jwt-bearer grant type
|
|
9
|
+
* (RFC 7523) at the OAuth token endpoint.
|
|
10
|
+
*
|
|
11
|
+
* Flow:
|
|
12
|
+
* 1. Client authenticates user via corporate SSO
|
|
13
|
+
* 2. IdP issues ID-JAG (signed JWT with policy decision)
|
|
14
|
+
* 3. Client presents ID-JAG to Flair's /OAuthToken endpoint
|
|
15
|
+
* 4. Flair validates signature, issuer, audience, domain, expiry, replay
|
|
16
|
+
* 5. Flair resolves or JIT-creates a Principal
|
|
17
|
+
* 6. Flair issues scoped access + refresh tokens
|
|
18
|
+
*
|
|
19
|
+
* Per FLAIR-XAA spec §§ 3-4.
|
|
20
|
+
*/
|
|
21
|
+
const ACCESS_TOKEN_TTL_MS = 3600_000; // 1 hour
|
|
22
|
+
const REFRESH_TOKEN_TTL_MS = 7 * 86400_000; // 7 days
|
|
23
|
+
const CLOCK_SKEW_MS = 30_000; // 30 seconds
|
|
24
|
+
// JWKS remote key set cache per issuer (jose handles caching internally)
|
|
25
|
+
const jwksSetCache = new Map();
|
|
26
|
+
function sha256(input) {
|
|
27
|
+
return createHash("sha256").update(input).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
function randomToken(prefix) {
|
|
30
|
+
return `${prefix}${randomBytes(32).toString("base64url")}`;
|
|
31
|
+
}
|
|
32
|
+
function nowISO() {
|
|
33
|
+
return new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
function futureISO(ms) {
|
|
36
|
+
return new Date(Date.now() + ms).toISOString();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Get or create a remote JWKS key set for an IdP.
|
|
40
|
+
* jose handles caching, key rotation, and refetching internally.
|
|
41
|
+
*/
|
|
42
|
+
function getJwksKeySet(jwksUri) {
|
|
43
|
+
let keySet = jwksSetCache.get(jwksUri);
|
|
44
|
+
if (!keySet) {
|
|
45
|
+
keySet = createRemoteJWKSet(new URL(jwksUri));
|
|
46
|
+
jwksSetCache.set(jwksUri, keySet);
|
|
47
|
+
}
|
|
48
|
+
return keySet;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate an ID-JAG token against a configured IdP.
|
|
52
|
+
* Returns the validated payload or throws with a specific error.
|
|
53
|
+
*/
|
|
54
|
+
export async function validateIdJag(assertion, expectedAudience) {
|
|
55
|
+
// Pre-decode to find the issuer (needed to look up the IdP config + JWKS URI)
|
|
56
|
+
const parts = assertion.split(".");
|
|
57
|
+
if (parts.length !== 3)
|
|
58
|
+
throw new Error("invalid JWT format");
|
|
59
|
+
let prePayload;
|
|
60
|
+
try {
|
|
61
|
+
prePayload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error("invalid JWT payload encoding");
|
|
65
|
+
}
|
|
66
|
+
const issuer = prePayload.iss;
|
|
67
|
+
if (!issuer)
|
|
68
|
+
throw new Error("missing iss claim");
|
|
69
|
+
// 2. Issuer lookup
|
|
70
|
+
let idpConfig = null;
|
|
71
|
+
for await (const cfg of databases.flair.IdpConfig.search({
|
|
72
|
+
conditions: [
|
|
73
|
+
{ attribute: "issuer", comparator: "equals", value: issuer },
|
|
74
|
+
{ attribute: "enabled", comparator: "equals", value: true },
|
|
75
|
+
],
|
|
76
|
+
})) {
|
|
77
|
+
idpConfig = cfg;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (!idpConfig)
|
|
81
|
+
throw new Error(`unknown issuer: ${issuer}`);
|
|
82
|
+
// 1. Cryptographic signature verification via jose + IdP JWKS.
|
|
83
|
+
// This is the core security check — without it, all claims are forgeable.
|
|
84
|
+
// Sherlock's 2026-04-11 review finding: deferring signature verification
|
|
85
|
+
// defeats the entire purpose of JWTs.
|
|
86
|
+
const jwks = getJwksKeySet(idpConfig.jwksUri);
|
|
87
|
+
const { payload } = await jwtVerify(assertion, jwks, {
|
|
88
|
+
issuer,
|
|
89
|
+
audience: expectedAudience,
|
|
90
|
+
clockTolerance: CLOCK_SKEW_MS / 1000,
|
|
91
|
+
});
|
|
92
|
+
// 8. Domain restriction (post-verification — claims are now trusted)
|
|
93
|
+
if (idpConfig.requiredDomain) {
|
|
94
|
+
const hd = payload.hd;
|
|
95
|
+
const tid = payload.tid;
|
|
96
|
+
if (hd && hd !== idpConfig.requiredDomain) {
|
|
97
|
+
throw new Error(`domain mismatch: expected ${idpConfig.requiredDomain}, got ${hd}`);
|
|
98
|
+
}
|
|
99
|
+
if (tid && tid !== idpConfig.requiredDomain) {
|
|
100
|
+
throw new Error(`tenant mismatch: expected ${idpConfig.requiredDomain}, got ${tid}`);
|
|
101
|
+
}
|
|
102
|
+
if (!hd && !tid) {
|
|
103
|
+
throw new Error(`domain required but no hd/tid claim present — consumer account rejected`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Replay prevention (jti)
|
|
107
|
+
if (payload.jti) {
|
|
108
|
+
const existing = await databases.flair.IdJagReplay.get(payload.jti);
|
|
109
|
+
if (existing)
|
|
110
|
+
throw new Error("token replay detected");
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
await databases.flair.IdJagReplay.put({
|
|
113
|
+
id: payload.jti,
|
|
114
|
+
expiresAt: futureISO(Math.max((payload.exp ?? 0) * 1000 - now + CLOCK_SKEW_MS, 300_000)),
|
|
115
|
+
createdAt: nowISO(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return { payload: payload, idpConfig };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolve or JIT-create a Principal from validated ID-JAG claims.
|
|
122
|
+
*/
|
|
123
|
+
async function resolveOrCreatePrincipal(payload, idpConfig) {
|
|
124
|
+
const idpSubject = payload.sub;
|
|
125
|
+
const email = payload.email ?? payload.preferred_username;
|
|
126
|
+
const displayName = payload.name ?? email ?? idpSubject;
|
|
127
|
+
// Look for existing credential with this IdP subject
|
|
128
|
+
for await (const cred of databases.flair.Credential.search({
|
|
129
|
+
conditions: [
|
|
130
|
+
{ attribute: "kind", comparator: "equals", value: "idp" },
|
|
131
|
+
{ attribute: "idpSubject", comparator: "equals", value: idpSubject },
|
|
132
|
+
{ attribute: "idpProvider", comparator: "equals", value: idpConfig.id },
|
|
133
|
+
],
|
|
134
|
+
})) {
|
|
135
|
+
// Update last used
|
|
136
|
+
await databases.flair.Credential.put({
|
|
137
|
+
...cred,
|
|
138
|
+
lastUsedAt: nowISO(),
|
|
139
|
+
});
|
|
140
|
+
return cred.principalId;
|
|
141
|
+
}
|
|
142
|
+
// No existing credential — JIT provision if enabled
|
|
143
|
+
if (!idpConfig.jitProvision) {
|
|
144
|
+
throw new Error(`no principal for IdP subject ${idpSubject} and JIT provisioning is disabled`);
|
|
145
|
+
}
|
|
146
|
+
const principalId = `usr_${idpSubject.replace(/[^a-zA-Z0-9]/g, "_").slice(0, 20)}_${randomBytes(4).toString("hex")}`;
|
|
147
|
+
const now = nowISO();
|
|
148
|
+
// Create principal (via Agent table)
|
|
149
|
+
await databases.flair.Agent.put({
|
|
150
|
+
id: principalId,
|
|
151
|
+
name: displayName,
|
|
152
|
+
displayName,
|
|
153
|
+
kind: "human",
|
|
154
|
+
type: "human",
|
|
155
|
+
status: "active",
|
|
156
|
+
publicKey: `idp:${idpConfig.id}:${idpSubject}`, // placeholder — humans don't have real Ed25519 keys
|
|
157
|
+
defaultTrustTier: idpConfig.defaultTrustTier ?? "unverified",
|
|
158
|
+
admin: false,
|
|
159
|
+
createdAt: now,
|
|
160
|
+
updatedAt: now,
|
|
161
|
+
});
|
|
162
|
+
// Create IdP credential
|
|
163
|
+
await databases.flair.Credential.put({
|
|
164
|
+
id: `cred_idp_${randomBytes(8).toString("hex")}`,
|
|
165
|
+
principalId,
|
|
166
|
+
kind: "idp",
|
|
167
|
+
label: idpConfig.name,
|
|
168
|
+
status: "active",
|
|
169
|
+
idpProvider: idpConfig.id,
|
|
170
|
+
idpSubject,
|
|
171
|
+
idpEmail: email,
|
|
172
|
+
createdAt: now,
|
|
173
|
+
lastUsedAt: now,
|
|
174
|
+
});
|
|
175
|
+
return principalId;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Handle the jwt-bearer grant type at the token endpoint.
|
|
179
|
+
* Called from OAuthToken when grant_type is urn:ietf:params:oauth:grant-type:jwt-bearer.
|
|
180
|
+
*/
|
|
181
|
+
export async function handleJwtBearerGrant(data) {
|
|
182
|
+
const assertion = data?.assertion;
|
|
183
|
+
if (!assertion) {
|
|
184
|
+
return new Response(JSON.stringify({
|
|
185
|
+
error: "invalid_request",
|
|
186
|
+
error_description: "assertion parameter required for jwt-bearer grant",
|
|
187
|
+
}), { status: 400, headers: { "content-type": "application/json" } });
|
|
188
|
+
}
|
|
189
|
+
const baseUrl = process.env.FLAIR_PUBLIC_URL || `http://127.0.0.1:${process.env.HTTP_PORT || 19926}`;
|
|
190
|
+
try {
|
|
191
|
+
const { payload, idpConfig } = await validateIdJag(assertion, baseUrl);
|
|
192
|
+
// Resolve or create principal
|
|
193
|
+
const principalId = await resolveOrCreatePrincipal(payload, idpConfig);
|
|
194
|
+
// Determine scopes — intersection of ID-JAG scopes and IdP allowed scopes
|
|
195
|
+
const requestedScopes = (payload.scope ?? "memory:read").split(" ");
|
|
196
|
+
const allowedScopes = idpConfig.allowedScopes?.length
|
|
197
|
+
? new Set(idpConfig.allowedScopes)
|
|
198
|
+
: null;
|
|
199
|
+
const grantedScopes = allowedScopes
|
|
200
|
+
? requestedScopes.filter((s) => allowedScopes.has(s))
|
|
201
|
+
: requestedScopes;
|
|
202
|
+
const scope = grantedScopes.join(" ");
|
|
203
|
+
// Issue tokens
|
|
204
|
+
const now = nowISO();
|
|
205
|
+
const clientId = data?.client_id ?? idpConfig.clientId;
|
|
206
|
+
const accessTokenRaw = randomToken("flair_at_");
|
|
207
|
+
const refreshTokenRaw = randomToken("flair_rt_");
|
|
208
|
+
const accessId = `at_${randomBytes(8).toString("hex")}`;
|
|
209
|
+
const refreshId = `rt_${randomBytes(8).toString("hex")}`;
|
|
210
|
+
await databases.flair.OAuthToken.put({
|
|
211
|
+
id: accessId,
|
|
212
|
+
tokenHash: sha256(accessTokenRaw),
|
|
213
|
+
tokenType: "access",
|
|
214
|
+
clientId,
|
|
215
|
+
principalId,
|
|
216
|
+
scope,
|
|
217
|
+
expiresAt: futureISO(ACCESS_TOKEN_TTL_MS),
|
|
218
|
+
idpIssuer: payload.iss,
|
|
219
|
+
idpSubject: payload.sub,
|
|
220
|
+
createdAt: now,
|
|
221
|
+
});
|
|
222
|
+
await databases.flair.OAuthToken.put({
|
|
223
|
+
id: refreshId,
|
|
224
|
+
tokenHash: sha256(refreshTokenRaw),
|
|
225
|
+
tokenType: "refresh",
|
|
226
|
+
clientId,
|
|
227
|
+
principalId,
|
|
228
|
+
scope,
|
|
229
|
+
expiresAt: futureISO(REFRESH_TOKEN_TTL_MS),
|
|
230
|
+
parentTokenId: accessId,
|
|
231
|
+
idpIssuer: payload.iss,
|
|
232
|
+
idpSubject: payload.sub,
|
|
233
|
+
createdAt: now,
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
access_token: accessTokenRaw,
|
|
237
|
+
token_type: "Bearer",
|
|
238
|
+
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
|
|
239
|
+
refresh_token: refreshTokenRaw,
|
|
240
|
+
scope,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
return new Response(JSON.stringify({
|
|
245
|
+
error: "invalid_grant",
|
|
246
|
+
error_description: err.message,
|
|
247
|
+
}), { status: 400, headers: { "content-type": "application/json" } });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* IdP management resource — CRUD for IdP configurations.
|
|
252
|
+
* Admin-only access.
|
|
253
|
+
*/
|
|
254
|
+
export class IdpConfig extends databases.flair.IdpConfig {
|
|
255
|
+
async put(content) {
|
|
256
|
+
const now = nowISO();
|
|
257
|
+
content.enabled ??= true;
|
|
258
|
+
content.jitProvision ??= true;
|
|
259
|
+
content.defaultTrustTier ??= "unverified";
|
|
260
|
+
content.createdAt ??= now;
|
|
261
|
+
content.updatedAt = now;
|
|
262
|
+
return super.put(content);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTML layout for the Flair web admin.
|
|
3
|
+
* Server-rendered, no JS framework. Minimal CSS for Nathan-grade UX.
|
|
4
|
+
*/
|
|
5
|
+
const VERSION = process.env.npm_package_version ?? "dev";
|
|
6
|
+
/** Escape HTML special characters to prevent stored XSS. */
|
|
7
|
+
export function esc(str) {
|
|
8
|
+
if (!str)
|
|
9
|
+
return "";
|
|
10
|
+
return String(str)
|
|
11
|
+
.replace(/&/g, "&")
|
|
12
|
+
.replace(/</g, "<")
|
|
13
|
+
.replace(/>/g, ">")
|
|
14
|
+
.replace(/"/g, """)
|
|
15
|
+
.replace(/'/g, "'");
|
|
16
|
+
}
|
|
17
|
+
export function layout(title, content, activePage) {
|
|
18
|
+
const nav = [
|
|
19
|
+
{ href: "/AdminDashboard", label: "Home", id: "home" },
|
|
20
|
+
{ href: "/AdminMemory", label: "Memory", id: "memory" },
|
|
21
|
+
{ href: "/AdminPrincipals", label: "Principals", id: "principals" },
|
|
22
|
+
{ href: "/AdminConnectors", label: "Connectors", id: "connectors" },
|
|
23
|
+
{ href: "/AdminIdp", label: "IdP", id: "idp" },
|
|
24
|
+
{ href: "/AdminInstance", label: "Instance", id: "instance" },
|
|
25
|
+
];
|
|
26
|
+
const navHtml = nav.map(n => `<a href="${n.href}" class="nav-item${activePage === n.id ? " active" : ""}">${n.label}</a>`).join("\n ");
|
|
27
|
+
return `<!DOCTYPE html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
+
<title>${title} — Flair Admin</title>
|
|
33
|
+
<style>
|
|
34
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
35
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #f8f9fa; color: #1a1a1a; }
|
|
36
|
+
.layout { display: flex; min-height: 100vh; }
|
|
37
|
+
.sidebar {
|
|
38
|
+
width: 220px; background: #1a1a2e; color: #e0e0e0; padding: 20px 0;
|
|
39
|
+
display: flex; flex-direction: column; flex-shrink: 0;
|
|
40
|
+
}
|
|
41
|
+
.sidebar-brand { padding: 0 20px 20px; font-size: 1.3em; font-weight: 700; color: #fff; border-bottom: 1px solid #2a2a4e; }
|
|
42
|
+
.nav-item {
|
|
43
|
+
display: block; padding: 10px 20px; color: #b0b0c0; text-decoration: none;
|
|
44
|
+
font-size: 0.95em; transition: background 0.15s;
|
|
45
|
+
}
|
|
46
|
+
.nav-item:hover { background: #2a2a4e; color: #fff; }
|
|
47
|
+
.nav-item.active { background: #2563eb; color: #fff; font-weight: 600; }
|
|
48
|
+
.main { flex: 1; padding: 32px 40px; max-width: 1100px; }
|
|
49
|
+
.main h1 { font-size: 1.6em; margin-bottom: 8px; }
|
|
50
|
+
.main .subtitle { color: #666; margin-bottom: 24px; }
|
|
51
|
+
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
52
|
+
th { text-align: left; padding: 12px 16px; background: #f1f3f5; font-size: 0.85em; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
53
|
+
td { padding: 12px 16px; border-top: 1px solid #eee; font-size: 0.95em; }
|
|
54
|
+
tr:hover td { background: #f8f9ff; }
|
|
55
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.8em; font-weight: 600; }
|
|
56
|
+
.badge-green { background: #d4edda; color: #155724; }
|
|
57
|
+
.badge-gray { background: #e9ecef; color: #495057; }
|
|
58
|
+
.badge-blue { background: #cce5ff; color: #004085; }
|
|
59
|
+
.badge-yellow { background: #fff3cd; color: #856404; }
|
|
60
|
+
.card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; }
|
|
61
|
+
.card h3 { font-size: 1.1em; margin-bottom: 8px; }
|
|
62
|
+
.card .value { font-size: 2em; font-weight: 700; color: #2563eb; }
|
|
63
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
|
64
|
+
.empty { text-align: center; padding: 40px; color: #888; }
|
|
65
|
+
.btn { display: inline-block; padding: 8px 16px; border-radius: 6px; font-size: 0.9em; text-decoration: none; cursor: pointer; border: none; }
|
|
66
|
+
.btn-primary { background: #2563eb; color: #fff; }
|
|
67
|
+
.btn-danger { background: #dc3545; color: #fff; }
|
|
68
|
+
.footer { padding: 20px; margin-top: auto; font-size: 0.8em; color: #666; border-top: 1px solid #2a2a4e; }
|
|
69
|
+
@media (max-width: 768px) {
|
|
70
|
+
.sidebar { width: 60px; } .sidebar-brand { display: none; } .nav-item { padding: 10px; text-align: center; font-size: 0.8em; }
|
|
71
|
+
.main { padding: 16px; }
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div class="layout">
|
|
77
|
+
<nav class="sidebar">
|
|
78
|
+
<div class="sidebar-brand">Flair</div>
|
|
79
|
+
${navHtml}
|
|
80
|
+
<div class="footer">v${VERSION}</div>
|
|
81
|
+
</nav>
|
|
82
|
+
<main class="main">
|
|
83
|
+
${content}
|
|
84
|
+
</main>
|
|
85
|
+
</div>
|
|
86
|
+
</body>
|
|
87
|
+
</html>`;
|
|
88
|
+
}
|
|
89
|
+
export function htmlResponse(html) {
|
|
90
|
+
return new Response(html, {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -7,29 +7,26 @@ import { getEmbedding } from "./embeddings-provider.js";
|
|
|
7
7
|
//
|
|
8
8
|
// FLAIR_ADMIN_TOKEN env var is still accepted for backwards compat but
|
|
9
9
|
// emits a deprecation warning on first use.
|
|
10
|
-
|
|
10
|
+
//
|
|
11
|
+
// No permanent cache — env vars are read on every call. This is a no-op
|
|
12
|
+
// performance-wise (env reads are fast) but means a process restart with a
|
|
13
|
+
// different password works immediately without stale state.
|
|
11
14
|
let _deprecationWarned = false;
|
|
12
15
|
function getAdminPass() {
|
|
13
|
-
if (_adminPass)
|
|
14
|
-
return _adminPass;
|
|
15
16
|
// Primary source: Harper's own admin password (set at startup via env)
|
|
16
17
|
const primary = process.env.HDB_ADMIN_PASSWORD ?? process.env.FLAIR_ADMIN_PASSWORD;
|
|
17
|
-
if (primary)
|
|
18
|
-
|
|
19
|
-
return _adminPass;
|
|
20
|
-
}
|
|
18
|
+
if (primary)
|
|
19
|
+
return primary;
|
|
21
20
|
// Backwards compat: FLAIR_ADMIN_TOKEN (deprecated — never write to disk)
|
|
22
21
|
if (process.env.FLAIR_ADMIN_TOKEN) {
|
|
23
22
|
if (!_deprecationWarned) {
|
|
24
23
|
console.warn("[auth] DEPRECATION: FLAIR_ADMIN_TOKEN is deprecated. Use HDB_ADMIN_PASSWORD instead.");
|
|
25
24
|
_deprecationWarned = true;
|
|
26
25
|
}
|
|
27
|
-
|
|
28
|
-
return _adminPass;
|
|
26
|
+
return process.env.FLAIR_ADMIN_TOKEN;
|
|
29
27
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
throw new Error(msg);
|
|
28
|
+
// No admin password configured — return null and let callers fall through
|
|
29
|
+
return null;
|
|
33
30
|
}
|
|
34
31
|
const WINDOW_MS = 30_000;
|
|
35
32
|
const nonceSeen = new Map();
|
|
@@ -119,7 +116,17 @@ server.http(async (request, nextLayer) => {
|
|
|
119
116
|
url.pathname === "/A2AAdapter" ||
|
|
120
117
|
url.pathname === "/AgentCard" ||
|
|
121
118
|
url.pathname.startsWith("/A2AAdapter/") ||
|
|
122
|
-
url.pathname.startsWith("/AgentCard/")
|
|
119
|
+
url.pathname.startsWith("/AgentCard/") ||
|
|
120
|
+
// Federation endpoints handle their own auth via Ed25519 body signatures
|
|
121
|
+
url.pathname === "/FederationPair" ||
|
|
122
|
+
url.pathname === "/FederationSync" ||
|
|
123
|
+
// OAuth 2.1 public endpoints (spec requires no pre-auth)
|
|
124
|
+
url.pathname === "/OAuthRegister" ||
|
|
125
|
+
url.pathname === "/OAuthAuthorize" ||
|
|
126
|
+
url.pathname === "/OAuthToken" ||
|
|
127
|
+
url.pathname === "/OAuthRevoke" ||
|
|
128
|
+
url.pathname === "/.well-known/oauth-authorization-server" ||
|
|
129
|
+
url.pathname === "/OAuthMetadata")
|
|
123
130
|
return nextLayer(request);
|
|
124
131
|
// Skip re-entry: if we already swapped auth to Basic, pass through
|
|
125
132
|
if (request._tpsAuthVerified)
|
|
@@ -132,7 +139,8 @@ server.http(async (request, nextLayer) => {
|
|
|
132
139
|
try {
|
|
133
140
|
const decoded = Buffer.from(header.slice(6), "base64").toString("utf-8");
|
|
134
141
|
const [user, pass] = decoded.split(":");
|
|
135
|
-
|
|
142
|
+
const adminPass = getAdminPass();
|
|
143
|
+
if (adminPass !== null && user === "admin" && pass === adminPass) {
|
|
136
144
|
// Mark as verified and set Harper user directly
|
|
137
145
|
request._tpsAuthVerified = true;
|
|
138
146
|
try {
|
|
@@ -187,14 +195,24 @@ server.http(async (request, nextLayer) => {
|
|
|
187
195
|
// pipeline (passport) authenticates the request with full permissions including
|
|
188
196
|
// HNSW vector search. This requires HDB_ADMIN_PASSWORD to be set.
|
|
189
197
|
// NOTE: server.getUser() alone doesn't grant HNSW permissions in Harper v5.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
request.headers.
|
|
198
|
+
const adminPass = getAdminPass();
|
|
199
|
+
if (adminPass !== null) {
|
|
200
|
+
try {
|
|
201
|
+
const superAuth = "Basic " + btoa("admin:" + adminPass);
|
|
202
|
+
request.headers.set("authorization", superAuth);
|
|
203
|
+
if (request.headers.asObject)
|
|
204
|
+
request.headers.asObject.authorization = superAuth;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Header manipulation failed — fall back to getUser
|
|
208
|
+
try {
|
|
209
|
+
request.user = await server.getUser("admin", null, request);
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
}
|
|
195
213
|
}
|
|
196
|
-
|
|
197
|
-
// No admin password — try server.getUser as fallback (limited permissions)
|
|
214
|
+
else {
|
|
215
|
+
// No admin password configured — try server.getUser as fallback (limited permissions)
|
|
198
216
|
try {
|
|
199
217
|
request.user = await server.getUser("admin", null, request);
|
|
200
218
|
}
|
|
@@ -3,28 +3,28 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Wrapper around harper-fabric-embeddings for Flair resources.
|
|
5
5
|
*
|
|
6
|
-
* Harper loads resources in a VM sandbox
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Solution: we call hfe.init() ourselves on first use. The model is already
|
|
12
|
-
* on disk (downloaded by Harper's plugin loader), so init just loads the
|
|
13
|
-
* native binary and model file — no download needed.
|
|
6
|
+
* Harper 5.0.0 loads resources in a VM sandbox that can't statically link
|
|
7
|
+
* npm packages during module resolution (async race in getOrCreateModule).
|
|
8
|
+
* Using dynamic import() defers the module load to first use, bypassing
|
|
9
|
+
* the VM linker entirely.
|
|
14
10
|
*/
|
|
15
|
-
import * as hfe from "harper-fabric-embeddings";
|
|
16
11
|
import { join } from "node:path";
|
|
17
12
|
let _state = "uninitialized";
|
|
18
13
|
let _initError;
|
|
19
14
|
let _warnedOnce = false;
|
|
15
|
+
let _hfe = null;
|
|
20
16
|
async function ensureInit() {
|
|
21
17
|
if (_state === "ready")
|
|
22
18
|
return;
|
|
23
19
|
if (_state === "failed")
|
|
24
20
|
return; // Don't retry — already logged warning
|
|
25
21
|
try {
|
|
22
|
+
// Dynamic import — deferred to avoid Harper 5.0.0 VM linker race
|
|
23
|
+
if (!_hfe) {
|
|
24
|
+
_hfe = await import("harper-fabric-embeddings");
|
|
25
|
+
}
|
|
26
26
|
// Check if already initialized (e.g. shared context)
|
|
27
|
-
|
|
27
|
+
_hfe.dimensions();
|
|
28
28
|
_state = "ready";
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
@@ -35,6 +35,9 @@ async function ensureInit() {
|
|
|
35
35
|
// VM sandbox / worker threads, so we use process.cwd() which points to the
|
|
36
36
|
// Flair application directory.
|
|
37
37
|
try {
|
|
38
|
+
if (!_hfe) {
|
|
39
|
+
_hfe = await import("harper-fabric-embeddings");
|
|
40
|
+
}
|
|
38
41
|
const modelsDir = join(process.cwd(), "models");
|
|
39
42
|
// Find the native addon binary explicitly to avoid __dirname-dependent
|
|
40
43
|
// discovery in @node-llama-cpp which fails in Harper's VM sandbox.
|
|
@@ -48,7 +51,7 @@ async function ensureInit() {
|
|
|
48
51
|
break;
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
|
-
await
|
|
54
|
+
await _hfe.init({ modelsDir, ...(addonPath ? { addonPath } : {}) });
|
|
52
55
|
_state = "ready";
|
|
53
56
|
}
|
|
54
57
|
catch (err) {
|
|
@@ -68,18 +71,15 @@ async function ensureInit() {
|
|
|
68
71
|
export async function getEmbedding(text) {
|
|
69
72
|
try {
|
|
70
73
|
await ensureInit();
|
|
71
|
-
if (_state !== "ready")
|
|
74
|
+
if (_state !== "ready" || !_hfe)
|
|
72
75
|
return null;
|
|
73
|
-
return await
|
|
76
|
+
return await _hfe.embed(text);
|
|
74
77
|
}
|
|
75
78
|
catch (err) {
|
|
76
79
|
console.error(`[embeddings] embed failed: ${err.message}`);
|
|
77
80
|
return null;
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
|
-
/**
|
|
81
|
-
* Check if the embedding engine is currently available.
|
|
82
|
-
*/
|
|
83
83
|
/**
|
|
84
84
|
* Check if the embedding engine is currently available.
|
|
85
85
|
* If still uninitialized, attempts initialization first.
|
|
@@ -93,20 +93,23 @@ export function getMode() {
|
|
|
93
93
|
if (_state === "failed")
|
|
94
94
|
return "none";
|
|
95
95
|
// Still uninitialized — try direct check first
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
catch {
|
|
102
|
-
// Not yet initialized. Trigger async init on first call so subsequent
|
|
103
|
-
// calls (including getEmbedding) will find the engine ready.
|
|
104
|
-
if (!_getModeInitAttempted) {
|
|
105
|
-
_getModeInitAttempted = true;
|
|
106
|
-
ensureInit().catch(() => { }); // fire-and-forget
|
|
96
|
+
if (_hfe) {
|
|
97
|
+
try {
|
|
98
|
+
_hfe.dimensions();
|
|
99
|
+
_state = "ready";
|
|
100
|
+
return "local";
|
|
107
101
|
}
|
|
108
|
-
|
|
102
|
+
catch {
|
|
103
|
+
// fall through
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Not yet initialized. Trigger async init on first call so subsequent
|
|
107
|
+
// calls (including getEmbedding) will find the engine ready.
|
|
108
|
+
if (!_getModeInitAttempted) {
|
|
109
|
+
_getModeInitAttempted = true;
|
|
110
|
+
ensureInit().catch(() => { }); // fire-and-forget
|
|
109
111
|
}
|
|
112
|
+
return "none";
|
|
110
113
|
}
|
|
111
114
|
/**
|
|
112
115
|
* Get the current embedding model identifier.
|
|
@@ -120,12 +123,12 @@ export function getModelId() {
|
|
|
120
123
|
*/
|
|
121
124
|
export function getStatus() {
|
|
122
125
|
const mode = getMode();
|
|
123
|
-
if (mode === "local") {
|
|
126
|
+
if (mode === "local" && _hfe) {
|
|
124
127
|
try {
|
|
125
128
|
return {
|
|
126
129
|
mode,
|
|
127
130
|
model: "nomic-embed-text-v1.5",
|
|
128
|
-
dims:
|
|
131
|
+
dims: _hfe.dimensions(),
|
|
129
132
|
};
|
|
130
133
|
}
|
|
131
134
|
catch {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federation cryptographic utilities — pure functions, no HarperDB dependency.
|
|
3
|
+
* Shared by Federation.ts (server) and cli.ts (client).
|
|
4
|
+
*/
|
|
5
|
+
import nacl from "tweetnacl";
|
|
6
|
+
// ─── Canonical JSON ─────────────────────────────────────────────────────────
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic JSON serialization: recursively sort object keys, then stringify.
|
|
9
|
+
* Used as the signing input for federation requests.
|
|
10
|
+
*/
|
|
11
|
+
export function canonicalize(obj) {
|
|
12
|
+
return JSON.stringify(sortKeys(obj));
|
|
13
|
+
}
|
|
14
|
+
function sortKeys(val) {
|
|
15
|
+
if (val === null || val === undefined || typeof val !== "object")
|
|
16
|
+
return val;
|
|
17
|
+
if (Array.isArray(val))
|
|
18
|
+
return val.map(sortKeys);
|
|
19
|
+
const sorted = {};
|
|
20
|
+
for (const key of Object.keys(val).sort()) {
|
|
21
|
+
sorted[key] = sortKeys(val[key]);
|
|
22
|
+
}
|
|
23
|
+
return sorted;
|
|
24
|
+
}
|
|
25
|
+
// ─── Signing ────────────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Create a detached Ed25519 signature over the canonical form of a body.
|
|
28
|
+
* Returns base64url-encoded signature.
|
|
29
|
+
*/
|
|
30
|
+
export function signBody(body, secretKey) {
|
|
31
|
+
const message = new TextEncoder().encode(canonicalize(body));
|
|
32
|
+
const sig = nacl.sign.detached(message, secretKey);
|
|
33
|
+
return Buffer.from(sig).toString("base64url");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Verify a signature field on a request body.
|
|
37
|
+
* The canonical form is the body WITHOUT the `signature` field.
|
|
38
|
+
*/
|
|
39
|
+
export function verifyBodySignature(body, publicKeyB64url) {
|
|
40
|
+
const { signature, ...rest } = body;
|
|
41
|
+
if (!signature)
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
const message = new TextEncoder().encode(canonicalize(rest));
|
|
45
|
+
const sig = Buffer.from(signature, "base64url");
|
|
46
|
+
const pubKey = Buffer.from(publicKeyB64url, "base64url");
|
|
47
|
+
return nacl.sign.detached.verify(message, new Uint8Array(sig), new Uint8Array(pubKey));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|