@inetafrica/open-claudia 3.3.2 → 3.4.1

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/core/enforcer.js CHANGED
@@ -112,6 +112,35 @@ function loadTriageGrant(speaker, kind) {
112
112
  } catch (e) { return ""; }
113
113
  }
114
114
 
115
+ // What this proposed output costs on the capability ladder. Talking (a reply,
116
+ // or the triage decision to engage at all) is the weakest rung; running
117
+ // something on the person's behalf is `act`, and a destructive tool run keeps
118
+ // its own rung so an `act` grant can never authorize one.
119
+ function neededCapability(kind, tier) {
120
+ if (kind === "reply" || kind === "triage") return "read";
121
+ return String(tier || "").toLowerCase() === "destructive" ? "destructive" : "act";
122
+ }
123
+
124
+ // The deterministic gate, ahead of the model. A live (principal × room) grant is
125
+ // the owner's own decision and needs no judging — the model is advisory INSIDE a
126
+ // grant, never the thing that issues one. No grant is not a denial here: it just
127
+ // means nothing has been decided yet, so the model layers below still run and
128
+ // anything they don't clearly cover escalates to an owner as before.
129
+ function grantClearance(speaker, kind, tier) {
130
+ try {
131
+ if (!speaker || !speaker.speakerId || !speaker.room) return null;
132
+ const grants = require("./grants");
133
+ const decision = grants.allows({
134
+ adapter: speaker.adapterType,
135
+ senderId: speaker.speakerId,
136
+ room: speaker.room,
137
+ needed: neededCapability(kind, tier),
138
+ });
139
+ if (!decision.ok) return null;
140
+ return decision.grant;
141
+ } catch (e) { return null; }
142
+ }
143
+
115
144
  // verdict cache: identical (kind, relationship, layers, payload) → same answer
116
145
  // within a short TTL, so a chatty external turn that re-checks the same reply
117
146
  // doesn't re-bill the model (R9). Every policy layer (and the unverified flag)
@@ -173,6 +202,7 @@ function logVerdict(audit, context, result) {
173
202
  cached: !!result.cached,
174
203
  ...(result.retried ? { retried: true } : {}),
175
204
  ...(result.code ? { code: result.code } : {}),
205
+ ...(result.grantId ? { grantId: result.grantId } : {}),
176
206
  });
177
207
  }
178
208
 
@@ -201,6 +231,21 @@ function createEnforcerService(dependencies = {}) {
201
231
  logVerdict(audit, auditContext, disabled);
202
232
  return disabled;
203
233
  }
234
+ const cleared = grantClearance(speaker, kind, tier);
235
+ if (cleared) {
236
+ const granted = {
237
+ decision: "allow",
238
+ reason: `covered by owner grant ${cleared.id} (${cleared.capability}${cleared.room === "*" ? ", all rooms" : ""})`,
239
+ ...providerMetadata(null),
240
+ status: "granted",
241
+ grantId: cleared.id,
242
+ };
243
+ logVerdict(audit, auditContext, granted);
244
+ if (cleared.turnsRemaining != null) {
245
+ try { require("./grants").consumeTurn(cleared.id); } catch (e) {}
246
+ }
247
+ return granted;
248
+ }
204
249
  const key = `${namespace}:${cacheKey({ kind, relationship, mandate, guardrails, channelRules, turnGrant, unverified, payload: clipped, tier })}`;
205
250
  const hit = cacheGet(key);
206
251
  if (hit) {
@@ -336,7 +381,7 @@ function esc(s) {
336
381
  // Framing: the owner is deciding whether the bot should ACT on a request from
337
382
  // this person — the card leads with WHO is asking WHAT, then what the bot
338
383
  // proposes to do about it, never with the bot's output as the headline.
339
- async function postOwnerButtons(rec, owner, { kind, summary, reason, strandedMandate = "" }) {
384
+ async function postOwnerButtons(rec, owner, { kind, summary, reason, strandedMandate = "", expiredNote = "" }) {
340
385
  const adapter = adapterFor(owner.adapter);
341
386
  if (!adapter) return false;
342
387
  const who = rec.personName || "An external contact";
@@ -381,14 +426,26 @@ async function postOwnerButtons(rec, owner, { kind, summary, reason, strandedMan
381
426
  const lines = [head, ``, ...askBlock, ...body,
382
427
  unverifiedLine ? `\n${unverifiedLine}` : ``,
383
428
  strandedMandate ? `\n⚠️ ${esc(strandedMandate)}` : ``,
429
+ expiredNote ? `\n⏳ ${esc(expiredNote)}` : ``,
384
430
  reason ? `\nWhy I'm checking: ${esc(reason)}` : ``];
385
431
  const yes = kind === "triage" ? "✅ Yes, handle it" : "✅ Approve once";
386
432
  const no = kind === "triage" ? "⛔ No, decline" : "⛔ Deny";
433
+ // Reach row: standing capability, and how far it travels. The default is
434
+ // deliberately the narrow one — a grant is for THIS person in THIS room, so
435
+ // trusting someone in one group never silently trusts them in another. Only
436
+ // offered when the record identifies the speaker apart from the room.
437
+ const reachRow = rec.speakerId && rec.room
438
+ ? [
439
+ { text: `✅ Always — here`, callback_data: `grn:${rec.id}:room` },
440
+ { text: `🌍 Always — everywhere`, callback_data: `grn:${rec.id}:all` },
441
+ ]
442
+ : null;
387
443
  const keyboard = { inline_keyboard: [
388
444
  [
389
445
  { text: yes, callback_data: `apr:${rec.id}:ok` },
390
446
  { text: no, callback_data: `apr:${rec.id}:no` },
391
447
  ],
448
+ ...(reachRow ? [reachRow] : []),
392
449
  [
393
450
  { text: "📝 Make a rule…", callback_data: `apr:${rec.id}:rule` },
394
451
  ...(verifyRow || []),
@@ -426,7 +483,12 @@ async function escalate({
426
483
  const approvals = require("./approvals");
427
484
  const relationship = require("./relationship");
428
485
  const audit = safeAudit();
429
- const owner = await relationship.ownerTarget();
486
+ // Approval belongs to the owner SET. The card goes to every reachable owner;
487
+ // the first is recorded as the nominal approver only because the record has
488
+ // one such field — the apr: handler authorizes on isChatOwner, so any of them
489
+ // can decide, and decide() is idempotent so the second tap can't flip it.
490
+ const owners = await relationship.ownerTargets();
491
+ const owner = owners[0];
430
492
  if (!owner) {
431
493
  if (audit) audit.log("enforcer.escalate.failed", { reason: "no reachable owner", guardKind: kind, person: speaker && speaker.name });
432
494
  return { ok: false, escalated: false, reason: "no reachable owner to approve" };
@@ -478,6 +540,22 @@ async function escalate({
478
540
  }
479
541
  }
480
542
  } catch (e) { strandedMandate = ""; }
543
+ // A grant that simply ran out looks, from the owner's side, exactly like one
544
+ // they never gave — the bot just starts asking again. Say which line lapsed
545
+ // and when, so re-approving is an informed choice rather than a puzzle.
546
+ let expiredNote = "";
547
+ try {
548
+ if (speaker && speaker.slug) {
549
+ const entities = require("./entities");
550
+ const ent = entities.readEntity(speaker.slug);
551
+ const section = String((ent && ent.sections && ent.sections.Mandate) || "");
552
+ const lapsed = require("./mandates").expiredMandateLines(speaker.slug, section);
553
+ if (lapsed.length) {
554
+ const first = lapsed[0];
555
+ expiredNote = `A previous grant for ${personName || "them"} expired on ${first.expiresAt}: "${first.text.slice(0, 100)}"${lapsed.length > 1 ? ` (+${lapsed.length - 1} more)` : ""}. That is why I'm asking again.`;
556
+ }
557
+ }
558
+ } catch (e) { expiredNote = ""; }
481
559
  const rec = approvals.create({
482
560
  tool: kind === "reply" ? "external-reply" : kind === "triage" ? "external-triage" : "external-action",
483
561
  verb: "",
@@ -507,8 +585,12 @@ async function escalate({
507
585
  entitySlug: (speaker && speaker.slug) || "",
508
586
  inboundText,
509
587
  channelLabel,
588
+ speakerId: (speaker && speaker.speakerId) || "",
589
+ room: (speaker && speaker.room) || "",
510
590
  });
511
- const posted = await postOwnerButtons(rec, owner, { kind, summary, reason, strandedMandate });
591
+ const results = await Promise.all(owners.map((o) =>
592
+ postOwnerButtons(rec, o, { kind, summary, reason, strandedMandate, expiredNote }).catch(() => false)));
593
+ const posted = results.some(Boolean);
512
594
  if (!posted) {
513
595
  approvals.decide(rec.id, "denied", "owner-unreachable");
514
596
  if (audit) audit.log("enforcer.escalate.failed", { reason: "could not post to owner", id: rec.id });
package/core/fsutil.js CHANGED
@@ -52,4 +52,45 @@ function readJsonWithFallback(file, fallback) {
52
52
  return fallback;
53
53
  }
54
54
 
55
- module.exports = { atomicWriteFileSync, readJsonWithFallback };
55
+ // Cross-process mutex for read-modify-write on a shared JSON store. Atomic
56
+ // writes stop a file being torn in half, but they cannot stop two processes
57
+ // each reading the same list, each appending one entry, and the second rename
58
+ // discarding the first's. The bot and the `open-claudia` CLI are separate
59
+ // processes writing the same stores, so that race is reachable.
60
+ //
61
+ // An exclusive-create lockfile is the portable primitive (no flock on every
62
+ // filesystem we run on). A held lock older than LOCK_STALE_MS is assumed to
63
+ // belong to a process that died mid-write and is reclaimed, so a crash can
64
+ // never wedge the store permanently. Failing to acquire is not fatal: the
65
+ // caller proceeds unlocked, which is exactly today's behaviour.
66
+ const LOCK_STALE_MS = 10_000;
67
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
68
+
69
+ function withFileLock(file, fn) {
70
+ const lock = `${file}.lock`;
71
+ let held = null;
72
+ for (let attempt = 0; attempt < 21 && !held; attempt += 1) {
73
+ try {
74
+ held = fs.openSync(lock, "wx", 0o600);
75
+ } catch (error) {
76
+ if (error.code !== "EEXIST") break;
77
+ try {
78
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
79
+ fs.rmSync(lock, { force: true });
80
+ continue;
81
+ }
82
+ } catch (_) { continue; }
83
+ Atomics.wait(LOCK_WAIT, 0, 0, 5);
84
+ }
85
+ }
86
+ try {
87
+ return fn();
88
+ } finally {
89
+ if (held !== null) {
90
+ try { fs.closeSync(held); } catch (_) {}
91
+ try { fs.rmSync(lock, { force: true }); } catch (_) {}
92
+ }
93
+ }
94
+ }
95
+
96
+ module.exports = { atomicWriteFileSync, readJsonWithFallback, withFileLock };
package/core/grants.js ADDED
@@ -0,0 +1,221 @@
1
+ // (principal × room) capability grants — the only source of non-owner
2
+ // authority on this pod.
3
+ //
4
+ // The three external-policy layers that predate this file are each keyed by
5
+ // exactly one thing: global guardrails apply to every external person, channel
6
+ // rules to everyone in one room, a person's mandate to one person in every
7
+ // room. The pair — THIS person, in THIS room — had no home, so trusting
8
+ // someone in one group silently trusted them in every group they shared with
9
+ // the bot.
10
+ //
11
+ // Two boundaries this module must never cross:
12
+ // 1. Authority is pod-owned. Room membership and platform roles (a Kazee
13
+ // groupAdmin, a Telegram chat admin) confer nothing here. Whoever
14
+ // administers a room decides only whether the agent is IN it — an agent is
15
+ // a user, and that is exactly the power any member holds over any other.
16
+ // 2. Rooms are surface-agnostic. `<surface>:<id>` covers a Kazee group, a
17
+ // Spaces task thread, a Telegram group and a DM alike, so a pod that lives
18
+ // only on Spaces and never touches Kazee chat resolves grants identically.
19
+
20
+ const fs = require("fs");
21
+ const { GRANTS_FILE } = require("./config");
22
+ const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
23
+
24
+ // Ordered weakest → strongest. A grant answers with one of these; the enforcer
25
+ // maps a proposed action onto the same ladder and refuses anything above.
26
+ const CAPABILITIES = ["read", "act", "destructive"];
27
+
28
+ const ALL_ROOMS = "*";
29
+
30
+ function nowIso() { return new Date().toISOString(); }
31
+
32
+ function capabilityRank(capability) {
33
+ const idx = CAPABILITIES.indexOf(String(capability || "").toLowerCase());
34
+ return idx < 0 ? -1 : idx;
35
+ }
36
+
37
+ function normalizeCapability(capability) {
38
+ const c = String(capability || "").toLowerCase();
39
+ return CAPABILITIES.includes(c) ? c : "read";
40
+ }
41
+
42
+ // Rooms are keyed `<surface>:<id>`, matching core/identity.conversationKeyFor
43
+ // so a caller can hand us a conversation key directly.
44
+ function roomKey(surface, roomId) {
45
+ const s = String(surface || "").trim().toLowerCase();
46
+ const r = String(roomId || "").trim();
47
+ if (!s || !r) return "";
48
+ return `${s}:${r}`;
49
+ }
50
+
51
+ function principalKey(adapter, senderId) {
52
+ const a = String(adapter || "").trim().toLowerCase();
53
+ const s = String(senderId || "").trim();
54
+ if (!a || !s) return "";
55
+ return `${a}:${s}`;
56
+ }
57
+
58
+ function load() {
59
+ const raw = readJsonWithFallback(GRANTS_FILE, null);
60
+ const list = raw && Array.isArray(raw.grants) ? raw.grants : [];
61
+ return list.filter((g) => g && typeof g === "object");
62
+ }
63
+
64
+ function save(grants) {
65
+ atomicWriteFileSync(GRANTS_FILE, JSON.stringify({ grants }, null, 2), { backup: true });
66
+ }
67
+
68
+ function isLive(grant, at = Date.now()) {
69
+ if (!grant || grant.revokedAt) return false;
70
+ if (grant.expiresAt && Date.parse(grant.expiresAt) <= at) return false;
71
+ if (grant.turnsRemaining != null && Number(grant.turnsRemaining) <= 0) return false;
72
+ return true;
73
+ }
74
+
75
+ // The deterministic lookup that replaces the LLM as the gate. Returns the
76
+ // strongest live grant for this principal in this room, preferring a grant
77
+ // issued for this exact room over an all-rooms grant, so a narrow room grant
78
+ // can never be weakened by a broad one and vice versa.
79
+ function resolve({ adapter, senderId, room }) {
80
+ const principal = principalKey(adapter, senderId);
81
+ const target = String(room || "");
82
+ if (!principal) return null;
83
+ const at = Date.now();
84
+ const candidates = load().filter((g) =>
85
+ isLive(g, at)
86
+ && principalKey(g.principal?.adapter, g.principal?.channelId) === principal
87
+ && (g.room === target || g.room === ALL_ROOMS));
88
+ if (!candidates.length) return null;
89
+ candidates.sort((a, b) => {
90
+ // Exact room beats all-rooms; then the stronger capability wins.
91
+ const exact = (b.room === target) - (a.room === target);
92
+ if (exact) return exact;
93
+ return capabilityRank(b.capability) - capabilityRank(a.capability);
94
+ });
95
+ return candidates[0];
96
+ }
97
+
98
+ // True when this principal may take an action at `needed` strength in `room`.
99
+ // Owners never reach here — they are resolved before any grant lookup.
100
+ function allows({ adapter, senderId, room, needed = "read", subject = null }) {
101
+ const grant = resolve({ adapter, senderId, room });
102
+ if (!grant) return { ok: false, reason: "no_grant", grant: null };
103
+ if (capabilityRank(grant.capability) < capabilityRank(needed)) {
104
+ return { ok: false, reason: "capability_too_low", grant };
105
+ }
106
+ const scope = Array.isArray(grant.scope) ? grant.scope : [];
107
+ // An empty scope means "anything within this capability" — a scoped grant
108
+ // only covers the subjects it names.
109
+ if (scope.length && subject && !scope.includes(String(subject))) {
110
+ return { ok: false, reason: "out_of_scope", grant };
111
+ }
112
+ return { ok: true, reason: "granted", grant };
113
+ }
114
+
115
+ // Only an owner may call this — enforced by the caller, which holds the turn
116
+ // context needed to know who is asking.
117
+ function grant({
118
+ adapter,
119
+ senderId,
120
+ room,
121
+ capability = "read",
122
+ scope = [],
123
+ grantedBy = "",
124
+ personId = null,
125
+ expiresAt = null,
126
+ turns = null,
127
+ allRooms = false,
128
+ }) {
129
+ const principal = principalKey(adapter, senderId);
130
+ if (!principal) throw new Error("adapter and senderId are required");
131
+ const target = allRooms ? ALL_ROOMS : String(room || "");
132
+ if (!target) throw new Error("room is required unless allRooms is set");
133
+ const grants = load();
134
+ // Re-granting the same pair replaces rather than stacks, so revoking one
135
+ // record can never leave a weaker duplicate behind still granting access.
136
+ const kept = grants.filter((g) =>
137
+ !(principalKey(g.principal?.adapter, g.principal?.channelId) === principal
138
+ && g.room === target
139
+ && isLive(g)));
140
+ const record = {
141
+ id: `grant_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
142
+ principal: { adapter: String(adapter).toLowerCase(), channelId: String(senderId) },
143
+ personId: personId || null,
144
+ room: target,
145
+ capability: normalizeCapability(capability),
146
+ scope: Array.isArray(scope) ? scope.map(String) : [],
147
+ grantedBy: String(grantedBy || ""),
148
+ grantedAt: nowIso(),
149
+ expiresAt: expiresAt || null,
150
+ turnsRemaining: turns == null ? null : Number(turns),
151
+ revokedAt: null,
152
+ };
153
+ kept.push(record);
154
+ save(kept);
155
+ return record;
156
+ }
157
+
158
+ function revoke(grantId) {
159
+ const grants = load();
160
+ const g = grants.find((x) => x.id === grantId);
161
+ if (!g || g.revokedAt) return null;
162
+ g.revokedAt = nowIso();
163
+ save(grants);
164
+ return g;
165
+ }
166
+
167
+ // Revoke every live grant for a principal, optionally only in one room.
168
+ function revokeAllFor({ adapter, senderId, room = null }) {
169
+ const principal = principalKey(adapter, senderId);
170
+ if (!principal) return 0;
171
+ const grants = load();
172
+ let n = 0;
173
+ for (const g of grants) {
174
+ if (!isLive(g)) continue;
175
+ if (principalKey(g.principal?.adapter, g.principal?.channelId) !== principal) continue;
176
+ if (room && g.room !== room) continue;
177
+ g.revokedAt = nowIso();
178
+ n += 1;
179
+ }
180
+ if (n) save(grants);
181
+ return n;
182
+ }
183
+
184
+ // One-turn grants burn down as they are used, so an owner's "just this once"
185
+ // can't linger as standing access.
186
+ function consumeTurn(grantId) {
187
+ const grants = load();
188
+ const g = grants.find((x) => x.id === grantId);
189
+ if (!g || g.turnsRemaining == null) return null;
190
+ g.turnsRemaining = Math.max(0, Number(g.turnsRemaining) - 1);
191
+ save(grants);
192
+ return g;
193
+ }
194
+
195
+ function list({ includeExpired = false } = {}) {
196
+ const all = load();
197
+ return includeExpired ? all : all.filter((g) => isLive(g));
198
+ }
199
+
200
+ function listForRoom(room, { includeExpired = false } = {}) {
201
+ return list({ includeExpired }).filter((g) => g.room === String(room) || g.room === ALL_ROOMS);
202
+ }
203
+
204
+ module.exports = {
205
+ CAPABILITIES,
206
+ ALL_ROOMS,
207
+ GRANTS_FILE,
208
+ roomKey,
209
+ principalKey,
210
+ capabilityRank,
211
+ normalizeCapability,
212
+ isLive,
213
+ resolve,
214
+ allows,
215
+ grant,
216
+ revoke,
217
+ revokeAllFor,
218
+ consumeTurn,
219
+ list,
220
+ listForRoom,
221
+ };
package/core/handlers.js CHANGED
@@ -20,6 +20,7 @@ const {
20
20
  getProviderSettings,
21
21
  getProjectSessions, getLastProjectSession, userOwnsProviderSession,
22
22
  createSessionCallbackToken, linkIdentity,
23
+ roomSettingsFor, setRoomSetting,
23
24
  } = require("./state");
24
25
  const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
25
26
  const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
@@ -208,6 +209,13 @@ function switchProviderState(providerId, options = {}) {
208
209
  providerSettings: structuredClone(state.providerSettings || {}),
209
210
  isFirstMessage: state.isFirstMessage,
210
211
  };
212
+ // The room is authoritative for who answers in it; the speaker's own value
213
+ // stays updated as their default for rooms they have never pinned. Without
214
+ // the room write, switching provider only moved the switcher — everyone else
215
+ // in the room, and every job it spawned, stayed on the old one.
216
+ const roomKey = state.roomKey ? String(state.roomKey) : "";
217
+ const previousRoomBackend = roomKey ? (roomSettingsFor(roomKey)?.backend ?? null) : null;
218
+ if (roomKey) setRoomSetting(roomKey, "backend", provider);
211
219
  state.settings.backend = provider;
212
220
  if (Object.prototype.hasOwnProperty.call(options, "model")) state.settings.model = options.model;
213
221
  const project = getProjectKey(state);
@@ -220,6 +228,7 @@ function switchProviderState(providerId, options = {}) {
220
228
  state.activeSessions = previous.activeSessions;
221
229
  state.providerSettings = previous.providerSettings;
222
230
  state.settings.backend = previous.provider;
231
+ if (roomKey) setRoomSetting(roomKey, "backend", previousRoomBackend);
223
232
  state.isFirstMessage = previous.isFirstMessage;
224
233
  });
225
234
  return persisted.ok
@@ -1815,6 +1824,46 @@ register({
1815
1824
  },
1816
1825
  });
1817
1826
 
1827
+ register({
1828
+ name: "grants", description: "View/revoke capability grants (who may do what, in which room)", args: "[revoke <id> | revoke-all <adapter>:<sender>]", ownerOnly: true,
1829
+ handler: async (env, { tail }) => {
1830
+ if (!ownerEnv(env)) return send("Owner only — grants are restricted.");
1831
+ const grants = require("./grants");
1832
+ const trimmed = (tail || "").trim();
1833
+ const [sub, ...restArr] = trimmed ? trimmed.split(/\s+/) : [];
1834
+ const subCmd = (sub || "").toLowerCase();
1835
+
1836
+ if (subCmd === "revoke") {
1837
+ const id = restArr.join(" ").trim();
1838
+ if (!id) return send("Usage: /grants revoke <id>");
1839
+ const g = grants.revoke(id);
1840
+ if (!g) return send(`No live grant with id ${id}.`);
1841
+ try { require("./audit").log("grant.revoked", { id: g.id, room: g.room, capability: g.capability }); } catch (e) {}
1842
+ return send(`Revoked ${g.id} — ${g.capability} in ${g.room === grants.ALL_ROOMS ? "every room" : g.room}.`);
1843
+ }
1844
+
1845
+ if (subCmd === "revoke-all") {
1846
+ const target = restArr.join(" ").trim();
1847
+ const idx = target.lastIndexOf(":");
1848
+ if (idx <= 0) return send("Usage: /grants revoke-all <adapter>:<senderId>");
1849
+ const n = grants.revokeAllFor({ adapter: target.slice(0, idx), senderId: target.slice(idx + 1) });
1850
+ return send(n ? `Revoked ${n} grant${n === 1 ? "" : "s"} for ${target}.` : `No live grants for ${target}.`);
1851
+ }
1852
+
1853
+ const live = grants.list();
1854
+ if (live.length === 0) {
1855
+ return send("No capability grants yet.\n\nA grant is what lets a non-owner act: it covers ONE person in ONE room, so trusting someone in one group never trusts them elsewhere. Make one from an approval card (\"Always — here\" / \"Always — everywhere\").");
1856
+ }
1857
+ const lines = live.map((g) => {
1858
+ const where = g.room === grants.ALL_ROOMS ? "everywhere" : g.room;
1859
+ const until = g.expiresAt ? ` · until ${g.expiresAt.slice(0, 10)}` : "";
1860
+ const turns = g.turnsRemaining != null ? ` · ${g.turnsRemaining} turn(s) left` : "";
1861
+ return `🔑 [${g.id}] ${g.principal.adapter}:${g.principal.channelId} → ${g.capability} in ${where}${until}${turns}`;
1862
+ });
1863
+ return send(`Capability grants (${live.length}):\n\n${lines.join("\n")}\n\n/grants revoke <id> · /grants revoke-all <adapter>:<senderId>\nFile: ${grants.GRANTS_FILE}`);
1864
+ },
1865
+ });
1866
+
1818
1867
  register({
1819
1868
  name: "ideas", description: "View/manage the self-improvement backlog", args: "[add <idea> | remove <id>]",
1820
1869
  handler: async (env, { tail }) => {
@@ -7,7 +7,7 @@ const people = require("./people");
7
7
  const intros = require("./intros");
8
8
  const audit = require("./audit");
9
9
  const registry = require("./adapter-registry");
10
- const { bootstrapOwner, loadAuth, saveAuth, updateAuthorizedChatEnv } = require("./access");
10
+ const { bootstrapOwner, mutateAuth, updateAuthorizedChatEnv } = require("./access");
11
11
  const { CHAT_ID } = require("./config");
12
12
 
13
13
  const MAX_BIO_LEN = 500;
@@ -103,25 +103,26 @@ async function notifyOwnerAboutIntro(intro) {
103
103
  // from people.isOwner: dropping it here is what demoted owners approved onto
104
104
  // a second channel (isChatOwner reads this file, not the person).
105
105
  function ensureAuthEntry(channelId, { name = null, username = null, isOwner = false } = {}) {
106
- const auth = loadAuth();
107
106
  const id = String(channelId);
108
- const existing = auth.authorized.find((a) => String(a.chatId) === id);
109
- if (existing) {
110
- if (isOwner && existing.isOwner !== true) {
111
- existing.isOwner = true;
112
- saveAuth(auth);
107
+ // Under the store lock: this is the write that sets isOwner, and an unlocked
108
+ // read-modify-write racing another approval would drop the promotion — the
109
+ // roster would look right to whoever wrote last and the owner would be
110
+ // guarded on their own channel.
111
+ return mutateAuth((auth) => {
112
+ const existing = auth.authorized.find((a) => String(a.chatId) === id);
113
+ if (existing) {
114
+ if (isOwner && existing.isOwner !== true) existing.isOwner = true;
115
+ return auth;
113
116
  }
117
+ auth.authorized.push({
118
+ chatId: id,
119
+ name: name || "",
120
+ username: username || null,
121
+ isOwner: !!isOwner,
122
+ authorizedAt: new Date().toISOString(),
123
+ });
114
124
  return auth;
115
- }
116
- auth.authorized.push({
117
- chatId: id,
118
- name: name || "",
119
- username: username || null,
120
- isOwner: !!isOwner,
121
- authorizedAt: new Date().toISOString(),
122
125
  });
123
- saveAuth(auth);
124
- return auth;
125
126
  }
126
127
 
127
128
  // A sender whose transport user id already maps to a person is never
package/core/mandates.js CHANGED
@@ -88,6 +88,15 @@ function activeMandateText(sectionText, now) {
88
88
  .join("\n");
89
89
  }
90
90
 
91
+ // The lines that HAVE lapsed for one person. activeMandateText drops them
92
+ // silently by design (the judge must never see a dead grant), but silence is
93
+ // wrong for the owner: a tap-grant expiring after GRANT_TTL_DAYS looked
94
+ // identical to never having granted it, so the bot simply started asking again
95
+ // with nothing to explain why. Callers surface these at the moment they bite.
96
+ function expiredMandateLines(slug, sectionText, now) {
97
+ return parseMandate(slug, sectionText, now).filter((l) => l.expired);
98
+ }
99
+
91
100
  function defaultGrantExpiry(now) {
92
101
  if (!GRANT_TTL_DAYS) return null;
93
102
  return utcDate((now === undefined ? Date.now() : now) + GRANT_TTL_DAYS * 24 * 60 * 60 * 1000);
@@ -155,6 +164,6 @@ function revokeLine(id) {
155
164
  module.exports = {
156
165
  GRANT_TTL_DAYS,
157
166
  normalize, splitExpiry, formatLine, isExpired, lineId,
158
- parseMandate, activeMandateText, defaultGrantExpiry, extractExpiry,
167
+ parseMandate, activeMandateText, expiredMandateLines, defaultGrantExpiry, extractExpiry,
159
168
  listPersonMandates, revokeLine,
160
169
  };