@tpsdev-ai/flair 0.22.1 → 0.24.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/dist/cli.js +1836 -523
- package/dist/deploy.js +16 -1
- package/dist/doctor-client.js +176 -0
- package/dist/hook-install.js +324 -0
- package/dist/install/clients.js +46 -11
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +98 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +3 -3
- package/schemas/memory.graphql +2 -2
package/dist/probe.js
CHANGED
|
@@ -64,27 +64,40 @@ export async function probeInstance(baseUrl, opts = {}) {
|
|
|
64
64
|
version: null,
|
|
65
65
|
versionMatch: null,
|
|
66
66
|
ok: false,
|
|
67
|
+
authFailureKind: null,
|
|
67
68
|
error: `instance did not answer ${base}/Health within ${timeoutMs}ms` +
|
|
68
69
|
(lastHealthError ? ` (last error: ${lastHealthError})` : ""),
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
if (!authedGet) {
|
|
72
|
-
return { healthy: true, authenticated: null, version: null, versionMatch: null, ok: true };
|
|
73
|
+
return { healthy: true, authenticated: null, version: null, versionMatch: null, ok: true, authFailureKind: null };
|
|
73
74
|
}
|
|
74
75
|
let version = null;
|
|
75
76
|
let authError;
|
|
77
|
+
let authFailureKind = null;
|
|
76
78
|
try {
|
|
77
79
|
const body = await authedGet(versionPath);
|
|
78
80
|
version = typeof body?.version === "string" ? body.version : null;
|
|
79
81
|
}
|
|
80
82
|
catch (err) {
|
|
81
83
|
authError = err?.message ?? String(err);
|
|
84
|
+
// flair#741: classify the failure so callers can tell "server responded,
|
|
85
|
+
// credentials rejected" (liveness proven) from "can't tell what state
|
|
86
|
+
// this instance is in". Duck-typed on `.status` — any authedGet
|
|
87
|
+
// implementation can opt in by throwing an error with a numeric status;
|
|
88
|
+
// api() (src/cli.ts) does via ApiHttpError. No recognizable status (a
|
|
89
|
+
// plain network error, or an authedGet that doesn't set one) stays
|
|
90
|
+
// conservative and lands in "server".
|
|
91
|
+
const status = typeof err?.status === "number" ? err.status : null;
|
|
92
|
+
authFailureKind = status === 401 || status === 403 ? "credentials" : "server";
|
|
82
93
|
}
|
|
83
94
|
const authenticated = authError === undefined;
|
|
84
95
|
let versionMatch = null;
|
|
85
96
|
if (authenticated && expectVersion !== undefined) {
|
|
86
97
|
versionMatch = version === expectVersion;
|
|
87
98
|
}
|
|
99
|
+
if (authenticated)
|
|
100
|
+
authFailureKind = null;
|
|
88
101
|
const ok = healthy && authenticated && versionMatch !== false;
|
|
89
102
|
let error;
|
|
90
103
|
if (!authenticated) {
|
|
@@ -93,5 +106,5 @@ export async function probeInstance(baseUrl, opts = {}) {
|
|
|
93
106
|
else if (versionMatch === false) {
|
|
94
107
|
error = `version mismatch: expected ${expectVersion}, instance reports ${version ?? "unknown"}`;
|
|
95
108
|
}
|
|
96
|
-
return { healthy, authenticated, version, versionMatch, ok, error };
|
|
109
|
+
return { healthy, authenticated, version, versionMatch, ok, error, authFailureKind };
|
|
97
110
|
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
|
-
import { isAdmin, resolveAgentAuth
|
|
3
|
+
import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
|
|
4
4
|
import { localInstanceId } from "./instance-identity.js";
|
|
5
5
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
6
6
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
7
7
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
8
8
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
9
|
-
import { resolveReadScope } from "./memory-read-scope.js";
|
|
10
9
|
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
11
|
-
import { buildProvenance } from "./
|
|
12
|
-
|
|
13
|
-
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
14
|
-
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
10
|
+
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
11
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
15
12
|
/**
|
|
16
|
-
* Owner ids a non-admin agent may READ (resolveAllowedOwners)
|
|
17
|
-
* read-scope
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
13
|
+
* Owner ids a non-admin agent may READ (resolveAllowedOwners) live in
|
|
14
|
+
* ./memory-read-scope.ts — still exported/used elsewhere (admin tooling).
|
|
15
|
+
* The full read-scope condition + private-exclusion predicate is now
|
|
16
|
+
* consumed through ./record-type-kit.ts's makeReadScope(), parameterized
|
|
17
|
+
* from RECORD_TYPES.Memory (record-types slice 2, flair#520) rather than a
|
|
18
|
+
* hand-typed "open-within-org" literal — the registry is now the single
|
|
19
|
+
* source of truth this class draws its read-scope mode from. makeReadScope
|
|
20
|
+
* delegates "open-within-org" to ./memory-read-scope.ts's resolveReadScope()
|
|
21
|
+
* UNCHANGED — the ONE centralized helper every cross-agent Memory read path
|
|
22
|
+
* (search()/get() here, SemanticSearch.ts, MemoryBootstrap.ts, auth-
|
|
23
|
+
* middleware.ts's by-id guard) resolves its scope through, so the scoping
|
|
24
|
+
* rule cannot drift per-path again (a SemanticSearch inline
|
|
25
|
+
* `visibility === "office"` OR-clause leaked office memories to any
|
|
26
|
+
* authenticated agent because the rule had scattered). See memory-read-
|
|
27
|
+
* scope.ts's doc for the migration invariant (no-visibility-field reads as
|
|
28
|
+
* "shared", never "private").
|
|
29
|
+
*
|
|
30
|
+
* Exported (not just a module-local const) solely so
|
|
31
|
+
* test/unit/record-types-registry.test.ts's drift tripwire can introspect
|
|
32
|
+
* the composed resolver's tagged `.mode`/`.ownerField` (see makeReadScope's
|
|
33
|
+
* doc in record-type-kit.ts) against RECORD_TYPES.Memory — not for any
|
|
34
|
+
* other runtime consumer.
|
|
26
35
|
*/
|
|
36
|
+
export const memoryReadScope = makeReadScope(RECORD_TYPES.Memory.readScope, RECORD_TYPES.Memory.ownerField);
|
|
37
|
+
const memoryByIdReadGate = makeByIdReadGate(memoryReadScope);
|
|
38
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
39
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
40
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
41
|
+
const memoryAuthGate = makeAuthGate();
|
|
27
42
|
/**
|
|
28
43
|
* ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
|
|
29
44
|
*
|
|
@@ -276,8 +291,26 @@ async function hasWriteGrant(granteeId, ownerId) {
|
|
|
276
291
|
* requires a "write" MemoryGrant from the target's owner (reuses the existing
|
|
277
292
|
* agent-auth/grant machinery — no parallel auth logic). Returns a Response to
|
|
278
293
|
* short-circuit with (400/403), or null to continue.
|
|
294
|
+
*
|
|
295
|
+
* flair#704: an explicit `supersedes: null` — the shape most JSON writers
|
|
296
|
+
* produce for an unset optional field (`JSON.stringify({supersedes: undefined})`
|
|
297
|
+
* drops the key, but plenty of writers instead do `{supersedes: x ?? null}`)
|
|
298
|
+
* — must be treated identically to the key being OMITTED, per the
|
|
299
|
+
* additive-schema convention (flair#695: an explicit null on an
|
|
300
|
+
* optional/nullable field reads as absent, not as a distinct value). Fixed by
|
|
301
|
+
* deleting the key BEFORE the type check below, so (a) the check never
|
|
302
|
+
* rejects it, and (b) `super.put()`/`super.post()` — Harper full-record
|
|
303
|
+
* replacement, see table-helpers.ts's header comment — never persists a
|
|
304
|
+
* literal `null` where "absent" was intended: the stored row ends up
|
|
305
|
+
* byte-for-byte identical to the omitted-key case, so every downstream
|
|
306
|
+
* `!content.supersedes` / `content.supersedes &&` check below (and in
|
|
307
|
+
* closeSupersededIfNeeded) already treats it as unset with no further
|
|
308
|
+
* changes needed.
|
|
279
309
|
*/
|
|
280
310
|
async function validateAndAuthorizeSupersedes(content, auth) {
|
|
311
|
+
if (content.supersedes === null) {
|
|
312
|
+
delete content.supersedes;
|
|
313
|
+
}
|
|
281
314
|
if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
|
|
282
315
|
return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
|
|
283
316
|
status: 400, headers: { "Content-Type": "application/json" },
|
|
@@ -335,14 +368,16 @@ function defaultVisibilityForDurability(durability) {
|
|
|
335
368
|
/**
|
|
336
369
|
* ─── Write-time provenance stamp (memory-provenance slice 1) ────────────────
|
|
337
370
|
*
|
|
338
|
-
* `buildProvenance` itself
|
|
371
|
+
* `buildProvenance` itself lives in ./provenance.ts, re-exported unmodified
|
|
372
|
+
* via ./record-type-kit.ts (imported above) for a single kit import surface —
|
|
339
373
|
* extracted so resources/Relationship.ts's write path can reuse the EXACT
|
|
340
374
|
* same `{v, verified, claimed?}` shape (the relationship-write-path spec's
|
|
341
375
|
* "reuse buildProvenance as-is" contract) instead of a hand-copied format
|
|
342
376
|
* that could drift. See that module for the full field-by-field rationale
|
|
343
377
|
* (verified.agentId from the auth verdict never the body, verified.timestamp
|
|
344
|
-
* = the server-computed createdAt, optional unverified claimed.model
|
|
345
|
-
*
|
|
378
|
+
* = the server-computed createdAt, optional unverified claimed.model /
|
|
379
|
+
* claimed.client passthroughs — the latter added by flair#718 authorship-
|
|
380
|
+
* provenance). Deliberately NOT implemented in this slice: a
|
|
346
381
|
* context-fingerprint field — bootstrap doesn't return the IDs a fingerprint
|
|
347
382
|
* would need, so it requires client cooperation that's out of scope here.
|
|
348
383
|
*/
|
|
@@ -394,13 +429,15 @@ export class Memory extends databases.flair.Memory {
|
|
|
394
429
|
* unverified, risks regressing owner writes/deletes on a P0 security fix
|
|
395
430
|
* that is scoped to the read leak — left as-is on purpose.
|
|
396
431
|
*/
|
|
397
|
-
allowRead() { return
|
|
432
|
+
allowRead() { return memoryAuthGate.call(this); }
|
|
398
433
|
/**
|
|
399
434
|
* Override get() to scope by-id reads the same way search() scopes
|
|
400
435
|
* collection reads (memory-soul-read-gate fix). Never distinguishes
|
|
401
436
|
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
402
437
|
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
403
|
-
* memory ids.
|
|
438
|
+
* memory ids. Wired through record-type-kit.ts's makeByIdReadGate, scoped
|
|
439
|
+
* with Memory's own "open-within-org" read-scope resolver above — same
|
|
440
|
+
* dispatch shape Relationship.ts/WorkspaceState.ts's get() overrides use.
|
|
404
441
|
*/
|
|
405
442
|
async get(target) {
|
|
406
443
|
// Collection / query reads — the `GET /Memory/?<query>` form and the bare
|
|
@@ -412,31 +449,13 @@ export class Memory extends databases.flair.Memory {
|
|
|
412
449
|
// authenticated self-query would 404 (regression caught by the auth-
|
|
413
450
|
// middleware e2e "TPS-Ed25519 on GET /Memory/?agentId=X → 200"). A by-id
|
|
414
451
|
// get (RequestTarget with isCollection false, or a bare id) falls through.
|
|
452
|
+
// makeByIdReadGate re-applies this same guard internally (delegating to
|
|
453
|
+
// this.search via `.call(this, ...)`) — kept here too as documentation of
|
|
454
|
+
// the invariant at the call site, harmless no-op double-check.
|
|
415
455
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
416
456
|
return this.search(target);
|
|
417
457
|
}
|
|
418
|
-
|
|
419
|
-
const auth = await resolveAgentAuth(ctx);
|
|
420
|
-
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
421
|
-
// this is defense-in-depth if get() is ever reached directly.
|
|
422
|
-
if (auth.kind === "anonymous") {
|
|
423
|
-
return NOT_FOUND();
|
|
424
|
-
}
|
|
425
|
-
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
426
|
-
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
427
|
-
return super.get(target);
|
|
428
|
-
}
|
|
429
|
-
// Non-admin agent: only its own memories (any visibility), or a granted
|
|
430
|
-
// owner's SHARED memories — never that owner's private ones (Layer 1
|
|
431
|
-
// private-exclusion). Centralized in resolveReadScope() so this
|
|
432
|
-
// and search() below cannot drift.
|
|
433
|
-
const record = await super.get(target);
|
|
434
|
-
if (!record)
|
|
435
|
-
return NOT_FOUND();
|
|
436
|
-
const scope = await resolveReadScope(auth.agentId);
|
|
437
|
-
if (!scope.isAllowed(record))
|
|
438
|
-
return NOT_FOUND();
|
|
439
|
-
return record;
|
|
458
|
+
return memoryByIdReadGate.call(this, target, (t) => super.get(t));
|
|
440
459
|
}
|
|
441
460
|
/**
|
|
442
461
|
* Override search() to scope collection GETs by authenticated agent.
|
|
@@ -451,23 +470,24 @@ export class Memory extends databases.flair.Memory {
|
|
|
451
470
|
async search(query) {
|
|
452
471
|
// Access request context via Harper's Resource instance context.
|
|
453
472
|
const ctx = this.getContext?.();
|
|
454
|
-
const auth = await resolveAgentAuth(ctx);
|
|
455
473
|
// Anonymous HTTP must NOT read memories. (Previously `!authAgent` was treated
|
|
456
474
|
// as unfiltered — the anonymous-read leak once the gate stops rejecting.)
|
|
457
|
-
if (auth.kind === "anonymous") {
|
|
458
|
-
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
459
|
-
status: 401, headers: { "content-type": "application/json" },
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
475
|
// Trusted internal call (no request context) or admin agent — unfiltered.
|
|
463
|
-
|
|
476
|
+
// Non-admin agent: scoped below. Dispatch shape shared via
|
|
477
|
+
// record-type-kit.ts's resolveAuthGate — same three-way branch
|
|
478
|
+
// Relationship.ts/WorkspaceState.ts's search() use.
|
|
479
|
+
const gate = await resolveAuthGate(ctx, UNAUTH());
|
|
480
|
+
if (gate.kind === "denied")
|
|
481
|
+
return gate.response;
|
|
482
|
+
if (gate.kind === "unfiltered")
|
|
464
483
|
return super.search(query);
|
|
465
|
-
}
|
|
466
484
|
// Non-admin agent: scope to own (any visibility) + granted owners' SHARED
|
|
467
485
|
// memories only (Layer 1 private-exclusion). Centralized in
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
486
|
+
// memoryReadScope (record-type-kit.ts's makeReadScope(), parameterized
|
|
487
|
+
// from RECORD_TYPES.Memory — see this file's header — delegating
|
|
488
|
+
// "open-within-org" to memory-read-scope.ts's resolveReadScope()
|
|
489
|
+
// unchanged) so get() above and search() here cannot drift.
|
|
490
|
+
const scope = await memoryReadScope(gate.agentId);
|
|
471
491
|
const agentIdCondition = scope.condition;
|
|
472
492
|
// Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
|
|
473
493
|
// conditions array. For URL-based GET /Memory?... calls, URL params are no
|
|
@@ -509,9 +529,14 @@ export class Memory extends databases.flair.Memory {
|
|
|
509
529
|
if (auth.kind === "anonymous") {
|
|
510
530
|
return UNAUTH();
|
|
511
531
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
532
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.Memory
|
|
533
|
+
// (record-types slice 2, flair#520) rather than a hand-typed literal.
|
|
534
|
+
// "validate-truthy" (see record-type-kit.ts's stampAttribution doc):
|
|
535
|
+
// reject a PRESENT, mismatched agentId; never stamp when absent (the
|
|
536
|
+
// caller is expected to have set it).
|
|
537
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.Memory.ownerField, RECORD_TYPES.Memory.attribution.post, "forbidden: cannot write memory owned by another agent");
|
|
538
|
+
if (attr.denied)
|
|
539
|
+
return attr.denied;
|
|
515
540
|
}
|
|
516
541
|
content.durability ||= "standard";
|
|
517
542
|
content.createdAt = new Date().toISOString();
|
|
@@ -605,6 +630,12 @@ export class Memory extends databases.flair.Memory {
|
|
|
605
630
|
// buildProvenance's doc above. Stamped last, right before persist, so it
|
|
606
631
|
// reflects the final resolved `content.createdAt`.
|
|
607
632
|
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
633
|
+
// flair#718 authorship-provenance: `claimedClient` is a WRITE-BODY-ONLY
|
|
634
|
+
// passthrough — buildProvenance above already folded it into
|
|
635
|
+
// `provenance.claimed.client` (sanitized/capped). Strip it from the row
|
|
636
|
+
// itself so it is NEVER persisted as a second, undeclared/unsanitized
|
|
637
|
+
// top-level field — authorship lives in the provenance JSON only.
|
|
638
|
+
delete content.claimedClient;
|
|
608
639
|
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
609
640
|
// 1) — see stampOriginatorInstanceId's doc above. No-op if already set
|
|
610
641
|
// (never fires for a genuine local write — no client sets this field).
|
|
@@ -652,9 +683,12 @@ export class Memory extends databases.flair.Memory {
|
|
|
652
683
|
if (auth.kind === "anonymous") {
|
|
653
684
|
return UNAUTH();
|
|
654
685
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
686
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.Memory,
|
|
687
|
+
// same rule as post(). "validate-truthy" (see record-type-kit.ts's
|
|
688
|
+
// stampAttribution doc).
|
|
689
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.Memory.ownerField, RECORD_TYPES.Memory.attribution.put, "forbidden: cannot write memory owned by another agent");
|
|
690
|
+
if (attr.denied)
|
|
691
|
+
return attr.denied;
|
|
658
692
|
}
|
|
659
693
|
const now = new Date().toISOString();
|
|
660
694
|
content.updatedAt = now;
|
|
@@ -771,6 +805,10 @@ export class Memory extends databases.flair.Memory {
|
|
|
771
805
|
// always gets a freshly-stamped provenance reflecting the CURRENT
|
|
772
806
|
// authenticated actor performing this write.
|
|
773
807
|
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
808
|
+
// flair#718 authorship-provenance — see post()'s identical comment above:
|
|
809
|
+
// strip the write-body-only `claimedClient` passthrough now that it's
|
|
810
|
+
// folded into `provenance.claimed.client`. Never persisted as a row field.
|
|
811
|
+
delete content.claimedClient;
|
|
774
812
|
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
775
813
|
// 1) — see stampOriginatorInstanceId's doc above post(). No-op if
|
|
776
814
|
// already set: an update/patch of an existing local record carries its
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
* distinguishes internal/agent/anonymous explicitly.
|
|
12
12
|
*/
|
|
13
13
|
import { databases } from "@harperfast/harper";
|
|
14
|
-
import { resolveAgentAuth
|
|
14
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
15
15
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
import { makeAuthGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
17
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
18
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
19
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
20
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
21
|
+
const orgEventAuthGate = makeAuthGate();
|
|
18
22
|
export class OrgEvent extends databases.flair.OrgEvent {
|
|
19
|
-
allowRead() { return
|
|
23
|
+
allowRead() { return orgEventAuthGate.call(this); }
|
|
20
24
|
_auth() {
|
|
21
25
|
return resolveAgentAuth(this.getContext?.());
|
|
22
26
|
}
|
|
@@ -24,19 +28,21 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
24
28
|
const auth = await this._auth();
|
|
25
29
|
if (auth.kind === "anonymous")
|
|
26
30
|
return UNAUTH();
|
|
27
|
-
// No-forge attribution
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.OrgEvent
|
|
32
|
+
// (record-types slice 2, flair#520) rather than hand-typed literals.
|
|
33
|
+
// "stamp-default" (see record-type-kit.ts's stampAttribution doc): a
|
|
34
|
+
// non-admin agent's events are ALWAYS attributed to its authenticated
|
|
35
|
+
// identity (from the Ed25519 signature), never the body — an agent can
|
|
36
|
+
// only publish AS itself. We overwrite `authorId` rather than 403'ing a
|
|
37
|
+
// mismatch so a CLI client never has to echo its own id into the body
|
|
38
|
+
// (mirrors A2A message/send's "sender must match params.agentId" guard
|
|
39
|
+
// and Presence's "agentId from signature, NOT from body"). Admin agents
|
|
40
|
+
// may publish on behalf of another agent (body authorId honored, else
|
|
41
|
+
// their own).
|
|
42
|
+
// "stamp-default" never denies (no rejection branch for non-admin) —
|
|
43
|
+
// the forbiddenMessage arg is dead for this mode, passed for signature
|
|
44
|
+
// completeness only.
|
|
45
|
+
stampAttribution(auth, content, RECORD_TYPES.OrgEvent.ownerField, RECORD_TYPES.OrgEvent.attribution.post, "forbidden: unreachable for stamp-default");
|
|
40
46
|
if (!content.id)
|
|
41
47
|
content.id = `${content.authorId}-${new Date().toISOString()}`;
|
|
42
48
|
content.createdAt = new Date().toISOString();
|
|
@@ -53,9 +59,13 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
53
59
|
const auth = await this._auth();
|
|
54
60
|
if (auth.kind === "anonymous")
|
|
55
61
|
return UNAUTH();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.OrgEvent.
|
|
63
|
+
// "validate-strict" (see record-type-kit.ts's stampAttribution doc):
|
|
64
|
+
// rejects a mismatch INCLUDING when authorId is absent (a bare `!==`
|
|
65
|
+
// compare, no truthy guard).
|
|
66
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.OrgEvent.ownerField, RECORD_TYPES.OrgEvent.attribution.put, "forbidden: authorId must match authenticated agent");
|
|
67
|
+
if (attr.denied)
|
|
68
|
+
return attr.denied;
|
|
59
69
|
// attention-plane vocabulary gate (flair#675) — same as post() above.
|
|
60
70
|
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
61
71
|
if (entitiesError)
|
|
@@ -63,16 +73,17 @@ export class OrgEvent extends databases.flair.OrgEvent {
|
|
|
63
73
|
return databases.flair.OrgEvent.put(content);
|
|
64
74
|
}
|
|
65
75
|
async delete(id, context) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
76
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
77
|
+
// three-way branch Memory.ts/Relationship.ts/WorkspaceState.ts use.
|
|
78
|
+
const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
|
|
79
|
+
if (gate.kind === "denied")
|
|
80
|
+
return gate.response;
|
|
81
|
+
if (gate.kind === "unfiltered")
|
|
70
82
|
return super.delete(id, context);
|
|
71
|
-
}
|
|
72
83
|
const record = await this.get(id);
|
|
73
84
|
if (!record)
|
|
74
85
|
return super.delete(id, context);
|
|
75
|
-
if (record.authorId !==
|
|
86
|
+
if (record.authorId !== gate.agentId) {
|
|
76
87
|
return FORBIDDEN("forbidden: cannot delete events authored by another agent");
|
|
77
88
|
}
|
|
78
89
|
return super.delete(id, context);
|
|
@@ -32,7 +32,7 @@ import { dirname, join } from "node:path";
|
|
|
32
32
|
import { fileURLToPath } from "node:url";
|
|
33
33
|
import { createRequire } from "node:module";
|
|
34
34
|
import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
|
|
35
|
-
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
35
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
36
36
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
37
37
|
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
38
38
|
const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "debugging", "idle"]);
|
|
@@ -409,11 +409,11 @@ export class Presence extends databases.flair.Presence {
|
|
|
409
409
|
agentId = middlewareAgent;
|
|
410
410
|
}
|
|
411
411
|
else {
|
|
412
|
-
const
|
|
413
|
-
if (!
|
|
412
|
+
const parsed = parseTpsEd25519Header(authHeader);
|
|
413
|
+
if (!parsed) {
|
|
414
414
|
return new Response(JSON.stringify({ error: "Ed25519 agent auth required for heartbeat" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
415
415
|
}
|
|
416
|
-
const
|
|
416
|
+
const { agentId: headerAgentId, tsRaw, nonce, signatureB64: sigB64 } = parsed;
|
|
417
417
|
const ts = Number(tsRaw);
|
|
418
418
|
const now = Date.now();
|
|
419
419
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
4
|
import { localInstanceId } from "./instance-identity.js";
|
|
5
|
-
import { buildProvenance } from "./
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
6
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
7
|
+
// Parameterized from RECORD_TYPES.Relationship (record-types slice 2,
|
|
8
|
+
// flair#520) rather than a hand-typed "owner-only" literal — the registry is
|
|
9
|
+
// now the single source of truth this class draws its read-scope mode from.
|
|
10
|
+
// Exported solely so test/unit/record-types-registry.test.ts's drift
|
|
11
|
+
// tripwire can introspect the composed resolver's tagged `.mode`/
|
|
12
|
+
// `.ownerField` against RECORD_TYPES.Relationship — not for any other
|
|
13
|
+
// runtime consumer.
|
|
14
|
+
export const relationshipReadScope = makeReadScope(RECORD_TYPES.Relationship.readScope, RECORD_TYPES.Relationship.ownerField);
|
|
15
|
+
const relationshipByIdReadGate = makeByIdReadGate(relationshipReadScope);
|
|
16
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
17
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
18
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
19
|
+
const relationshipAuthGate = makeAuthGate();
|
|
9
20
|
/**
|
|
10
21
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
11
22
|
*
|
|
@@ -28,13 +39,14 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
28
39
|
* ownership scoping happens in get() below; the collection scope is still
|
|
29
40
|
* in search().
|
|
30
41
|
*/
|
|
31
|
-
allowRead() { return
|
|
42
|
+
allowRead() { return relationshipAuthGate.call(this); }
|
|
32
43
|
/**
|
|
33
44
|
* Override get() to scope by-id reads the same way search() scopes
|
|
34
45
|
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
35
46
|
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
36
47
|
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
37
|
-
* relationship ids.
|
|
48
|
+
* relationship ids. Wired through record-type-kit.ts's makeByIdReadGate,
|
|
49
|
+
* scoped "owner-only" — same dispatch shape Memory.ts's get() uses.
|
|
38
50
|
*/
|
|
39
51
|
async get(target) {
|
|
40
52
|
// Collection / query reads arrive as a RequestTarget with
|
|
@@ -47,39 +59,23 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
47
59
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
48
60
|
return this.search(target);
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
52
|
-
// this is defense-in-depth if get() is ever reached directly.
|
|
53
|
-
if (auth.kind === "anonymous") {
|
|
54
|
-
return NOT_FOUND();
|
|
55
|
-
}
|
|
56
|
-
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
57
|
-
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
58
|
-
return super.get(target);
|
|
59
|
-
}
|
|
60
|
-
// Non-admin agent: only its own relationships.
|
|
61
|
-
const record = await super.get(target);
|
|
62
|
-
if (!record)
|
|
63
|
-
return NOT_FOUND();
|
|
64
|
-
if (record.agentId !== auth.agentId)
|
|
65
|
-
return NOT_FOUND();
|
|
66
|
-
return record;
|
|
62
|
+
return relationshipByIdReadGate.call(this, target, (t) => super.get(t));
|
|
67
63
|
}
|
|
68
64
|
async search(query) {
|
|
69
|
-
const
|
|
65
|
+
const ctx = this.getContext?.();
|
|
70
66
|
// Anonymous HTTP must NOT read relationships (previously `!authAgent` was
|
|
71
|
-
// treated as unfiltered — the anonymous-read leak).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
67
|
+
// treated as unfiltered — the anonymous-read leak). Trusted internal call
|
|
68
|
+
// or admin agent → unfiltered. Dispatch shape shared via
|
|
69
|
+
// record-type-kit.ts's resolveAuthGate — same three-way branch Memory.ts/
|
|
70
|
+
// WorkspaceState.ts's search() use.
|
|
71
|
+
const gate = await resolveAuthGate(ctx, UNAUTH());
|
|
72
|
+
if (gate.kind === "denied")
|
|
73
|
+
return gate.response;
|
|
74
|
+
if (gate.kind === "unfiltered")
|
|
79
75
|
return super.search(query);
|
|
80
|
-
}
|
|
81
76
|
// Non-admin agent: scope to own relationships.
|
|
82
|
-
const
|
|
77
|
+
const scope = await relationshipReadScope(gate.agentId);
|
|
78
|
+
const agentCondition = scope.condition;
|
|
83
79
|
if (!query?.conditions) {
|
|
84
80
|
return super.search({ conditions: [agentCondition], ...(query || {}) });
|
|
85
81
|
}
|
|
@@ -130,16 +126,15 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
130
126
|
if (auth.kind === "anonymous") {
|
|
131
127
|
return UNAUTH();
|
|
132
128
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// internal: content.agentId left as provided (unfiltered) — see doc above.
|
|
129
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.Relationship
|
|
130
|
+
// (record-types slice 2, flair#520) rather than a hand-typed literal.
|
|
131
|
+
// "stamp-strict" (see record-type-kit.ts's stampAttribution doc): reject
|
|
132
|
+
// a PRESENT, mismatched agentId, else unconditionally stamp with the
|
|
133
|
+
// verified identity. Admin/internal: content.agentId left as provided
|
|
134
|
+
// (unfiltered) — see doc above.
|
|
135
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.Relationship.ownerField, RECORD_TYPES.Relationship.attribution.put, "cannot write a relationship owned by another agent");
|
|
136
|
+
if (attr.denied)
|
|
137
|
+
return attr.denied;
|
|
143
138
|
if (!content.agentId || typeof content.agentId !== "string") {
|
|
144
139
|
return new Response(JSON.stringify({ error: "agentId is required" }), {
|
|
145
140
|
status: 400, headers: { "content-type": "application/json" },
|
|
@@ -190,6 +185,11 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
190
185
|
// pre-existing row with no provenance field reads back `undefined`,
|
|
191
186
|
// unchanged behavior (migration-equivalence gate).
|
|
192
187
|
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
188
|
+
// flair#718 authorship-provenance — same contract as resources/Memory.ts's
|
|
189
|
+
// post()/put(): `claimedClient` is a write-body-only passthrough, already
|
|
190
|
+
// folded into `provenance.claimed.client` above. Strip it so it is NEVER
|
|
191
|
+
// persisted as a row field.
|
|
192
|
+
delete content.claimedClient;
|
|
193
193
|
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
194
194
|
// 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
|
|
195
195
|
// full contract. No-op if already set (never fires for a genuine local
|
|
@@ -214,17 +214,16 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
214
214
|
*/
|
|
215
215
|
async delete(_) {
|
|
216
216
|
const ctx = this.getContext?.();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
217
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
218
|
+
// three-way branch put()/get()/search() above use.
|
|
219
|
+
const gate = await resolveAuthGate(ctx, UNAUTH());
|
|
220
|
+
if (gate.kind === "denied")
|
|
221
|
+
return gate.response;
|
|
222
|
+
if (gate.kind === "unfiltered")
|
|
223
223
|
return super.delete(_);
|
|
224
|
-
}
|
|
225
224
|
// Non-admin agent: verify ownership before delete.
|
|
226
225
|
const existing = await super.get();
|
|
227
|
-
if (existing?.agentId && existing.agentId !==
|
|
226
|
+
if (existing?.agentId && existing.agentId !== gate.agentId) {
|
|
228
227
|
return FORBIDDEN("cannot delete another agent's relationship");
|
|
229
228
|
}
|
|
230
229
|
return super.delete(_);
|