@tpsdev-ai/flair 0.20.1 → 0.22.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.
Files changed (104) hide show
  1. package/README.md +39 -11
  2. package/SECURITY.md +52 -20
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +2235 -260
  5. package/dist/deploy.js +96 -15
  6. package/dist/doctor-client.js +312 -0
  7. package/dist/fleet-presence.js +98 -0
  8. package/dist/fleet-verify.js +291 -0
  9. package/dist/install/clients.js +18 -0
  10. package/dist/mcp-client-assertion.js +364 -0
  11. package/dist/probe.js +97 -0
  12. package/dist/rem/restore.js +1 -1
  13. package/dist/rem/runner.js +82 -12
  14. package/dist/resources/Admin.js +1 -1
  15. package/dist/resources/AdminConnectors.js +1 -1
  16. package/dist/resources/AdminDashboard.js +1 -1
  17. package/dist/resources/AdminIdp.js +1 -1
  18. package/dist/resources/AdminInstance.js +1 -1
  19. package/dist/resources/AdminMemory.js +2 -2
  20. package/dist/resources/AdminPrincipals.js +1 -1
  21. package/dist/resources/Agent.js +14 -0
  22. package/dist/resources/AgentCard.js +1 -1
  23. package/dist/resources/AgentSeed.js +1 -1
  24. package/dist/resources/AttentionQuery.js +356 -0
  25. package/dist/resources/Credential.js +11 -1
  26. package/dist/resources/Federation.js +121 -2
  27. package/dist/resources/Integration.js +1 -1
  28. package/dist/resources/MCPClientMetadata.js +88 -0
  29. package/dist/resources/Memory.js +127 -22
  30. package/dist/resources/MemoryBootstrap.js +441 -85
  31. package/dist/resources/MemoryGrant.js +1 -1
  32. package/dist/resources/MemoryReflect.js +105 -49
  33. package/dist/resources/MemoryUsage.js +104 -0
  34. package/dist/resources/OAuth.js +69 -5
  35. package/dist/resources/OrgEvent.js +11 -0
  36. package/dist/resources/OrgEventCatchup.js +1 -1
  37. package/dist/resources/Presence.js +268 -17
  38. package/dist/resources/RecordUsage.js +230 -0
  39. package/dist/resources/Relationship.js +108 -26
  40. package/dist/resources/SemanticSearch.js +88 -320
  41. package/dist/resources/SkillScan.js +15 -0
  42. package/dist/resources/Soul.js +14 -0
  43. package/dist/resources/WorkspaceLatest.js +1 -1
  44. package/dist/resources/WorkspaceState.js +12 -1
  45. package/dist/resources/agent-auth.js +61 -3
  46. package/dist/resources/agentcard-fields.js +2 -2
  47. package/dist/resources/auth-middleware.js +42 -14
  48. package/dist/resources/bm25-filter.js +1 -1
  49. package/dist/resources/bm25.js +13 -7
  50. package/dist/resources/collision-lib.js +157 -0
  51. package/dist/resources/dedup.js +2 -2
  52. package/dist/resources/ed25519-auth.js +2 -2
  53. package/dist/resources/embeddings-boot.js +149 -0
  54. package/dist/resources/embeddings-provider.js +364 -106
  55. package/dist/resources/entity-vocab.js +139 -0
  56. package/dist/resources/federation-nonce-store.js +195 -0
  57. package/dist/resources/health.js +97 -6
  58. package/dist/resources/instance-identity.js +53 -0
  59. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  60. package/dist/resources/mcp-handler.js +1 -1
  61. package/dist/resources/mcp-tools.js +88 -10
  62. package/dist/resources/memory-bootstrap-lib.js +1 -1
  63. package/dist/resources/memory-read-scope.js +58 -71
  64. package/dist/resources/memory-reflect-lib.js +289 -0
  65. package/dist/resources/memory-visibility.js +37 -0
  66. package/dist/resources/migration-boot.js +143 -0
  67. package/dist/resources/migrations/dir-safety.js +71 -0
  68. package/dist/resources/migrations/embedding-stamp.js +178 -0
  69. package/dist/resources/migrations/envelope.js +43 -0
  70. package/dist/resources/migrations/export.js +38 -0
  71. package/dist/resources/migrations/ledger.js +55 -0
  72. package/dist/resources/migrations/lock.js +154 -0
  73. package/dist/resources/migrations/progress.js +31 -0
  74. package/dist/resources/migrations/registry.js +36 -0
  75. package/dist/resources/migrations/risk-policy.js +23 -0
  76. package/dist/resources/migrations/runner.js +456 -0
  77. package/dist/resources/migrations/snapshot.js +127 -0
  78. package/dist/resources/migrations/source-fields.js +93 -0
  79. package/dist/resources/migrations/space.js +73 -0
  80. package/dist/resources/migrations/state.js +64 -0
  81. package/dist/resources/migrations/status.js +39 -0
  82. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  83. package/dist/resources/migrations/types.js +9 -0
  84. package/dist/resources/presence-internal.js +29 -0
  85. package/dist/resources/provenance.js +50 -0
  86. package/dist/resources/rate-limiter.js +8 -1
  87. package/dist/resources/scoring.js +124 -5
  88. package/dist/resources/semantic-retrieval-core.js +317 -0
  89. package/dist/version-check.js +167 -0
  90. package/dist/version-handshake.js +122 -0
  91. package/package.json +4 -5
  92. package/schemas/agent.graphql +7 -0
  93. package/schemas/event.graphql +5 -0
  94. package/schemas/federation.graphql +12 -0
  95. package/schemas/memory.graphql +82 -0
  96. package/schemas/schema.graphql +5 -43
  97. package/schemas/workspace.graphql +3 -0
  98. package/dist/resources/IngestEvents.js +0 -162
  99. package/dist/resources/IssueTokens.js +0 -19
  100. package/dist/resources/ObsAgentSnapshot.js +0 -13
  101. package/dist/resources/ObsEventFeed.js +0 -13
  102. package/dist/resources/ObsOffice.js +0 -19
  103. package/dist/resources/ObservationCenter.js +0 -23
  104. package/ui/observation-center.html +0 -385
@@ -0,0 +1,195 @@
1
+ /**
2
+ * federation-nonce-store.ts — persistent backing for the federation
3
+ * anti-replay NonceStore (federation-edge-hardening slice 4).
4
+ *
5
+ * The gap: `federationNonceStore` in Federation.ts was a bare in-memory Map
6
+ * (`createNonceStore()` in federation-crypto.ts). On process restart it's
7
+ * wiped, so a signed federation request captured within its ±30s freshness
8
+ * window (verifyBodySignatureFresh) could be replayed once, post-restart.
9
+ * This module closes that window by backing the SAME `NonceStore` interface
10
+ * with the `Nonce` Harper table, while keeping every method synchronous —
11
+ * `verifyBodySignatureFresh` and its 2 call sites in Federation.ts are
12
+ * UNCHANGED (they still call `.has()` / `.set()` / `.evict()` with no
13
+ * `await`), so the entire existing federation-crypto-replay test suite
14
+ * (all synchronous assertions) keeps working unmodified.
15
+ *
16
+ * How the sync interface stays sync against an inherently-async DB API:
17
+ * - `has()` / `evict()` read/mutate an in-memory Map (via the existing
18
+ * `createNonceStore()`), exactly as before — no DB round trip.
19
+ * - `set()` updates that same in-memory Map synchronously (so replay
20
+ * detection within THIS process's lifetime is unchanged), and fires an
21
+ * async `Nonce.put()` in the background (not awaited) to persist the
22
+ * nonce for the NEXT restart. A failed/slow persist never blocks or
23
+ * fails request handling — see the perf/durability tradeoff note below.
24
+ * - `hydrate()` is the new piece: an explicit async step that loads
25
+ * not-yet-expired rows from the `Nonce` table into the in-memory Map.
26
+ * Call it once, on startup, BEFORE the store starts guarding live
27
+ * traffic — that's what makes a nonce recorded by the PREVIOUS process
28
+ * visible to a FRESH store instance after a restart (the whole point).
29
+ *
30
+ * Known tradeoff (flagged, not fixed here — would require making NonceStore
31
+ * async, which ripples into ~40 synchronous call sites across
32
+ * federation-crypto-replay.test.ts + federation-sync-e2e.test.ts):
33
+ * `set()`'s persistence is fire-and-forget. A crash in the narrow gap
34
+ * between "response sent" and "async put() resolved" can still lose that
35
+ * one nonce — narrowing the replay window to that gap, not eliminating it
36
+ * outright. Defense-in-depth: the ±30s freshness check + signature
37
+ * verification are still the primary guards; this closes the common case
38
+ * (graceful restart / redeploy), not the crash-mid-request edge case.
39
+ */
40
+ import { createNonceStore } from "./federation-crypto.js";
41
+ /** 2x the standard 30s freshness window — matches the in-memory `evict()` cutoff already used by verifyBodySignatureFresh. */
42
+ export const DEFAULT_RETENTION_MS = 60_000;
43
+ async function resolveDb(opts, cache) {
44
+ if (opts.db)
45
+ return opts.db;
46
+ if (!cache.db) {
47
+ cache.db = import("@harperfast/harper").then((h) => h.databases);
48
+ }
49
+ return cache.db;
50
+ }
51
+ /**
52
+ * Create a `NonceStore` backed by the `Nonce` Harper table.
53
+ *
54
+ * Wraps the existing in-memory `createNonceStore()` (unchanged behavior for
55
+ * `has()`/`set()`/`evict()` within this process) and adds:
56
+ * - background persistence of newly-set nonces (`set()`)
57
+ * - an explicit `hydrate()` to reload previously-persisted nonces (used
58
+ * at startup, and by tests simulating a restart with a fresh instance)
59
+ */
60
+ export function createPersistentNonceStore(opts = {}) {
61
+ const memory = createNonceStore();
62
+ const dbCache = {};
63
+ return {
64
+ has(key) {
65
+ return memory.has(key);
66
+ },
67
+ evict(olderThan) {
68
+ memory.evict(olderThan);
69
+ },
70
+ set(key, value) {
71
+ memory.set(key, value);
72
+ // Fire-and-forget persistence — see module header for the tradeoff.
73
+ void (async () => {
74
+ try {
75
+ const db = await resolveDb(opts, dbCache);
76
+ await db.flair.Nonce.put({ id: key, seenAt: value });
77
+ }
78
+ catch (err) {
79
+ console.error("[federation-nonce-store] failed to persist nonce (in-memory record already made — this process's replay guard is unaffected):", err?.message ?? err);
80
+ }
81
+ })();
82
+ },
83
+ async hydrate(hydrateOpts) {
84
+ const retentionMs = hydrateOpts?.retentionMs ?? DEFAULT_RETENTION_MS;
85
+ const now = hydrateOpts?.now ?? Date.now();
86
+ const cutoff = now - retentionMs;
87
+ try {
88
+ const db = await resolveDb(opts, dbCache);
89
+ for await (const row of db.flair.Nonce.search()) {
90
+ if (row?.id != null && typeof row.seenAt === "number" && row.seenAt >= cutoff) {
91
+ memory.set(String(row.id), row.seenAt);
92
+ }
93
+ }
94
+ }
95
+ catch (err) {
96
+ console.error("[federation-nonce-store] failed to hydrate from Nonce table (starting with an empty replay guard):", err?.message ?? err);
97
+ }
98
+ },
99
+ };
100
+ }
101
+ // ─── Eviction sweep — periodic Nonce table hygiene ─────────────────────────
102
+ //
103
+ // Mirrors federation-cleanup.ts's 5-min setInterval hub-pattern. Decoupled
104
+ // from the in-memory `evict()` above (which runs per-request and only trims
105
+ // the local Map): this sweep deletes rows from the `Nonce` TABLE itself so
106
+ // it doesn't grow unbounded across restarts. No native TTL in this repo —
107
+ // same app-managed-sweep idiom as PairingToken cleanup.
108
+ //
109
+ // Runs on every instance (hub AND spoke) — unlike PairingToken cleanup
110
+ // (hub-only, since only hubs issue pairing tokens), both FederationPair and
111
+ // FederationSync can be the RECEIVING side of a signed request on either
112
+ // role, so both roles accumulate rows in the Nonce table and both need the
113
+ // sweep.
114
+ const NONCE_CLEANUP_INTERVAL_MS = 300_000; // 5 minutes
115
+ let nonceCleanupTimer = null;
116
+ /**
117
+ * Core sweep logic — exposed for unit testing. Deletes `Nonce` rows with
118
+ * `seenAt` older than `retentionMs` (default: 2x the 30s freshness window).
119
+ */
120
+ export async function runNonceCleanupTick(opts = {}) {
121
+ let db;
122
+ if (opts.db) {
123
+ db = opts.db;
124
+ }
125
+ else {
126
+ const harper = await import("@harperfast/harper");
127
+ db = harper.databases;
128
+ }
129
+ const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
130
+ const now = opts.now ?? Date.now();
131
+ const cutoff = now - retentionMs;
132
+ const toDelete = [];
133
+ let scanned = 0;
134
+ try {
135
+ for await (const row of db.flair.Nonce.search()) {
136
+ scanned++;
137
+ if (typeof row?.seenAt === "number" && row.seenAt < cutoff) {
138
+ toDelete.push(row.id);
139
+ }
140
+ }
141
+ }
142
+ catch (err) {
143
+ console.error("[federation-nonce-store] failed to query Nonce records:", err?.message ?? err);
144
+ return { deleted: 0, scanned: 0 };
145
+ }
146
+ let deleted = 0;
147
+ for (const id of toDelete) {
148
+ try {
149
+ await db.flair.Nonce.delete(id);
150
+ deleted++;
151
+ }
152
+ catch (err) {
153
+ console.error("[federation-nonce-store] failed to delete expired nonce:", err?.message ?? err);
154
+ }
155
+ }
156
+ return { deleted, scanned };
157
+ }
158
+ /**
159
+ * Initialise the periodic Nonce-table eviction sweep (5-min cadence).
160
+ *
161
+ * In test environments, callers pass a mock `db` via `opts` so this module
162
+ * never imports @harperfast/harper at the top level (mirrors
163
+ * federation-cleanup.ts's `initFederationCleanup`).
164
+ */
165
+ export async function initNonceStoreCleanup(opts) {
166
+ const immediate = opts?.immediateTick ?? true;
167
+ let db;
168
+ if (opts?.db) {
169
+ db = opts.db;
170
+ }
171
+ else {
172
+ try {
173
+ const harper = await import("@harperfast/harper");
174
+ db = harper.databases;
175
+ }
176
+ catch (err) {
177
+ console.error("[federation-nonce-store] failed to load @harperfast/harper:", err?.message ?? err);
178
+ return;
179
+ }
180
+ }
181
+ const retentionMs = opts?.retentionMs ?? DEFAULT_RETENTION_MS;
182
+ console.log("[federation-nonce-store] starting nonce eviction sweep (5-min cadence)");
183
+ if (nonceCleanupTimer)
184
+ clearInterval(nonceCleanupTimer);
185
+ nonceCleanupTimer = setInterval(() => {
186
+ runNonceCleanupTick({ db, retentionMs }).catch((err) => {
187
+ console.error("[federation-nonce-store] cleanup tick error:", err?.message ?? err);
188
+ });
189
+ }, NONCE_CLEANUP_INTERVAL_MS);
190
+ if (immediate) {
191
+ runNonceCleanupTick({ db, retentionMs }).catch((err) => {
192
+ console.error("[federation-nonce-store] initial cleanup tick error:", err?.message ?? err);
193
+ });
194
+ }
195
+ }
@@ -1,8 +1,11 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
- import { promises as fsp } from "node:fs";
2
+ import { promises as fsp, existsSync, readFileSync } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { getRerankStatus } from "./rerank-provider.js";
7
+ import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
8
+ import { getMigrationStatusSnapshot } from "./migrations/status.js";
6
9
  const db = databases;
7
10
  const redactHome = (p) => {
8
11
  const home = homedir();
@@ -17,6 +20,35 @@ const exists = async (path) => {
17
20
  return false;
18
21
  }
19
22
  };
23
+ /**
24
+ * Resolve the runtime package version from package.json (duplicated from
25
+ * admin-layout.ts / AdminInstance.ts — same "keep in sync" idiom as the
26
+ * federation-crypto helpers in src/cli.ts; see those two files' comments for
27
+ * why it's inlined rather than imported). `process.env.npm_package_version`
28
+ * is only populated inside `npm run`, so reading package.json relative to
29
+ * THIS running module is the only way to report the version of the code
30
+ * that's actually executing — which is exactly what `flair upgrade`'s
31
+ * post-restart verification (flair#635) needs: proof the NEW package is
32
+ * what's serving, not just what landed on disk before a restart.
33
+ */
34
+ function resolveVersion() {
35
+ try {
36
+ const here = dirname(fileURLToPath(import.meta.url));
37
+ const candidates = [
38
+ join(here, "..", "..", "package.json"),
39
+ join(here, "..", "package.json"),
40
+ ];
41
+ for (const p of candidates) {
42
+ if (existsSync(p)) {
43
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
44
+ if (pkg.version)
45
+ return pkg.version;
46
+ }
47
+ }
48
+ }
49
+ catch { /* fall through */ }
50
+ return process.env.npm_package_version ?? "dev";
51
+ }
20
52
  /**
21
53
  * Health endpoint — truly public, returns only { ok: true }.
22
54
  *
@@ -45,25 +77,57 @@ export class Health extends Resource {
45
77
  return true;
46
78
  }
47
79
  async get() {
48
- return { ok: true };
80
+ // `version` (flair#695, the CLI↔server handshake — src/version-handshake.ts):
81
+ // exposed on the PUBLIC endpoint deliberately, so the check works even
82
+ // before an agent identity/key exists (fresh install, pre-`flair init`).
83
+ // Sherlock verdict: fine while locally bound; if /Health is ever fronted
84
+ // PUBLICLY over Fabric, the public surface must omit this field (the
85
+ // richer, auth-gated /HealthDetail already carried version pre-existing —
86
+ // this only adds it to the anonymous endpoint too).
87
+ return { ok: true, version: resolveVersion() };
49
88
  }
50
89
  }
51
90
  /**
52
91
  * Authenticated health detail — returns memory/agent/soul stats + process info.
53
92
  * Requires Ed25519 agent auth or admin basic auth.
54
93
  *
94
+ * allowRead()=allowVerified (authorizeLocal-escalation-class follow-up to
95
+ * #601/#604/#609/#612 — flair#614's backstop found this resource had NO
96
+ * allow* at all, despite this docstring's stated intent). Harper's own
97
+ * default (`user?.role.permission.super_user`, satisfiable only by a genuine
98
+ * admin OR authorizeLocal's forged loopback super_user) was silently
99
+ * standing in instead. Any verified agent may read — get() below still
100
+ * filters sensitive fields (peer/OAuth-client lists, absolute paths, full
101
+ * agent roster) down to admin-only.
102
+ *
55
103
  * Every optional-subsystem lookup is wrapped so a missing table or absent
56
104
  * schema downgrades to "not configured" rather than failing the whole call.
57
105
  */
58
106
  export class HealthDetail extends Resource {
107
+ async allowRead() {
108
+ return allowVerified(this.getContext?.());
109
+ }
59
110
  async get() {
60
111
  const stats = { ok: true };
61
112
  const nowMs = Date.now();
62
113
  const warnings = [];
63
114
  const ctx = this.getContext?.();
64
- const request = ctx?.request ?? ctx;
65
- const callerAgent = request?.tpsAgent;
66
- const isAdmin = request?.tpsAgentIsAdmin === true || !callerAgent;
115
+ // #614 fix: resolve identity via the shared three-way verdict
116
+ // (internal/agent/anonymous agent-auth.ts) instead of reading
117
+ // tpsAgent/tpsAgentIsAdmin off the raw request directly. The OLD
118
+ // computation was `request?.tpsAgentIsAdmin === true || !callerAgent` —
119
+ // the `|| !callerAgent` half meant an UNRESOLVED caller (no tpsAgent at
120
+ // all — i.e. anonymous) defaulted to isAdmin=TRUE, backwards from every
121
+ // other resource in this codebase and the opposite of fail-safe.
122
+ // allowRead() above already denies a genuine anonymous HTTP caller
123
+ // before get() ever runs; this fixes the internal computation to match
124
+ // (defense-in-depth, and correct semantics if get() is ever reached
125
+ // another way). Only a true "internal" verdict (no HTTP request at all —
126
+ // a programmatic/in-process call) or a verified admin agent is isAdmin;
127
+ // an unresolved/anonymous HTTP caller is never treated as admin.
128
+ const auth = await resolveAgentAuth(ctx);
129
+ const callerAgent = auth.kind === "agent" ? auth.agentId : undefined;
130
+ const isAdmin = auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin);
67
131
  stats.caller = { agentId: callerAgent ?? null, isAdmin };
68
132
  let memoriesList = [];
69
133
  // ── Memory stats ──
@@ -435,6 +499,29 @@ export class HealthDetail extends Resource {
435
499
  catch {
436
500
  stats.rem = null;
437
501
  }
502
+ // ── Migrations (flair#695: zero-touch boot-keyed auto-migration) ──
503
+ // `{ id, rowsDone, rowsRemaining, state }` per registered migration, per
504
+ // flair#695 §A. `cyclePhase: "pre-hash"` is the
505
+ // "pre-flight integrity check in progress" state the K&S verdict calls
506
+ // for; a halted migration surfaces here (with `reason`) AND as a
507
+ // warning below so it's visible without a separate lookup.
508
+ try {
509
+ const migrationsDataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
510
+ const snapshot = getMigrationStatusSnapshot(migrationsDataDir);
511
+ stats.migrations = {
512
+ cyclePhase: snapshot.cyclePhase,
513
+ lastCycleAt: snapshot.lastCycleAt ?? null,
514
+ migrations: snapshot.migrations,
515
+ };
516
+ for (const m of snapshot.migrations) {
517
+ if (m.state === "halted" || m.state === "failed") {
518
+ warnings.push({ level: "warn", message: `migration '${m.id}' ${m.state}${m.reason ? `: ${m.reason}` : ""} — see \`flair doctor\`` });
519
+ }
520
+ }
521
+ }
522
+ catch {
523
+ stats.migrations = null;
524
+ }
438
525
  // ── Disk ──
439
526
  try {
440
527
  const dataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
@@ -535,6 +622,10 @@ export class HealthDetail extends Resource {
535
622
  // ── Warnings ──
536
623
  stats.warnings = warnings;
537
624
  // ── Process info ──
625
+ // version: the RUNNING process's own package.json — used by `flair
626
+ // upgrade`'s post-restart verification (flair#635) to prove the new
627
+ // code is actually serving, not just installed on disk.
628
+ stats.version = resolveVersion();
538
629
  stats.pid = process.pid;
539
630
  stats.uptimeSeconds = Math.floor(process.uptime());
540
631
  return stats;
@@ -0,0 +1,53 @@
1
+ import { databases } from "@harperfast/harper";
2
+ /**
3
+ * ─── Local instance identity (federation-edge-hardening slice 1) ────────────
4
+ *
5
+ * The write-time `originatorInstanceId` stamp (Memory.ts/Soul.ts/Agent.ts/
6
+ * Relationship.ts post()/put()) needs to know THIS instance's own federation
7
+ * identity — the same `id` FederationInstance.get() (resources/Federation.ts)
8
+ * finds-or-creates on first boot and persists in the `Instance` table
9
+ * (schemas/federation.graphql). Exactly one row is expected in a given
10
+ * instance's own Instance table — that row IS this instance; other instances
11
+ * it has paired with live in the separate `Peer` table, never here.
12
+ *
13
+ * Deliberately READ-ONLY: this module never creates an Instance row — that
14
+ * first-boot bootstrap (keypair generation + keystore write) stays
15
+ * FederationInstance.get()'s job (resources/Federation.ts). If no Instance
16
+ * row exists yet (a fresh, never-federated instance, or a unit-test
17
+ * environment with no Instance table at all), localInstanceId() resolves to
18
+ * null and callers stamp nothing — originatorInstanceId is nullable by
19
+ * design (schemas/memory.graphql), and a null tag reads as "pre-tag /
20
+ * local-origin by default" per the federation-edge-hardening design.
21
+ *
22
+ * Cached at module scope after the FIRST successful resolution — a write
23
+ * must not pay a DB lookup every call. An unresolved (null) result is NOT
24
+ * cached, since that state can legitimately change later (federation gets
25
+ * bootstrapped after this process already started serving writes) and the
26
+ * cost of re-checking only applies to instances that have never federated.
27
+ */
28
+ let cachedInstanceId = null;
29
+ export async function localInstanceId() {
30
+ if (cachedInstanceId)
31
+ return cachedInstanceId;
32
+ try {
33
+ for await (const row of databases.flair.Instance.search()) {
34
+ if (row?.id) {
35
+ cachedInstanceId = row.id;
36
+ }
37
+ break; // exactly one row expected — this instance's own identity
38
+ }
39
+ }
40
+ catch {
41
+ // Instance table not present (test env / not yet migrated) — no local id.
42
+ }
43
+ return cachedInstanceId;
44
+ }
45
+ /**
46
+ * Test-only: force re-resolution on the next localInstanceId() call. The
47
+ * module-level cache otherwise persists across test files that share the
48
+ * same `bun test` process (same collision class documented in
49
+ * memory-integrity.test.ts re: the Memory class singleton).
50
+ */
51
+ export function _resetLocalInstanceIdCacheForTests() {
52
+ cachedInstanceId = null;
53
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Client ID Metadata Document (CIMD) field logic for Flair agent identities.
3
+ *
4
+ * Produces the JSON metadata document an OAuth authorization server fetches
5
+ * when a client_id is an HTTPS URL — draft-ietf-oauth-client-id-metadata-
6
+ * document-00, adopted by the MCP draft authorization spec IN PLACE OF DCR
7
+ * (no registration row to create or replicate across nodes; the served
8
+ * document IS the registration).
9
+ *
10
+ * Shape is pinned to HarperFast/oauth **issue #161** ("client_credentials
11
+ * (2/4): CIMD-first client resolution for private_key_jwt agents") — the
12
+ * FORMAL shape spec for this document, shipped in @harperfast/oauth@2.2.0
13
+ * via PR #170 (with #167's CIMD resolution layer). #161 requires:
14
+ *
15
+ * - `grant_types: ["client_credentials"]`;
16
+ * `token_endpoint_auth_method: "private_key_jwt"`.
17
+ * - `jwks` = JWK Set of PUBLIC OKP/Ed25519 keys ONLY — reject any key
18
+ * carrying a private `d`, reject empty sets, reject non-OKP/non-Ed25519
19
+ * keys.
20
+ * - client_credentials-only clients carry NEITHER `redirect_uris` NOR
21
+ * `response_types` — #161 explicitly blesses this as a documented
22
+ * deviation from the CIMD draft's general required-fields list (which
23
+ * includes `redirect_uris` for interactive/DCR-style clients).
24
+ * - `client_credentials` combined with `refresh_token` is rejected.
25
+ * - Security gate (AS-side, enforced by the plugin, documented here for
26
+ * deployment coordination): `clientIdMetadataDocuments.allowedHosts`
27
+ * MUST be configured and this document's `client_id` host MUST be on
28
+ * it — merely hosting a reachable document must never be sufficient to
29
+ * mint tokens. Replaces the old DCR `initialAccessToken` gate.
30
+ *
31
+ * The AS-side machinery consuming this document (fetch/validate/cache —
32
+ * #167's CIMD resolution layer, extended by #170 for client_credentials +
33
+ * private_key_jwt) is SHIPPED in the published @harperfast/oauth@2.2.0.
34
+ * This module's output is proven against that real published code — not a
35
+ * mirror — in test/unit/mcp-client-credentials-live-package.test.ts, which
36
+ * drives 2.2.0's actual `resolveCimdClient` pipeline (via its exported
37
+ * `_setDnsLookup`/`_setFetch` test hooks) and confirms the document
38
+ * resolves and validates end-to-end, plus the fail-closed negatives (no
39
+ * `allowedHosts` configured → rejected; leaked private `d` in a JWK →
40
+ * rejected by the plugin even if our own build-time guard were bypassed).
41
+ *
42
+ * Kept free of any @harperfast/harper import (mirrors agentcard-fields.ts)
43
+ * so the document shape is unit-testable without spinning up Harper.
44
+ */
45
+ /** The grant this agent's CIMD document targets — formalized by oauth#161, accepted by the published 2.2.0 validator. See module header. */
46
+ export const CIMD_TARGET_GRANT_TYPES = ["client_credentials"];
47
+ /** The auth method this agent's CIMD document targets — formalized by oauth#161, accepted by the published 2.2.0 validator. See module header. */
48
+ export const CIMD_TARGET_AUTH_METHOD = "private_key_jwt";
49
+ /** Build a Client ID Metadata Document for a headless Flair agent. */
50
+ export function buildCimdDocument(params) {
51
+ const { clientId, clientName, jwk } = params;
52
+ if (!clientId)
53
+ throw new Error("clientId is required");
54
+ if (!clientName)
55
+ throw new Error("clientName is required");
56
+ if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string" || !jwk.x) {
57
+ throw new Error("jwk must be a public Ed25519 JWK ({kty:'OKP', crv:'Ed25519', x:'...'})");
58
+ }
59
+ // Belt-and-suspenders (oauth#161: "reject any key carrying private `d`").
60
+ // Ed25519Jwk's TS type has no `d` field, so a well-typed caller can't
61
+ // construct one — but this function's argument still crosses a runtime
62
+ // boundary (a JWK read from disk/network, or a caller using `as any`), so
63
+ // check for it explicitly rather than trust the type alone. A CIMD
64
+ // document is fetched by an unauthenticated AS over the public internet;
65
+ // there is no scenario where leaking `d` here is acceptable.
66
+ if (typeof jwk.d !== "undefined") {
67
+ throw new Error("jwk must be a PUBLIC key — refusing to build a CIMD document from a JWK carrying a private 'd' component");
68
+ }
69
+ return {
70
+ client_id: clientId,
71
+ client_name: clientName,
72
+ // Exactly one key, always non-empty and always OKP/Ed25519 — both are
73
+ // structurally guaranteed by the validation above (oauth#161 also
74
+ // requires rejecting an EMPTY jwks set and non-OKP/non-Ed25519 keys;
75
+ // this function's single-already-validated-jwk API makes both
76
+ // impossible to violate from here).
77
+ jwks: { keys: [jwk] },
78
+ // pending oauth#161 — rejected by today's open-draft #167 validator; see module header.
79
+ token_endpoint_auth_method: CIMD_TARGET_AUTH_METHOD,
80
+ grant_types: [...CIMD_TARGET_GRANT_TYPES],
81
+ // redirect_uris AND response_types are both deliberately omitted — #161
82
+ // blesses this explicitly for client_credentials-only CIMD clients (no
83
+ // redirect-based flow is ever possible for a headless agent). Neither
84
+ // field is declared on CimdDocument, so neither can slip back in via
85
+ // this object literal.
86
+ };
87
+ }
88
+ /**
89
+ * Normalize a Flair `Agent.publicKey` value into a JWK OKP `x`. Accepts both
90
+ * encodings `Agent.publicKey` is written in across this repo (hex 64-char,
91
+ * or base64/base64url 44-char raw 32-byte key) — the same two forms
92
+ * resources/ed25519-auth.ts's `importEd25519Key` accepts, so a document
93
+ * built from this always matches what the verified-request path already
94
+ * treats as the agent's key.
95
+ */
96
+ export function agentPublicKeyToJwk(publicKeyStr, kid) {
97
+ const trimmed = (publicKeyStr ?? "").trim();
98
+ let raw;
99
+ if (/^[0-9a-f]{64}$/i.test(trimmed)) {
100
+ raw = Buffer.from(trimmed, "hex");
101
+ }
102
+ else {
103
+ raw = Buffer.from(trimmed.replace(/-/g, "+").replace(/_/g, "/"), "base64");
104
+ }
105
+ if (raw.length !== 32) {
106
+ throw new Error(`agent public key must decode to 32 bytes (got ${raw.length})`);
107
+ }
108
+ const jwk = { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") };
109
+ if (kid)
110
+ jwk.kid = kid;
111
+ return jwk;
112
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * mcp-handler.ts — the Model-2 custom MCP protocol handler.
3
3
  *
4
- * A minimal in-process MCP (JSON-RPC 2.0) handler serving the 9 curated flair
4
+ * A minimal in-process MCP (JSON-RPC 2.0) handler serving the 12 curated flair
5
5
  * tools over Streamable HTTP. It is wrapped by `@harperfast/oauth`'s
6
6
  * `withMCPAuth` (see mcp-oauth.ts), which fails closed on any missing/invalid
7
7
  * Bearer token BEFORE this handler runs and, on success, sets