@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.
@@ -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, "&lt;")
13
+ .replace(/>/g, "&gt;")
14
+ .replace(/"/g, "&quot;")
15
+ .replace(/'/g, "&#39;");
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
- let _adminPass = null;
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
- _adminPass = primary;
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
- _adminPass = process.env.FLAIR_ADMIN_TOKEN;
28
- return _adminPass;
26
+ return process.env.FLAIR_ADMIN_TOKEN;
29
27
  }
30
- const msg = "[auth] FATAL: no admin password found. Set HDB_ADMIN_PASSWORD env var.";
31
- console.error(msg);
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
- if (user === "admin" && pass === getAdminPass()) {
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
- try {
191
- const superAuth = "Basic " + btoa("admin:" + getAdminPass());
192
- request.headers.set("authorization", superAuth);
193
- if (request.headers.asObject)
194
- request.headers.asObject.authorization = superAuth;
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
- catch {
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 with a separate module cache from
7
- * the main thread. This means our import of harper-fabric-embeddings gets
8
- * a different (uninitialized) instance from the one Harper initialized via
9
- * handleApplication in config.yaml.
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
- hfe.dimensions();
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 hfe.init({ modelsDir, ...(addonPath ? { addonPath } : {}) });
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 hfe.embed(text);
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
- try {
97
- hfe.dimensions();
98
- _state = "ready";
99
- return "local";
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
- return "none";
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: hfe.dimensions(),
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
+ }