@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/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
  }
@@ -1,29 +1,44 @@
1
1
  import { databases } from "@harperfast/harper";
2
2
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
3
- import { isAdmin, resolveAgentAuth, allowVerified } from "./agent-auth.js";
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 "./provenance.js";
12
- const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
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) and the full
17
- * read-scope condition + private-exclusion predicate (resolveReadScope) now
18
- * live in ./memory-read-scope.ts the ONE centralized helper every
19
- * cross-agent Memory read path (search()/get() here, SemanticSearch.ts,
20
- * MemoryBootstrap.ts, auth-middleware.ts's by-id guard) resolves its scope
21
- * through, so the scoping rule cannot drift per-path again (a
22
- * SemanticSearch inline `visibility === "office"` OR-clause leaked office
23
- * memories to any authenticated agent because the rule had scattered). See
24
- * that module's doc for the migration invariant (no-visibility-field reads
25
- * as "shared", never "private").
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 now lives in ./provenance.ts (imported above) —
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
- * passthrough). Deliberately NOT implemented in this slice: a
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 allowVerified(this.getContext?.()); }
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
- const ctx = this.getContext?.();
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
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
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
- // resolveReadScope() so get() above and search() here cannot drift.
469
- const authAgent = auth.agentId;
470
- const scope = await resolveReadScope(authAgent);
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
- if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
513
- return FORBIDDEN("forbidden: cannot write memory owned by another agent");
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
- if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
656
- return FORBIDDEN("forbidden: cannot write memory owned by another agent");
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, allowVerified } from "./agent-auth.js";
14
+ import { resolveAgentAuth } from "./agent-auth.js";
15
15
  import { invalidEntitiesResponse } from "./entity-vocab.js";
16
- const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
17
- const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
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 allowVerified(this.getContext?.()); }
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: a non-admin agent's events are ALWAYS attributed to
28
- // its authenticated identity (from the Ed25519 signature), never the body
29
- // an agent can only publish AS itself. We overwrite `authorId` rather than
30
- // 403'ing a mismatch so a CLI client never has to echo its own id into the
31
- // body (mirrors A2A message/send's "sender must match params.agentId" guard
32
- // and Presence's "agentId from signature, NOT from body"). Admin agents may
33
- // publish on behalf of another agent (body authorId honored, else their own).
34
- if (auth.kind === "agent" && !auth.isAdmin) {
35
- content.authorId = auth.agentId;
36
- }
37
- else if (auth.kind === "agent" && auth.isAdmin) {
38
- content.authorId ||= auth.agentId;
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
- if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
57
- return FORBIDDEN("forbidden: authorId must match authenticated agent");
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
- const auth = await this._auth();
67
- if (auth.kind === "anonymous")
68
- return UNAUTH();
69
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
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 !== auth.agentId) {
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 m = authHeader.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
413
- if (!m) {
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 [, headerAgentId, tsRaw, nonce, sigB64] = m;
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, allowVerified } from "./agent-auth.js";
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 "./provenance.js";
6
- const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
7
- const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
8
- const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
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 allowVerified(this.getContext?.()); }
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
- const auth = await resolveAgentAuth(this.getContext?.());
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 auth = await resolveAgentAuth(this.getContext?.());
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
- if (auth.kind === "anonymous") {
73
- return new Response(JSON.stringify({ error: "authentication required" }), {
74
- status: 401, headers: { "content-type": "application/json" },
75
- });
76
- }
77
- // Trusted internal call or admin agent → unfiltered.
78
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
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 agentCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
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
- if (auth.kind === "agent") {
134
- if (!auth.isAdmin) {
135
- if (content?.agentId && content.agentId !== auth.agentId) {
136
- return FORBIDDEN("cannot write a relationship owned by another agent");
137
- }
138
- content.agentId = auth.agentId;
139
- }
140
- // admin: content.agentId left as provided (unfiltered) — see doc above.
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
- const auth = await resolveAgentAuth(ctx);
218
- if (auth.kind === "anonymous") {
219
- return UNAUTH();
220
- }
221
- // Trusted internal call or admin agent — unfiltered, unchanged behavior.
222
- if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
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 !== auth.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(_);