@ouro.bot/friends 0.1.0-alpha.4
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/LICENSE +201 -0
- package/README.md +514 -0
- package/changelog.json +34 -0
- package/dist/a2a/index.d.ts +102 -0
- package/dist/a2a/index.js +198 -0
- package/dist/agent-peer.d.ts +17 -0
- package/dist/agent-peer.js +57 -0
- package/dist/channel.d.ts +11 -0
- package/dist/channel.js +132 -0
- package/dist/consent.d.ts +34 -0
- package/dist/consent.js +62 -0
- package/dist/coordination.d.ts +100 -0
- package/dist/coordination.js +255 -0
- package/dist/file-bundle.d.ts +12 -0
- package/dist/file-bundle.js +23 -0
- package/dist/grant-store-file.d.ts +16 -0
- package/dist/grant-store-file.js +136 -0
- package/dist/grant-store.d.ts +7 -0
- package/dist/grant-store.js +8 -0
- package/dist/grants.d.ts +39 -0
- package/dist/grants.js +84 -0
- package/dist/group-context.d.ts +21 -0
- package/dist/group-context.js +144 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +105 -0
- package/dist/link-identity.d.ts +14 -0
- package/dist/link-identity.js +88 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +16 -0
- package/dist/mcp/dispatch.d.ts +14 -0
- package/dist/mcp/dispatch.js +432 -0
- package/dist/mcp/index.d.ts +6 -0
- package/dist/mcp/index.js +14 -0
- package/dist/mcp/run-main.d.ts +7 -0
- package/dist/mcp/run-main.js +45 -0
- package/dist/mcp/schemas.d.ts +10 -0
- package/dist/mcp/schemas.js +398 -0
- package/dist/mcp/server.d.ts +21 -0
- package/dist/mcp/server.js +194 -0
- package/dist/mission-share.d.ts +94 -0
- package/dist/mission-share.js +232 -0
- package/dist/mission-store-file.d.ts +18 -0
- package/dist/mission-store-file.js +153 -0
- package/dist/mission-store.d.ts +10 -0
- package/dist/mission-store.js +9 -0
- package/dist/missions.d.ts +31 -0
- package/dist/missions.js +98 -0
- package/dist/notes.d.ts +11 -0
- package/dist/notes.js +90 -0
- package/dist/observability.d.ts +27 -0
- package/dist/observability.js +31 -0
- package/dist/outcomes.d.ts +9 -0
- package/dist/outcomes.js +51 -0
- package/dist/resolver.d.ts +28 -0
- package/dist/resolver.js +187 -0
- package/dist/results.d.ts +8 -0
- package/dist/results.js +2 -0
- package/dist/room.d.ts +22 -0
- package/dist/room.js +40 -0
- package/dist/share.d.ts +106 -0
- package/dist/share.js +223 -0
- package/dist/standing.d.ts +83 -0
- package/dist/standing.js +111 -0
- package/dist/store-file.d.ts +21 -0
- package/dist/store-file.js +264 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +4 -0
- package/dist/tokens.d.ts +8 -0
- package/dist/tokens.js +26 -0
- package/dist/trust-explanation.d.ts +16 -0
- package/dist/trust-explanation.js +74 -0
- package/dist/trust-mutation.d.ts +4 -0
- package/dist/trust-mutation.js +29 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +51 -0
- package/dist/util/cap-string.d.ts +7 -0
- package/dist/util/cap-string.js +35 -0
- package/dist/verifier.d.ts +11 -0
- package/dist/verifier.js +29 -0
- package/dist/whoami.d.ts +7 -0
- package/dist/whoami.js +39 -0
- package/package.json +68 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.prepareCoordination = prepareCoordination;
|
|
4
|
+
exports.importCoordination = importCoordination;
|
|
5
|
+
// prepareCoordination (producer) + importCoordination (consumer) — the shared-work
|
|
6
|
+
// assignment ledger (brick 5). Structural twins of mission-share.ts's mission
|
|
7
|
+
// producer / consumer, re-aimed from a FACT (a learning / an outcome) at an
|
|
8
|
+
// ASSIGNMENT (who is doing this mission).
|
|
9
|
+
//
|
|
10
|
+
// Coordination is a tiny, append-only set of mission-coordination messages (five
|
|
11
|
+
// verbs: request / offer / accept / decline / handoff) that ride the brick-2
|
|
12
|
+
// mailbox under a new `kind:"coordination"`, gated by trust (brick 1) + consent
|
|
13
|
+
// (the existing grant stack, new `"coordinate"` scope), whose ONLY persisted effect
|
|
14
|
+
// is one bounded sub-object on the mission a participant already shares — its
|
|
15
|
+
// `coordination` (assignee + an append-only log). Store-only + transport-agnostic:
|
|
16
|
+
// prepareCoordination returns an envelope, importCoordination consumes one; the
|
|
17
|
+
// WIRE is the caller's job. Pure — the only node builtin is `node:crypto`.
|
|
18
|
+
//
|
|
19
|
+
// Through-line invariants (every one is tested in coordination.test.ts):
|
|
20
|
+
// - the mission is named by its JOIN KEY (`missionKey`), NEVER the local UUID;
|
|
21
|
+
// - the message is consent-gated (subject = the missionKey, scope = "coordinate");
|
|
22
|
+
// - imported intents NEVER touch first-party `learnings`/`notes`/`status` (they
|
|
23
|
+
// only append to `coordination.log` and, for an `accept`, set `assignee`);
|
|
24
|
+
// - the source agent's trust CAPS acceptance (a stranger peer is refused);
|
|
25
|
+
// - `status` / `participants` / `trustLevel` / `standing` are NEVER recomputed
|
|
26
|
+
// from a coordination message (non-transitive);
|
|
27
|
+
// - a `handoff` NEVER forces an `assignee` onto the receiver — only the receiver's
|
|
28
|
+
// own `accept` sets it (non-transitive handoff);
|
|
29
|
+
// - the ONE producer-side precondition: you must HOLD the assignment to hand it off;
|
|
30
|
+
// - assignee-conflict is last-writer-wins by `issuedAt` (the mailbox's total order);
|
|
31
|
+
// - an unknown mission may be SEEDED only by a friend/family introducing peer.
|
|
32
|
+
const node_crypto_1 = require("node:crypto");
|
|
33
|
+
const observability_1 = require("./observability");
|
|
34
|
+
const consent_1 = require("./consent");
|
|
35
|
+
const verifier_1 = require("./verifier");
|
|
36
|
+
/** Append a log entry to a mission's coordination sub-object (creating the
|
|
37
|
+
* sub-object if absent), returning a NEW MissionCoordination (never mutates the
|
|
38
|
+
* input). The log is append-only — this only ever ADDS one step. */
|
|
39
|
+
function appendLog(existing, entry) {
|
|
40
|
+
const base = existing ?? { log: [] };
|
|
41
|
+
return { ...base, log: [...base.log, entry] };
|
|
42
|
+
}
|
|
43
|
+
/** Whether this agent currently HOLDS the mission's assignment — the one
|
|
44
|
+
* producer-side precondition (you can only hand off what you hold). A one-line
|
|
45
|
+
* equality check, not a state machine. */
|
|
46
|
+
function holdsAssignment(record, selfAgentId) {
|
|
47
|
+
return record.coordination?.assignee?.agentId === selfAgentId;
|
|
48
|
+
}
|
|
49
|
+
/** Apply the OUTGOING intent to the producer's own mission record as a first-party
|
|
50
|
+
* step: always append the intent to `coordination.log`; on an `accept`, also claim
|
|
51
|
+
* the assignment for self (the accepter is taking it). No other intent moves the
|
|
52
|
+
* producer's `assignee`. Mirrors how recordMission stamps first-party provenance. */
|
|
53
|
+
function applyOutgoingIntent(record, input, now) {
|
|
54
|
+
const entry = {
|
|
55
|
+
intent: input.intent,
|
|
56
|
+
fromAgentId: input.selfAgentId,
|
|
57
|
+
at: now,
|
|
58
|
+
provenance: { origin: "first_party" },
|
|
59
|
+
...(input.note !== undefined ? { note: input.note } : {}),
|
|
60
|
+
};
|
|
61
|
+
const withLog = appendLog(record.coordination, entry);
|
|
62
|
+
const coordination = input.intent === "accept"
|
|
63
|
+
? { ...withLog, assignee: { agentId: input.selfAgentId }, assignedAt: now }
|
|
64
|
+
: withLog;
|
|
65
|
+
return { ...record, coordination, updatedAt: now };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Producer half of the coordination primitive. Consent-gated (subject = the
|
|
69
|
+
* mission's `missionKey`, scope = `"coordinate"`), names the mission by its join
|
|
70
|
+
* key (never the local UUID). The recipient's trust — read off this agent's own
|
|
71
|
+
* friend record for `toAgentId` — is the authorization input the policy uses. The
|
|
72
|
+
* ONLY precondition: `handoff` requires this agent to hold the assignment. Also
|
|
73
|
+
* records the outgoing intent on the local mission as a first-party log step.
|
|
74
|
+
*/
|
|
75
|
+
async function prepareCoordination(missions, store, grants, input, consent = consent_1.DEFAULT_CONSENT_POLICY) {
|
|
76
|
+
const record = await missions.get(input.missionId);
|
|
77
|
+
if (!record) {
|
|
78
|
+
return { ok: false, status: "not_found" };
|
|
79
|
+
}
|
|
80
|
+
// The recipient's trust is read from this agent's own knowledge of it (the
|
|
81
|
+
// a2a-agent friend record for toAgentId). An unknown recipient defaults to
|
|
82
|
+
// stranger — never trusted by default.
|
|
83
|
+
const recipientRecord = await store.findByExternalId("a2a-agent", input.toAgentId);
|
|
84
|
+
const recipientTrust = recipientRecord?.trustLevel ?? "stranger";
|
|
85
|
+
const recipient = { agentId: input.toAgentId, trustLevel: recipientTrust };
|
|
86
|
+
// The subject is the mission, keyed by its missionKey. A coordination message
|
|
87
|
+
// consents through the EXISTING grant machinery via the new identity-tier
|
|
88
|
+
// `"coordinate"` scope — so trust ≥ friend suffices under the tiered default,
|
|
89
|
+
// with ZERO change to consent-policy logic (only the scope set grew).
|
|
90
|
+
const consented = await consent.consents({
|
|
91
|
+
subjectKey: record.missionKey,
|
|
92
|
+
recipient,
|
|
93
|
+
scope: "coordinate",
|
|
94
|
+
grants,
|
|
95
|
+
});
|
|
96
|
+
if (!consented) {
|
|
97
|
+
return { ok: false, status: "no_consent" };
|
|
98
|
+
}
|
|
99
|
+
// The ONE producer-side state check: you must HOLD the assignment to hand it off.
|
|
100
|
+
if (input.intent === "handoff" && !holdsAssignment(record, input.selfAgentId)) {
|
|
101
|
+
return { ok: false, status: "not_assignee" };
|
|
102
|
+
}
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const envelope = {
|
|
105
|
+
subject: { missionKey: record.missionKey, title: record.title },
|
|
106
|
+
fromAgentId: input.selfAgentId,
|
|
107
|
+
intent: input.intent,
|
|
108
|
+
issuedAt: now,
|
|
109
|
+
...(input.note !== undefined ? { note: input.note } : {}),
|
|
110
|
+
...(input.intent === "handoff" && input.proposedAssignee !== undefined
|
|
111
|
+
? { proposedAssignee: input.proposedAssignee }
|
|
112
|
+
: {}),
|
|
113
|
+
...(input.proof !== undefined ? { proof: input.proof } : {}),
|
|
114
|
+
};
|
|
115
|
+
// Record the outgoing intent on the producer's own mission (first-party), so the
|
|
116
|
+
// sender's record reflects "I asked / I offered / I accepted".
|
|
117
|
+
const updated = applyOutgoingIntent(record, input, now);
|
|
118
|
+
await missions.put(updated.id, updated);
|
|
119
|
+
(0, observability_1.emitNervesEvent)({
|
|
120
|
+
component: "friends",
|
|
121
|
+
event: "friends.coordination_prepared",
|
|
122
|
+
message: "prepared coordination envelope",
|
|
123
|
+
meta: { intent: input.intent, toAgentId: input.toAgentId, consentPolicy: consent.name },
|
|
124
|
+
});
|
|
125
|
+
return { ok: true, envelope };
|
|
126
|
+
}
|
|
127
|
+
// ── Consumer ──
|
|
128
|
+
/** Trust levels a peer must hold to INTRODUCE a previously-unknown mission via a
|
|
129
|
+
* coordination message. A friend/family peer may seed a new mission; a stranger /
|
|
130
|
+
* acquaintance peer may not (mirrors the mission-share SEEDING_TRUST). */
|
|
131
|
+
const SEEDING_TRUST = new Set(["family", "friend"]);
|
|
132
|
+
const TRUST_RANK = { family: 4, friend: 3, acquaintance: 2, stranger: 1 };
|
|
133
|
+
/** Whether an incoming intent is already in the log under the same identity-tuple
|
|
134
|
+
* `(intent, fromAgentId, issuedAt)` — the same idempotency technique
|
|
135
|
+
* mergeImportedOutcomes uses, so a replayed coordination message is a no-op on the
|
|
136
|
+
* log. `issuedAt` is matched against the entry's `at` (imports stamp `at = issuedAt`). */
|
|
137
|
+
function alreadyLogged(coordination, intent, fromAgentId, issuedAt) {
|
|
138
|
+
if (!coordination)
|
|
139
|
+
return false;
|
|
140
|
+
return coordination.log.some((e) => e.intent === intent && e.fromAgentId === fromAgentId && e.at === issuedAt);
|
|
141
|
+
}
|
|
142
|
+
/** Apply an INCOMING coordination envelope to a mission record. Appends the intent
|
|
143
|
+
* to `coordination.log` stamped `origin:imported` + attributed (never first-party,
|
|
144
|
+
* never duplicated). Applies the assignee effect — the ONLY mutation beyond the
|
|
145
|
+
* log, and tightly bounded:
|
|
146
|
+
* - `accept` → set assignee = the sender (the accepter is claiming it), with
|
|
147
|
+
* last-writer-wins by `issuedAt`: a later accept overrides an earlier one, an
|
|
148
|
+
* earlier accept never clobbers a later holder.
|
|
149
|
+
* - everything else (request/offer/decline/handoff) → log only; a `handoff`
|
|
150
|
+
* NEVER sets assignee on receipt (non-transitive — only a self-accept does).
|
|
151
|
+
* NEVER recomputes status/participants/trustLevel/standing (non-transitive). */
|
|
152
|
+
function applyIncomingIntent(record, envelope, fromAgentId, now) {
|
|
153
|
+
// Replay/idempotency: a message already in the log adds nothing (and can't move
|
|
154
|
+
// the assignee a second time).
|
|
155
|
+
if (alreadyLogged(record.coordination, envelope.intent, fromAgentId, envelope.issuedAt)) {
|
|
156
|
+
return { record, assigned: false };
|
|
157
|
+
}
|
|
158
|
+
const entry = {
|
|
159
|
+
intent: envelope.intent,
|
|
160
|
+
fromAgentId,
|
|
161
|
+
at: envelope.issuedAt,
|
|
162
|
+
provenance: { origin: "imported", assertedBy: { agentId: fromAgentId }, importedAt: now },
|
|
163
|
+
...(envelope.note !== undefined ? { note: envelope.note } : {}),
|
|
164
|
+
};
|
|
165
|
+
const withLog = appendLog(record.coordination, entry);
|
|
166
|
+
if (envelope.intent === "accept") {
|
|
167
|
+
// Last-writer-wins by issuedAt: the accept with the later issuedAt is the
|
|
168
|
+
// effective assignee. An earlier-issued accept arriving after a later one does
|
|
169
|
+
// NOT clobber the later holder (both stay in the append-only log either way).
|
|
170
|
+
const currentAssignedAt = record.coordination?.assignedAt;
|
|
171
|
+
const isLater = currentAssignedAt === undefined || envelope.issuedAt >= currentAssignedAt;
|
|
172
|
+
const coordination = isLater
|
|
173
|
+
? { ...withLog, assignee: { agentId: fromAgentId }, assignedAt: envelope.issuedAt }
|
|
174
|
+
: withLog;
|
|
175
|
+
return { record: { ...record, coordination, updatedAt: now }, assigned: isLater };
|
|
176
|
+
}
|
|
177
|
+
// request / offer / decline / handoff → log only; assignee untouched.
|
|
178
|
+
return { record: { ...record, coordination: withLog, updatedAt: now }, assigned: false };
|
|
179
|
+
}
|
|
180
|
+
/** Create a freshly-seeded mission for a previously-unknown key, carrying the
|
|
181
|
+
* subject's join key + title, `status:"active"`, empty first-party `learnings`. The
|
|
182
|
+
* coordination sub-object starts with an empty log (the incoming intent is applied
|
|
183
|
+
* by the caller). */
|
|
184
|
+
function seedMission(envelope, now) {
|
|
185
|
+
return {
|
|
186
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
187
|
+
missionKey: envelope.subject.missionKey,
|
|
188
|
+
title: envelope.subject.title,
|
|
189
|
+
status: "active",
|
|
190
|
+
participants: [],
|
|
191
|
+
outcomes: [],
|
|
192
|
+
learnings: {},
|
|
193
|
+
importedLearnings: {},
|
|
194
|
+
coordination: { log: [] },
|
|
195
|
+
createdAt: now,
|
|
196
|
+
updatedAt: now,
|
|
197
|
+
schemaVersion: 1,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Consumer half of the coordination primitive — the non-clobbering merge. Resolves
|
|
202
|
+
* the mission by `findByMissionKey`; appends the incoming intent to
|
|
203
|
+
* `coordination.log` stamped `origin:imported` WITHOUT touching first-party
|
|
204
|
+
* `learnings`/`notes`/`status`; applies the bounded assignee effect (only `accept`
|
|
205
|
+
* sets it; a `handoff` never forces it; conflicts are last-writer-wins by
|
|
206
|
+
* `issuedAt`); NEVER recomputes status / participants / trust / standing; the
|
|
207
|
+
* source agent's trust caps acceptance; seeds an unknown mission only when a
|
|
208
|
+
* friend/family peer introduces it.
|
|
209
|
+
*/
|
|
210
|
+
async function importCoordination(missions, input, options = {}) {
|
|
211
|
+
const verifier = options.verifier ?? verifier_1.DEFAULT_AGENT_VERIFIER;
|
|
212
|
+
const minTrust = options.minTrustToAccept ?? "acquaintance";
|
|
213
|
+
// Authentication (caller's seam) AND authorization (trust ladder) must BOTH pass.
|
|
214
|
+
const authenticated = verifier.verify(input.fromAgentId, input.envelope.proof);
|
|
215
|
+
const trustedEnough = TRUST_RANK[input.trustOfSource] >= TRUST_RANK[minTrust];
|
|
216
|
+
if (!authenticated || !trustedEnough) {
|
|
217
|
+
(0, observability_1.emitNervesEvent)({
|
|
218
|
+
component: "friends",
|
|
219
|
+
event: "friends.coordination_refused",
|
|
220
|
+
message: "refused coordination from untrusted source",
|
|
221
|
+
meta: { fromAgentId: input.fromAgentId, trustOfSource: input.trustOfSource, authenticated },
|
|
222
|
+
});
|
|
223
|
+
return { ok: false, status: "untrusted_source" };
|
|
224
|
+
}
|
|
225
|
+
const now = new Date().toISOString();
|
|
226
|
+
const existing = await missions.findByMissionKey(input.envelope.subject.missionKey);
|
|
227
|
+
if (!existing) {
|
|
228
|
+
// Unknown mission. Only a friend/family peer may seed a new one.
|
|
229
|
+
if (!SEEDING_TRUST.has(input.trustOfSource)) {
|
|
230
|
+
return { ok: false, status: "untrusted_introduction" };
|
|
231
|
+
}
|
|
232
|
+
const seeded = seedMission(input.envelope, now);
|
|
233
|
+
const { record: withIntent, assigned } = applyIncomingIntent(seeded, input.envelope, input.fromAgentId, now);
|
|
234
|
+
await missions.put(withIntent.id, withIntent);
|
|
235
|
+
(0, observability_1.emitNervesEvent)({
|
|
236
|
+
component: "friends",
|
|
237
|
+
event: "friends.coordination_seeded",
|
|
238
|
+
message: "seeded new mission from coordination",
|
|
239
|
+
meta: { missionId: withIntent.id, fromAgentId: input.fromAgentId, intent: input.envelope.intent },
|
|
240
|
+
});
|
|
241
|
+
// A seeded mission reports `seeded` even when the intent was an accept (the
|
|
242
|
+
// record creation is the salient fact); `assigned` is reflected in the record.
|
|
243
|
+
void assigned;
|
|
244
|
+
return { ok: true, status: "seeded", record: withIntent };
|
|
245
|
+
}
|
|
246
|
+
const { record: updated, assigned } = applyIncomingIntent(existing, input.envelope, input.fromAgentId, now);
|
|
247
|
+
await missions.put(updated.id, updated);
|
|
248
|
+
(0, observability_1.emitNervesEvent)({
|
|
249
|
+
component: "friends",
|
|
250
|
+
event: "friends.coordination_imported",
|
|
251
|
+
message: "imported coordination into existing mission",
|
|
252
|
+
meta: { missionId: updated.id, fromAgentId: input.fromAgentId, intent: input.envelope.intent, assigned },
|
|
253
|
+
});
|
|
254
|
+
return { ok: true, status: assigned ? "assigned" : "logged", record: updated };
|
|
255
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FileFriendStore } from "./store-file";
|
|
2
|
+
import { FileGrantStore } from "./grant-store-file";
|
|
3
|
+
import { FileMissionStore } from "./mission-store-file";
|
|
4
|
+
export interface FileBundle {
|
|
5
|
+
store: FileFriendStore;
|
|
6
|
+
grants: FileGrantStore;
|
|
7
|
+
missions: FileMissionStore;
|
|
8
|
+
friendsDir: string;
|
|
9
|
+
grantsDir: string;
|
|
10
|
+
missionsDir: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function openFileBundle(friendsDir: string): FileBundle;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openFileBundle = openFileBundle;
|
|
4
|
+
// openFileBundle — one-liner wiring of a bundle's friends dir into the two file
|
|
5
|
+
// stores, encapsulating the sibling `_grants/` convention. Additive ergonomics;
|
|
6
|
+
// the explicit two-store construction stays exported and unchanged.
|
|
7
|
+
const store_file_1 = require("./store-file");
|
|
8
|
+
const grant_store_file_1 = require("./grant-store-file");
|
|
9
|
+
const mission_store_file_1 = require("./mission-store-file");
|
|
10
|
+
const observability_1 = require("./observability");
|
|
11
|
+
function openFileBundle(friendsDir) {
|
|
12
|
+
const grantsDir = (0, grant_store_file_1.grantsDirFor)(friendsDir);
|
|
13
|
+
const missionsDir = (0, mission_store_file_1.missionsDirFor)(friendsDir);
|
|
14
|
+
(0, observability_1.emitNervesEvent)({ component: "friends", event: "friends.file_bundle_opened", message: "opened file bundle", meta: {} });
|
|
15
|
+
return {
|
|
16
|
+
store: new store_file_1.FileFriendStore(friendsDir),
|
|
17
|
+
grants: new grant_store_file_1.FileGrantStore(grantsDir),
|
|
18
|
+
missions: new mission_store_file_1.FileMissionStore(missionsDir),
|
|
19
|
+
friendsDir,
|
|
20
|
+
grantsDir,
|
|
21
|
+
missionsDir,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GrantStore } from "./grant-store";
|
|
2
|
+
import type { ShareGrant } from "./types";
|
|
3
|
+
/** The sibling grants directory for a given friends directory: `<friendsDir>/_grants`.
|
|
4
|
+
* The collection lives UNDER the friends dir (a reserved `_`-prefixed subdir) so a
|
|
5
|
+
* single `--dir` still points the whole substrate at one place. */
|
|
6
|
+
export declare function grantsDirFor(friendsDir: string): string;
|
|
7
|
+
export declare class FileGrantStore implements GrantStore {
|
|
8
|
+
private readonly grantsPath;
|
|
9
|
+
constructor(grantsPath: string);
|
|
10
|
+
get(id: string): Promise<ShareGrant | null>;
|
|
11
|
+
put(id: string, grant: ShareGrant): Promise<void>;
|
|
12
|
+
delete(id: string): Promise<void>;
|
|
13
|
+
listAll(): Promise<ShareGrant[]>;
|
|
14
|
+
private normalize;
|
|
15
|
+
private readJson;
|
|
16
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// FileGrantStore — filesystem adapter for GrantStore.
|
|
3
|
+
// Stores each ShareGrant as one JSON file in a sibling `_grants/` collection next
|
|
4
|
+
// to the friends directory. Mirrors FileFriendStore's structure (mkdir on
|
|
5
|
+
// construct, one file per record, guarded reads) so the two stores feel uniform.
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
+
}) : function(o, v) {
|
|
20
|
+
o["default"] = v;
|
|
21
|
+
});
|
|
22
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
+
var ownKeys = function(o) {
|
|
24
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
+
var ar = [];
|
|
26
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
+
return ar;
|
|
28
|
+
};
|
|
29
|
+
return ownKeys(o);
|
|
30
|
+
};
|
|
31
|
+
return function (mod) {
|
|
32
|
+
if (mod && mod.__esModule) return mod;
|
|
33
|
+
var result = {};
|
|
34
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
+
__setModuleDefault(result, mod);
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.FileGrantStore = void 0;
|
|
41
|
+
exports.grantsDirFor = grantsDirFor;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const fsPromises = __importStar(require("fs/promises"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const observability_1 = require("./observability");
|
|
46
|
+
const types_1 = require("./types");
|
|
47
|
+
/** The sibling grants directory for a given friends directory: `<friendsDir>/_grants`.
|
|
48
|
+
* The collection lives UNDER the friends dir (a reserved `_`-prefixed subdir) so a
|
|
49
|
+
* single `--dir` still points the whole substrate at one place. */
|
|
50
|
+
function grantsDirFor(friendsDir) {
|
|
51
|
+
return path.join(friendsDir, "_grants");
|
|
52
|
+
}
|
|
53
|
+
class FileGrantStore {
|
|
54
|
+
grantsPath;
|
|
55
|
+
constructor(grantsPath) {
|
|
56
|
+
this.grantsPath = grantsPath;
|
|
57
|
+
fs.mkdirSync(grantsPath, { recursive: true });
|
|
58
|
+
(0, observability_1.emitNervesEvent)({
|
|
59
|
+
component: "friends",
|
|
60
|
+
event: "friends.grant_store_init",
|
|
61
|
+
message: "file grant store initialized",
|
|
62
|
+
meta: {},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async get(id) {
|
|
66
|
+
const grant = await this.readJson(path.join(this.grantsPath, `${id}.json`));
|
|
67
|
+
return grant ? this.normalize(grant) : null;
|
|
68
|
+
}
|
|
69
|
+
async put(id, grant) {
|
|
70
|
+
await fsPromises.writeFile(path.join(this.grantsPath, `${id}.json`), JSON.stringify(this.normalize(grant), null, 2), "utf-8");
|
|
71
|
+
}
|
|
72
|
+
async delete(id) {
|
|
73
|
+
try {
|
|
74
|
+
await fsPromises.unlink(path.join(this.grantsPath, `${id}.json`));
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err?.code === "ENOENT")
|
|
78
|
+
return;
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async listAll() {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await fsPromises.readdir(this.grantsPath);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* v8 ignore next -- defensive: dir is mkdir'd in the constructor, so readdir
|
|
89
|
+
only throws if it's deleted mid-run; unreachable through the API @preserve */
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const grants = [];
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!entry.endsWith(".json"))
|
|
95
|
+
continue;
|
|
96
|
+
const raw = await this.readJson(path.join(this.grantsPath, entry));
|
|
97
|
+
if (!raw)
|
|
98
|
+
continue;
|
|
99
|
+
grants.push(this.normalize(raw));
|
|
100
|
+
}
|
|
101
|
+
return grants;
|
|
102
|
+
}
|
|
103
|
+
normalize(raw) {
|
|
104
|
+
return {
|
|
105
|
+
id: raw.id,
|
|
106
|
+
// Fork D compat seam (a): on-disk schemaVersion-1 grants carry the legacy
|
|
107
|
+
// `subjectFriendId`; newer ones carry `subjectKey`. Read both, persist
|
|
108
|
+
// forward as `subjectKey` so the legacy field migrates on the next write.
|
|
109
|
+
subjectKey: raw.subjectKey ?? raw.subjectFriendId ?? "",
|
|
110
|
+
recipientAgentId: raw.recipientAgentId,
|
|
111
|
+
scope: (0, types_1.isShareScope)(raw.scope) ? raw.scope : "identity",
|
|
112
|
+
grantedAt: typeof raw.grantedAt === "string" ? raw.grantedAt : new Date().toISOString(),
|
|
113
|
+
...(typeof raw.expiresAt === "string" ? { expiresAt: raw.expiresAt } : {}),
|
|
114
|
+
...(typeof raw.revokedAt === "string" ? { revokedAt: raw.revokedAt } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async readJson(filePath) {
|
|
118
|
+
try {
|
|
119
|
+
const raw = await fsPromises.readFile(filePath, "utf-8");
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
122
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
exports.FileGrantStore = FileGrantStore;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Grant store abstraction.
|
|
3
|
+
// Consent state (ShareGrant records) persists through GrantStore — a sibling to
|
|
4
|
+
// FriendStore, mirroring its shape. Grants are many-to-many (one subject ↔ many
|
|
5
|
+
// recipients ↔ many scopes) with their own lifecycle, so they live in their own
|
|
6
|
+
// collection rather than on the friend record. No grant module imports `fs`
|
|
7
|
+
// directly except the FileGrantStore adapter.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/grants.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GrantStore } from "./grant-store";
|
|
2
|
+
import type { ShareGrant, ShareScope } from "./types";
|
|
3
|
+
/** Whether a grant currently consents: not revoked, and not past its expiry as
|
|
4
|
+
* of `now`. The single source of truth for "effective", shared by the consent
|
|
5
|
+
* policies and the audit listing. */
|
|
6
|
+
export declare function isGrantEffective(grant: ShareGrant, now?: Date): boolean;
|
|
7
|
+
export interface GrantShareInput {
|
|
8
|
+
/** The subject whose data may be shared — a friend UUID, or a missionKey
|
|
9
|
+
* (Fork D: opaque subject key). */
|
|
10
|
+
subjectKey: string;
|
|
11
|
+
recipientAgentId: string;
|
|
12
|
+
scope: ShareScope;
|
|
13
|
+
/** Optional ISO expiry; absent ⇒ the grant never expires. */
|
|
14
|
+
expiresAt?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Mint an explicit share grant. Returns the persisted ShareGrant. */
|
|
17
|
+
export declare function grantShare(grants: GrantStore, input: GrantShareInput): Promise<ShareGrant>;
|
|
18
|
+
export interface RevokeShareResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
status: "revoked" | "not_found" | "noop";
|
|
21
|
+
grant?: ShareGrant;
|
|
22
|
+
}
|
|
23
|
+
/** Revoke a grant by id. Tombstones it (sets `revokedAt`) rather than deleting,
|
|
24
|
+
* so the audit trail survives. Re-revoking an already-revoked grant is a noop. */
|
|
25
|
+
export declare function revokeShare(grants: GrantStore, grantId: string): Promise<RevokeShareResult>;
|
|
26
|
+
export interface ListSharesFilter {
|
|
27
|
+
/** Filter to one subject (a friend UUID, or a missionKey). */
|
|
28
|
+
subjectKey?: string;
|
|
29
|
+
recipientAgentId?: string;
|
|
30
|
+
/** When true, only grants that currently consent (effective) are returned. */
|
|
31
|
+
effectiveOnly?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface ListedShare extends ShareGrant {
|
|
34
|
+
/** Whether this grant currently consents (not revoked, not expired). */
|
|
35
|
+
effective: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** List grants with their effective state, optionally filtered by subject /
|
|
38
|
+
* recipient / effectiveness. The inspect-and-revoke surface. */
|
|
39
|
+
export declare function listShares(grants: GrantStore, filter?: ListSharesFilter): Promise<ListedShare[]>;
|
package/dist/grants.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isGrantEffective = isGrantEffective;
|
|
4
|
+
exports.grantShare = grantShare;
|
|
5
|
+
exports.revokeShare = revokeShare;
|
|
6
|
+
exports.listShares = listShares;
|
|
7
|
+
// Consent-grant lifecycle — grantShare / revokeShare / listShares.
|
|
8
|
+
//
|
|
9
|
+
// The audit + revoke surface over a GrantStore (the GDPR / right-to-be-forgotten
|
|
10
|
+
// seam). `grantShare` mints an explicit ShareGrant; `revokeShare` tombstones one
|
|
11
|
+
// (sets `revokedAt` rather than deleting, so the audit trail survives);
|
|
12
|
+
// `listShares` returns grants with their effective state for inspection. The
|
|
13
|
+
// consent policies in `consent.ts` read these grants to decide whether a share is
|
|
14
|
+
// permitted — this module owns their CRUD, not the permission decision.
|
|
15
|
+
const node_crypto_1 = require("node:crypto");
|
|
16
|
+
const observability_1 = require("./observability");
|
|
17
|
+
/** Whether a grant currently consents: not revoked, and not past its expiry as
|
|
18
|
+
* of `now`. The single source of truth for "effective", shared by the consent
|
|
19
|
+
* policies and the audit listing. */
|
|
20
|
+
function isGrantEffective(grant, now = new Date()) {
|
|
21
|
+
if (grant.revokedAt)
|
|
22
|
+
return false;
|
|
23
|
+
if (grant.expiresAt && Date.parse(grant.expiresAt) <= now.getTime())
|
|
24
|
+
return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
/** Mint an explicit share grant. Returns the persisted ShareGrant. */
|
|
28
|
+
async function grantShare(grants, input) {
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
const grant = {
|
|
31
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
32
|
+
subjectKey: input.subjectKey,
|
|
33
|
+
recipientAgentId: input.recipientAgentId,
|
|
34
|
+
scope: input.scope,
|
|
35
|
+
grantedAt: now,
|
|
36
|
+
...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
|
|
37
|
+
};
|
|
38
|
+
await grants.put(grant.id, grant);
|
|
39
|
+
(0, observability_1.emitNervesEvent)({
|
|
40
|
+
component: "friends",
|
|
41
|
+
event: "friends.share_granted",
|
|
42
|
+
message: "granted profile share",
|
|
43
|
+
meta: { subjectKey: input.subjectKey, recipientAgentId: input.recipientAgentId, scope: input.scope },
|
|
44
|
+
});
|
|
45
|
+
return grant;
|
|
46
|
+
}
|
|
47
|
+
/** Revoke a grant by id. Tombstones it (sets `revokedAt`) rather than deleting,
|
|
48
|
+
* so the audit trail survives. Re-revoking an already-revoked grant is a noop. */
|
|
49
|
+
async function revokeShare(grants, grantId) {
|
|
50
|
+
const grant = await grants.get(grantId);
|
|
51
|
+
if (!grant) {
|
|
52
|
+
return { ok: false, status: "not_found" };
|
|
53
|
+
}
|
|
54
|
+
if (grant.revokedAt) {
|
|
55
|
+
return { ok: true, status: "noop", grant };
|
|
56
|
+
}
|
|
57
|
+
const revoked = { ...grant, revokedAt: new Date().toISOString() };
|
|
58
|
+
await grants.put(revoked.id, revoked);
|
|
59
|
+
(0, observability_1.emitNervesEvent)({
|
|
60
|
+
component: "friends",
|
|
61
|
+
event: "friends.share_revoked",
|
|
62
|
+
message: "revoked profile share",
|
|
63
|
+
meta: { grantId },
|
|
64
|
+
});
|
|
65
|
+
return { ok: true, status: "revoked", grant: revoked };
|
|
66
|
+
}
|
|
67
|
+
/** List grants with their effective state, optionally filtered by subject /
|
|
68
|
+
* recipient / effectiveness. The inspect-and-revoke surface. */
|
|
69
|
+
async function listShares(grants, filter = {}) {
|
|
70
|
+
const all = await grants.listAll();
|
|
71
|
+
const now = new Date();
|
|
72
|
+
const listed = all
|
|
73
|
+
.filter((g) => filter.subjectKey === undefined || g.subjectKey === filter.subjectKey)
|
|
74
|
+
.filter((g) => filter.recipientAgentId === undefined || g.recipientAgentId === filter.recipientAgentId)
|
|
75
|
+
.map((g) => ({ ...g, effective: isGrantEffective(g, now) }))
|
|
76
|
+
.filter((g) => filter.effectiveOnly !== true || g.effective);
|
|
77
|
+
(0, observability_1.emitNervesEvent)({
|
|
78
|
+
component: "friends",
|
|
79
|
+
event: "friends.shares_listed",
|
|
80
|
+
message: "listed profile shares",
|
|
81
|
+
meta: { count: listed.length },
|
|
82
|
+
});
|
|
83
|
+
return listed;
|
|
84
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FriendStore } from "./store";
|
|
2
|
+
import type { TrustLevel } from "./types";
|
|
3
|
+
export interface GroupContextParticipant {
|
|
4
|
+
provider: "imessage-handle" | "aad" | "teams-conversation";
|
|
5
|
+
externalId: string;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GroupContextUpsertResult {
|
|
9
|
+
friendId: string;
|
|
10
|
+
name: string;
|
|
11
|
+
trustLevel: TrustLevel;
|
|
12
|
+
created: boolean;
|
|
13
|
+
updated: boolean;
|
|
14
|
+
addedGroupExternalId: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function upsertGroupContextParticipants(input: {
|
|
17
|
+
store: FriendStore;
|
|
18
|
+
participants: GroupContextParticipant[];
|
|
19
|
+
groupExternalId: string;
|
|
20
|
+
now?: () => string;
|
|
21
|
+
}): Promise<GroupContextUpsertResult[]>;
|