@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
package/dist/keystore.js
ADDED
|
@@ -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 };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* GET /AdminConnectors — OAuth clients and active sessions.
|
|
5
|
+
*/
|
|
6
|
+
export class AdminConnectors extends Resource {
|
|
7
|
+
async get() {
|
|
8
|
+
const clients = [];
|
|
9
|
+
try {
|
|
10
|
+
for await (const c of databases.flair.OAuthClient.search()) {
|
|
11
|
+
clients.push(c);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch { /* table may not exist */ }
|
|
15
|
+
clients.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
16
|
+
let tableRows = "";
|
|
17
|
+
if (clients.length === 0) {
|
|
18
|
+
tableRows = `<tr><td colspan="4" class="empty">No OAuth clients registered. Clients are created via Dynamic Client Registration when Claude connects.</td></tr>`;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
for (const c of clients) {
|
|
22
|
+
const created = c.createdAt?.slice(0, 10) ?? "—";
|
|
23
|
+
const source = c.registeredBy === "dcr" ? "DCR" : c.registeredBy ?? "—";
|
|
24
|
+
const redirects = (c.redirectUris ?? []).join(", ");
|
|
25
|
+
tableRows += `
|
|
26
|
+
<tr>
|
|
27
|
+
<td><strong>${esc(c.name || "Unnamed")}</strong><br><small>${esc(c.id)}</small></td>
|
|
28
|
+
<td>${esc(redirects || "—")}</td>
|
|
29
|
+
<td><span class="badge badge-gray">${esc(source)}</span></td>
|
|
30
|
+
<td>${created}</td>
|
|
31
|
+
</tr>`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const content = `
|
|
35
|
+
<h1>Connectors</h1>
|
|
36
|
+
<p class="subtitle">OAuth clients that can access this Flair instance</p>
|
|
37
|
+
|
|
38
|
+
<table>
|
|
39
|
+
<thead>
|
|
40
|
+
<tr>
|
|
41
|
+
<th>Client</th>
|
|
42
|
+
<th>Redirect URIs</th>
|
|
43
|
+
<th>Source</th>
|
|
44
|
+
<th>Created</th>
|
|
45
|
+
</tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
${tableRows}
|
|
49
|
+
</tbody>
|
|
50
|
+
</table>
|
|
51
|
+
`;
|
|
52
|
+
return htmlResponse(layout("Connectors", content, "connectors"));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { layout, htmlResponse } from "./admin-layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* GET /AdminDashboard — admin home page with system overview.
|
|
5
|
+
*/
|
|
6
|
+
export class AdminDashboard extends Resource {
|
|
7
|
+
async get() {
|
|
8
|
+
let principalCount = 0;
|
|
9
|
+
let memoryCount = 0;
|
|
10
|
+
let humanCount = 0;
|
|
11
|
+
let agentCount = 0;
|
|
12
|
+
try {
|
|
13
|
+
for await (const p of databases.flair.Agent.search()) {
|
|
14
|
+
principalCount++;
|
|
15
|
+
if (p.kind === "human")
|
|
16
|
+
humanCount++;
|
|
17
|
+
else
|
|
18
|
+
agentCount++;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch { /* table may not exist */ }
|
|
22
|
+
try {
|
|
23
|
+
for await (const _ of databases.flair.Memory.search()) {
|
|
24
|
+
memoryCount++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch { /* table may not exist */ }
|
|
28
|
+
let idpCount = 0;
|
|
29
|
+
try {
|
|
30
|
+
for await (const _ of databases.flair.IdpConfig.search()) {
|
|
31
|
+
idpCount++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* table may not exist */ }
|
|
35
|
+
let relationshipCount = 0;
|
|
36
|
+
try {
|
|
37
|
+
for await (const _ of databases.flair.Relationship.search()) {
|
|
38
|
+
relationshipCount++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch { /* table may not exist */ }
|
|
42
|
+
const content = `
|
|
43
|
+
<h1>Dashboard</h1>
|
|
44
|
+
<p class="subtitle">Flair instance overview</p>
|
|
45
|
+
|
|
46
|
+
<div class="stats">
|
|
47
|
+
<div class="card">
|
|
48
|
+
<h3>Principals</h3>
|
|
49
|
+
<div class="value">${principalCount}</div>
|
|
50
|
+
<div>${humanCount} human, ${agentCount} agent</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="card">
|
|
53
|
+
<h3>Memories</h3>
|
|
54
|
+
<div class="value">${memoryCount}</div>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="card">
|
|
57
|
+
<h3>Relationships</h3>
|
|
58
|
+
<div class="value">${relationshipCount}</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="card">
|
|
61
|
+
<h3>IdP Configs</h3>
|
|
62
|
+
<div class="value">${idpCount}</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
`;
|
|
66
|
+
return htmlResponse(layout("Dashboard", content, "home"));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* GET /AdminIdp — enterprise IdP configuration management.
|
|
5
|
+
*/
|
|
6
|
+
export class AdminIdp extends Resource {
|
|
7
|
+
async get() {
|
|
8
|
+
const idps = [];
|
|
9
|
+
try {
|
|
10
|
+
for await (const cfg of databases.flair.IdpConfig.search()) {
|
|
11
|
+
idps.push(cfg);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch { /* table may not exist */ }
|
|
15
|
+
let tableRows = "";
|
|
16
|
+
if (idps.length === 0) {
|
|
17
|
+
tableRows = `<tr><td colspan="5" class="empty">No IdPs configured. Use <code>flair idp add</code> to register an enterprise IdP.</td></tr>`;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
for (const cfg of idps) {
|
|
21
|
+
const status = cfg.enabled
|
|
22
|
+
? `<span class="badge badge-green">enabled</span>`
|
|
23
|
+
: `<span class="badge badge-gray">disabled</span>`;
|
|
24
|
+
const jit = cfg.jitProvision ? "yes" : "no";
|
|
25
|
+
const domain = cfg.requiredDomain ?? "—";
|
|
26
|
+
tableRows += `
|
|
27
|
+
<tr>
|
|
28
|
+
<td><strong>${esc(cfg.name)}</strong><br><small>${esc(cfg.id)}</small></td>
|
|
29
|
+
<td>${esc(cfg.issuer)}</td>
|
|
30
|
+
<td>${esc(domain)}</td>
|
|
31
|
+
<td>${jit}</td>
|
|
32
|
+
<td>${status}</td>
|
|
33
|
+
</tr>`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const content = `
|
|
37
|
+
<h1>Enterprise IdP</h1>
|
|
38
|
+
<p class="subtitle">Identity providers for XAA (Enterprise-Managed Authorization)</p>
|
|
39
|
+
|
|
40
|
+
<table>
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
<th>Name</th>
|
|
44
|
+
<th>Issuer</th>
|
|
45
|
+
<th>Domain</th>
|
|
46
|
+
<th>JIT</th>
|
|
47
|
+
<th>Status</th>
|
|
48
|
+
</tr>
|
|
49
|
+
</thead>
|
|
50
|
+
<tbody>
|
|
51
|
+
${tableRows}
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
`;
|
|
55
|
+
return htmlResponse(layout("IdP", content, "idp"));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Resource } from "@harperfast/harper";
|
|
2
|
+
import { layout, htmlResponse } from "./admin-layout.js";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
/**
|
|
6
|
+
* GET /AdminInstance — instance info, public key, version.
|
|
7
|
+
*/
|
|
8
|
+
export class AdminInstance extends Resource {
|
|
9
|
+
async get() {
|
|
10
|
+
const version = process.env.npm_package_version ?? "dev";
|
|
11
|
+
const httpPort = process.env.HTTP_PORT ?? "19926";
|
|
12
|
+
const publicUrl = process.env.FLAIR_PUBLIC_URL ?? `http://127.0.0.1:${httpPort}`;
|
|
13
|
+
// Try to read instance public key
|
|
14
|
+
let publicKey = "—";
|
|
15
|
+
const keyDir = join(homedir(), ".flair", "keys");
|
|
16
|
+
try {
|
|
17
|
+
// Look for any .pub file
|
|
18
|
+
const { readdirSync } = await import("node:fs");
|
|
19
|
+
const files = readdirSync(keyDir).filter((f) => f.endsWith(".pub") || f.endsWith(".key"));
|
|
20
|
+
if (files.length > 0) {
|
|
21
|
+
publicKey = `${files.length} key(s) in ${keyDir}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
publicKey = "Key directory not found";
|
|
26
|
+
}
|
|
27
|
+
const content = `
|
|
28
|
+
<h1>Instance</h1>
|
|
29
|
+
<p class="subtitle">Flair instance configuration and identity</p>
|
|
30
|
+
|
|
31
|
+
<div class="stats">
|
|
32
|
+
<div class="card">
|
|
33
|
+
<h3>Version</h3>
|
|
34
|
+
<div class="value" style="font-size:1.4em">${version}</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="card">
|
|
37
|
+
<h3>Public URL</h3>
|
|
38
|
+
<div style="font-family:monospace;font-size:0.9em;word-break:break-all">${publicUrl}</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="card">
|
|
43
|
+
<h3>Keys</h3>
|
|
44
|
+
<p>${publicKey}</p>
|
|
45
|
+
<p style="margin-top:8px;color:#666;font-size:0.9em">
|
|
46
|
+
Ed25519 keys are stored in <code>${keyDir}</code>. Each principal has its own keypair.
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="card">
|
|
51
|
+
<h3>Endpoints</h3>
|
|
52
|
+
<table style="box-shadow:none">
|
|
53
|
+
<tr><td>API</td><td><code>${publicUrl}/</code></td></tr>
|
|
54
|
+
<tr><td>MCP</td><td><code>${publicUrl}/mcp</code></td></tr>
|
|
55
|
+
<tr><td>OAuth Discovery</td><td><code>${publicUrl}/OAuthMetadata</code></td></tr>
|
|
56
|
+
<tr><td>OAuth Authorize</td><td><code>${publicUrl}/OAuthAuthorize</code></td></tr>
|
|
57
|
+
<tr><td>OAuth Token</td><td><code>${publicUrl}/OAuthToken</code></td></tr>
|
|
58
|
+
<tr><td>Admin</td><td><code>${publicUrl}/AdminDashboard</code></td></tr>
|
|
59
|
+
</table>
|
|
60
|
+
</div>
|
|
61
|
+
`;
|
|
62
|
+
return htmlResponse(layout("Instance", content, "instance"));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* GET /AdminMemory — browse and search memories.
|
|
5
|
+
*/
|
|
6
|
+
export class AdminMemory extends Resource {
|
|
7
|
+
async get() {
|
|
8
|
+
const request = this.request;
|
|
9
|
+
const url = new URL(request?.url ?? "http://localhost", "http://localhost");
|
|
10
|
+
const query = url.searchParams.get("q") ?? "";
|
|
11
|
+
const subject = url.searchParams.get("subject") ?? "";
|
|
12
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
|
|
13
|
+
const memories = [];
|
|
14
|
+
try {
|
|
15
|
+
const conditions = [];
|
|
16
|
+
if (subject) {
|
|
17
|
+
conditions.push({ attribute: "subject", comparator: "equals", value: subject.toLowerCase() });
|
|
18
|
+
}
|
|
19
|
+
conditions.push({ attribute: "archived", comparator: "not_equal", value: true });
|
|
20
|
+
const searchQuery = conditions.length > 0 ? { conditions } : {};
|
|
21
|
+
let count = 0;
|
|
22
|
+
for await (const m of databases.flair.Memory.search(searchQuery)) {
|
|
23
|
+
if (m.expiresAt && Date.parse(m.expiresAt) < Date.now())
|
|
24
|
+
continue;
|
|
25
|
+
if (query && !String(m.content || "").toLowerCase().includes(query.toLowerCase()))
|
|
26
|
+
continue;
|
|
27
|
+
memories.push(m);
|
|
28
|
+
count++;
|
|
29
|
+
if (count >= limit)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { /* table may not exist */ }
|
|
34
|
+
memories.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
35
|
+
let tableRows = "";
|
|
36
|
+
if (memories.length === 0) {
|
|
37
|
+
tableRows = `<tr><td colspan="5" class="empty">No memories found${query ? ` matching "${query}"` : ""}.</td></tr>`;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
for (const m of memories) {
|
|
41
|
+
const preview = (m.content || "").slice(0, 120) + ((m.content || "").length > 120 ? "…" : "");
|
|
42
|
+
const durability = m.durability ?? "standard";
|
|
43
|
+
const durBadge = durability === "permanent"
|
|
44
|
+
? `<span class="badge badge-green">${durability}</span>`
|
|
45
|
+
: durability === "persistent"
|
|
46
|
+
? `<span class="badge badge-blue">${durability}</span>`
|
|
47
|
+
: `<span class="badge badge-gray">${durability}</span>`;
|
|
48
|
+
const subjectStr = m.subject ?? "—";
|
|
49
|
+
const created = m.createdAt?.slice(0, 10) ?? "—";
|
|
50
|
+
const validity = m.validTo ? `<small>expired ${m.validTo.slice(0, 10)}</small>` : "";
|
|
51
|
+
tableRows += `
|
|
52
|
+
<tr>
|
|
53
|
+
<td style="max-width:400px">${esc(preview)}</td>
|
|
54
|
+
<td>${durBadge}</td>
|
|
55
|
+
<td>${esc(subjectStr)}</td>
|
|
56
|
+
<td>${esc(m.agentId ?? "—")}</td>
|
|
57
|
+
<td>${created} ${validity}</td>
|
|
58
|
+
</tr>`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const content = `
|
|
62
|
+
<h1>Memory</h1>
|
|
63
|
+
<p class="subtitle">${memories.length} memor${memories.length !== 1 ? "ies" : "y"} shown</p>
|
|
64
|
+
|
|
65
|
+
<div style="margin-bottom: 20px">
|
|
66
|
+
<form method="GET" action="/AdminMemory" style="display:flex;gap:8px">
|
|
67
|
+
<input type="text" name="q" value="${esc(query)}" placeholder="Search memories..."
|
|
68
|
+
style="flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:6px;font-size:0.95em">
|
|
69
|
+
<input type="text" name="subject" value="${esc(subject)}" placeholder="Subject filter"
|
|
70
|
+
style="width:150px;padding:8px 12px;border:1px solid #ddd;border-radius:6px;font-size:0.95em">
|
|
71
|
+
<button type="submit" class="btn btn-primary">Search</button>
|
|
72
|
+
</form>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<table>
|
|
76
|
+
<thead>
|
|
77
|
+
<tr>
|
|
78
|
+
<th>Content</th>
|
|
79
|
+
<th>Durability</th>
|
|
80
|
+
<th>Subject</th>
|
|
81
|
+
<th>Agent</th>
|
|
82
|
+
<th>Created</th>
|
|
83
|
+
</tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody>
|
|
86
|
+
${tableRows}
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
`;
|
|
90
|
+
return htmlResponse(layout("Memory", content, "memory"));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
+
import { layout, htmlResponse, esc } from "./admin-layout.js";
|
|
3
|
+
/**
|
|
4
|
+
* GET /AdminPrincipals — list all principals with kind, trust, status.
|
|
5
|
+
*/
|
|
6
|
+
export class AdminPrincipals extends Resource {
|
|
7
|
+
async get() {
|
|
8
|
+
const principals = [];
|
|
9
|
+
try {
|
|
10
|
+
for await (const p of databases.flair.Agent.search()) {
|
|
11
|
+
principals.push(p);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch { /* table may not exist */ }
|
|
15
|
+
principals.sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
|
|
16
|
+
let tableRows = "";
|
|
17
|
+
if (principals.length === 0) {
|
|
18
|
+
tableRows = `<tr><td colspan="6" class="empty">No principals registered yet.</td></tr>`;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
for (const p of principals) {
|
|
22
|
+
const kind = p.kind ?? "agent";
|
|
23
|
+
const kindBadge = kind === "human"
|
|
24
|
+
? `<span class="badge badge-blue">human</span>`
|
|
25
|
+
: `<span class="badge badge-gray">agent</span>`;
|
|
26
|
+
const trust = p.defaultTrustTier ?? "—";
|
|
27
|
+
const trustBadge = trust === "endorsed"
|
|
28
|
+
? `<span class="badge badge-green">${trust}</span>`
|
|
29
|
+
: trust === "corroborated"
|
|
30
|
+
? `<span class="badge badge-blue">${trust}</span>`
|
|
31
|
+
: `<span class="badge badge-yellow">${trust}</span>`;
|
|
32
|
+
const status = p.status ?? "active";
|
|
33
|
+
const statusBadge = status === "active"
|
|
34
|
+
? `<span class="badge badge-green">${status}</span>`
|
|
35
|
+
: `<span class="badge badge-gray">${status}</span>`;
|
|
36
|
+
const admin = p.admin ? "yes" : "";
|
|
37
|
+
const created = p.createdAt?.slice(0, 10) ?? "—";
|
|
38
|
+
tableRows += `
|
|
39
|
+
<tr>
|
|
40
|
+
<td><strong>${esc(p.id)}</strong><br><small>${esc(p.displayName || p.name || "")}</small></td>
|
|
41
|
+
<td>${kindBadge}</td>
|
|
42
|
+
<td>${trustBadge}</td>
|
|
43
|
+
<td>${statusBadge}</td>
|
|
44
|
+
<td>${admin}</td>
|
|
45
|
+
<td>${created}</td>
|
|
46
|
+
</tr>`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const content = `
|
|
50
|
+
<h1>Principals</h1>
|
|
51
|
+
<p class="subtitle">${principals.length} registered principal${principals.length !== 1 ? "s" : ""}</p>
|
|
52
|
+
|
|
53
|
+
<table>
|
|
54
|
+
<thead>
|
|
55
|
+
<tr>
|
|
56
|
+
<th>ID / Name</th>
|
|
57
|
+
<th>Kind</th>
|
|
58
|
+
<th>Trust</th>
|
|
59
|
+
<th>Status</th>
|
|
60
|
+
<th>Admin</th>
|
|
61
|
+
<th>Created</th>
|
|
62
|
+
</tr>
|
|
63
|
+
</thead>
|
|
64
|
+
<tbody>
|
|
65
|
+
${tableRows}
|
|
66
|
+
</tbody>
|
|
67
|
+
</table>
|
|
68
|
+
`;
|
|
69
|
+
return htmlResponse(layout("Principals", content, "principals"));
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/resources/Agent.js
CHANGED
|
@@ -1,9 +1,56 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
/**
|
|
3
|
+
* Agent resource — serves as the Principal table in 1.0.
|
|
4
|
+
*
|
|
5
|
+
* The Agent table is extended (not replaced) to serve as the Principal
|
|
6
|
+
* model. The `kind` field distinguishes humans from agents. Pre-1.0
|
|
7
|
+
* records without `kind` are treated as agents with default trust tier.
|
|
8
|
+
*
|
|
9
|
+
* Principal fields added in 1.0:
|
|
10
|
+
* - kind: "human" | "agent"
|
|
11
|
+
* - displayName: human-friendly label
|
|
12
|
+
* - status: "active" | "deactivated"
|
|
13
|
+
* - defaultTrustTier: "endorsed" | "corroborated" | "unverified"
|
|
14
|
+
* - admin: boolean
|
|
15
|
+
* - runtime: how to reach this principal
|
|
16
|
+
* - subjects: soul-level subject interests
|
|
17
|
+
*/
|
|
2
18
|
export class Agent extends databases.flair.Agent {
|
|
3
19
|
async post(content, context) {
|
|
20
|
+
const now = new Date().toISOString();
|
|
21
|
+
// Backward compat: set type for legacy code
|
|
4
22
|
content.type ||= "agent";
|
|
5
|
-
|
|
6
|
-
content.
|
|
23
|
+
// 1.0 Principal defaults
|
|
24
|
+
content.kind ||= "agent";
|
|
25
|
+
content.status ||= "active";
|
|
26
|
+
content.displayName ||= content.name;
|
|
27
|
+
content.admin ??= false;
|
|
28
|
+
// Trust tier defaults per kind
|
|
29
|
+
if (!content.defaultTrustTier) {
|
|
30
|
+
content.defaultTrustTier = content.admin ? "endorsed" : "unverified";
|
|
31
|
+
}
|
|
32
|
+
content.createdAt = now;
|
|
33
|
+
content.updatedAt = now;
|
|
7
34
|
return super.post(content, context);
|
|
8
35
|
}
|
|
36
|
+
async put(content) {
|
|
37
|
+
const ctx = this.getContext?.();
|
|
38
|
+
const request = ctx?.request ?? ctx;
|
|
39
|
+
const authAgent = request?.tpsAgent;
|
|
40
|
+
const isAdminAgent = request?.tpsAgentIsAdmin ?? false;
|
|
41
|
+
// Only admin principals can modify other principals
|
|
42
|
+
if (authAgent && !isAdminAgent) {
|
|
43
|
+
const existing = await super.get();
|
|
44
|
+
if (existing && existing.id !== authAgent) {
|
|
45
|
+
return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
|
|
46
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
content.updatedAt = new Date().toISOString();
|
|
51
|
+
// Protect immutable fields
|
|
52
|
+
delete content.createdAt;
|
|
53
|
+
delete content.publicKey; // key rotation goes through dedicated endpoint
|
|
54
|
+
return super.put(content);
|
|
55
|
+
}
|
|
9
56
|
}
|