@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.
@@ -1,6 +1,72 @@
1
- import { Resource } from "@harperfast/harper";
1
+ import { Resource, databases } from "@harperfast/harper";
2
+ const db = databases;
3
+ /**
4
+ * Health endpoint — unauthenticated, returns only { ok: true }.
5
+ *
6
+ * Rich stats (memory counts, agent names, etc.) are behind /HealthDetail
7
+ * which requires authentication. This prevents information leakage on
8
+ * publicly exposed instances.
9
+ */
2
10
  export class Health extends Resource {
3
11
  async get() {
4
12
  return { ok: true };
5
13
  }
6
14
  }
15
+ /**
16
+ * Authenticated health detail — returns memory/agent/soul stats + process info.
17
+ * Requires Ed25519 agent auth or admin basic auth.
18
+ */
19
+ export class HealthDetail extends Resource {
20
+ async get() {
21
+ const stats = { ok: true };
22
+ // ── Memory stats ──
23
+ try {
24
+ const list = [];
25
+ for await (const m of db.flair.Memory.search({})) {
26
+ list.push(m);
27
+ }
28
+ stats.memories = {
29
+ total: list.length,
30
+ withEmbeddings: list.filter((m) => m.embeddingModel && m.embeddingModel !== "hash-512d").length,
31
+ hashFallback: list.filter((m) => !m.embeddingModel || m.embeddingModel === "hash-512d").length,
32
+ };
33
+ if (list.length > 0) {
34
+ const sorted = list.filter((m) => m.createdAt).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
35
+ if (sorted[0])
36
+ stats.lastWrite = sorted[0].createdAt;
37
+ }
38
+ }
39
+ catch {
40
+ stats.memories = null;
41
+ }
42
+ // ── Agent stats ──
43
+ try {
44
+ const agents = [];
45
+ for await (const a of db.flair.Agent.search({})) {
46
+ agents.push(a);
47
+ }
48
+ stats.agents = {
49
+ count: agents.length,
50
+ names: agents.map((a) => a.id).filter(Boolean),
51
+ };
52
+ }
53
+ catch {
54
+ stats.agents = null;
55
+ }
56
+ // ── Soul stats ──
57
+ try {
58
+ let count = 0;
59
+ for await (const _ of db.flair.Soul.search({})) {
60
+ count++;
61
+ }
62
+ stats.soulEntries = count;
63
+ }
64
+ catch {
65
+ stats.soulEntries = null;
66
+ }
67
+ // ── Process info ──
68
+ stats.pid = process.pid;
69
+ stats.uptimeSeconds = Math.floor(process.uptime());
70
+ return stats;
71
+ }
72
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Keystore — encrypted file-based storage for Ed25519 private key seeds.
3
+ *
4
+ * Primary: AES-256-GCM encrypted files at ~/.flair/keys/<instanceId>.key
5
+ * Fallback: HarperDB (migration path from pre-keystore installs)
6
+ *
7
+ * Encryption key derived via HKDF from FLAIR_KEY_PASSPHRASE env var,
8
+ * or an auto-generated random passphrase stored at ~/.flair/keys/.passphrase
9
+ * (mode 0600). Never falls back to guessable data.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { randomBytes, createCipheriv, createDecipheriv, hkdfSync, } from "node:crypto";
15
+ // ─── Helpers ────────────────────────────────────────────────────────────────
16
+ function keysDir() {
17
+ return join(homedir(), ".flair", "keys");
18
+ }
19
+ function keyPath(instanceId) {
20
+ // Sanitize instanceId to prevent directory traversal
21
+ const safe = instanceId.replace(/[^a-zA-Z0-9_-]/g, "_");
22
+ return join(keysDir(), `${safe}.key`);
23
+ }
24
+ /**
25
+ * Path to the auto-generated passphrase file.
26
+ * Created on first keystore use if FLAIR_KEY_PASSPHRASE env var is not set.
27
+ */
28
+ function passphrasePath() {
29
+ return join(keysDir(), ".passphrase");
30
+ }
31
+ /**
32
+ * Get or create the keystore passphrase.
33
+ * Priority: FLAIR_KEY_PASSPHRASE env var > auto-generated file.
34
+ * Never falls back to guessable data (hostname, username, etc.).
35
+ */
36
+ function getPassphrase() {
37
+ // Explicit env var takes priority
38
+ if (process.env.FLAIR_KEY_PASSPHRASE) {
39
+ return process.env.FLAIR_KEY_PASSPHRASE;
40
+ }
41
+ const pp = passphrasePath();
42
+ // Read existing auto-generated passphrase
43
+ if (existsSync(pp)) {
44
+ return readFileSync(pp, "utf-8").trim();
45
+ }
46
+ // Generate a cryptographically random passphrase and persist it
47
+ const dir = keysDir();
48
+ if (!existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ }
51
+ const generated = randomBytes(32).toString("base64url");
52
+ writeFileSync(pp, generated, { mode: 0o600 });
53
+ return generated;
54
+ }
55
+ /**
56
+ * Derive a 256-bit encryption key using HKDF.
57
+ * Input keying material: FLAIR_KEY_PASSPHRASE env var, or auto-generated random passphrase.
58
+ */
59
+ function deriveKey() {
60
+ const passphrase = getPassphrase();
61
+ return Buffer.from(hkdfSync("sha256", passphrase, "flair-keystore-salt", "flair-key-encryption", 32));
62
+ }
63
+ // ─── File-based encrypted keystore ──────────────────────────────────────────
64
+ /**
65
+ * Encrypt a seed with AES-256-GCM.
66
+ * File format: 12-byte IV | 16-byte auth tag | ciphertext
67
+ */
68
+ function encryptSeed(seed) {
69
+ const key = deriveKey();
70
+ const iv = randomBytes(12);
71
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
72
+ const encrypted = Buffer.concat([cipher.update(seed), cipher.final()]);
73
+ const tag = cipher.getAuthTag();
74
+ return Buffer.concat([iv, tag, encrypted]);
75
+ }
76
+ /**
77
+ * Decrypt a seed from the file format.
78
+ */
79
+ function decryptSeed(data) {
80
+ const key = deriveKey();
81
+ const iv = data.subarray(0, 12);
82
+ const tag = data.subarray(12, 28);
83
+ const ciphertext = data.subarray(28);
84
+ const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
85
+ decipher.setAuthTag(tag);
86
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
87
+ return new Uint8Array(decrypted);
88
+ }
89
+ // ─── KeyStore implementation ────────────────────────────────────────────────
90
+ class FileKeyStore {
91
+ getPrivateKeySeed(instanceId) {
92
+ const p = keyPath(instanceId);
93
+ if (!existsSync(p))
94
+ return null;
95
+ try {
96
+ const data = readFileSync(p);
97
+ return decryptSeed(data);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ setPrivateKeySeed(instanceId, seed) {
104
+ const dir = keysDir();
105
+ if (!existsSync(dir)) {
106
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
107
+ }
108
+ const p = keyPath(instanceId);
109
+ const encrypted = encryptSeed(seed);
110
+ writeFileSync(p, encrypted, { mode: 0o600 });
111
+ }
112
+ }
113
+ /** Singleton keystore instance. */
114
+ export const keystore = new FileKeyStore();
115
+ // Export helpers for testing
116
+ export { encryptSeed, decryptSeed, deriveKey, keyPath, keysDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.4.15",
3
+ "version": "0.5.0",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -39,7 +39,8 @@
39
39
  "build": "tsc -p tsconfig.json --noCheck",
40
40
  "build:cli": "tsc -p tsconfig.cli.json --noCheck",
41
41
  "prepublishOnly": "npm run build && npm run build:cli",
42
- "test": "bun test"
42
+ "test": "bun test",
43
+ "release": "./scripts/release.sh"
43
44
  },
44
45
  "publishConfig": {
45
46
  "access": "public"
@@ -48,9 +49,10 @@
48
49
  "node": ">=22"
49
50
  },
50
51
  "dependencies": {
51
- "@harperfast/harper": "5.0.0-beta.8",
52
+ "@harperfast/harper": "5.0.0",
52
53
  "commander": "14.0.3",
53
54
  "harper-fabric-embeddings": "0.2.2",
55
+ "jose": "^6.2.2",
54
56
  "tweetnacl": "1.0.3"
55
57
  },
56
58
  "devDependencies": {
@@ -1,13 +1,62 @@
1
+ # Agent table — backward compatible with 0.x, extended for 1.0 Principals.
2
+ # The Agent table IS the Principal table. We keep the name "Agent" for backward
3
+ # compatibility with existing code, queries, and exports. The `kind` field
4
+ # distinguishes humans from agents. Pre-1.0 records without `kind` are treated
5
+ # as agents.
1
6
  type Agent @table(database: "flair") @export {
2
7
  id: ID @primaryKey
3
8
  name: String!
4
9
  role: String
5
- type: String @indexed
6
- publicKey: String!
10
+ type: String @indexed # legacy (0.x): "agent", "tool", etc.
11
+
12
+ # 1.0 Principal fields
13
+ kind: String @indexed # "human" | "agent" (default: "agent" for 0.x compat)
14
+ displayName: String # human-friendly name
15
+ status: String @indexed # "active" | "deactivated" (default: "active")
16
+ publicKey: String! # Ed25519 public key
17
+ defaultTrustTier: String @indexed # "endorsed" | "corroborated" | "unverified"
18
+ admin: Boolean @indexed # admin principals can manage other principals
19
+ runtime: String # "openclaw" | "claude-code" | "headless" | "external" | null
20
+ runtimeEndpoint: String # how to reach this principal programmatically
21
+ subjects: [String] # soul-level subject interests
22
+
23
+ createdAt: String!
24
+ updatedAt: String
25
+ }
26
+
27
+ # Credential table — auth surfaces for Principals.
28
+ # A Principal can have multiple credentials (passkey + bearer token + Ed25519).
29
+ # Credentials are NOT the Ed25519 signing keypair — they're authentication methods.
30
+ type Credential @table(database: "flair") @export {
31
+ id: ID @primaryKey
32
+ principalId: String! @indexed # FK to Agent.id
33
+ kind: String! @indexed # "webauthn" | "bearer-token" | "ed25519" | "idp"
34
+ label: String # "iPhone 16 Pro", "OpenClaw plugin", "Harper Corporate"
35
+ status: String @indexed # "active" | "revoked"
36
+
37
+ # Bearer token (kind: "bearer-token")
38
+ tokenHash: String # bcrypt/sha256 hash — never store raw token
39
+ tokenPrefix: String # first 8 chars for identification ("flair_at_")
40
+
41
+ # WebAuthn (kind: "webauthn")
42
+ webauthnCredentialId: String # base64url credential ID
43
+ webauthnPublicKey: String # COSE public key
44
+ webauthnAaguid: String # authenticator identifier
45
+ webauthnTransports: [String] # ["internal", "hybrid", "usb"]
46
+ webauthnCounter: Int # signature counter for replay detection
47
+
48
+ # IdP (kind: "idp") — for XAA enterprise auth
49
+ idpProvider: String # IdP config ID
50
+ idpSubject: String @indexed # stable user identifier from IdP
51
+ idpEmail: String # email from IdP claims
52
+
7
53
  createdAt: String!
54
+ lastUsedAt: String
8
55
  updatedAt: String
9
56
  }
10
57
 
58
+ # Integration table — kept for backward compat with 0.x platform connections.
59
+ # New code should use Credential instead.
11
60
  type Integration @table(database: "flair") @export {
12
61
  id: ID @primaryKey
13
62
  agentId: String! @indexed
@@ -0,0 +1,51 @@
1
+ # Federation tables — hub-and-spoke sync per FLAIR-FEDERATION spec.
2
+
3
+ # Instance identity — one record per Flair instance.
4
+ type Instance @table(database: "flair") @export {
5
+ id: ID @primaryKey # "flair_h82kx9" — generated on first boot
6
+ publicKey: String! # Ed25519 instance key (base64url)
7
+ role: String @indexed # "hub" | "spoke"
8
+ fabricEndpoint: String # wss:// URL if hub
9
+ status: String @indexed # "active" | "paused" | "decommissioned"
10
+ lastSeen: String
11
+ createdAt: String! @indexed
12
+ updatedAt: String
13
+ }
14
+
15
+ # One-time pairing tokens — generated by hub admin, consumed during peer pairing.
16
+ type PairingToken @table(database: "flair") @export {
17
+ id: ID @primaryKey # the token value (crypto random)
18
+ createdBy: String # admin who generated it
19
+ consumedBy: String # peer instanceId that used it
20
+ consumedAt: String # when it was consumed
21
+ expiresAt: String! @indexed # token TTL (default 1 hour)
22
+ createdAt: String! @indexed
23
+ }
24
+
25
+ # Peer records — known federation peers.
26
+ # Hub has one entry per spoke. Spokes have one entry for the hub.
27
+ type Peer @table(database: "flair") @export {
28
+ id: ID @primaryKey # peer's instance ID
29
+ publicKey: String! # peer's Ed25519 public key (pinned on first pair)
30
+ role: String @indexed # "hub" | "spoke"
31
+ endpoint: String # wss:// URL for the peer
32
+ status: String @indexed # "paired" | "connected" | "disconnected" | "revoked"
33
+ lastSyncAt: String # last successful sync timestamp
34
+ lastSyncCursor: String # Lamport clock or ISO timestamp for incremental sync
35
+ relayOnly: Boolean # true for spoke-to-spoke peers announced by hub
36
+ pairedAt: String
37
+ createdAt: String! @indexed
38
+ updatedAt: String
39
+ }
40
+
41
+ # Sync log — audit trail for federation operations.
42
+ type SyncLog @table(database: "flair") {
43
+ id: ID @primaryKey
44
+ peerId: String! @indexed # which peer this sync was with
45
+ direction: String! @indexed # "push" | "pull"
46
+ recordCount: Int # how many records in this sync batch
47
+ status: String @indexed # "success" | "partial" | "failed"
48
+ error: String # error message if failed
49
+ durationMs: Int # how long the sync took
50
+ createdAt: String! @indexed
51
+ }
@@ -26,6 +26,26 @@ type Memory @table(database: "flair") {
26
26
  lastReflected: String # ISO timestamp of last reflection pass
27
27
  supersedes: String @indexed # ID of memory this one replaces (version chain)
28
28
  subject: String @indexed # entity this memory is about (person, service, project)
29
+ validFrom: String @indexed # ISO timestamp — when this fact became true
30
+ validTo: String @indexed # ISO timestamp — when this fact stopped being true (null = still valid)
31
+ _safetyFlags: [String] # content safety flags from scanContent()
32
+ }
33
+
34
+ # Explicit entity-to-entity relationships with temporal validity.
35
+ # Enables queries like "who was team lead in Q1?" or "what changed about X on date Y?"
36
+ # Inspired by knowledge graph triples with time-bounded validity windows.
37
+ type Relationship @table(database: "flair") {
38
+ id: ID @primaryKey
39
+ agentId: String! @indexed # who created this relationship
40
+ subject: String! @indexed # source entity (person, project, service)
41
+ predicate: String! @indexed # relationship type (manages, owns, depends_on, etc.)
42
+ object: String! @indexed # target entity
43
+ validFrom: String @indexed # when this relationship became true
44
+ validTo: String @indexed # when it ended (null = still active)
45
+ confidence: Float # 0.0–1.0, how certain (1.0 = explicitly stated)
46
+ source: String # where this was learned (memory ID, conversation, etc.)
47
+ createdAt: String! @indexed
48
+ updatedAt: String
29
49
  }
30
50
 
31
51
  type Soul @table(database: "flair") {
@@ -0,0 +1,72 @@
1
+ # OAuth 2.1 Authorization Server tables.
2
+ # Implements: RFC 6749 (OAuth 2.0), RFC 7591 (DCR), RFC 7636 (PKCE),
3
+ # with 1.0 constraints from FLAIR-PRINCIPALS § 2.
4
+
5
+ # Registered OAuth clients (via DCR or admin).
6
+ # 1.0: only claude.com redirect URI permitted for DCR clients.
7
+ type OAuthClient @table(database: "flair") @export {
8
+ id: ID @primaryKey # client_id
9
+ clientSecret: String # hashed — only for confidential clients
10
+ name: String!
11
+ redirectUris: [String]! # 1.0: only ["https://claude.com/api/mcp/auth_callback"]
12
+ grantTypes: [String] # ["authorization_code", "refresh_token"]
13
+ scope: String # space-separated default scopes
14
+ registeredBy: String @indexed # principal ID who registered, or "dcr"
15
+ createdAt: String! @indexed
16
+ updatedAt: String
17
+ }
18
+
19
+ # Authorization codes — short-lived, single-use.
20
+ type OAuthAuthCode @table(database: "flair") {
21
+ id: ID @primaryKey # the authorization code itself (random, URL-safe)
22
+ clientId: String! @indexed
23
+ principalId: String! @indexed # who authorized
24
+ redirectUri: String!
25
+ scope: String # granted scopes
26
+ codeChallenge: String # PKCE S256
27
+ codeChallengeMethod: String # "S256"
28
+ expiresAt: String! @indexed # short TTL (10 min max)
29
+ used: Boolean @indexed # single-use enforcement
30
+ createdAt: String!
31
+ }
32
+
33
+ # IdP configuration for XAA (Enterprise-Managed Authorization).
34
+ # Stores trusted enterprise IdP endpoints for ID-JAG validation.
35
+ type IdpConfig @table(database: "flair") @export {
36
+ id: ID @primaryKey # stable UUID
37
+ name: String! # display name ("Harper Corporate")
38
+ issuer: String! @indexed # must match ID-JAG `iss` (e.g., "https://accounts.google.com")
39
+ jwksUri: String! # JWKS endpoint for signature verification
40
+ clientId: String! # Flair's client_id at this IdP
41
+ requiredDomain: String # domain boundary: `hd` (Google), `tid` (Azure), issuer-scoped (Okta)
42
+ jitProvision: Boolean # auto-create principals on first login
43
+ defaultTrustTier: String # "unverified" | "corroborated" for JIT principals
44
+ allowedScopes: [String] # restrict which scopes this IdP can grant
45
+ enabled: Boolean @indexed
46
+ jwksCachedAt: String # when JWKS was last fetched
47
+ createdAt: String! @indexed
48
+ updatedAt: String
49
+ }
50
+
51
+ # ID-JAG replay prevention — tracks used `jti` claims.
52
+ type IdJagReplay @table(database: "flair") {
53
+ id: ID @primaryKey # the jti value
54
+ expiresAt: String! @indexed # TTL for cleanup
55
+ createdAt: String!
56
+ }
57
+
58
+ # Access + refresh tokens.
59
+ type OAuthToken @table(database: "flair") {
60
+ id: ID @primaryKey # token ID (internal reference)
61
+ tokenHash: String! @indexed # SHA-256 of actual token — never store raw
62
+ tokenType: String! @indexed # "access" | "refresh"
63
+ clientId: String! @indexed
64
+ principalId: String! @indexed
65
+ scope: String # space-separated granted scopes
66
+ expiresAt: String! @indexed
67
+ revokedAt: String # null = active
68
+ parentTokenId: String # refresh token that generated this access token
69
+ idpIssuer: String # if token was issued via XAA, the IdP issuer
70
+ idpSubject: String # if token was issued via XAA, the IdP subject
71
+ createdAt: String! @indexed
72
+ }