@ouro.bot/friends 0.1.0-alpha.5 → 0.1.0-alpha.6
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 +14 -3
- package/changelog.json +6 -0
- package/dist/a2a-client/index.d.ts +1 -0
- package/dist/a2a-client/index.js +5 -1
- package/dist/a2a-client/roster-verify.d.ts +15 -0
- package/dist/a2a-client/roster-verify.js +61 -0
- package/dist/account-roster.d.ts +52 -0
- package/dist/account-roster.js +108 -0
- package/dist/agent-peer.js +5 -1
- package/dist/audit.d.ts +38 -0
- package/dist/audit.js +86 -0
- package/dist/file-bundle.d.ts +5 -0
- package/dist/file-bundle.js +4 -0
- package/dist/friend-lookup.d.ts +9 -0
- package/dist/friend-lookup.js +69 -0
- package/dist/identity.d.ts +17 -0
- package/dist/identity.js +68 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +31 -2
- package/dist/mcp/dispatch.d.ts +12 -1
- package/dist/mcp/dispatch.js +45 -3
- package/dist/mcp/run-main.js +8 -5
- package/dist/mcp/schemas.js +1 -1
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +2 -2
- package/dist/resolver.d.ts +32 -1
- package/dist/resolver.js +50 -3
- package/dist/roster-store-file.d.ts +16 -0
- package/dist/roster-store-file.js +125 -0
- package/dist/roster-store-memory.d.ts +9 -0
- package/dist/roster-store-memory.js +20 -0
- package/dist/roster-store.d.ts +29 -0
- package/dist/roster-store.js +9 -0
- package/dist/roster-verifier.d.ts +23 -0
- package/dist/roster-verifier.js +47 -0
- package/dist/trust-explanation.d.ts +7 -1
- package/dist/trust-explanation.js +52 -34
- package/dist/trust-mutation.d.ts +13 -1
- package/dist/trust-mutation.js +31 -2
- package/dist/types.d.ts +11 -0
- package/package.json +1 -1
package/dist/identity.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveAgentIdentity = resolveAgentIdentity;
|
|
4
|
+
exports.withMigratedIdentity = withMigratedIdentity;
|
|
5
|
+
// Agent identity migrate-on-read (p11 Item 2 — the DID re-key).
|
|
6
|
+
//
|
|
7
|
+
// The durable identity home is `AgentMeta.identity` ({ did, pinnedKey?, handle?,
|
|
8
|
+
// pinnedAt? }). Legacy records carry only the optional `a2a.did` hint (or nothing).
|
|
9
|
+
// `resolveAgentIdentity` reads either, preferring the durable home and lifting
|
|
10
|
+
// `a2a.did` on a miss (migrate-on-read), mirroring FileGrantStore.normalize's
|
|
11
|
+
// legacy-field handling. `withMigratedIdentity` backfills `identity.did` from
|
|
12
|
+
// `a2a.did` so the next `put` persists it forward (migrate-on-write), matching the
|
|
13
|
+
// resolver's local-id migration + the grant subjectFriendId→subjectKey pattern.
|
|
14
|
+
const observability_1 = require("./observability");
|
|
15
|
+
/** Read an agent's durable identity, preferring `meta.identity` and lifting the
|
|
16
|
+
* legacy `meta.a2a.did` on a miss (migrate-on-read). Returns `{}` for a did-less
|
|
17
|
+
* or absent meta. (Unit 4a stub — not implemented.) */
|
|
18
|
+
function resolveAgentIdentity(meta) {
|
|
19
|
+
if (!meta)
|
|
20
|
+
return {};
|
|
21
|
+
// Durable home wins (authoritative). Spread only the present optional fields so
|
|
22
|
+
// a partial identity ({ did } only) doesn't surface undefined keys. SECURITY
|
|
23
|
+
// (finding 6, LOW): an empty-string did is NOT a did — omit it so it can never be a
|
|
24
|
+
// matchable identity key (ties to findFriendByDid's falsy-did guard, finding 4).
|
|
25
|
+
//
|
|
26
|
+
// NOTE (finding 5-D): when BOTH meta.identity.did and a legacy meta.a2a.did are
|
|
27
|
+
// present and DIVERGE, the durable home silently wins here. That divergence can be a
|
|
28
|
+
// tampering signal (a record's legacy hint disagreeing with its pinned identity).
|
|
29
|
+
// We don't warn from this hot path (resolveAgentIdentity runs per-record in the
|
|
30
|
+
// findFriendByDid scan and per-audit-derivation); a divergence audit belongs in a
|
|
31
|
+
// write-time reconciliation (e.g. withMigratedIdentity / the onboard path), not here.
|
|
32
|
+
if (meta.identity) {
|
|
33
|
+
const { did, pinnedKey, handle, pinnedAt } = meta.identity;
|
|
34
|
+
return {
|
|
35
|
+
...(did ? { did } : {}),
|
|
36
|
+
...(pinnedKey !== undefined ? { pinnedKey } : {}),
|
|
37
|
+
...(handle !== undefined ? { handle } : {}),
|
|
38
|
+
...(pinnedAt !== undefined ? { pinnedAt } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Migrate-on-read: lift the legacy a2a.did hint when the durable home is absent.
|
|
42
|
+
// A falsy (absent or empty-string) hint is treated as no-did.
|
|
43
|
+
if (meta.a2a?.did)
|
|
44
|
+
return { did: meta.a2a.did };
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
/** Return a meta whose `identity.did` is backfilled from `a2a.did` when the durable
|
|
48
|
+
* home is absent (migrate-on-write); a meta already carrying `identity` is returned
|
|
49
|
+
* unchanged (no clobber). Absent meta is returned as-is. (Unit 4a stub.) */
|
|
50
|
+
function withMigratedIdentity(meta) {
|
|
51
|
+
if (!meta)
|
|
52
|
+
return undefined;
|
|
53
|
+
// Already carries the durable home → no clobber.
|
|
54
|
+
if (meta.identity)
|
|
55
|
+
return meta;
|
|
56
|
+
// Nothing to migrate from → unchanged. A falsy (absent or empty-string) a2a.did is
|
|
57
|
+
// not a real did to backfill (finding 6).
|
|
58
|
+
if (!meta.a2a?.did)
|
|
59
|
+
return meta;
|
|
60
|
+
// Backfill identity.did from the legacy a2a.did so the next put persists forward.
|
|
61
|
+
(0, observability_1.emitNervesEvent)({
|
|
62
|
+
component: "friends",
|
|
63
|
+
event: "friends.identity_migrated",
|
|
64
|
+
message: "backfilled AgentMeta.identity.did from legacy a2a.did",
|
|
65
|
+
meta: { did: meta.a2a.did },
|
|
66
|
+
});
|
|
67
|
+
return { ...meta, identity: { did: meta.a2a.did } };
|
|
68
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +29,21 @@ export { upsertGroupContextParticipants } from "./group-context";
|
|
|
29
29
|
export { accumulateFriendTokens } from "./tokens";
|
|
30
30
|
export { applyFriendNote } from "./notes";
|
|
31
31
|
export { setFriendTrust } from "./trust-mutation";
|
|
32
|
+
export type { SetFriendTrustContext } from "./trust-mutation";
|
|
33
|
+
export { MemoryAuditSink, FileAuditSink, auditPathFor } from "./audit";
|
|
34
|
+
export type { AuditSink, ControlPlaneAuditRecord } from "./audit";
|
|
32
35
|
export { linkExternalId, unlinkExternalId } from "./link-identity";
|
|
33
36
|
export { upsertAgentPeer } from "./agent-peer";
|
|
37
|
+
export { resolveAgentIdentity, withMigratedIdentity } from "./identity";
|
|
38
|
+
export type { ResolvedAgentIdentity } from "./identity";
|
|
39
|
+
export { findFriendByDid } from "./friend-lookup";
|
|
40
|
+
export { FileRosterStore, rostersDirFor } from "./roster-store-file";
|
|
41
|
+
export type { RosterStore, AccountRoster, RosterPin } from "./roster-store";
|
|
42
|
+
export { identityRosterVerifier, DEFAULT_ROSTER_VERIFIER } from "./roster-verifier";
|
|
43
|
+
export type { RosterVerifier } from "./roster-verifier";
|
|
44
|
+
export { MemoryRosterStore } from "./roster-store-memory";
|
|
45
|
+
export { evaluateAccountMembership, verifiedCandidate, _resetRosterVerifierWarningForTest } from "./account-roster";
|
|
46
|
+
export type { AccountMembershipDecision, AccountMembershipResult, EvaluateAccountMembershipInput, VerifiedCandidate, } from "./account-roster";
|
|
34
47
|
export { recordRelationshipOutcome } from "./outcomes";
|
|
35
48
|
export { recordMission } from "./missions";
|
|
36
49
|
export type { RecordMissionInput } from "./missions";
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// multi-agent (a2a peer) aware, consumed through the FriendStore interface +
|
|
7
7
|
// FriendResolver.
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.
|
|
10
|
-
exports.setNervesEmitter = exports.emitNervesEvent = exports.isGrantEffective = exports.listShares = void 0;
|
|
9
|
+
exports.resolveRoom = exports.whoami = exports.recordMission = exports.recordRelationshipOutcome = exports._resetRosterVerifierWarningForTest = exports.verifiedCandidate = exports.evaluateAccountMembership = exports.MemoryRosterStore = exports.DEFAULT_ROSTER_VERIFIER = exports.identityRosterVerifier = exports.rostersDirFor = exports.FileRosterStore = exports.findFriendByDid = exports.withMigratedIdentity = exports.resolveAgentIdentity = exports.upsertAgentPeer = exports.unlinkExternalId = exports.linkExternalId = exports.auditPathFor = exports.FileAuditSink = exports.MemoryAuditSink = exports.setFriendTrust = exports.applyFriendNote = exports.accumulateFriendTokens = exports.upsertGroupContextParticipants = exports.DEFAULT_STANDING_RULE = exports.explainStanding = exports.assessStanding = exports.describeTrustContext = exports.getAlwaysOnSenseNames = exports.isRemoteChannel = exports.channelToFacing = exports.getChannelCapabilities = exports._setMachineOwnerUsernameForTest = exports.isLocalMachineOwnerIdentity = exports.machineOwnerUsername = exports.FriendResolver = exports.openFileBundle = exports.missionsDirFor = exports.FileMissionStore = exports.grantsDirFor = exports.FileGrantStore = exports.FileFriendStore = exports.isCoordinationIntent = exports.isShareScope = exports.isIntegration = exports.isIdentityProvider = exports.isTrustedLevel = exports.IDENTITY_SCOPES = exports.TRUSTED_LEVELS = void 0;
|
|
10
|
+
exports.setNervesEmitter = exports.emitNervesEvent = exports.isGrantEffective = exports.listShares = exports.revokeShare = exports.grantShare = exports.importCoordination = exports.prepareCoordination = exports.importMissionShare = exports.prepareMissionShare = exports.importProfileShare = exports.prepareProfileShare = exports.DEFAULT_AGENT_VERIFIER = exports.tofuVerifier = exports.DEFAULT_CONSENT_POLICY = exports.tieredPolicy = exports.trustImpliedPolicy = exports.strictPolicy = void 0;
|
|
11
11
|
// -- Values --
|
|
12
12
|
var types_1 = require("./types");
|
|
13
13
|
Object.defineProperty(exports, "TRUSTED_LEVELS", { enumerable: true, get: function () { return types_1.TRUSTED_LEVELS; } });
|
|
@@ -52,11 +52,40 @@ var notes_1 = require("./notes");
|
|
|
52
52
|
Object.defineProperty(exports, "applyFriendNote", { enumerable: true, get: function () { return notes_1.applyFriendNote; } });
|
|
53
53
|
var trust_mutation_1 = require("./trust-mutation");
|
|
54
54
|
Object.defineProperty(exports, "setFriendTrust", { enumerable: true, get: function () { return trust_mutation_1.setFriendTrust; } });
|
|
55
|
+
// -- Control-plane audit (Bug B): append-only record of trust mutations --
|
|
56
|
+
var audit_1 = require("./audit");
|
|
57
|
+
Object.defineProperty(exports, "MemoryAuditSink", { enumerable: true, get: function () { return audit_1.MemoryAuditSink; } });
|
|
58
|
+
Object.defineProperty(exports, "FileAuditSink", { enumerable: true, get: function () { return audit_1.FileAuditSink; } });
|
|
59
|
+
Object.defineProperty(exports, "auditPathFor", { enumerable: true, get: function () { return audit_1.auditPathFor; } });
|
|
55
60
|
var link_identity_1 = require("./link-identity");
|
|
56
61
|
Object.defineProperty(exports, "linkExternalId", { enumerable: true, get: function () { return link_identity_1.linkExternalId; } });
|
|
57
62
|
Object.defineProperty(exports, "unlinkExternalId", { enumerable: true, get: function () { return link_identity_1.unlinkExternalId; } });
|
|
58
63
|
var agent_peer_1 = require("./agent-peer");
|
|
59
64
|
Object.defineProperty(exports, "upsertAgentPeer", { enumerable: true, get: function () { return agent_peer_1.upsertAgentPeer; } });
|
|
65
|
+
// -- Agent identity (p11 Item 2 — DID re-key): durable home + migrate-on-read --
|
|
66
|
+
var identity_1 = require("./identity");
|
|
67
|
+
Object.defineProperty(exports, "resolveAgentIdentity", { enumerable: true, get: function () { return identity_1.resolveAgentIdentity; } });
|
|
68
|
+
Object.defineProperty(exports, "withMigratedIdentity", { enumerable: true, get: function () { return identity_1.withMigratedIdentity; } });
|
|
69
|
+
// did-aware friend lookup (the durable cross-agent primary key is the DID).
|
|
70
|
+
var friend_lookup_1 = require("./friend-lookup");
|
|
71
|
+
Object.defineProperty(exports, "findFriendByDid", { enumerable: true, get: function () { return friend_lookup_1.findFriendByDid; } });
|
|
72
|
+
// -- Account roster (p11 Item 3): pinned roster + TOFU roster-key storage seam --
|
|
73
|
+
var roster_store_file_1 = require("./roster-store-file");
|
|
74
|
+
Object.defineProperty(exports, "FileRosterStore", { enumerable: true, get: function () { return roster_store_file_1.FileRosterStore; } });
|
|
75
|
+
Object.defineProperty(exports, "rostersDirFor", { enumerable: true, get: function () { return roster_store_file_1.rostersDirFor; } });
|
|
76
|
+
// -- RosterVerifier seam (Q1): core declares the interface + identity-only default;
|
|
77
|
+
// the a2a-client provides the Ed25519 impl (host-injected). Core stays crypto-free.
|
|
78
|
+
var roster_verifier_1 = require("./roster-verifier");
|
|
79
|
+
Object.defineProperty(exports, "identityRosterVerifier", { enumerable: true, get: function () { return roster_verifier_1.identityRosterVerifier; } });
|
|
80
|
+
Object.defineProperty(exports, "DEFAULT_ROSTER_VERIFIER", { enumerable: true, get: function () { return roster_verifier_1.DEFAULT_ROSTER_VERIFIER; } });
|
|
81
|
+
var roster_store_memory_1 = require("./roster-store-memory");
|
|
82
|
+
Object.defineProperty(exports, "MemoryRosterStore", { enumerable: true, get: function () { return roster_store_memory_1.MemoryRosterStore; } });
|
|
83
|
+
// -- Account-roster membership (Item 3 payoff): family via same_account for a
|
|
84
|
+
// key-verified, TOFU-pinned roster member; changed roster key hard-fails. --
|
|
85
|
+
var account_roster_1 = require("./account-roster");
|
|
86
|
+
Object.defineProperty(exports, "evaluateAccountMembership", { enumerable: true, get: function () { return account_roster_1.evaluateAccountMembership; } });
|
|
87
|
+
Object.defineProperty(exports, "verifiedCandidate", { enumerable: true, get: function () { return account_roster_1.verifiedCandidate; } });
|
|
88
|
+
Object.defineProperty(exports, "_resetRosterVerifierWarningForTest", { enumerable: true, get: function () { return account_roster_1._resetRosterVerifierWarningForTest; } });
|
|
60
89
|
var outcomes_1 = require("./outcomes");
|
|
61
90
|
Object.defineProperty(exports, "recordRelationshipOutcome", { enumerable: true, get: function () { return outcomes_1.recordRelationshipOutcome; } });
|
|
62
91
|
var missions_1 = require("./missions");
|
package/dist/mcp/dispatch.d.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { FriendStore } from "../store";
|
|
2
2
|
import type { GrantStore } from "../grant-store";
|
|
3
3
|
import type { MissionStore } from "../mission-store";
|
|
4
|
+
import type { AuditSink } from "../audit";
|
|
4
5
|
type Args = Record<string, unknown>;
|
|
5
6
|
export interface DispatchResult {
|
|
6
7
|
result: unknown;
|
|
7
8
|
isError: boolean;
|
|
8
9
|
}
|
|
10
|
+
/** WHO/WHENCE context stamped onto a control-plane audit record (finding 3). The MCP
|
|
11
|
+
* server passes the local owner/sense it was constructed with. */
|
|
12
|
+
export interface ControlPlaneContext {
|
|
13
|
+
actor?: string;
|
|
14
|
+
originSense?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Whether wiring an AuditSink should also stamp a record for an `onboard_agent` trust
|
|
17
|
+
* seat: only when the owner explicitly set a trustLevel (a deliberate trust decision).
|
|
18
|
+
* A cold contact with no trustLevel lands at the safe `stranger` default (Bug A) and is
|
|
19
|
+
* NOT an owner trust mutation, so it is not audited. */
|
|
9
20
|
export declare function coerceBool(v: unknown): boolean;
|
|
10
21
|
export declare function coerceInt(v: unknown): number | undefined;
|
|
11
22
|
export declare function coerceString(v: unknown): string;
|
|
12
23
|
export declare function coerceOptionalString(v: unknown): string | undefined;
|
|
13
|
-
export declare function dispatchTool(store: FriendStore, name: string, args: Args, grants?: GrantStore, missions?: MissionStore): Promise<DispatchResult>;
|
|
24
|
+
export declare function dispatchTool(store: FriendStore, name: string, args: Args, grants?: GrantStore, missions?: MissionStore, audit?: AuditSink, controlContext?: ControlPlaneContext): Promise<DispatchResult>;
|
|
14
25
|
export {};
|
package/dist/mcp/dispatch.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.dispatchTool = dispatchTool;
|
|
|
11
11
|
// the library fns. `dispatchTool` is a flat tool → library-fn map (D9/D10) with
|
|
12
12
|
// NO domain logic of its own — every behavior lives in the friends library.
|
|
13
13
|
const observability_1 = require("../observability");
|
|
14
|
+
const identity_1 = require("../identity");
|
|
14
15
|
const types_1 = require("../types");
|
|
15
16
|
const resolver_1 = require("../resolver");
|
|
16
17
|
const trust_explanation_1 = require("../trust-explanation");
|
|
@@ -31,6 +32,17 @@ const missions_1 = require("../missions");
|
|
|
31
32
|
const mission_share_1 = require("../mission-share");
|
|
32
33
|
const coordination_1 = require("../coordination");
|
|
33
34
|
const types_2 = require("../types");
|
|
35
|
+
/** SECURITY (finding 3-A): the friends MCP server speaks JSON-RPC over **stdio**, and
|
|
36
|
+
* stdio is an owner-only channel — the local user who launched the process is the only
|
|
37
|
+
* actor. So when no explicit controlContext is wired, audited mutations are attributed
|
|
38
|
+
* to the stdio owner boundary rather than the generic "unknown". A network/multi-tenant
|
|
39
|
+
* transport MUST pass its own authenticated actor instead of relying on these. */
|
|
40
|
+
const STDIO_OWNER_ACTOR = "owner:stdio";
|
|
41
|
+
const STDIO_ORIGIN_SENSE = "stdio";
|
|
42
|
+
/** Whether wiring an AuditSink should also stamp a record for an `onboard_agent` trust
|
|
43
|
+
* seat: only when the owner explicitly set a trustLevel (a deliberate trust decision).
|
|
44
|
+
* A cold contact with no trustLevel lands at the safe `stranger` default (Bug A) and is
|
|
45
|
+
* NOT an owner trust mutation, so it is not audited. */
|
|
34
46
|
function coerceBool(v) {
|
|
35
47
|
return v === true || v === "true";
|
|
36
48
|
}
|
|
@@ -69,13 +81,18 @@ const NO_GRANT_STORE = { ok: false, status: "unsupported", message: "no grant st
|
|
|
69
81
|
* embedding). The mission ledger needs mission persistence, so report it cleanly
|
|
70
82
|
* rather than guessing. */
|
|
71
83
|
const NO_MISSION_STORE = { ok: false, status: "unsupported", message: "no mission store configured (mission tools require one)" };
|
|
72
|
-
async function dispatchTool(store, name, args, grants, missions) {
|
|
84
|
+
async function dispatchTool(store, name, args, grants, missions, audit, controlContext) {
|
|
73
85
|
(0, observability_1.emitNervesEvent)({
|
|
74
86
|
component: "clients",
|
|
75
87
|
event: "clients.mcp_dispatch",
|
|
76
88
|
message: "dispatching friends mcp tool",
|
|
77
89
|
meta: { tool: name },
|
|
78
90
|
});
|
|
91
|
+
// SECURITY (finding 3 / 3-A): resolve the WHO/WHENCE for an audited mutation. With
|
|
92
|
+
// no explicit context, attribute to the stdio owner boundary (the only actor on an
|
|
93
|
+
// owner-only stdio channel) rather than the generic "unknown".
|
|
94
|
+
const auditActor = controlContext?.actor ?? STDIO_OWNER_ACTOR;
|
|
95
|
+
const auditOriginSense = controlContext?.originSense ?? STDIO_ORIGIN_SENSE;
|
|
79
96
|
switch (name) {
|
|
80
97
|
case "resolve_party": {
|
|
81
98
|
const provider = coerceString(args.provider);
|
|
@@ -188,7 +205,14 @@ async function dispatchTool(store, name, args, grants, missions) {
|
|
|
188
205
|
return { result: results, isError: false };
|
|
189
206
|
}
|
|
190
207
|
case "set_trust": {
|
|
191
|
-
|
|
208
|
+
// SECURITY (finding 3): thread the audit sink + owner/sense context so the LIVE
|
|
209
|
+
// trust mutation actually writes a control-plane record. With no sink wired,
|
|
210
|
+
// setFriendTrust treats the ctx as a no-op (back-compat).
|
|
211
|
+
const result = await (0, trust_mutation_1.setFriendTrust)(store, coerceString(args.friendId), coerceString(args.trustLevel), {
|
|
212
|
+
...(audit ? { sink: audit } : {}),
|
|
213
|
+
actor: auditActor,
|
|
214
|
+
originSense: auditOriginSense,
|
|
215
|
+
});
|
|
192
216
|
return { result, isError: result.ok === false };
|
|
193
217
|
}
|
|
194
218
|
case "link_identity": {
|
|
@@ -207,14 +231,32 @@ async function dispatchTool(store, name, args, grants, missions) {
|
|
|
207
231
|
return { result, isError: result.ok === false };
|
|
208
232
|
}
|
|
209
233
|
case "onboard_agent": {
|
|
234
|
+
const explicitTrustLevel = coerceOptionalString(args.trustLevel);
|
|
210
235
|
const record = await (0, agent_peer_1.upsertAgentPeer)(store, {
|
|
211
236
|
name: coerceString(args.name),
|
|
212
237
|
agentId: coerceString(args.agentId),
|
|
213
|
-
trustLevel:
|
|
238
|
+
trustLevel: explicitTrustLevel,
|
|
214
239
|
a2a: parseMaybeJson(args.a2a),
|
|
215
240
|
mailbox: parseMaybeJson(args.mailbox),
|
|
216
241
|
bundleName: coerceOptionalString(args.bundleName),
|
|
217
242
|
});
|
|
243
|
+
// SECURITY (finding 3): an owner-initiated trust SEAT (an explicit trustLevel) is
|
|
244
|
+
// a control-plane trust mutation, so audit it through the wired sink. A cold
|
|
245
|
+
// contact with no trustLevel falls to the safe `stranger` default (Bug A) — not
|
|
246
|
+
// an owner trust decision — so it is left unaudited.
|
|
247
|
+
if (audit && explicitTrustLevel !== undefined) {
|
|
248
|
+
const targetDid = (0, identity_1.resolveAgentIdentity)(record.agentMeta).did;
|
|
249
|
+
const auditRecord = {
|
|
250
|
+
action: "set_trust",
|
|
251
|
+
targetId: record.id,
|
|
252
|
+
...(targetDid !== undefined ? { targetDid } : {}),
|
|
253
|
+
level: explicitTrustLevel,
|
|
254
|
+
actor: auditActor,
|
|
255
|
+
originSense: auditOriginSense,
|
|
256
|
+
ts: record.updatedAt,
|
|
257
|
+
};
|
|
258
|
+
await audit.append(auditRecord);
|
|
259
|
+
}
|
|
218
260
|
return { result: record, isError: false };
|
|
219
261
|
}
|
|
220
262
|
case "whoami": {
|
package/dist/mcp/run-main.js
CHANGED
|
@@ -35,11 +35,14 @@ function runMain(argv, env, io) {
|
|
|
35
35
|
message: "friends mcp run-main",
|
|
36
36
|
meta: { source },
|
|
37
37
|
});
|
|
38
|
-
// The consent-grant + mission collections are sibling `_grants
|
|
39
|
-
// dirs under the friends dir, so the single `--dir` wires the
|
|
40
|
-
//
|
|
41
|
-
const { store, grants, missions } = (0, file_bundle_1.openFileBundle)(dir);
|
|
42
|
-
|
|
38
|
+
// The consent-grant + mission + audit collections are sibling `_grants/`,
|
|
39
|
+
// `_missions/`, `_audit/` dirs under the friends dir, so the single `--dir` wires the
|
|
40
|
+
// whole substrate. `openFileBundle` encapsulates that convention.
|
|
41
|
+
const { store, grants, missions, audit } = (0, file_bundle_1.openFileBundle)(dir);
|
|
42
|
+
// SECURITY (finding 3 / 3-A): thread the FileAuditSink into the live server so trust
|
|
43
|
+
// mutations are audited. The `friends-mcp` bin speaks owner-only stdio, so the
|
|
44
|
+
// default actor/originSense (the stdio owner boundary) is the correct attribution.
|
|
45
|
+
const server = (0, server_1.createFriendsMcpServer)({ store, grants, missions, audit, stdin: io.stdin, stdout: io.stdout });
|
|
43
46
|
server.start();
|
|
44
47
|
return server;
|
|
45
48
|
}
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -187,7 +187,7 @@ function getToolSchemas() {
|
|
|
187
187
|
properties: {
|
|
188
188
|
name: { type: "string", description: "the peer agent's name" },
|
|
189
189
|
agentId: { type: "string", description: "the a2a agent id" },
|
|
190
|
-
trustLevel: { type: "string", description: "trust level (default
|
|
190
|
+
trustLevel: { type: "string", description: "trust level (default stranger (cold contact))" },
|
|
191
191
|
a2a: { type: "object", description: "a2a coordinates { cardUrl?, endpointUrl?, protocolVersion? }" },
|
|
192
192
|
mailbox: { type: "object", description: "optional A2A git-mailbox coords { repo, selfOutboxAgentId }" },
|
|
193
193
|
bundleName: { type: "string", description: "optional bundle name" },
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { FriendStore } from "../store";
|
|
2
2
|
import type { GrantStore } from "../grant-store";
|
|
3
3
|
import type { MissionStore } from "../mission-store";
|
|
4
|
+
import type { AuditSink } from "../audit";
|
|
5
|
+
import type { ControlPlaneContext } from "./dispatch";
|
|
4
6
|
export interface FriendsMcpServerOptions {
|
|
5
7
|
store: FriendStore;
|
|
6
8
|
/** Optional consent-grant store. When omitted, the consent/share tools
|
|
@@ -11,6 +13,13 @@ export interface FriendsMcpServerOptions {
|
|
|
11
13
|
* (record_mission / get_mission / list_missions / share_mission /
|
|
12
14
|
* import_mission) report `unsupported`; everything else works without it. */
|
|
13
15
|
missions?: MissionStore;
|
|
16
|
+
/** Optional control-plane audit sink (Bug B, finding 3). When wired, the LIVE
|
|
17
|
+
* trust mutations (`set_trust`, and an explicit-trust-seat `onboard_agent`) append
|
|
18
|
+
* an append-only audit record. When omitted, those mutations are unaudited. */
|
|
19
|
+
audit?: AuditSink;
|
|
20
|
+
/** Optional WHO/WHENCE context for audited mutations. Defaults to the stdio
|
|
21
|
+
* owner-only boundary (finding 3-A) when omitted. */
|
|
22
|
+
controlContext?: ControlPlaneContext;
|
|
14
23
|
stdin: NodeJS.ReadableStream;
|
|
15
24
|
stdout: NodeJS.WritableStream;
|
|
16
25
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,7 +13,7 @@ const observability_1 = require("../observability");
|
|
|
13
13
|
const schemas_1 = require("./schemas");
|
|
14
14
|
const dispatch_1 = require("./dispatch");
|
|
15
15
|
function createFriendsMcpServer(options) {
|
|
16
|
-
const { store, grants, missions, stdin, stdout } = options;
|
|
16
|
+
const { store, grants, missions, audit, controlContext, stdin, stdout } = options;
|
|
17
17
|
let buffer = "";
|
|
18
18
|
let running = false;
|
|
19
19
|
let useContentLengthFraming = true;
|
|
@@ -145,7 +145,7 @@ function createFriendsMcpServer(options) {
|
|
|
145
145
|
const toolName = params.name ?? "";
|
|
146
146
|
const toolArgs = params.arguments ?? {};
|
|
147
147
|
try {
|
|
148
|
-
const { result, isError } = await (0, dispatch_1.dispatchTool)(store, toolName, toolArgs, grants, missions);
|
|
148
|
+
const { result, isError } = await (0, dispatch_1.dispatchTool)(store, toolName, toolArgs, grants, missions, audit, controlContext);
|
|
149
149
|
writeResponse({
|
|
150
150
|
jsonrpc: "2.0",
|
|
151
151
|
id: request.id,
|
package/dist/resolver.d.ts
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
import type { FriendStore } from "./store";
|
|
2
2
|
import type { IdentityProvider, ResolvedContext } from "./types";
|
|
3
|
+
import type { RosterStore } from "./roster-store";
|
|
4
|
+
import type { RosterVerifier } from "./roster-verifier";
|
|
5
|
+
/** Optional roster context for a cold-contact resolution (Bug C). When supplied AND
|
|
6
|
+
* the candidate's `did` is a key-verified member of the pinned account roster, the
|
|
7
|
+
* resolver seats `family` (attributable to `same_account`) even when the peer is on
|
|
8
|
+
* a different OS user. Constructor-injected (not a `resolve()` arg) so existing
|
|
9
|
+
* `new FriendResolver(store, params)` call sites stay source-compatible. The
|
|
10
|
+
* resolver stays core-clean: the Ed25519 `verifier` arrives via the seam — never an
|
|
11
|
+
* a2a-client import. */
|
|
12
|
+
export interface FriendResolverRosterContext {
|
|
13
|
+
store: RosterStore;
|
|
14
|
+
accountId: string;
|
|
15
|
+
/** The peer's did. PRECONDITION (finding 2): the host has already authenticated
|
|
16
|
+
* that the peer controls this did (the a2a-client sealed-envelope gate runs
|
|
17
|
+
* `DidVerifier` before resolution). The resolver wraps it in a `VerifiedCandidate`
|
|
18
|
+
* on the caller's behalf — so only seed this from a verified inbound identity, never
|
|
19
|
+
* an unauthenticated, peer-claimed string. */
|
|
20
|
+
candidateDid: string;
|
|
21
|
+
/** PRECONDITION (finding 1): to actually seat family, this MUST be a cryptographic
|
|
22
|
+
* verifier (`ed25519RosterVerifier`, `grantsFamily: true`). With it absent (the
|
|
23
|
+
* identity-only default), `evaluateAccountMembership` fails closed and the resolver
|
|
24
|
+
* keeps the cold-A2A `stranger` default — never family. */
|
|
25
|
+
verifier?: RosterVerifier;
|
|
26
|
+
}
|
|
3
27
|
export interface FriendResolverParams {
|
|
4
28
|
provider: IdentityProvider;
|
|
5
29
|
externalId: string;
|
|
@@ -22,7 +46,14 @@ export declare function isLocalMachineOwnerIdentity(provider: string, externalId
|
|
|
22
46
|
export declare class FriendResolver {
|
|
23
47
|
private readonly store;
|
|
24
48
|
private readonly params;
|
|
25
|
-
|
|
49
|
+
private readonly roster?;
|
|
50
|
+
constructor(store: FriendStore, params: FriendResolverParams, roster?: FriendResolverRosterContext);
|
|
26
51
|
resolve(): Promise<ResolvedContext>;
|
|
27
52
|
private resolveOrCreate;
|
|
53
|
+
/** Bug C — whether the candidate is family via a key-verified account roster.
|
|
54
|
+
* Reads the pinned roster + roster key from the injected RosterStore and reuses
|
|
55
|
+
* `evaluateAccountMembership`. Returns false (no family) when no roster context is
|
|
56
|
+
* wired, the roster/pin is absent, or membership is anything but
|
|
57
|
+
* `family_same_account` (unverified / not-member / key-mismatch all stay false). */
|
|
58
|
+
private evaluateRosterFamily;
|
|
28
59
|
}
|
package/dist/resolver.js
CHANGED
|
@@ -11,6 +11,7 @@ const crypto_1 = require("crypto");
|
|
|
11
11
|
const os_1 = require("os");
|
|
12
12
|
const channel_1 = require("./channel");
|
|
13
13
|
const observability_1 = require("./observability");
|
|
14
|
+
const account_roster_1 = require("./account-roster");
|
|
14
15
|
const CURRENT_SCHEMA_VERSION = 1;
|
|
15
16
|
// Test seam: when set (including to null), overrides OS detection of the
|
|
16
17
|
// machine-owner username so resolver tests are deterministic.
|
|
@@ -46,9 +47,11 @@ function isLocalMachineOwnerIdentity(provider, externalId, ownerUsername) {
|
|
|
46
47
|
class FriendResolver {
|
|
47
48
|
store;
|
|
48
49
|
params;
|
|
49
|
-
|
|
50
|
+
roster;
|
|
51
|
+
constructor(store, params, roster) {
|
|
50
52
|
this.store = store;
|
|
51
53
|
this.params = params;
|
|
54
|
+
this.roster = roster;
|
|
52
55
|
}
|
|
53
56
|
async resolve() {
|
|
54
57
|
const friend = await this.resolveOrCreate();
|
|
@@ -123,6 +126,13 @@ class FriendResolver {
|
|
|
123
126
|
}
|
|
124
127
|
const isFirstImprint = !hasAnyFriends;
|
|
125
128
|
const isA2AAgent = this.params.provider === "a2a-agent";
|
|
129
|
+
// Bug C — roster-awareness. When a roster context is injected AND the candidate's
|
|
130
|
+
// did is a key-verified member of the pinned account roster, seat `family` (even
|
|
131
|
+
// on a different OS user). Reuses `evaluateAccountMembership` against the pinned
|
|
132
|
+
// roster fetched from the injected RosterStore. Absent/unverifiable roster ⇒ the
|
|
133
|
+
// OS-owner + cold-A2A default below is unchanged. The resolver never imports
|
|
134
|
+
// a2a-client — the Ed25519 verifier arrives via the injected seam.
|
|
135
|
+
const isRosterFamily = await this.evaluateRosterFamily();
|
|
126
136
|
// The local friend that names the OS user running the daemon is the machine
|
|
127
137
|
// owner (family) — they own the agent + its bundle. Usually this friend already
|
|
128
138
|
// exists as a family/primary hatch imprint; this covers the un-imprinted boss
|
|
@@ -146,8 +156,11 @@ class FriendResolver {
|
|
|
146
156
|
const friend = {
|
|
147
157
|
id: (0, crypto_1.randomUUID)(),
|
|
148
158
|
name: this.params.displayName,
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
// Bug C: a key-verified roster member is family (role + trustLevel), overriding
|
|
160
|
+
// the cold-A2A `agent-peer`/`stranger` default. Otherwise the matrix is
|
|
161
|
+
// unchanged: a2a ⇒ agent-peer/stranger; first imprint or machine owner ⇒ family.
|
|
162
|
+
role: isRosterFamily ? "family" : isA2AAgent ? "agent-peer" : isFirstImprint ? "primary" : isLocalMachineOwner ? "family" : "stranger",
|
|
163
|
+
trustLevel: isRosterFamily ? "family" : isA2AAgent ? "stranger" : (isFirstImprint || isLocalMachineOwner) ? "family" : "stranger",
|
|
151
164
|
connections: [],
|
|
152
165
|
externalIds: [externalId],
|
|
153
166
|
tenantMemberships,
|
|
@@ -183,5 +196,39 @@ class FriendResolver {
|
|
|
183
196
|
}
|
|
184
197
|
return friend;
|
|
185
198
|
}
|
|
199
|
+
/** Bug C — whether the candidate is family via a key-verified account roster.
|
|
200
|
+
* Reads the pinned roster + roster key from the injected RosterStore and reuses
|
|
201
|
+
* `evaluateAccountMembership`. Returns false (no family) when no roster context is
|
|
202
|
+
* wired, the roster/pin is absent, or membership is anything but
|
|
203
|
+
* `family_same_account` (unverified / not-member / key-mismatch all stay false). */
|
|
204
|
+
async evaluateRosterFamily() {
|
|
205
|
+
const ctx = this.roster;
|
|
206
|
+
if (!ctx)
|
|
207
|
+
return false;
|
|
208
|
+
const roster = await ctx.store.getRoster(ctx.accountId);
|
|
209
|
+
const pin = await ctx.store.getPin(ctx.accountId);
|
|
210
|
+
// The resolver grants family only against an ALREADY-pinned roster key (the pin
|
|
211
|
+
// is established during explicit onboarding, never silently at resolve time).
|
|
212
|
+
if (!roster || !pin)
|
|
213
|
+
return false;
|
|
214
|
+
const result = await (0, account_roster_1.evaluateAccountMembership)({
|
|
215
|
+
roster,
|
|
216
|
+
// The host authenticated the peer's control of this did upstream (see the
|
|
217
|
+
// FriendResolverRosterContext.candidateDid precondition); wrap it as verified.
|
|
218
|
+
candidate: (0, account_roster_1.verifiedCandidate)(ctx.candidateDid),
|
|
219
|
+
rosterKey: pin.rosterKey,
|
|
220
|
+
store: ctx.store,
|
|
221
|
+
...(ctx.verifier !== undefined ? { verifier: ctx.verifier } : {}),
|
|
222
|
+
});
|
|
223
|
+
if (result.decision !== "family_same_account")
|
|
224
|
+
return false;
|
|
225
|
+
(0, observability_1.emitNervesEvent)({
|
|
226
|
+
component: "friends",
|
|
227
|
+
event: "friends.family_via_roster",
|
|
228
|
+
message: "seated family via the account roster (same_account)",
|
|
229
|
+
meta: { accountId: ctx.accountId, candidateDid: ctx.candidateDid },
|
|
230
|
+
});
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
186
233
|
}
|
|
187
234
|
exports.FriendResolver = FriendResolver;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AccountRoster, RosterPin, RosterStore } from "./roster-store";
|
|
2
|
+
/** The sibling rosters directory for a given friends directory:
|
|
3
|
+
* `<friendsDir>/_rosters`. A reserved `_`-prefixed subdir (like `_grants`) so one
|
|
4
|
+
* `--dir` still points the whole substrate at one place. */
|
|
5
|
+
export declare function rostersDirFor(friendsDir: string): string;
|
|
6
|
+
export declare class FileRosterStore implements RosterStore {
|
|
7
|
+
private readonly rostersPath;
|
|
8
|
+
constructor(rostersPath: string);
|
|
9
|
+
getRoster(accountId: string): Promise<AccountRoster | null>;
|
|
10
|
+
putRoster(roster: AccountRoster): Promise<void>;
|
|
11
|
+
getPin(accountId: string): Promise<RosterPin | null>;
|
|
12
|
+
putPin(pin: RosterPin): Promise<void>;
|
|
13
|
+
/** Read + parse a JSON file, returning null on a missing file, invalid JSON, or a
|
|
14
|
+
* non-object payload (guarded; mirrors FileGrantStore.readJson). */
|
|
15
|
+
private readJson;
|
|
16
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FileRosterStore = void 0;
|
|
37
|
+
exports.rostersDirFor = rostersDirFor;
|
|
38
|
+
// FileRosterStore — filesystem adapter for RosterStore.
|
|
39
|
+
//
|
|
40
|
+
// Stores the account roster and its pinned key as JSON files in a sibling
|
|
41
|
+
// `_rosters/` collection next to the friends directory (one `<accountId>.roster.json`
|
|
42
|
+
// and one `<accountId>.pin.json` per account). Mirrors FileGrantStore's structure
|
|
43
|
+
// (mkdir on construct, one file per record, guarded reads) so the stores feel
|
|
44
|
+
// uniform.
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const fsPromises = __importStar(require("fs/promises"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const observability_1 = require("./observability");
|
|
49
|
+
/** The sibling rosters directory for a given friends directory:
|
|
50
|
+
* `<friendsDir>/_rosters`. A reserved `_`-prefixed subdir (like `_grants`) so one
|
|
51
|
+
* `--dir` still points the whole substrate at one place. */
|
|
52
|
+
function rostersDirFor(friendsDir) {
|
|
53
|
+
return path.join(friendsDir, "_rosters");
|
|
54
|
+
}
|
|
55
|
+
/** SECURITY (finding 8, LOW): the accountId is interpolated into the
|
|
56
|
+
* `<accountId>.roster.json` / `.pin.json` filename, so a wire-influenced value like
|
|
57
|
+
* "../evil" could otherwise escape the `_rosters/` dir. Enforce a strict allowlist
|
|
58
|
+
* (alphanumerics, dot, underscore, hyphen — no path separators) AND a basename
|
|
59
|
+
* identity (no normalization surprises), rejecting `.`/`..` and the empty string.
|
|
60
|
+
* Throws on anything unsafe so a bad accountId can never reach the filesystem. */
|
|
61
|
+
const SAFE_ACCOUNT_ID = /^[A-Za-z0-9._-]+$/;
|
|
62
|
+
function assertSafeAccountId(accountId) {
|
|
63
|
+
// Basename identity is the primary traversal guard: any value carrying a path
|
|
64
|
+
// separator (`/` on POSIX, `/` or `\` on Windows) basenames to something else and is
|
|
65
|
+
// rejected. The charset allowlist then rejects the remaining unsafe-but-separatorless
|
|
66
|
+
// bytes (spaces, shell metachars, control chars, the empty string — `+` needs ≥1
|
|
67
|
+
// char). The explicit `.`/`..` reject closes the two in-charset relative-dir refs.
|
|
68
|
+
if (path.basename(accountId) !== accountId) {
|
|
69
|
+
throw new Error(`unsafe accountId ${JSON.stringify(accountId)}: must not contain a path separator`);
|
|
70
|
+
}
|
|
71
|
+
if (!SAFE_ACCOUNT_ID.test(accountId) || accountId === "." || accountId === "..") {
|
|
72
|
+
throw new Error(`unsafe accountId ${JSON.stringify(accountId)}: must match ${SAFE_ACCOUNT_ID} (not "." or "..")`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
class FileRosterStore {
|
|
76
|
+
rostersPath;
|
|
77
|
+
constructor(rostersPath) {
|
|
78
|
+
this.rostersPath = rostersPath;
|
|
79
|
+
fs.mkdirSync(rostersPath, { recursive: true });
|
|
80
|
+
(0, observability_1.emitNervesEvent)({
|
|
81
|
+
component: "friends",
|
|
82
|
+
event: "friends.roster_store_init",
|
|
83
|
+
message: "file roster store initialized",
|
|
84
|
+
meta: {},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async getRoster(accountId) {
|
|
88
|
+
assertSafeAccountId(accountId);
|
|
89
|
+
const raw = await this.readJson(path.join(this.rostersPath, `${accountId}.roster.json`));
|
|
90
|
+
return raw;
|
|
91
|
+
}
|
|
92
|
+
async putRoster(roster) {
|
|
93
|
+
assertSafeAccountId(roster.accountId);
|
|
94
|
+
await fsPromises.writeFile(path.join(this.rostersPath, `${roster.accountId}.roster.json`), JSON.stringify(roster, null, 2), "utf-8");
|
|
95
|
+
}
|
|
96
|
+
async getPin(accountId) {
|
|
97
|
+
assertSafeAccountId(accountId);
|
|
98
|
+
const raw = await this.readJson(path.join(this.rostersPath, `${accountId}.pin.json`));
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
async putPin(pin) {
|
|
102
|
+
assertSafeAccountId(pin.accountId);
|
|
103
|
+
await fsPromises.writeFile(path.join(this.rostersPath, `${pin.accountId}.pin.json`), JSON.stringify(pin, null, 2), "utf-8");
|
|
104
|
+
}
|
|
105
|
+
/** Read + parse a JSON file, returning null on a missing file, invalid JSON, or a
|
|
106
|
+
* non-object payload (guarded; mirrors FileGrantStore.readJson). */
|
|
107
|
+
async readJson(filePath) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = await fsPromises.readFile(filePath, "utf-8");
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(raw);
|
|
112
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
113
|
+
return null;
|
|
114
|
+
return parsed;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
exports.FileRosterStore = FileRosterStore;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AccountRoster, RosterPin, RosterStore } from "./roster-store";
|
|
2
|
+
export declare class MemoryRosterStore implements RosterStore {
|
|
3
|
+
private readonly rosters;
|
|
4
|
+
private readonly pins;
|
|
5
|
+
getRoster(accountId: string): Promise<AccountRoster | null>;
|
|
6
|
+
putRoster(roster: AccountRoster): Promise<void>;
|
|
7
|
+
getPin(accountId: string): Promise<RosterPin | null>;
|
|
8
|
+
putPin(pin: RosterPin): Promise<void>;
|
|
9
|
+
}
|