@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.
- package/README.md +39 -11
- package/SECURITY.md +52 -20
- package/config.yaml +32 -5
- package/dist/cli.js +2235 -260
- package/dist/deploy.js +96 -15
- package/dist/doctor-client.js +312 -0
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/install/clients.js +18 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/restore.js +1 -1
- package/dist/rem/runner.js +82 -12
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +121 -2
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +127 -22
- package/dist/resources/MemoryBootstrap.js +441 -85
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +69 -5
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +268 -17
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +108 -26
- package/dist/resources/SemanticSearch.js +88 -320
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +12 -1
- package/dist/resources/agent-auth.js +61 -3
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +42 -14
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +13 -7
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-check.js +167 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/agent.graphql +7 -0
- package/schemas/event.graphql +5 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +82 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- 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
|
|
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
|
|
5
|
-
* prompt
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
if (
|
|
53
|
-
return new Response(JSON.stringify(
|
|
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 =
|
|
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
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|
package/dist/resources/OAuth.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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?.());
|