@tpsdev-ai/flair 0.31.0 → 0.32.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 +32 -6
- package/dist/cli.js +227 -71
- package/dist/resources/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- package/dist/resources/in-process-api.js +382 -0
- package/dist/resources/in-process.js +9 -0
- package/dist/resources/mcp-handler.js +14 -4
- package/dist/resources/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- package/docs/deployment-shapes.md +35 -0
- package/docs/deployment.md +2 -2
- package/docs/embedding-in-a-harper-app.md +174 -75
- package/docs/hosted-on-fabric.md +203 -0
- package/docs/secrets-and-keys.md +4 -4
- package/docs/standalone-local.md +243 -0
- package/docs/upgrade.md +7 -3
- package/package.json +7 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* record-owner-guard.ts — ONE enforcement point for "you may not modify a
|
|
3
|
+
* record you do not own".
|
|
4
|
+
*
|
|
5
|
+
* ─── The defect this deletes ─────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Harper's REST layer maps verbs to resource methods ONE-TO-ONE (see
|
|
8
|
+
* harper/dist/server/REST.js's method switch): GET→get, POST→post, PUT→put,
|
|
9
|
+
* PATCH→patch, DELETE→delete. There is no fallback — `patch()` does NOT route
|
|
10
|
+
* through `put()`. So an ownership rule written inside a resource's `put()` is
|
|
11
|
+
* enforced on PUT and on nothing else, and a resource with no `patch()` override
|
|
12
|
+
* reaches the table with only its `allow*` gate, which is typically
|
|
13
|
+
* `allowVerified()` — "any verified agent".
|
|
14
|
+
*
|
|
15
|
+
* Almost every flair resource wrote its per-record rules in `put()`. That is not
|
|
16
|
+
* a mistake anyone made once; it is what the resource model invites, because
|
|
17
|
+
* `put()` is where the write logic naturally goes and nothing anywhere says the
|
|
18
|
+
* other verbs exist. Fixing it resource-by-resource would mean N hand-written
|
|
19
|
+
* guards — N chances to get one subtly wrong — and would still leave the NEXT
|
|
20
|
+
* resource broken by default, because its author would have to know this file
|
|
21
|
+
* exists to be safe. So the rule lives here instead, once, on the path every
|
|
22
|
+
* HTTP request already takes.
|
|
23
|
+
*
|
|
24
|
+
* ─── The rule, and why it is this narrow ─────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* On a record that ALREADY EXISTS, a non-admin caller whose agent id does not
|
|
27
|
+
* match the record's stored owner is refused — on every mutating verb.
|
|
28
|
+
*
|
|
29
|
+
* It deliberately says nothing about creation. Over-blocking is the failure mode
|
|
30
|
+
* a security fix reaches for, and a blunter rule ("the caller must own whatever
|
|
31
|
+
* it names") would break real flows: Presence heartbeats arrive at the
|
|
32
|
+
* collection with no record yet, credential provisioning creates rows for other
|
|
33
|
+
* principals, and a MemoryGrant's `granteeId` is SUPPOSED to be someone else.
|
|
34
|
+
* Restricting this to records that exist means the guard can only ever narrow
|
|
35
|
+
* mutation of another agent's data. It cannot break a create, and it cannot
|
|
36
|
+
* break an agent writing its own record.
|
|
37
|
+
*
|
|
38
|
+
* ─── Owner is read from STORED STATE, never from the request body ────────────
|
|
39
|
+
*
|
|
40
|
+
* The guards this replaces compared the owner field in the request BODY — the
|
|
41
|
+
* owner the CALLER CLAIMS — and denied only when that field was present and
|
|
42
|
+
* mismatched. A body that simply omitted it was compared against nothing and
|
|
43
|
+
* passed, whatever record the URL named. Harper binds the write to the URL's id,
|
|
44
|
+
* not to the body, so body-derived authorization was answering a question nobody
|
|
45
|
+
* asked. That is the same defect as the verb gap wearing different clothes: an
|
|
46
|
+
* authorization decision made against attacker-supplied data instead of stored
|
|
47
|
+
* state. Both are closed here, together, because closing one leaves the other
|
|
48
|
+
* reachable.
|
|
49
|
+
*
|
|
50
|
+
* Per-resource body checks (no-forge attribution on CREATE) still belong in the
|
|
51
|
+
* resources — they answer a different question, "may you attribute a NEW record
|
|
52
|
+
* to someone else", which this guard does not address.
|
|
53
|
+
*
|
|
54
|
+
* ─── Keeping this honest as the codebase grows ───────────────────────────────
|
|
55
|
+
*
|
|
56
|
+
* OWNER_FIELDS below is static and PR-reviewed, matching the posture of
|
|
57
|
+
* resources/record-types.ts. A static map alone would rot, so
|
|
58
|
+
* test/unit/record-owner-guard-coverage.test.ts parses `schemas/*.graphql` and
|
|
59
|
+
* FAILS when a table declares an owner-shaped column and is neither listed here
|
|
60
|
+
* nor exempted with a stated reason. A table added later enters that test's
|
|
61
|
+
* scope the moment it declares the column — nobody has to know this file exists.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Table → the attribute naming the principal that owns a row.
|
|
65
|
+
*
|
|
66
|
+
* Derived from the columns actually declared in `schemas/*.graphql`, and pinned
|
|
67
|
+
* against them by the coverage test. Tables with no resource class are listed
|
|
68
|
+
* anyway: costing nothing when the route does not exist is much better than
|
|
69
|
+
* being absent on the day someone adds one.
|
|
70
|
+
*/
|
|
71
|
+
export const OWNER_FIELDS = Object.freeze({
|
|
72
|
+
Credential: "principalId",
|
|
73
|
+
Integration: "agentId",
|
|
74
|
+
Memory: "agentId",
|
|
75
|
+
MemoryCandidate: "agentId",
|
|
76
|
+
MemoryGrant: "ownerId",
|
|
77
|
+
MemoryUsage: "agentId",
|
|
78
|
+
OAuthAuthCode: "principalId",
|
|
79
|
+
OAuthToken: "principalId",
|
|
80
|
+
OrgEvent: "authorId",
|
|
81
|
+
Presence: "agentId",
|
|
82
|
+
Relationship: "agentId",
|
|
83
|
+
Soul: "agentId",
|
|
84
|
+
WorkspaceState: "agentId",
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* Tables that declare an owner-shaped column but are deliberately NOT guarded
|
|
88
|
+
* here, each with the reason. The coverage test accepts these and rejects
|
|
89
|
+
* anything else, so an omission has to be argued rather than merely happen.
|
|
90
|
+
*/
|
|
91
|
+
export const OWNER_GUARD_EXEMPT = Object.freeze({
|
|
92
|
+
// The principal table's own rule is "the record IS the caller", not "the
|
|
93
|
+
// record has an owner column" — a principal may edit itself, and only an
|
|
94
|
+
// admin may change anyone's admin status. That is enforced in
|
|
95
|
+
// resources/Agent.ts's shared write-authorization helper, which both its
|
|
96
|
+
// put() and its patch() route through.
|
|
97
|
+
Agent: "self-ownership by primary key; enforced in resources/Agent.ts for every verb",
|
|
98
|
+
});
|
|
99
|
+
/** The verbs that can mutate a record, and therefore need the rule applied. */
|
|
100
|
+
export const MUTATING_METHODS = Object.freeze(["POST", "PUT", "PATCH", "DELETE"]);
|
|
101
|
+
export function isMutatingMethod(method) {
|
|
102
|
+
return MUTATING_METHODS.includes(method.toUpperCase());
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a request path to the guarded table and record id it addresses, or
|
|
106
|
+
* null when the path is not a guarded single-record route.
|
|
107
|
+
*
|
|
108
|
+
* Matches `/<Table>/<id>` ONLY. A collection path (`/<Table>`) addresses no
|
|
109
|
+
* existing record, so there is nothing to own and nothing to check — that is the
|
|
110
|
+
* "creation is untouched" property, expressed as a parse rather than a special
|
|
111
|
+
* case. The table segment is matched EXACTLY so `/SoulFeed/x` can never be read
|
|
112
|
+
* as a `Soul` route.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveGuardedRecord(pathname) {
|
|
115
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
116
|
+
if (parts.length < 2)
|
|
117
|
+
return null;
|
|
118
|
+
const table = parts[0];
|
|
119
|
+
const ownerField = OWNER_FIELDS[table];
|
|
120
|
+
if (!ownerField)
|
|
121
|
+
return null;
|
|
122
|
+
let id;
|
|
123
|
+
try {
|
|
124
|
+
id = decodeURIComponent(parts[1]);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null; // malformed percent-encoding addresses no record we can resolve
|
|
128
|
+
}
|
|
129
|
+
if (!id)
|
|
130
|
+
return null;
|
|
131
|
+
return { table, ownerField, id };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Decide whether a caller may mutate an already-stored record.
|
|
135
|
+
*
|
|
136
|
+
* Pure, so the decision is testable without a Harper instance — the middleware
|
|
137
|
+
* supplies the record it loaded. A record that does not exist, or that carries
|
|
138
|
+
* no owner value, is NOT refused here: the first is a create (or a 404 the
|
|
139
|
+
* resource will produce), and the second is a row with nothing to own, neither
|
|
140
|
+
* of which this rule is about.
|
|
141
|
+
*/
|
|
142
|
+
export function isForbiddenOwnerMutation(record, ownerField, callerAgentId) {
|
|
143
|
+
if (!record)
|
|
144
|
+
return false;
|
|
145
|
+
const owner = record[ownerField];
|
|
146
|
+
if (owner == null || owner === "")
|
|
147
|
+
return false;
|
|
148
|
+
return owner !== callerAgentId;
|
|
149
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Which Flair deployment shape are you in?
|
|
2
|
+
|
|
3
|
+
Flair runs in one of three shapes. Pick yours and follow only that path.
|
|
4
|
+
|
|
5
|
+
| You want to... | Shape | Start here |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Run Flair on your own machine or VPS. `flair init` installs Harper, creates your agent identity, and you're running. | **Standalone local** | [standalone-local.md](standalone-local.md) |
|
|
8
|
+
| Run Flair on [Harper Fabric](https://www.harperdb.io/) — managed hosting, multi-region replication, no shell on the node. You deploy a component; agents connect over HTTPS. | **Hosted on Fabric** | [hosted-on-fabric.md](hosted-on-fabric.md) |
|
|
9
|
+
| Load Flair into a Harper instance you already run. In-process calls — no HTTP, no second process, no key to distribute. Over HTTP it is one memory API among many. | **Embedded in a Harper app** | [embedding-in-a-harper-app.md](embedding-in-a-harper-app.md) |
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What changes across shapes
|
|
14
|
+
|
|
15
|
+
Not everything is the same. The critical differences:
|
|
16
|
+
|
|
17
|
+
### Identity
|
|
18
|
+
|
|
19
|
+
- **Standalone local & Fabric (over HTTP):** Agents authenticate with Ed25519-signed requests. Each agent holds a private key and signs `agentId:timestamp:nonce:METHOD:/path` on every request. The server verifies the signature against the agent's registered public key.
|
|
20
|
+
- **Embedded (in-process):** Identity is **asserted**, not verified. You pass the agent id via the call context — `agentContext("mybot")` — and Flair acts as that agent. No key, no signature, no `Agent`-table lookup. Co-location *is* the trust boundary: a caller inside the same Harper instance could write the storage tables directly anyway, so demanding a signature from same-process code would be theatre. **Never build the context from request data** — that is privilege escalation with no error and no trace.
|
|
21
|
+
|
|
22
|
+
### Upgrade
|
|
23
|
+
|
|
24
|
+
- **Standalone local:** `flair upgrade` — install, restart, verify, rollback-on-failure in one step.
|
|
25
|
+
- **Hosted on Fabric:** `flair upgrade --target <fabric-url>` — resolves the target version, stages a clean deployable, and pushes it. Fabric has no `flair upgrade` equivalent to the local path; redeploy the component.
|
|
26
|
+
- **Embedded:** Flair follows your host app's dependency lifecycle. When you update `@tpsdev-ai/flair` in your app's `package.json` and redeploy, Flair upgrades with the rest. There is no `flair upgrade` — it's a component, not a standalone process.
|
|
27
|
+
|
|
28
|
+
### Federation
|
|
29
|
+
|
|
30
|
+
- **Standalone local & Fabric:** Available. Hub-and-spoke sync with pairing tokens, signed requests, and originator enforcement. The CLI drives pairing (`flair federation pair`).
|
|
31
|
+
- **Embedded:** Pairing is **CLI-only** today — it requires shell access on the node ([flair#947](https://github.com/tpsdev-ai/flair/issues/947)). If your deployment has no shell, federation is not available.
|
|
32
|
+
|
|
33
|
+
### `@export`
|
|
34
|
+
|
|
35
|
+
`@export` on a GraphQL schema means **REST exposure only** — it does not mean replication. `Memory` has no `@export` and still replicates in a Harper Fabric cluster (replication is per-database, not per-export). This is a genuine and easily-inverted trap: do not assume `@export` controls sync or that the absence of `@export` means "not replicated."
|
package/docs/deployment.md
CHANGED
|
@@ -192,10 +192,10 @@ Set these in the Flair process environment (`~/Library/LaunchAgents/ai.tpsdev.fl
|
|
|
192
192
|
|
|
193
193
|
```bash
|
|
194
194
|
# Backup all data (agents, memories, souls)
|
|
195
|
-
flair backup
|
|
195
|
+
flair backup --output ~/flair-backup-$(date +%Y%m%d).json --admin-pass-file ~/.flair/admin-pass
|
|
196
196
|
|
|
197
197
|
# Restore to a fresh instance
|
|
198
|
-
flair restore
|
|
198
|
+
flair restore ~/flair-backup-20260405.json
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
Always backup before upgrades.
|
|
@@ -18,86 +18,86 @@ Embedding *adds* the in-process path; `rest: true` keeps serving MCP clients and
|
|
|
18
18
|
|
|
19
19
|
**1. Add Flair to your instance.** Deploy `@tpsdev-ai/flair` as a component the way you deploy your own — Fabric's component deploy, Studio, or your pipeline. Its tables are declared `@table(database: "flair")`, so they never collide with yours.
|
|
20
20
|
|
|
21
|
-
**2.
|
|
21
|
+
**2. Import the facade and write a memory.**
|
|
22
22
|
|
|
23
23
|
```javascript
|
|
24
|
-
import {
|
|
25
|
-
// Flair ships these two helpers so you do not have to get either of them right
|
|
26
|
-
// by hand. They are the whole in-process contract.
|
|
27
|
-
import { agentContext, internalContext, collectionResource } from "@tpsdev-ai/flair/dist/resources/in-process.js";
|
|
24
|
+
import { Flair } from "@tpsdev-ai/flair";
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Keys carry NO leading slash: get("Memory"), never get("/Memory").
|
|
32
|
-
const flair = (path) => server.resources.get(path).Resource;
|
|
26
|
+
const flair = new Flair(server);
|
|
27
|
+
const planner = flair.as("planner");
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
// A create needs a COLLECTION-bound instance. `new Cls(...)` does not give
|
|
36
|
-
// you one, and cannot be made to — see the note below.
|
|
37
|
-
const h = await collectionResource(flair("Memory"), agentContext(agentId));
|
|
38
|
-
return h.post({
|
|
39
|
-
agentId, // required — an absent one is never filled in
|
|
40
|
-
content,
|
|
41
|
-
durability: opts.durability ?? "standard",
|
|
42
|
-
});
|
|
43
|
-
}
|
|
29
|
+
await planner.memory.write("deploy runs at 0200 UTC", { durability: "standard" });
|
|
44
30
|
```
|
|
45
31
|
|
|
46
|
-
|
|
47
|
-
>
|
|
48
|
-
> A resource's `post()` only works on an instance Harper has marked as a **collection**, and that mark is a *private* field only Harper's own `getResource()` can set. The public `isCollection` is a getter with no setter, so the obvious spelling fails two different ways, neither of which names the cause:
|
|
49
|
-
>
|
|
50
|
-
> ```javascript
|
|
51
|
-
> const h = new (flair("Memory"))(undefined, agentContext(agentId));
|
|
52
|
-
> h.isCollection = true; // TypeError: Cannot set property isCollection ... which has only a getter
|
|
53
|
-
> h.post({ ... }); // without the line above: 405 "The Memory does not have a post method implemented"
|
|
54
|
-
> ```
|
|
55
|
-
>
|
|
56
|
-
> `collectionResource(Cls, context)` is a two-line wrapper over the supported call — `Cls.getResource({}, context, { isCollection: true })` — and exists so this is written once. **Reads do not need it:** `Cls.get(id, context)` and `Cls.search(query, context)` thread the context themselves.
|
|
57
|
-
|
|
58
|
-
> ### ⚠️ A resource with no context is an administrator
|
|
59
|
-
>
|
|
60
|
-
> A resource built without a context resolves to Flair's trusted `internal` verdict and runs **unfiltered** — every read unscoped, every write unowned. Silently. No error, no warning, no trace.
|
|
61
|
-
>
|
|
62
|
-
> Measured, not inferred: a context-less `Memory.search()` returns every agent's `private` records, and so does a context-less `SemanticSearch`.
|
|
63
|
-
>
|
|
64
|
-
> Correct for Flair's own maintenance passes. In your app it is a data leak you find months later.
|
|
65
|
-
>
|
|
66
|
-
> **Make `agentId` a required argument, as above.** Never export a version that defaults it.
|
|
32
|
+
That is the whole API. No deep import, no `server.resources`, no `.Resource`, no `collectionResource()`, no double-passing `agentId`. The facade stamps `agentId` from the handle's context onto the body internally — you pass it once, at `flair.as(id)`.
|
|
67
33
|
|
|
68
34
|
**3. Read it back, scoped to that agent.**
|
|
69
35
|
|
|
70
36
|
```javascript
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return h.post({ q: query, limit });
|
|
74
|
-
}
|
|
37
|
+
const hits = await planner.recall("deploy schedule", { limit: 5 });
|
|
38
|
+
const record = await planner.memory.get(hits[0].id);
|
|
75
39
|
```
|
|
76
40
|
|
|
77
|
-
**4.
|
|
41
|
+
**4. Register an agent, no CLI.**
|
|
78
42
|
|
|
79
43
|
```javascript
|
|
80
|
-
await
|
|
81
|
-
|
|
44
|
+
await flair.admin.registerAgent("planner", { publicKey: "pending" });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> **`flair.admin` is a root shell.** Every call site is greppable via `git grep "flair.admin"`. Use for provisioning and maintenance only, never as a request handler's default.
|
|
48
|
+
|
|
49
|
+
**5. Verify it worked.**
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
console.log(await planner.memory.search({ limit: 10 }));
|
|
82
53
|
console.log([...server.resources.keys()].sort()); // what Flair registered
|
|
83
54
|
```
|
|
84
55
|
|
|
85
|
-
|
|
86
|
-
>
|
|
87
|
-
> Run end to end on **Harper 5.1.22**, from a second component loaded into the same instance — the exact shape above. `test/integration/in-process-agents.test.ts` in the Flair repo is that run, and `test/fixtures/inproc-app` is the component it drives.
|
|
88
|
-
>
|
|
89
|
-
> | Claim | Result |
|
|
90
|
-
> |---|---|
|
|
91
|
-
> | `server.resources.get("Memory")` from another component | Returns an **entry object** `{ Resource, path, exportTypes, hasSubPaths, relativeURL }` — `.Resource` is required, it is not the class itself |
|
|
92
|
-
> | `.Resource` is Flair's resource, not the raw table | Confirmed: prototype chain `Memory → Memory → Resource`, and it is **not** `databases.flair.Memory` |
|
|
93
|
-
> | Key format | **No leading slash.** `get("Memory")` hits; `get("/Memory")` returns `undefined` |
|
|
94
|
-
> | `getMatch` | `getMatch("Memory")` hits. **`getMatch("/Memory")` misses** — do not use the slashed form |
|
|
95
|
-
> | When the lookup becomes valid | Flair's resources were already registered at the app component's **module top level** (55 entries, `Memory` and `Agent` present). The only entry missing at that moment was the app's *own*, still mid-registration. Resolving lazily, as above, is still the advice — it costs nothing and does not depend on component load order |
|
|
96
|
-
> | Per-agent scoping through `SemanticSearch` | Holds. Querying as `agent-beta` for a topic only `agent-alpha` has written returns **beta's own** memory, never alpha's private one — with real 768-dim embeddings attached, not a degraded path |
|
|
97
|
-
> | Cross-agent by-id read | `Memory.get(<beta's private id>)` as alpha returns **404**, never 403 — a denied caller cannot enumerate ids |
|
|
98
|
-
> | Context-less call | Unfiltered across all agents, via both `search` and `SemanticSearch` (see the warning above) |
|
|
56
|
+
---
|
|
99
57
|
|
|
100
|
-
|
|
58
|
+
## The facade
|
|
59
|
+
|
|
60
|
+
### `new Flair(server)`
|
|
61
|
+
|
|
62
|
+
One handle per Harper instance. Resolves resources lazily on first use — no lookup at construction time.
|
|
63
|
+
|
|
64
|
+
### `flair.as(agentId)`
|
|
65
|
+
|
|
66
|
+
Returns an `AgentHandle` scoped to that agent. The `agentId` is runtime-validated: missing, empty, blank, or non-string throws `InProcessContextError`.
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
const planner = flair.as("planner");
|
|
70
|
+
planner.agentId; // "planner"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Security:** In-process identity is asserted, not verified — co-location IS the grant. Build the `agentId` from your own server-side state, never from request data. If an agent id can reach `flair.as()` from user input — a body field, a query param, a header you did not verify yourself — that is privilege escalation with **no error, no 403 and no trace**.
|
|
74
|
+
|
|
75
|
+
### `AgentHandle`
|
|
76
|
+
|
|
77
|
+
| Method | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `handle.memory.write(content, opts?)` | Write a memory as this agent. `agentId` is stamped from the handle — the caller never passes it. |
|
|
80
|
+
| `handle.memory.get(id)` | Read a memory by id, scoped to this agent. |
|
|
81
|
+
| `handle.memory.search(opts?)` | Search memories scoped to this agent. |
|
|
82
|
+
| `handle.recall(query, opts?)` | Semantic search scoped to this agent. |
|
|
83
|
+
|
|
84
|
+
### `flair.admin`
|
|
85
|
+
|
|
86
|
+
Admin operations — unfiltered reads, cross-agent writes. Every call site is greppable via `git grep "flair.admin"`.
|
|
87
|
+
|
|
88
|
+
| Method | Description |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `flair.admin.registerAgent(id, opts?)` | Register an agent through the Agent resource (full Principal shape). |
|
|
91
|
+
| `flair.admin.memory.get(id)` | Read any memory by id, unfiltered. |
|
|
92
|
+
| `flair.admin.memory.write(asAgentId, content, opts?)` | Write a memory attributed to another agent. |
|
|
93
|
+
|
|
94
|
+
### `flair.internal`
|
|
95
|
+
|
|
96
|
+
Trusted, unattributed, unfiltered operations — Flair's `internal` verdict. Every call site is greppable via `git grep "flair.internal"`.
|
|
97
|
+
|
|
98
|
+
| Method | Description |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `flair.internal.agentTable.put(record)` | Write directly to the Agent resource (bypasses admin gate). |
|
|
101
101
|
|
|
102
102
|
---
|
|
103
103
|
|
|
@@ -107,7 +107,8 @@ Handlers return a `Response` for `401`/`403`/`400` rather than throwing — chec
|
|
|
107
107
|
|
|
108
108
|
```javascript
|
|
109
109
|
for (const id of ["planner", "researcher", "reviewer"]) {
|
|
110
|
-
|
|
110
|
+
const agent = flair.as(id);
|
|
111
|
+
await agent.memory.write(`${id} came online`);
|
|
111
112
|
}
|
|
112
113
|
```
|
|
113
114
|
|
|
@@ -115,25 +116,23 @@ for (const id of ["planner", "researcher", "reviewer"]) {
|
|
|
115
116
|
|
|
116
117
|
### Registering agents, no CLI
|
|
117
118
|
|
|
118
|
-
Go through
|
|
119
|
+
Go through `flair.admin.registerAgent()` — it goes through the `Agent` **resource**, which fills in the whole Principal shape for you:
|
|
119
120
|
|
|
120
121
|
```javascript
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
runtime: "headless",
|
|
127
|
-
...(admin ? { role: "admin", admin: true } : {}), // role is what actually grants admin
|
|
128
|
-
});
|
|
129
|
-
}
|
|
122
|
+
await flair.admin.registerAgent("researcher", {
|
|
123
|
+
publicKey: "pending",
|
|
124
|
+
displayName: "Research Agent",
|
|
125
|
+
admin: false,
|
|
126
|
+
});
|
|
130
127
|
```
|
|
131
128
|
|
|
132
129
|
Verified against a real instance: that lands `kind: "agent"`, `status: "active"`, `displayName`, `admin: false`, `defaultTrustTier: "unverified"`, `type: "agent"`, `createdAt`/`updatedAt` and the federation `originatorInstanceId` stamp — without you naming any of them.
|
|
133
130
|
|
|
134
131
|
> **Prefer this to `databases.flair.Agent.put()`.** The raw table applies **no** defaults, so a hand-written literal has to reproduce every field above and then stay in step with Flair as the Principal model grows. Records written that way are missing `kind`/`status`/`defaultTrustTier` and read as under-specified Principals in the admin surfaces.
|
|
135
132
|
|
|
136
|
-
>
|
|
133
|
+
> **Admin is one meaning with one answer.** `role === "admin"` is the authority; the `admin` boolean is a mirror of it that the server maintains. Write **either** through the `Agent` resource and both are set — you no longer have to know which one is real, and a record cannot be stored saying one thing in one field and the opposite in the other. Nothing reads the mirror to make an authorization decision, and a promotion applied through the resource takes effect on the next request rather than after the 60-second admin-lookup cache expires.
|
|
134
|
+
>
|
|
135
|
+
> A record written straight to the table (`databases.flair.Agent.put()`, an ops-API insert, a federation merge) skips that reconciliation and can still carry a mismatch. `flair principal show` and the admin dashboard flag such a record rather than silently picking a side; re-issuing the grant repairs it.
|
|
137
136
|
|
|
138
137
|
`publicKey` is non-nullable in the schema, but it does not have to be a real key. An agent that only ever acts in-process never authenticates, and Flair's own paths write placeholders — `"pending"` when seeding, `mcp-oauth:<sub>` for token-authenticated agents. Give an agent a real key only if it must also authenticate **over HTTP**, which your app can do without any CLI:
|
|
139
138
|
|
|
@@ -142,7 +141,7 @@ import { generateKeyPairSync } from "node:crypto";
|
|
|
142
141
|
|
|
143
142
|
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
144
143
|
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
|
|
145
|
-
await registerAgent("remote-worker", { publicKey: raw.toString("hex") });
|
|
144
|
+
await flair.admin.registerAgent("remote-worker", { publicKey: raw.toString("hex") });
|
|
146
145
|
// keep `privateKey` in your own secret store — Flair never sees it
|
|
147
146
|
```
|
|
148
147
|
|
|
@@ -294,6 +293,106 @@ A `SyncLog` row reads `direction: "pull"` — the receiver's label for a push it
|
|
|
294
293
|
|
|
295
294
|
---
|
|
296
295
|
|
|
296
|
+
## Appendix: the primitives layer
|
|
297
|
+
|
|
298
|
+
The facade wraps a lower-level API that is still available for callers who need direct access. Import it from `@tpsdev-ai/flair/server`:
|
|
299
|
+
|
|
300
|
+
```javascript
|
|
301
|
+
import { agentContext, adminContext, internalContext, collectionResource } from "@tpsdev-ai/flair/server";
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
This is the same seam Flair's own MCP handler and internal tooling use. You should not need it for ordinary agent operations — the facade covers those. Use the primitives when you are building your own abstraction on top of Flair's resources.
|
|
305
|
+
|
|
306
|
+
### Resolving a resource
|
|
307
|
+
|
|
308
|
+
```javascript
|
|
309
|
+
// The RESOURCE — carries auth, scoping, visibility, embedding.
|
|
310
|
+
// NOT databases.flair.Memory: that is the raw table, and enforces none of it.
|
|
311
|
+
// Keys carry NO leading slash: get("Memory"), never get("/Memory").
|
|
312
|
+
const flair = (path) => server.resources.get(path).Resource;
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Writing a memory (primitives)
|
|
316
|
+
|
|
317
|
+
```javascript
|
|
318
|
+
export async function remember(agentId, content, opts = {}) {
|
|
319
|
+
// A create needs a COLLECTION-bound instance. `new Cls(...)` does not give
|
|
320
|
+
// you one, and cannot be made to — see the note below.
|
|
321
|
+
const h = await collectionResource(flair("Memory"), agentContext(agentId));
|
|
322
|
+
return h.post({
|
|
323
|
+
agentId, // required — an absent one is never filled in
|
|
324
|
+
content,
|
|
325
|
+
durability: opts.durability ?? "standard",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
> ### Why `collectionResource`, and not `new Memory()`
|
|
331
|
+
>
|
|
332
|
+
> A resource's `post()` only works on an instance Harper has marked as a **collection**, and that mark is a *private* field only Harper's own `getResource()` can set. The public `isCollection` is a getter with no setter, so the obvious spelling fails two different ways, neither of which names the cause:
|
|
333
|
+
>
|
|
334
|
+
> ```javascript
|
|
335
|
+
> const h = new (flair("Memory"))(undefined, agentContext(agentId));
|
|
336
|
+
> h.isCollection = true; // TypeError: Cannot set property isCollection ... which has only a getter
|
|
337
|
+
> h.post({ ... }); // without the line above: 405 "The Memory does not have a post method implemented"
|
|
338
|
+
> ```
|
|
339
|
+
>
|
|
340
|
+
> `collectionResource(Cls, context)` is a two-line wrapper over the supported call — `Cls.getResource({}, context, { isCollection: true })` — and exists so this is written once. **Reads do not need it:** `Cls.get(id, context)` and `Cls.search(query, context)` thread the context themselves.
|
|
341
|
+
>
|
|
342
|
+
> They do still need the **context**. Only the collection binding is unnecessary for a read, never the identity — `Cls.search(query)` with the second argument left off resolves to the trusted `internal` verdict when it runs outside a request scope (a boot hook, a timer, a queue worker, a detached promise) and returns every agent's private records. On that path the resource's `allow*` gate is not consulted at all. Pass the context to every call, read and write.
|
|
343
|
+
|
|
344
|
+
> ### ⚠️ A resource with no context is an administrator
|
|
345
|
+
>
|
|
346
|
+
> A resource built without a context resolves to Flair's trusted `internal` verdict and runs **unfiltered** — every read unscoped, every write unowned. Silently. No error, no warning, no trace.
|
|
347
|
+
>
|
|
348
|
+
> Measured, not inferred: a context-less `Memory.search()` returns every agent's `private` records, and so does a context-less `SemanticSearch`.
|
|
349
|
+
>
|
|
350
|
+
> Correct for Flair's own maintenance passes. In your app it is a data leak you find months later.
|
|
351
|
+
>
|
|
352
|
+
> **Make `agentId` a required argument, as above.** Never export a version that defaults it.
|
|
353
|
+
|
|
354
|
+
### Reading back (primitives)
|
|
355
|
+
|
|
356
|
+
```javascript
|
|
357
|
+
export async function recall(agentId, query, limit = 5) {
|
|
358
|
+
const h = await collectionResource(flair("SemanticSearch"), agentContext(agentId));
|
|
359
|
+
return h.post({ q: query, limit });
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Registering agents (primitives)
|
|
364
|
+
|
|
365
|
+
```javascript
|
|
366
|
+
export async function registerAgent(id, { publicKey = "pending", admin = false } = {}) {
|
|
367
|
+
const h = await collectionResource(flair("Agent"), internalContext()); // provisioning is infrastructure, not an agent's write
|
|
368
|
+
return h.post({
|
|
369
|
+
id, name: id, displayName: id,
|
|
370
|
+
publicKey, // a placeholder is fine — see below
|
|
371
|
+
runtime: "headless",
|
|
372
|
+
...(admin ? { admin: true } : {}), // sets role:"admin" too — see below
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### What we measured, so you do not have to
|
|
378
|
+
|
|
379
|
+
Run end to end on **Harper 5.1.22**, from a second component loaded into the same instance — the exact shape above. `test/integration/in-process-agents.test.ts` in the Flair repo is that run, and `test/fixtures/inproc-app` is the component it drives.
|
|
380
|
+
|
|
381
|
+
| Claim | Result |
|
|
382
|
+
|---|---|
|
|
383
|
+
| `server.resources.get("Memory")` from another component | Returns an **entry object** `{ Resource, path, exportTypes, hasSubPaths, relativeURL }` — `.Resource` is required, it is not the class itself |
|
|
384
|
+
| `.Resource` is Flair's resource, not the raw table | Confirmed: prototype chain `Memory → Memory → Resource`, and it is **not** `databases.flair.Memory` |
|
|
385
|
+
| Key format | **No leading slash.** `get("Memory")` hits; `get("/Memory")` returns `undefined` |
|
|
386
|
+
| `getMatch` | `getMatch("Memory")` hits. **`getMatch("/Memory")` misses** — do not use the slashed form |
|
|
387
|
+
| When the lookup becomes valid | Flair's resources were already registered at the app component's **module top level** (55 entries, `Memory` and `Agent` present). The only entry missing at that moment was the app's *own*, still mid-registration. Resolving lazily, as above, is still the advice — it costs nothing and does not depend on component load order |
|
|
388
|
+
| Per-agent scoping through `SemanticSearch` | Holds. Querying as `agent-beta` for a topic only `agent-alpha` has written returns **beta's own** memory, never alpha's private one — with real 768-dim embeddings attached, not a degraded path |
|
|
389
|
+
| Cross-agent by-id read | `Memory.get(<beta's private id>)` as alpha returns **404**, never 403 — a denied caller cannot enumerate ids |
|
|
390
|
+
| Context-less call | Unfiltered across all agents, via both `search` and `SemanticSearch` (see the warning above) |
|
|
391
|
+
|
|
392
|
+
Handlers return a `Response` for `401`/`403`/`400` rather than throwing — check for one. `Memory.post()` is in-process only; over HTTP the schema exposes `PUT`.
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
297
396
|
## See also
|
|
298
397
|
|
|
299
398
|
[Integrations](integrations.md) · [Deployment](deployment.md) · [Federation](federation.md) · [Auth](auth.md) · [Architecture](../DESIGN.md)
|