@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.
Files changed (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +514 -0
  3. package/changelog.json +34 -0
  4. package/dist/a2a/index.d.ts +102 -0
  5. package/dist/a2a/index.js +198 -0
  6. package/dist/agent-peer.d.ts +17 -0
  7. package/dist/agent-peer.js +57 -0
  8. package/dist/channel.d.ts +11 -0
  9. package/dist/channel.js +132 -0
  10. package/dist/consent.d.ts +34 -0
  11. package/dist/consent.js +62 -0
  12. package/dist/coordination.d.ts +100 -0
  13. package/dist/coordination.js +255 -0
  14. package/dist/file-bundle.d.ts +12 -0
  15. package/dist/file-bundle.js +23 -0
  16. package/dist/grant-store-file.d.ts +16 -0
  17. package/dist/grant-store-file.js +136 -0
  18. package/dist/grant-store.d.ts +7 -0
  19. package/dist/grant-store.js +8 -0
  20. package/dist/grants.d.ts +39 -0
  21. package/dist/grants.js +84 -0
  22. package/dist/group-context.d.ts +21 -0
  23. package/dist/group-context.js +144 -0
  24. package/dist/index.d.ts +49 -0
  25. package/dist/index.js +105 -0
  26. package/dist/link-identity.d.ts +14 -0
  27. package/dist/link-identity.js +88 -0
  28. package/dist/mcp/bin.d.ts +2 -0
  29. package/dist/mcp/bin.js +16 -0
  30. package/dist/mcp/dispatch.d.ts +14 -0
  31. package/dist/mcp/dispatch.js +432 -0
  32. package/dist/mcp/index.d.ts +6 -0
  33. package/dist/mcp/index.js +14 -0
  34. package/dist/mcp/run-main.d.ts +7 -0
  35. package/dist/mcp/run-main.js +45 -0
  36. package/dist/mcp/schemas.d.ts +10 -0
  37. package/dist/mcp/schemas.js +398 -0
  38. package/dist/mcp/server.d.ts +21 -0
  39. package/dist/mcp/server.js +194 -0
  40. package/dist/mission-share.d.ts +94 -0
  41. package/dist/mission-share.js +232 -0
  42. package/dist/mission-store-file.d.ts +18 -0
  43. package/dist/mission-store-file.js +153 -0
  44. package/dist/mission-store.d.ts +10 -0
  45. package/dist/mission-store.js +9 -0
  46. package/dist/missions.d.ts +31 -0
  47. package/dist/missions.js +98 -0
  48. package/dist/notes.d.ts +11 -0
  49. package/dist/notes.js +90 -0
  50. package/dist/observability.d.ts +27 -0
  51. package/dist/observability.js +31 -0
  52. package/dist/outcomes.d.ts +9 -0
  53. package/dist/outcomes.js +51 -0
  54. package/dist/resolver.d.ts +28 -0
  55. package/dist/resolver.js +187 -0
  56. package/dist/results.d.ts +8 -0
  57. package/dist/results.js +2 -0
  58. package/dist/room.d.ts +22 -0
  59. package/dist/room.js +40 -0
  60. package/dist/share.d.ts +106 -0
  61. package/dist/share.js +223 -0
  62. package/dist/standing.d.ts +83 -0
  63. package/dist/standing.js +111 -0
  64. package/dist/store-file.d.ts +21 -0
  65. package/dist/store-file.js +264 -0
  66. package/dist/store.d.ts +9 -0
  67. package/dist/store.js +4 -0
  68. package/dist/tokens.d.ts +8 -0
  69. package/dist/tokens.js +26 -0
  70. package/dist/trust-explanation.d.ts +16 -0
  71. package/dist/trust-explanation.js +74 -0
  72. package/dist/trust-mutation.d.ts +4 -0
  73. package/dist/trust-mutation.js +29 -0
  74. package/dist/types.d.ts +164 -0
  75. package/dist/types.js +51 -0
  76. package/dist/util/cap-string.d.ts +7 -0
  77. package/dist/util/cap-string.js +35 -0
  78. package/dist/verifier.d.ts +11 -0
  79. package/dist/verifier.js +29 -0
  80. package/dist/whoami.d.ts +7 -0
  81. package/dist/whoami.js +39 -0
  82. 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,7 @@
1
+ import type { ShareGrant } from "./types";
2
+ export interface GrantStore {
3
+ get(id: string): Promise<ShareGrant | null>;
4
+ put(id: string, grant: ShareGrant): Promise<void>;
5
+ delete(id: string): Promise<void>;
6
+ listAll(): Promise<ShareGrant[]>;
7
+ }
@@ -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 });
@@ -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[]>;