@tpsdev-ai/flair 0.26.0 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli.js +434 -50
- package/dist/doctor-client.js +47 -0
- package/dist/resources/migrations/embedding-stamp.js +83 -2
- package/dist/resources/migrations/runner.js +44 -0
- package/docs/assets/flair-cross-orchestrator.cast +33 -0
- package/docs/assets/flair-cross-orchestrator.gif +0 -0
- package/docs/assets/flair-demo.cast +18 -0
- package/docs/assets/flair-demo.gif +0 -0
- package/docs/auth.md +178 -0
- package/docs/bridges.md +291 -0
- package/docs/claude-code.md +202 -0
- package/docs/deployment.md +204 -0
- package/docs/entity-vocabulary.md +109 -0
- package/docs/federation.md +179 -0
- package/docs/integrations.md +197 -0
- package/docs/mcp-clients.md +240 -0
- package/docs/n8n-management.md +142 -0
- package/docs/n8n.md +122 -0
- package/docs/notes/adk-spike-findings-2026-05-05.md +331 -0
- package/docs/notes/mcp-agent-auth-consumer.md +229 -0
- package/docs/notes/mcp-oauth-model2.md +132 -0
- package/docs/notes/rem-ux.md +39 -0
- package/docs/openclaw.md +131 -0
- package/docs/quickstart.md +153 -0
- package/docs/releasing.md +173 -0
- package/docs/rem.md +60 -0
- package/docs/rerank-provisioning.md +67 -0
- package/docs/secrets-and-keys.md +173 -0
- package/docs/spoke-bringup.md +303 -0
- package/docs/supply-chain-policy.md +156 -0
- package/docs/system-requirements.md +42 -0
- package/docs/the-team.md +129 -0
- package/docs/troubleshooting.md +187 -0
- package/docs/upgrade.md +416 -0
- package/package.json +2 -1
|
@@ -60,6 +60,47 @@
|
|
|
60
60
|
* known, narrow gap this bounded query cannot see;
|
|
61
61
|
* resources/migration-boot.ts mitigates the common case by waiting for the
|
|
62
62
|
* embeddings engine to settle before running migrations at all.
|
|
63
|
+
*
|
|
64
|
+
* flair#807 — completion-gate false-negative on 0.10-era / cross-version
|
|
65
|
+
* stores, root-caused against the installed `@harperfast/harper@5.1.22`
|
|
66
|
+
* source (node_modules/@harperfast/harper/resources/search.ts): for an
|
|
67
|
+
* INDEXED attribute (`embeddingModel` IS `@indexed` — schemas/memory.graphql),
|
|
68
|
+
* a non-negated `not_equal`/`ne` leaf condition takes `searchByIndex`'s
|
|
69
|
+
* `index && !skipIndex` branch, which reconstructs a SYNTHETIC partial record
|
|
70
|
+
* from the SECONDARY INDEX KEY alone (`recordMatcher = { [attribute_name]:
|
|
71
|
+
* key }`, resources/search.ts ~L418) and filters against THAT — never reading
|
|
72
|
+
* the live record. On a store whose rows were written under flair 0.10.0 /
|
|
73
|
+
* Harper 5.0.x and later rewritten via `PUT /Memory` (crossing a Harper
|
|
74
|
+
* 5.0.21→5.1.22 boot), the secondary index for `embeddingModel` can lag the
|
|
75
|
+
* live, on-disk value — the filter then evaluates a STALE key that never
|
|
76
|
+
* equals `getCurrentModelId()`, matching every row regardless of its true
|
|
77
|
+
* (already-current) stamp.
|
|
78
|
+
*
|
|
79
|
+
* The fix: use `comparator: "not_equals"` (the `not_` PREFIX form, distinct
|
|
80
|
+
* from the legacy `not_equal`/`ne` ALIAS — see search.ts's
|
|
81
|
+
* `resolveComparator`/`NEGATABLE_BASE_COMPARATORS`) for the not-current leg.
|
|
82
|
+
* `prepareConditions` resolves this to `{comparator: "equals", negated:
|
|
83
|
+
* true}`, which flips `searchByIndex`'s `skipIndex` flag
|
|
84
|
+
* (`searchCondition.negated && index && !isPrimaryKey`) — this is Harper's
|
|
85
|
+
* OWN documented mechanism for "bypass the secondary index, iterate the
|
|
86
|
+
* primary store directly" (see that file's comment: "we need to consider
|
|
87
|
+
* records whose attribute value is missing from the index... so we bypass
|
|
88
|
+
* the secondary index"). That path reads the LIVE record unconditionally,
|
|
89
|
+
* immune to any index/record divergence regardless of cause (this migration,
|
|
90
|
+
* a future one, or a Harper-internal index-rebuild timing issue). Strictly
|
|
91
|
+
* MORE reliable than the index-assisted path in every case — a genuinely
|
|
92
|
+
* stale row still matches (full scan over live values), so this changes
|
|
93
|
+
* nothing about the "3/3 success" happy path, only closes the false-positive
|
|
94
|
+
* gap on migrated stores. The `equals: null` leg is unaffected (not a
|
|
95
|
+
* negated comparator — no analogous bypass exists or is needed; its
|
|
96
|
+
* behavior was already verified against real Harper per the doc above).
|
|
97
|
+
*
|
|
98
|
+
* `recheckPending()` below is a SEPARATE, generic defense-in-depth layer
|
|
99
|
+
* (wired into resources/migrations/runner.ts's completion gate) — even a
|
|
100
|
+
* migration whose countPending() query is airtight can be defeated by some
|
|
101
|
+
* OTHER, not-yet-understood counting artifact; this lets the runner PROVE
|
|
102
|
+
* (via `.get()`, a primary-key lookup — always live, never index-assisted)
|
|
103
|
+
* that a nonzero countPending() result is real before halting on it.
|
|
63
104
|
*/
|
|
64
105
|
import { databases } from "@harperfast/harper";
|
|
65
106
|
import { getModelId } from "../embeddings-provider.js";
|
|
@@ -115,16 +156,24 @@ async function regenViaHttpPut(id, existing, fetchImpl) {
|
|
|
115
156
|
*/
|
|
116
157
|
export function createEmbeddingStampMigration(getTable = defaultMemoryTable, getCurrentModelId = getModelId, regen = (id, existing) => regenViaHttpPut(id, existing, fetch)) {
|
|
117
158
|
function staleCondition() {
|
|
118
|
-
// OR-combined: `
|
|
159
|
+
// OR-combined: `not_equals <current>` catches a stale non-null model
|
|
119
160
|
// string; `equals null` catches the explicit-null state this
|
|
120
161
|
// migration's own writes leave behind on a failed regen (see the
|
|
121
162
|
// module doc above — Harper's index never sees a TRULY ABSENT
|
|
122
163
|
// property, only an explicit null).
|
|
164
|
+
//
|
|
165
|
+
// "not_equals" (NOT the legacy "not_equal" alias — see the flair#807
|
|
166
|
+
// addendum in the module doc) is load-bearing: it's the `not_` PREFIX
|
|
167
|
+
// form Harper's query engine resolves to a `negated: true` leaf, which
|
|
168
|
+
// bypasses the (on some stores, stale/desynced) secondary index and
|
|
169
|
+
// reads the live record directly. Reverting this to "not_equal" would
|
|
170
|
+
// reopen #807 on any store where the embeddingModel index lags the
|
|
171
|
+
// on-disk value.
|
|
123
172
|
return [
|
|
124
173
|
{
|
|
125
174
|
operator: "or",
|
|
126
175
|
conditions: [
|
|
127
|
-
{ attribute: "embeddingModel", comparator: "
|
|
176
|
+
{ attribute: "embeddingModel", comparator: "not_equals", value: getCurrentModelId() },
|
|
128
177
|
{ attribute: "embeddingModel", comparator: "equals", value: null },
|
|
129
178
|
],
|
|
130
179
|
},
|
|
@@ -174,5 +223,37 @@ export function createEmbeddingStampMigration(getTable = defaultMemoryTable, get
|
|
|
174
223
|
}
|
|
175
224
|
return { processed: touchedIds.length, touchedIds };
|
|
176
225
|
},
|
|
226
|
+
/**
|
|
227
|
+
* flair#807 completion-gate safety net (resources/migrations/runner.ts
|
|
228
|
+
* calls this ONLY when a nonzero countPending() would otherwise halt the
|
|
229
|
+
* migration). Re-derives up to `limit` currently-"pending" ids via the
|
|
230
|
+
* SAME staleCondition() search countPending() uses, then re-checks each
|
|
231
|
+
* one by a DIRECT `.get()` — a primary-key lookup, never index-assisted,
|
|
232
|
+
* so it can't be fooled by the same secondary-index divergence that can
|
|
233
|
+
* make the search-based count wrong. Returns counts only (never ids —
|
|
234
|
+
* matches this codebase's structural-only-disclosure discipline, e.g.
|
|
235
|
+
* resources/migrations/ledger.ts's module doc), so the runner can log a
|
|
236
|
+
* loud, actionable WARN without ever needing to know which rows.
|
|
237
|
+
*/
|
|
238
|
+
async recheckPending(limit) {
|
|
239
|
+
const table = getTable();
|
|
240
|
+
const current = getCurrentModelId();
|
|
241
|
+
const ids = [];
|
|
242
|
+
for await (const row of table.search({ conditions: staleCondition(), limit })) {
|
|
243
|
+
const id = String(row.id ?? "");
|
|
244
|
+
if (id)
|
|
245
|
+
ids.push(id);
|
|
246
|
+
}
|
|
247
|
+
let falsePositives = 0;
|
|
248
|
+
for (const id of ids) {
|
|
249
|
+
const existing = await table.get(id);
|
|
250
|
+
// A row that vanished since the search, OR whose live embeddingModel
|
|
251
|
+
// still doesn't match current, is a GENUINE pending row (or a
|
|
252
|
+
// concurrent delete) — never counted as a false positive.
|
|
253
|
+
if (existing && existing.embeddingModel === current)
|
|
254
|
+
falsePositives++;
|
|
255
|
+
}
|
|
256
|
+
return { sampled: ids.length, falsePositives };
|
|
257
|
+
},
|
|
177
258
|
};
|
|
178
259
|
}
|
|
@@ -44,6 +44,17 @@ import { join } from "node:path";
|
|
|
44
44
|
* production deployment (gate off) can never alter the real 100ms throttle.
|
|
45
45
|
*/
|
|
46
46
|
export const TEST_BATCH_DELAY_ENV = "FLAIR_MIGRATION_TEST_BATCH_DELAY_MS";
|
|
47
|
+
/**
|
|
48
|
+
* flair#807 completion-gate safety net: the maximum `finalRemaining` count
|
|
49
|
+
* the gate will EXHAUSTIVELY re-verify (every claimed-pending row, never a
|
|
50
|
+
* probabilistic subset) via `migration.recheckPending()` before declaring a
|
|
51
|
+
* halt. Bounded so this stays "cheap" (a per-boot gate check, not a bulk
|
|
52
|
+
* job) and so the net can only ever flip a gate from fail→pass when it has
|
|
53
|
+
* proven — not sampled — that the WHOLE remaining set is a counting
|
|
54
|
+
* artifact. A `finalRemaining` above this threshold skips the net entirely
|
|
55
|
+
* and halts exactly as before (a partial recheck is not proof for the rest).
|
|
56
|
+
*/
|
|
57
|
+
const GATE_SAFETY_NET_MAX_RECHECK = 50;
|
|
47
58
|
function resolveTestBatchDelayMs(env = process.env) {
|
|
48
59
|
if (!shouldRegisterSyntheticMigration(env))
|
|
49
60
|
return undefined;
|
|
@@ -356,6 +367,39 @@ async function runOneMigration(migration, envelope, deps, lock) {
|
|
|
356
367
|
}
|
|
357
368
|
let hashEnvelopeMatch = null;
|
|
358
369
|
let gateOk = finalRemaining === 0;
|
|
370
|
+
// ── flair#807 safety net: a nonzero finalRemaining can itself be a
|
|
371
|
+
// COUNTING ARTIFACT (e.g. countPending()'s search-based query reading a
|
|
372
|
+
// stale/desynced secondary index on a migrated store — see
|
|
373
|
+
// resources/migrations/embedding-stamp.ts's flair#807 doc for the
|
|
374
|
+
// concrete mechanism that motivated this) rather than real pending work.
|
|
375
|
+
// Only engages when finalRemaining is small enough to verify
|
|
376
|
+
// EXHAUSTIVELY — every claimed-pending row, not a subset — via a DIRECT
|
|
377
|
+
// per-row read (migration.recheckPending(), opt-in per Migration type).
|
|
378
|
+
// If every single one comes back already correct on direct read, the
|
|
379
|
+
// gate's nonzero count was the bug: log loudly and PASS with a WARN
|
|
380
|
+
// instead of halting forever on an artifact a real re-run can never
|
|
381
|
+
// clear (countPending() would report the same wrong number next boot).
|
|
382
|
+
if (!gateOk &&
|
|
383
|
+
finalRemaining > 0 &&
|
|
384
|
+
finalRemaining <= GATE_SAFETY_NET_MAX_RECHECK &&
|
|
385
|
+
typeof migration.recheckPending === "function") {
|
|
386
|
+
try {
|
|
387
|
+
const recheck = await migration.recheckPending(finalRemaining);
|
|
388
|
+
if (recheck.sampled === finalRemaining && recheck.falsePositives === recheck.sampled) {
|
|
389
|
+
console.warn(`[migrations] ${migration.id}: completion gate countPending() reported ${finalRemaining} pending, ` +
|
|
390
|
+
`but a direct per-record read of ALL ${recheck.sampled} claimed-pending rows found every one already ` +
|
|
391
|
+
`correct. Treating this as a counting artifact (search/index divergence), NOT real pending work — ` +
|
|
392
|
+
`passing the gate with this WARN instead of halting.`);
|
|
393
|
+
gateOk = true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
// The safety net itself must never crash or worsen the gate outcome —
|
|
398
|
+
// a failure here just leaves gateOk as countPending() computed it.
|
|
399
|
+
console.warn(`[migrations] ${migration.id}: completion-gate safety net recheckPending() threw (` +
|
|
400
|
+
`${err?.message ?? String(err)}) — proceeding with the original gate result.`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
359
403
|
if (gateOk && posture.gate === "count+full-envelope") {
|
|
360
404
|
try {
|
|
361
405
|
const postEnvelope = await computeCorpusEnvelope(deps.getTable, deps.now);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{"version": 3, "term": {"cols": 96, "rows": 28}, "timestamp": 1778889600, "command": "/tmp/flair-cross-orchestrator-script.sh", "env": {"SHELL": "/bin/zsh"}}
|
|
2
|
+
[0.245, "o", "\u001b[H\u001b[J"]
|
|
3
|
+
[0.001, "o", " Flair \u00b7 same identity, every orchestrator\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n"]
|
|
4
|
+
[1.3, "o", "$ # One agent identity. One memory store. Three orchestrators.\r\n"]
|
|
5
|
+
[1.2, "o", "$ flair init --client all --agent flint\r\n"]
|
|
6
|
+
[0.5, "o", " \u001b[32m\u2713\u001b[0m Claude Code wired (~/.claude/mcp.json)\r\n"]
|
|
7
|
+
[0.18, "o", " \u001b[32m\u2713\u001b[0m Codex CLI wired (~/.codex/config.toml)\r\n"]
|
|
8
|
+
[0.18, "o", " \u001b[32m\u2713\u001b[0m Gemini CLI wired (~/.gemini/settings.json)\r\n\r\n"]
|
|
9
|
+
[1.0, "o", " Each MCP client now connects to: http://127.0.0.1:9926\r\n"]
|
|
10
|
+
[0.0, "o", " Ed25519 keypair shared: ~/.flair/keys/flint.ed25519\r\n\r\n"]
|
|
11
|
+
[2.2, "o", "$ # \u2500\u2500 Claude Code: agent writes a memory \u2500\u2500\r\n"]
|
|
12
|
+
[0.7, "o", "$ claude\r\n"]
|
|
13
|
+
[0.5, "o", "\u001b[36mflint>\u001b[0m \u001b[2mremember: we picked Postgres for replication-lag reasons; ScyllaDB ruled out by ops complexity\u001b[0m\r\n"]
|
|
14
|
+
[1.8, "o", " \u001b[32m\u2713\u001b[0m wrote memory mem_2026-05-14_3f9a (durability=persistent)\r\n\r\n"]
|
|
15
|
+
[1.5, "o", "$ # \u2500\u2500 New session. Different orchestrator. Same identity. \u2500\u2500\r\n"]
|
|
16
|
+
[1.0, "o", "$ codex\r\n"]
|
|
17
|
+
[0.5, "o", "\u001b[36mflint>\u001b[0m \u001b[2mwhat database did we pick and why?\u001b[0m\r\n"]
|
|
18
|
+
[1.5, "o", " \u001b[32m\u2713\u001b[0m bootstrap: 287 memories loaded for agent flint\r\n"]
|
|
19
|
+
[0.4, "o", " Postgres \u2014 chosen for replication-lag reasons.\r\n"]
|
|
20
|
+
[0.0, "o", " ScyllaDB was ruled out by ops complexity.\r\n"]
|
|
21
|
+
[0.0, "o", " \u001b[2m(source: mem_2026-05-14_3f9a, written via Claude Code 4m ago)\u001b[0m\r\n\r\n"]
|
|
22
|
+
[2.0, "o", "$ # \u2500\u2500 New session. Another orchestrator. Same identity. \u2500\u2500\r\n"]
|
|
23
|
+
[1.0, "o", "$ gemini\r\n"]
|
|
24
|
+
[0.5, "o", "\u001b[36mflint>\u001b[0m \u001b[2msummarize our recent infra decisions\u001b[0m\r\n"]
|
|
25
|
+
[1.7, "o", " \u001b[32m\u2713\u001b[0m bootstrap: same 287 memories, same soul, same Ed25519 identity\r\n"]
|
|
26
|
+
[0.5, "o", " \u2022 Postgres for replication-lag (2026-05-14)\r\n"]
|
|
27
|
+
[0.0, "o", " \u2022 Federation pair: local\u2192Fabric via Ed25519 (2026-05-05)\r\n"]
|
|
28
|
+
[0.0, "o", " \u2022 REM nightly cycle ships in v0.9.0 (2026-05-14)\r\n\r\n"]
|
|
29
|
+
[2.5, "o", " \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n"]
|
|
30
|
+
[0.0, "o", " No SaaS in the middle. No vendor lock.\r\n"]
|
|
31
|
+
[0.0, "o", " Memory follows the agent across orchestrators.\r\n"]
|
|
32
|
+
[0.0, "o", "\r\n github.com/tpsdev-ai/flair \u00b7 Apache 2.0\r\n\r\n"]
|
|
33
|
+
[5.0, "x", "0"]
|
|
Binary file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{"version":3,"term":{"cols":80,"rows":24},"timestamp":1778359292,"command":"/tmp/flair-demo-script.sh","env":{"SHELL":"/bin/zsh"}}
|
|
2
|
+
[0.245, "o", "\u001b[H\u001b[J"]
|
|
3
|
+
[0.001, "o", " Flair · identity infrastructure for AI agents\r\n ─────────────────────────────────────────────\r\n\r\n"]
|
|
4
|
+
[1.520, "o", "$ # Agent writes a memory\r\n"]
|
|
5
|
+
[0.628, "o", "$ flair memory add --agent flint-demo --content \"Postgres replication lag spiked when we added the read-replica\"\r\n"]
|
|
6
|
+
[0.796, "o", "{\r\n \"ok\": true\r\n}\r\n"]
|
|
7
|
+
[1.411, "o", "\r\n"]
|
|
8
|
+
[0.000, "o", "$ # Six months later — find it by meaning, not keywords\r\n"]
|
|
9
|
+
[0.509, "o", "$ flair search \"why did our database fall behind during scaling\" --agent flint-demo\r\n"]
|
|
10
|
+
[0.824, "o", " Postgres replication lag spiked when we added the read-replica\r\n (2026-05-09)\r\n"]
|
|
11
|
+
[0.000, "o", "\r\n"]
|
|
12
|
+
[2.054, "o", "\r\n$ # Same memory, every harness.\r\n"]
|
|
13
|
+
[0.630, "o", "$ # Claude Code · Cursor · Codex CLI · Gemini CLI · Continue.dev · Goose\r\n$ # all via @tpsdev-ai/flair-mcp\r\n$ # LangGraph @tpsdev-ai/langgraph-flair\r\n$ # OpenClaw @tpsdev-ai/openclaw-flair\r\n$ # n8n @tpsdev-ai/n8n-nodes-flair\r\n$ # Hermes Agent packages/hermes-flair (Python)\r\n$ # Pi agent @tpsdev-ai/pi-flair\r\n"]
|
|
14
|
+
[2.125, "o", "\r\n"]
|
|
15
|
+
[0.000, "o", " ─────────────────────────────────────────────\r\n"]
|
|
16
|
+
[0.000, "o", " Apache 2.0 · self-hosted · no SaaS in the middle\r\n github.com/tpsdev-ai/flair\r\n"]
|
|
17
|
+
[0.000, "o", "\r\n"]
|
|
18
|
+
[5.147, "x", "0"]
|
|
Binary file
|
package/docs/auth.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Authentication & Authorization
|
|
2
|
+
|
|
3
|
+
Flair supports three authentication methods, from simplest to most enterprise-ready.
|
|
4
|
+
|
|
5
|
+
## Auth across surfaces (read this first)
|
|
6
|
+
|
|
7
|
+
Different surfaces authenticate differently. The model in one place:
|
|
8
|
+
|
|
9
|
+
| Surface | Auth | Scope | Notes |
|
|
10
|
+
|---------|------|-------|-------|
|
|
11
|
+
| **CLI / SDK clients** (`flair`, `flair-client`) | **Ed25519 per-agent** | Own writes; org-wide non-private reads | Default, recommended. Signs every request; an agent can never write as another, and reads are scoped to its own memories (any visibility) plus every other agent's **non-private** memories on the instance. |
|
|
12
|
+
| **MCP server** (`@tpsdev-ai/flair-mcp`) | **Ed25519 per-agent** | Own writes; org-wide non-private reads | Same per-agent identity as the CLI — key auto-resolved from `~/.flair/keys/<agent>.key`. |
|
|
13
|
+
| **OpenClaw / pi / Hermes plugins** | **Ed25519 per-agent** | Own writes; org-wide non-private reads | Same secure path; auto-detect agent identity. |
|
|
14
|
+
| **`n8n-nodes-flair`** | **Harper admin-password Basic auth** | ⚠️ **Whole instance, read + write, including `private`** | The admin credential bypasses agent scoping entirely — every workflow gets read/write on the *entire* memory store, including other agents' `private`-marked memories, not just the org-wide non-private pool an Ed25519 identity would see. |
|
|
15
|
+
|
|
16
|
+
**The default, secure path is Ed25519 per-agent** (see below): each agent holds its own key and signs every request. That guarantees **write isolation** — no agent can write as another — and identity-verified reads. It does **not** mean cross-agent reads are refused: within one Flair instance (one org), any verified agent can read any other agent's memory unless that memory is explicitly marked `visibility: private` (owner-only). The hard access boundary is the **federation edge** (a separate Flair instance / org), not reads within an instance. See [SECURITY.md](../SECURITY.md) for the full model. Use Ed25519 per-agent everywhere you can regardless — it's still what makes writes and identity trustworthy.
|
|
17
|
+
|
|
18
|
+
### Known limitation — n8n uses admin-password Basic auth
|
|
19
|
+
|
|
20
|
+
The `n8n-nodes-flair` community node authenticates with the Harper **admin password** (Basic auth), which bypasses agent scoping entirely — not just the org-wide non-private reads an Ed25519 identity already gets. Concretely, an n8n workflow using the admin credential can write memories under *any* agent's identity (no per-agent write isolation) and can read *every* memory including ones marked `visibility: private` (which stay owner-only under normal Ed25519 auth). This is acceptable only when **all** of the following hold:
|
|
21
|
+
|
|
22
|
+
- The n8n instance is single-tenant and operator-controlled.
|
|
23
|
+
- Workflow inputs are trusted (your own CRM, your own webhook source).
|
|
24
|
+
- Write-forgery and full read access (including `private` memories) are acceptable for the use case.
|
|
25
|
+
|
|
26
|
+
If any of those don't hold, use Flair's CLI / SDK clients (which support per-agent Ed25519 today) and wait for the n8n credential to gain Ed25519 per-agent auth (planned). Full guidance in [docs/n8n.md](n8n.md#security).
|
|
27
|
+
|
|
28
|
+
## Ed25519 Agent Auth (Default)
|
|
29
|
+
|
|
30
|
+
Every agent has an Ed25519 key pair. Requests are signed with `agentId:timestamp:nonce:METHOD:/path` and verified against the agent's registered public key. 30-second replay window with nonce deduplication.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Register an agent
|
|
34
|
+
flair agent add myagent
|
|
35
|
+
|
|
36
|
+
# The key is stored at ~/.flair/keys/myagent.key
|
|
37
|
+
# Requests are signed automatically by flair-client and the MCP server
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
This is the default and recommended auth for single-instance deployments.
|
|
41
|
+
|
|
42
|
+
## Deployment shapes: personal vs org
|
|
43
|
+
|
|
44
|
+
Flair has no `mode`/`shape` config setting — the shape you get is emergent from *how you provision principals*, not something you declare:
|
|
45
|
+
|
|
46
|
+
- **Personal (the default).** `flair init` mints ONE agent identity and wires that same `FLAIR_AGENT_ID` into every MCP client it configures (Claude Code, Codex, Gemini, Cursor). One human driving several AI clients ends up with one canonical principal and one Ed25519 keypair — all clients share ownership of the same memory. This is intentional, not a limitation: all clients see each other's private rows (one human's memory, one view), a fact re-asserted from two different clients dedups to one memory, and usage counting treats the principal as one contributor.
|
|
47
|
+
- **Org (multiple real agents).** `flair agent add <id>` mints a distinct principal — its own keypair, its own ownership boundary — for each real agent that should be a separate identity. Wire each principal's own `FLAIR_AGENT_ID` into its own client(s).
|
|
48
|
+
|
|
49
|
+
Nothing in flair validates which shape you're in; a personal install that later grows into an org just runs `flair agent add` for the new distinct identities it needs.
|
|
50
|
+
|
|
51
|
+
### Authorship: which client wrote a row
|
|
52
|
+
|
|
53
|
+
The personal shape's one shared principal means `agentId` alone can't answer "was this written by Claude Code or Codex?" — every write it receives has the same owner. `flair init`'s per-client wiring closes that gap by setting an additional env var per client: `FLAIR_CLIENT` (`"claude-code"` / `"codex"` / `"gemini"` / `"cursor"`). `flair-client` and `flair-mcp` forward it on memory writes as `claimedClient`, and the server folds it into the write's `provenance` blob as `claimed.client` — self-reported, unverified metadata recording *which tool* authored a row, alongside the existing `claimed.model`.
|
|
54
|
+
|
|
55
|
+
This is deliberately in `claimed`, never `verified`: it carries **zero authority**. It is never read for access control, read-scope, attribution weighting, or dedup decisions — it exists purely for audit/analysis. Absent on any install that predates this field (no backfill, no migration) and absent whenever a client doesn't set `FLAIR_CLIENT`.
|
|
56
|
+
|
|
57
|
+
The native `/mcp` OAuth surface (see below) doesn't need any client-side wiring at all: the handler stamps `claimed.client` directly from the OAuth token's verified `client_id` claim — the server-generated `flair_cl_...` machine id assigned at Dynamic Client Registration, **not** the user-supplied `client_name` (which the registering client fully controls and could set to anything). Using `client_id` keeps the stamp a stable, server-verified label even though it still only carries `claimed`-level (unverified-by-content) authority.
|
|
58
|
+
|
|
59
|
+
## OAuth 2.1
|
|
60
|
+
|
|
61
|
+
Flair includes a built-in OAuth 2.1 authorization server for client integrations (e.g., Claude connecting to Flair as an MCP server).
|
|
62
|
+
|
|
63
|
+
### Dynamic Client Registration
|
|
64
|
+
|
|
65
|
+
Clients register automatically on first connection:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
POST /OAuthRegister
|
|
69
|
+
Content-Type: application/json
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
"client_name": "Claude Desktop",
|
|
73
|
+
"redirect_uris": ["http://localhost:3000/callback"],
|
|
74
|
+
"grant_types": ["authorization_code"],
|
|
75
|
+
"response_types": ["code"],
|
|
76
|
+
"token_endpoint_auth_method": "none"
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Returns `client_id` and `client_secret` (if applicable).
|
|
81
|
+
|
|
82
|
+
### Authorization Code Flow with PKCE
|
|
83
|
+
|
|
84
|
+
Standard OAuth 2.1 authorization code flow:
|
|
85
|
+
|
|
86
|
+
1. Client generates PKCE `code_verifier` and `code_challenge`
|
|
87
|
+
2. Client redirects user to `GET /OAuthAuthorize?client_id=...&code_challenge=...&redirect_uri=...`
|
|
88
|
+
3. User approves (or auto-approves for trusted clients)
|
|
89
|
+
4. Flair redirects back with authorization code
|
|
90
|
+
5. Client exchanges code for access token at `POST /OAuthToken`
|
|
91
|
+
|
|
92
|
+
### Endpoints
|
|
93
|
+
|
|
94
|
+
| Endpoint | Method | Description |
|
|
95
|
+
|----------|--------|-------------|
|
|
96
|
+
| `/OAuthRegister` | POST | Dynamic client registration |
|
|
97
|
+
| `/OAuthAuthorize` | GET/POST | Authorization endpoint |
|
|
98
|
+
| `/OAuthToken` | POST | Token endpoint |
|
|
99
|
+
| `/.well-known/oauth-authorization-server` | GET | Server metadata |
|
|
100
|
+
|
|
101
|
+
## XAA (Enterprise-Managed Authorization)
|
|
102
|
+
|
|
103
|
+
For organizations using an Identity Provider (IdP), XAA lets the IdP control who accesses Flair and with what scopes.
|
|
104
|
+
|
|
105
|
+
### How It Works
|
|
106
|
+
|
|
107
|
+
1. User authenticates with the organization's IdP (Google, Azure AD, Okta)
|
|
108
|
+
2. IdP issues an ID token (JWT) with user identity and group claims
|
|
109
|
+
3. Client sends the ID token to Flair's token endpoint using the `jwt-bearer` grant type
|
|
110
|
+
4. Flair validates the JWT signature, checks issuer/domain, maps to a Principal, and issues a scoped access token
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
POST /OAuthToken
|
|
114
|
+
Content-Type: application/x-www-form-urlencoded
|
|
115
|
+
|
|
116
|
+
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
|
|
117
|
+
&assertion=<id-token-from-idp>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### IdP Configuration
|
|
121
|
+
|
|
122
|
+
Register an IdP with the CLI:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
flair idp add \
|
|
126
|
+
--name "Google Workspace" \
|
|
127
|
+
--issuer "https://accounts.google.com" \
|
|
128
|
+
--jwks-uri "https://www.googleapis.com/oauth2/v3/certs" \
|
|
129
|
+
--required-domain "yourcompany.com"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Supported IdPs
|
|
133
|
+
|
|
134
|
+
| IdP | Issuer | Domain Claim |
|
|
135
|
+
|-----|--------|-------------|
|
|
136
|
+
| Google Workspace | `https://accounts.google.com` | `hd` (hosted domain) |
|
|
137
|
+
| Azure AD (Entra) | `https://login.microsoftonline.com/{tenant}/v2.0` | `tid` (tenant ID) |
|
|
138
|
+
| Okta / Auth0 | `https://{org}.okta.com` | Issuer-scoped |
|
|
139
|
+
|
|
140
|
+
### Scopes
|
|
141
|
+
|
|
142
|
+
| Scope | Description |
|
|
143
|
+
|-------|-------------|
|
|
144
|
+
| `memory:read` | Read memories and search |
|
|
145
|
+
| `memory:write` | Write and delete memories |
|
|
146
|
+
| `memory:admin` | Memory maintenance operations |
|
|
147
|
+
| `principal:read` | View principal list |
|
|
148
|
+
| `principal:admin` | Create/modify/disable principals |
|
|
149
|
+
| `connector:admin` | Manage OAuth clients and IdPs |
|
|
150
|
+
|
|
151
|
+
### JIT Provisioning
|
|
152
|
+
|
|
153
|
+
First-time IdP users are automatically created as `unverified` principals. An admin can promote them to `verified` or `admin` via:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
flair principal promote <principal-id>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### CLI Reference
|
|
160
|
+
|
|
161
|
+
| Command | Description |
|
|
162
|
+
|---------|-------------|
|
|
163
|
+
| `flair idp add` | Register an IdP |
|
|
164
|
+
| `flair idp list` | List configured IdPs |
|
|
165
|
+
| `flair idp remove <id>` | Remove an IdP |
|
|
166
|
+
| `flair idp test <id>` | Test IdP connectivity |
|
|
167
|
+
|
|
168
|
+
## Web Admin
|
|
169
|
+
|
|
170
|
+
The web admin at `/AdminDashboard` provides a UI for managing:
|
|
171
|
+
|
|
172
|
+
- **Principals:** view, promote, disable agents and users
|
|
173
|
+
- **Connectors:** OAuth clients and active sessions
|
|
174
|
+
- **IdP:** enterprise identity provider configuration
|
|
175
|
+
- **Memory:** browse and search stored memories
|
|
176
|
+
- **Instance:** federation status, peer connections
|
|
177
|
+
|
|
178
|
+
Access requires admin-level authentication (Basic auth with the Harper admin password).
|