@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
@@ -19,7 +19,7 @@ export class MemoryGrant extends databases.flair.MemoryGrant {
19
19
  }
20
20
  /**
21
21
  * Self-authorize now that the global gate is non-rejecting (memory-soul-
22
- * read-gate family fix, ops-oox7 — same pattern as Memory.ts/Soul.ts/
22
+ * read-gate family fix — same pattern as Memory.ts/Soul.ts/
23
23
  * WorkspaceState.ts/Relationship.ts/Integration.ts). Closes the same P0
24
24
  * leak: Harper routes `GET /MemoryGrant/<id>` to get() and the collection
25
25
  * describe (`GET /MemoryGrant`) outside search(), so neither was gated
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * POST /MemoryReflect
3
3
  *
4
- * Gathers recent memories for an agent and returns a structured reflection
5
- * prompt. The agent feeds the prompt + memories to its LLM and writes
6
- * insights back as persistent memories (with derivedFrom linking).
4
+ * Gathers recent memories for an agent and either (a) returns a structured
5
+ * reflection prompt for a human/agent to run through their own LLM, or (b)
6
+ * with execute:true, runs the distillation server-side via Harper's models
7
+ * facade and stages the results as MemoryCandidate rows for review. See
8
+ * specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md §3A (issue #707).
7
9
  *
8
10
  * Request:
9
11
  * agentId string — which agent to reflect on
@@ -12,22 +14,33 @@
12
14
  * maxMemories number? — cap (default: 50)
13
15
  * focus string? — "lessons_learned" | "patterns" | "decisions" | "errors" (default: "lessons_learned")
14
16
  * tag string? — required when scope="tagged"
17
+ * execute boolean? — default false. When true, distill server-side and
18
+ * stage MemoryCandidate rows instead of returning a prompt.
15
19
  *
16
- * Response:
20
+ * Response (execute: false, default — unchanged from pre-#707 behavior):
17
21
  * memories Memory[] — source memories included in the prompt
18
22
  * prompt string — structured LLM prompt
19
23
  * suggestedTags string[] — tags Flair detected in the source set
20
24
  * count number — number of memories included
25
+ *
26
+ * Response (execute: true):
27
+ * candidates MemoryCandidate[] — staged rows (rationalePrompt omitted — see below)
28
+ * count number
29
+ * model string — resolved model id (see generatedBy note below)
30
+ *
31
+ * The pure logic behind execute mode (prompt building, actor resolution,
32
+ * generate+validate+retry, dedup) lives in ./memory-reflect-lib.ts — see that
33
+ * file's header for why: importing Resource/databases/models here pulls in
34
+ * the Harper runtime and can't be unit-tested directly (Harper injects
35
+ * `Resource` as a runtime global; bun's ESM linker rejects `import {
36
+ * Resource }` outright — see test/unit/resource-allow.test.ts). This
37
+ * resource is a thin orchestrator over the lib's tested functions.
21
38
  */
22
- import { Resource, databases } from "@harperfast/harper";
39
+ import { Resource, databases, models, logger } from "@harperfast/harper";
40
+ import { randomBytes } from "node:crypto";
23
41
  import { isAdmin, allowVerified } from "./agent-auth.js";
24
42
  import { patchRecordSilent } from "./table-helpers.js";
25
- const FOCUS_PROMPTS = {
26
- lessons_learned: "Review these memories and identify concrete lessons learned. For each lesson: what happened, what you learned, and how it should change future behavior. Write atomic memories with durability=persistent.",
27
- patterns: "Identify recurring patterns across these memories. What themes, approaches, or outcomes appear multiple times? Extract each pattern as a persistent memory.",
28
- decisions: "Catalog the key decisions made and their outcomes. For each: what was decided, why, and what resulted. Promote important decisions to persistent.",
29
- errors: "Extract errors, bugs, and failures. For each: what failed, root cause, and fix applied. These are high-value persistent memories.",
30
- };
43
+ import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, } from "./memory-reflect-lib.js";
31
44
  export class ReflectMemories extends Resource {
32
45
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
33
46
  // admin elevation). Any verified agent may reflect; the isAdmin checks in post()
@@ -36,7 +49,7 @@ export class ReflectMemories extends Resource {
36
49
  return allowVerified(this.getContext?.());
37
50
  }
38
51
  async post(data) {
39
- const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
52
+ const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, execute = false, } = data || {};
40
53
  // Authenticated identity comes from getContext().request, not this.request
41
54
  // (see SemanticSearch / MemoryBootstrap for the same bug class). The prior
42
55
  // check was silently bypassed — bob could reflect on alice's memories and
@@ -46,13 +59,13 @@ export class ReflectMemories extends Resource {
46
59
  const actorId = request?.tpsAgent;
47
60
  const callerIsAdmin = request?.tpsAgentIsAdmin === true
48
61
  || (actorId ? await isAdmin(actorId) : false);
49
- if (!bodyAgentId && !actorId) {
50
- return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
51
- }
52
- if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
53
- return new Response(JSON.stringify({ error: "forbidden: can only reflect on own memories" }), { status: 403 });
62
+ // Same actor rules for both modes (spec §3A item 9) — resolved once via
63
+ // the shared, tested helper before either branch runs.
64
+ const actorResolution = resolveReflectActor({ bodyAgentId, actorId, callerIsAdmin });
65
+ if (actorResolution.error) {
66
+ return new Response(JSON.stringify(actorResolution.error.body), { status: actorResolution.error.status });
54
67
  }
55
- const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
68
+ const agentId = actorResolution.agentId;
56
69
  const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
57
70
  const memories = [];
58
71
  for await (const record of databases.flair.Memory.search()) {
@@ -83,38 +96,81 @@ export class ReflectMemories extends Resource {
83
96
  for (const t of m.tags ?? [])
84
97
  tagSet.add(t);
85
98
  }
86
- // Build prompt
87
- const focusText = FOCUS_PROMPTS[focus] ?? FOCUS_PROMPTS.lessons_learned;
88
- const memorySummary = memories
89
- .map((m, i) => `[${i + 1}] (${m.id}) ${m.createdAt?.slice(0, 10) ?? "?"}: ${m.content.slice(0, 300)}`)
90
- .join("\n");
91
- const prompt = `# Memory Reflection — ${agentId}
92
- Focus: ${focus}
93
- Scope: ${scope} (since ${sinceDate.toISOString()})
94
- Memories: ${memories.length}
95
-
96
- ## Task
97
- ${focusText}
98
-
99
- ## Source Memories
100
- ${memorySummary || "(none)"}
101
-
102
- ## Instructions
103
- For each insight:
104
- 1. Write a new memory with durability=persistent
105
- 2. Set derivedFrom=[<source memory ids>]
106
- 3. Set tags from the source memories where relevant
107
- 4. Keep each memory atomic — one insight per record`;
108
- // Update lastReflected on source memories (read-modify-write to preserve embeddings)
109
- const now = new Date().toISOString();
99
+ // Update lastReflected on source memories (read-modify-write to preserve
100
+ // embeddings). Unconditional for both modes — calling /ReflectMemories at
101
+ // all means these memories were considered, regardless of what happens next.
102
+ const nowISO = new Date().toISOString();
110
103
  for (const m of memories) {
111
- patchRecordSilent(databases.flair.Memory, m.id, { lastReflected: now });
104
+ patchRecordSilent(databases.flair.Memory, m.id, { lastReflected: nowISO });
105
+ }
106
+ const promptInputs = memories.map((m) => ({ id: m.id, createdAt: m.createdAt, content: m.content }));
107
+ if (!execute) {
108
+ const prompt = buildReflectionPrompt({ agentId, focus, scope, sinceISO: sinceDate.toISOString(), memories: promptInputs });
109
+ return {
110
+ memories,
111
+ prompt,
112
+ suggestedTags: [...tagSet].slice(0, 20),
113
+ count: memories.length,
114
+ };
115
+ }
116
+ // ── execute mode (spec §3A) ─────────────────────────────────────────────
117
+ const executePrompt = buildExecutePrompt({ agentId, focus, scope, sinceISO: sinceDate.toISOString(), memories: promptInputs });
118
+ const gatheredMemoryIds = new Set(promptInputs.map((m) => m.id));
119
+ const configuredModel = process.env.FLAIR_REM_MODEL || undefined;
120
+ const outcome = await generateCandidates({
121
+ prompt: executePrompt,
122
+ model: configuredModel,
123
+ gatheredMemoryIds,
124
+ generate: (input, opts) => models.generate(input, opts),
125
+ });
126
+ if (!outcome.ok) {
127
+ if (outcome.reason === "no_backend") {
128
+ // Static body (K&S) — never echo Harper version, backend lists, or endpoints.
129
+ return new Response(JSON.stringify({ error: "No generative backend configured. See the models configuration docs." }), { status: 503 });
130
+ }
131
+ return new Response(JSON.stringify({ error: "distillation_failed", detail: "model output did not validate after one retry" }), { status: 502 });
132
+ }
133
+ if (outcome.usedJsonFallback) {
134
+ logger.warn?.(`MemoryReflect: json-fallback path active for agent ${agentId} (schema-mode output failed validation)`);
135
+ }
136
+ // Dedup against this agent's existing pending candidates (spec §3A item 4).
137
+ const existingPendingClaims = [];
138
+ for await (const c of databases.flair.MemoryCandidate.search({})) {
139
+ if (c.agentId !== agentId)
140
+ continue;
141
+ if (c.status !== "pending")
142
+ continue;
143
+ existingPendingClaims.push(c.claim);
144
+ }
145
+ const toStage = dedupeCandidates(outcome.candidates, existingPendingClaims);
146
+ // generatedBy: GenerateResult in the pinned @harperfast/harper 5.1.17 has
147
+ // no model/backend-id field (content/finishReason/usage/toolCalls/trace
148
+ // only) — the "from the generate result if available" branch is
149
+ // unreachable in this version, so this always falls back to the
150
+ // configured logical name, matching Harper's own default routing name.
151
+ const resolvedModel = configuredModel ?? "default";
152
+ const generatedAt = new Date().toISOString();
153
+ const staged = [];
154
+ for (const c of toStage) {
155
+ const row = {
156
+ id: `cand_${randomBytes(8).toString("hex")}`,
157
+ agentId,
158
+ claim: c.claim,
159
+ sourceMemoryIds: c.sourceMemoryIds,
160
+ rationalePrompt: executePrompt,
161
+ generatedBy: resolvedModel,
162
+ generatedAt,
163
+ status: "pending",
164
+ };
165
+ await databases.flair.MemoryCandidate.put(row);
166
+ staged.push(row);
112
167
  }
113
- return {
114
- memories,
115
- prompt,
116
- suggestedTags: [...tagSet].slice(0, 20),
117
- count: memories.length,
118
- };
168
+ // Response omits rationalePrompt (spec §3A item 5: "no prompt field") —
169
+ // it's identical across every row in this batch and already persisted
170
+ // for audit on the MemoryCandidate row itself; echoing it back per
171
+ // candidate would just repeat the same large string N times. Matches
172
+ // `flair rem candidates`' own listing, which doesn't surface it either.
173
+ const responseCandidates = staged.map(({ rationalePrompt, ...rest }) => rest);
174
+ return { candidates: responseCandidates, count: responseCandidates.length, model: resolvedModel };
119
175
  }
120
176
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * resources/MemoryUsage.ts — the dedup ledger for the usage-feedback signal
3
+ * (flair#683). One row per (agentId, memoryId) contribution; see
4
+ * schemas/memory.graphql's MemoryUsage type doc for the full field/design
5
+ * rationale.
6
+ *
7
+ * This file is a LOCKED-DOWN table guard, not the public write surface: the
8
+ * actual "record that a memory was used" action lives in
9
+ * resources/RecordUsage.ts (POST /RecordUsage) — see that file's module doc
10
+ * for why the action lives on a SEPARATE, non-table-backed resource (a
11
+ * confirmed-live Harper gotcha: the base TableResource has no default
12
+ * `post()` for a static-style raw-table call outside an
13
+ * `isCollection`-instantiated resource — resources/Memory.ts documents the
14
+ * HTTP-facing shape of this same limitation for the Memory table).
15
+ * RecordUsage writes ledger rows via `.put()` on the RAW table object
16
+ * (`databases.flair.MemoryUsage` — an upsert against the deterministic
17
+ * composite id, not a `.post()`), the same "call the other table directly,
18
+ * bypass its resource class's own auth wrapper" pattern resources/Memory.ts
19
+ * already uses for MemoryGrant (hasWriteGrant()) — so nothing below is
20
+ * bypassed by that internal path; it only gates the DIRECT `/MemoryUsage`
21
+ * HTTP route.
22
+ *
23
+ * Why agents get READ but NOT UPDATE/DELETE here (mirrored in
24
+ * src/cli.ts's FLAIR_AGENT_PERMISSION native-role grant): the ledger IS the
25
+ * dedup/anti-gaming primitive — Sherlock's "(agent, memory) contributes ≤ 1"
26
+ * rule is enforced by RecordUsage checking for an EXISTING ledger row before
27
+ * bumping usageCount. If an agent could DELETE its own row over HTTP, it
28
+ * could re-trigger RecordUsage for the same memory indefinitely (create row
29
+ * → count once → delete row → count again → repeat), completely defeating
30
+ * the dedup cap. Locking put()/delete() to admin/internal here is therefore
31
+ * LOAD-BEARING, not just defense in depth — it's the only thing enforcing
32
+ * this in an environment where the native Harper role hasn't been
33
+ * (re-)provisioned yet (auth-middleware.ts's documented pre-migration
34
+ * admin-fallback — and every ephemeral test Harper spawned via
35
+ * test/helpers/harper-lifecycle.ts, which never runs `flair init`'s role
36
+ * provisioning at all).
37
+ *
38
+ * Read scope is deliberately narrower than Memory's "open-within-org" model:
39
+ * an agent sees only ITS OWN contributions (or admin sees everything). The
40
+ * ledger is an audit trail, not a shared surface — there is no product need
41
+ * to expose "which agent used which memory" cross-agent, and narrowing this
42
+ * costs nothing.
43
+ */
44
+ import { databases } from "@harperfast/harper";
45
+ import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
46
+ const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
47
+ const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
48
+ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
49
+ export class MemoryUsage extends databases.flair.MemoryUsage {
50
+ /** Self-authorize now that the global gate is non-rejecting — same pattern
51
+ * as every other table resource in this codebase (Memory.ts/MemoryGrant.ts
52
+ * etc.). Per-record scoping happens in get()/search() below. */
53
+ allowRead() { return allowVerified(this.getContext?.()); }
54
+ async get(target) {
55
+ if (!target || (typeof target === "object" && target.isCollection)) {
56
+ return this.search(target);
57
+ }
58
+ const auth = await resolveAgentAuth(this.getContext?.());
59
+ if (auth.kind === "anonymous")
60
+ return NOT_FOUND();
61
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
62
+ return super.get(target);
63
+ const record = await super.get(target);
64
+ if (!record || record.agentId !== auth.agentId)
65
+ return NOT_FOUND();
66
+ return record;
67
+ }
68
+ async search(query) {
69
+ const auth = await resolveAgentAuth(this.getContext?.());
70
+ if (auth.kind === "anonymous")
71
+ return UNAUTH();
72
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
73
+ return super.search(query);
74
+ const scope = { attribute: "agentId", comparator: "equals", value: auth.agentId };
75
+ if (query && typeof query === "object" && !Array.isArray(query)) {
76
+ const existing = query.conditions ?? [];
77
+ query.conditions = Array.isArray(existing) ? [scope, ...existing] : [scope, existing];
78
+ return super.search(query);
79
+ }
80
+ const conditions = Array.isArray(query) && query.length > 0 ? [scope, ...query] : [scope];
81
+ return super.search(conditions);
82
+ }
83
+ // Append-only ledger: rows are created via RecordUsage's RAW table call
84
+ // (bypasses this class entirely — see module doc), never via this
85
+ // instance-level HTTP route, for non-admin callers.
86
+ async post(content) {
87
+ const auth = await resolveAgentAuth(this.getContext?.());
88
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
89
+ return super.post(content);
90
+ return FORBIDDEN("forbidden: MemoryUsage rows are written by the /RecordUsage endpoint, not directly");
91
+ }
92
+ async put(content) {
93
+ const auth = await resolveAgentAuth(this.getContext?.());
94
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
95
+ return super.put(content);
96
+ return FORBIDDEN("forbidden: MemoryUsage rows are immutable once written");
97
+ }
98
+ async delete(id) {
99
+ const auth = await resolveAgentAuth(this.getContext?.());
100
+ if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin))
101
+ return super.delete(id);
102
+ return FORBIDDEN("forbidden: MemoryUsage rows cannot be deleted by non-admins (dedup-integrity invariant — see module doc)");
103
+ }
104
+ }
@@ -35,6 +35,27 @@ function nowISO() {
35
35
  function futureISO(ms) {
36
36
  return new Date(Date.now() + ms).toISOString();
37
37
  }
38
+ /**
39
+ * 302 redirect — deliberately NOT `Response.redirect()`.
40
+ *
41
+ * FOUND while writing the real-Harper integration test for #604 (this exact
42
+ * gap is why the coverage doc above calls OAuthAuthorize.post() untestable
43
+ * without a running Harper instance — no test had ever hit this path over
44
+ * real HTTP before). Pre-existing on main, unrelated to the #604 auth fix:
45
+ * per the Fetch spec, `Response.redirect(url, status)` constructs a Response
46
+ * whose Headers guard is set to "immutable". Harper's own HTTP response
47
+ * pipeline mutates the handler's returned Response's headers afterward (e.g.
48
+ * CORS/instrumentation headers) — against an immutable-guarded Headers this
49
+ * throws `TypeError: immutable`, turning EVERY real POST /OAuthAuthorize
50
+ * (both "deny" and "approve") into a 500, verified via curl against a built
51
+ * origin/main with no other changes. A manually-constructed Response with
52
+ * the same status/Location header has the default (mutable) "response"
53
+ * guard, so Harper's later mutation succeeds — identical bytes on the wire,
54
+ * no behavior change, just a mutable Headers object underneath.
55
+ */
56
+ function redirectTo(url, status = 302) {
57
+ return new Response(null, { status, headers: { Location: url } });
58
+ }
38
59
  // ─── Discovery metadata ──────────────────────────────────────────────────────
39
60
  export class OAuthMetadata extends Resource {
40
61
  // OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
@@ -123,7 +144,7 @@ export class OAuthAuthorize extends Resource {
123
144
  // In 1.0, this returns a simple HTML consent page.
124
145
  // The user (Nathan) approves or denies, which POSTs back.
125
146
  // Harper v5 does not populate this.request on Resource subclasses —
126
- // getContext() is the only reliable path (ops-sal4: the previous
147
+ // getContext() is the only reliable path (the previous
127
148
  // `(this as any).request` read was always undefined, so query params were
128
149
  // always empty).
129
150
  const ctx = this.getContext?.();
@@ -195,15 +216,22 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
195
216
  status: 400, headers: { "content-type": "application/json" },
196
217
  });
197
218
  }
198
- if (action === "deny") {
219
+ // flair#610 mint an authorization code ONLY on an explicit approve. The
220
+ // consent form rendered by get() submits action=approve|deny and nothing
221
+ // else, so treating every non-"approve" value (deny, missing, empty, or
222
+ // garbage) as a denial is safe — and it closes the previous loose
223
+ // `action !== "deny"` check, which minted a code for a malformed or absent
224
+ // action. redirect_uri was already validated above, so this access_denied
225
+ // redirect can only target the allowed URI.
226
+ if (action !== "approve") {
199
227
  const params = new URLSearchParams({ error: "access_denied", state });
200
- return Response.redirect(`${redirectUri}?${params}`, 302);
228
+ return redirectTo(`${redirectUri}?${params}`);
201
229
  }
202
230
  // Determine authenticated principal. Harper v5 does not populate
203
231
  // this.request on Resource subclasses, so `(this as any).request?.tpsAgent`
204
232
  // was always undefined and this ALWAYS fell back to the hardcoded "admin" —
205
233
  // every approved consent grant was minted for the admin principal
206
- // regardless of who actually approved it (ops-sal4 identity spoof).
234
+ // regardless of who actually approved it (identity spoof).
207
235
  // resolveAgentAuth(getContext()) resolves Basic super_user/admin auth to
208
236
  // { kind: "agent", agentId: username, isAdmin: true } (see agent-auth.ts).
209
237
  // We do NOT silently fall back to "admin" on an unresolved principal — if
@@ -215,6 +243,42 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
215
243
  // admin, or should any verified (non-admin) agent be able to approve its
216
244
  // own consent grant? The previous code always resolved to "admin" for
217
245
  // every caller, so this is a genuine policy decision, not just a bug fix.
246
+ //
247
+ // SECURITY FIX (#604 — authorizeLocal escalation): resolveAgentAuth's
248
+ // `context.user` fallback (agent-auth.ts, the super_user / username
249
+ // branches) ALSO matches Harper's `authorizeLocal` (config true) ambient
250
+ // super_user injection for ANY credential-less LOOPBACK request.
251
+ // /OAuthAuthorize sits on auth-middleware.ts's public early-return
252
+ // passthrough (any method), so a bare local POST never gets OUR
253
+ // middleware's tpsAgent/tpsAnonymous annotation either — resolveAgentAuth
254
+ // falls straight through to Harper's raw `context.user`, which
255
+ // authorizeLocal can forge with NO signature and NO password. A
256
+ // credential-less loopback POST therefore resolved to
257
+ // { kind: "agent", agentId: "admin", isAdmin: true } and minted a REAL
258
+ // admin authorization code, exchangeable at OAuthToken for a Bearer
259
+ // token — full local privilege escalation with zero credentials.
260
+ // authorizeLocal injects request.user=super_user ONLY when there is NO
261
+ // Authorization header present (see Presence.ts's get() doc for the
262
+ // fuller writeup of this exact mechanism) — a genuinely
263
+ // password-verified Basic admin/super_user, or a genuinely
264
+ // Ed25519-signed agent request, both carry a real header. Requiring one
265
+ // be present before trusting resolveAgentAuth's verdict closes the
266
+ // forged-approver path without touching resolveAgentAuth itself (the
267
+ // root-cause fix is a separate, larger follow-up — see #604): a
268
+ // credential-less local caller now gets 401 and no code is minted; a
269
+ // genuinely-authenticated approver (real Basic password, or a real
270
+ // TPS-Ed25519 signature verified by resolveAgentAuth's own fallback) is
271
+ // unaffected.
272
+ const ctx = this.getContext?.();
273
+ const request = ctx?.request ?? ctx;
274
+ const authHeader = request?.headers?.get?.("authorization") ??
275
+ request?.headers?.asObject?.authorization ??
276
+ "";
277
+ if (!authHeader) {
278
+ return new Response(JSON.stringify({ error: "authentication required" }), {
279
+ status: 401, headers: { "content-type": "application/json" },
280
+ });
281
+ }
218
282
  const auth = await resolveAgentAuth(this.getContext?.());
219
283
  if (auth.kind !== "agent") {
220
284
  return new Response(JSON.stringify({ error: "authentication required" }), {
@@ -238,7 +302,7 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
238
302
  createdAt: now,
239
303
  });
240
304
  const params = new URLSearchParams({ code, state });
241
- return Response.redirect(`${redirectUri}?${params}`, 302);
305
+ return redirectTo(`${redirectUri}?${params}`);
242
306
  }
243
307
  }
244
308
  // ─── Token endpoint ──────────────────────────────────────────────────────────
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { databases } from "@harperfast/harper";
14
14
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
15
+ import { invalidEntitiesResponse } from "./entity-vocab.js";
15
16
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
16
17
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
17
18
  export class OrgEvent extends databases.flair.OrgEvent {
@@ -39,6 +40,12 @@ export class OrgEvent extends databases.flair.OrgEvent {
39
40
  if (!content.id)
40
41
  content.id = `${content.authorId}-${new Date().toISOString()}`;
41
42
  content.createdAt = new Date().toISOString();
43
+ // attention-plane vocabulary gate (flair#675): `entities`, if present,
44
+ // must be well-formed vocabulary strings — see resources/entity-vocab.ts.
45
+ // Field is additive/optional; absent entities is not an error.
46
+ const entitiesError = invalidEntitiesResponse(content.entities);
47
+ if (entitiesError)
48
+ return entitiesError;
42
49
  // Harper 5: table resources use put() for create/upsert (post() removed).
43
50
  return databases.flair.OrgEvent.put(content);
44
51
  }
@@ -49,6 +56,10 @@ export class OrgEvent extends databases.flair.OrgEvent {
49
56
  if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
50
57
  return FORBIDDEN("forbidden: authorId must match authenticated agent");
51
58
  }
59
+ // attention-plane vocabulary gate (flair#675) — same as post() above.
60
+ const entitiesError = invalidEntitiesResponse(content.entities);
61
+ if (entitiesError)
62
+ return entitiesError;
52
63
  return databases.flair.OrgEvent.put(content);
53
64
  }
54
65
  async delete(id, context) {
@@ -23,7 +23,7 @@ export class OrgEventCatchup extends Resource {
23
23
  async get(pathInfo) {
24
24
  // Harper v5 does not populate this.request on Resource subclasses —
25
25
  // getContext() is the only reliable path to the gate's tpsAgent/
26
- // tpsAgentIsAdmin annotations (ops-sal4: the previous `(this as
26
+ // tpsAgentIsAdmin annotations (the previous `(this as
27
27
  // any).request` read was always undefined, so the ownership check below
28
28
  // never ran — fail-open cross-agent read).
29
29
  const auth = await resolveAgentAuth(this.getContext?.());