instar 1.3.1348 → 1.3.1349

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 (67) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +15 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/LifelineIngressSecret.d.ts +83 -0
  5. package/dist/core/LifelineIngressSecret.d.ts.map +1 -0
  6. package/dist/core/LifelineIngressSecret.js +203 -0
  7. package/dist/core/LifelineIngressSecret.js.map +1 -0
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +15 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  12. package/dist/core/WriteDomainRegistry.js +17 -0
  13. package/dist/core/WriteDomainRegistry.js.map +1 -1
  14. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  15. package/dist/core/devGatedFeatures.js +6 -0
  16. package/dist/core/devGatedFeatures.js.map +1 -1
  17. package/dist/core/keyMaterialPaths.d.ts.map +1 -1
  18. package/dist/core/keyMaterialPaths.js +16 -0
  19. package/dist/core/keyMaterialPaths.js.map +1 -1
  20. package/dist/core/types.d.ts +23 -0
  21. package/dist/core/types.d.ts.map +1 -1
  22. package/dist/core/types.js.map +1 -1
  23. package/dist/core/version-skew.d.ts +4 -2
  24. package/dist/core/version-skew.d.ts.map +1 -1
  25. package/dist/core/version-skew.js +3 -1
  26. package/dist/core/version-skew.js.map +1 -1
  27. package/dist/data/standards-guard-index.json +1 -1
  28. package/dist/data/standards-guard-index.meta.json +2 -2
  29. package/dist/data/standards-registry.meta.json +1 -1
  30. package/dist/lifeline/TelegramLifeline.d.ts +49 -0
  31. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  32. package/dist/lifeline/TelegramLifeline.js +202 -17
  33. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  34. package/dist/lifeline/forwardErrors.d.ts +11 -0
  35. package/dist/lifeline/forwardErrors.d.ts.map +1 -1
  36. package/dist/lifeline/forwardErrors.js +15 -1
  37. package/dist/lifeline/forwardErrors.js.map +1 -1
  38. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  39. package/dist/monitoring/guardManifest.js +24 -0
  40. package/dist/monitoring/guardManifest.js.map +1 -1
  41. package/dist/scaffold/templates.d.ts.map +1 -1
  42. package/dist/scaffold/templates.js +4 -0
  43. package/dist/scaffold/templates.js.map +1 -1
  44. package/dist/server/LifelineIngressGuard.d.ts +326 -0
  45. package/dist/server/LifelineIngressGuard.d.ts.map +1 -0
  46. package/dist/server/LifelineIngressGuard.js +713 -0
  47. package/dist/server/LifelineIngressGuard.js.map +1 -0
  48. package/dist/server/TelegramCallbackDedupe.d.ts +58 -0
  49. package/dist/server/TelegramCallbackDedupe.d.ts.map +1 -0
  50. package/dist/server/TelegramCallbackDedupe.js +151 -0
  51. package/dist/server/TelegramCallbackDedupe.js.map +1 -0
  52. package/dist/server/routes.d.ts.map +1 -1
  53. package/dist/server/routes.js +218 -10
  54. package/dist/server/routes.js.map +1 -1
  55. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  56. package/dist/testing/selfActionRegistry.js +98 -0
  57. package/dist/testing/selfActionRegistry.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/data/builtin-manifest.json +64 -64
  60. package/src/data/standards-guard-index.json +1 -1
  61. package/src/data/standards-guard-index.meta.json +2 -2
  62. package/src/data/standards-registry.meta.json +1 -1
  63. package/src/data/state-coherence-registry.json +19 -0
  64. package/src/scaffold/templates.ts +4 -0
  65. package/upgrades/1.3.1349.md +59 -0
  66. package/upgrades/side-effects/telegram-internal-route-lifeline-secret.md +187 -0
  67. package/upgrades/1.3.1348.md +0 -24
@@ -0,0 +1,713 @@
1
+ /**
2
+ * LifelineIngressGuard — decides whether a request on the internal Telegram
3
+ * routes (`/internal/telegram-forward`, `/internal/telegram-callback`,
4
+ * `/internal/lifeline-proof`) proved it came from the lifeline.
5
+ *
6
+ * Spec: docs/specs/telegram-internal-route-lifeline-secret.md
7
+ *
8
+ * Signal vs. authority: this is an exact, deterministic credential check
9
+ * (constant-time compare of a random secret), not a judgment about meaning, so
10
+ * it may hold blocking authority. Uncertainty about OUR side (secret file
11
+ * unreadable, state file unwritable) fails toward DELIVERY of messages and is
12
+ * counted — the agent must stay reachable — but never toward trusting a sender.
13
+ *
14
+ * Two modes:
15
+ * observe — accept a message without the secret, and count a `would-refuse`.
16
+ * enforce — refuse it (typed 401).
17
+ *
18
+ * The rules:
19
+ * 1. ARM ON PROOF. Nothing is refused until this guard has seen a request
20
+ * from the lifeline carrying the valid secret (persisted). A lifeline
21
+ * that predates this feature has never proven itself, so it is never
22
+ * refused — its requests are accepted and counted, and the restart signal
23
+ * moves it onto the new version. A current lifeline proves itself at
24
+ * startup (`/internal/lifeline-proof`), so arming does not wait for a
25
+ * Telegram message. Where the lifeline is not the Telegram ingress at all
26
+ * (the server polls Telegram itself, or Telegram is not configured),
27
+ * there is no lifeline to wait for and the guard arms at once.
28
+ * 2. NO SENDER TRUST WITHOUT PROOF, ONCE PROOF IS POSSIBLE. After arming, a
29
+ * message accepted without the secret (observe) is still delivered, but
30
+ * its body-supplied sender id is not an authenticated sender; and a
31
+ * button tap — which is nothing but a privileged act — is refused in
32
+ * either mode. In ENFORCE the same holds once the guard has been
33
+ * unarmed for `PRE_ARM_TRUST_MS`: there, the pre-change behaviour (trust
34
+ * the body) is a bounded migration allowance. In OBSERVE an unarmed
35
+ * guard changes nothing for as long as it stays unarmed — a fleet agent
36
+ * whose lifeline has not updated is reported loudly, but its operator's
37
+ * real messages and taps are never degraded without a soak (the
38
+ * maturation path: development agents take the strict behaviour first).
39
+ * Messages are never REFUSED before arming, in any mode.
40
+ * 3. OBSERVE HAS AN EXIT. With no explicit setting on a fleet agent, observe
41
+ * graduates to enforce by itself after `EXIT_QUIET_MS` with zero
42
+ * would-refuse and at least `EXIT_MIN_VERIFIED` verified Telegram
43
+ * requests in that window — and, whatever the counts say, no later than
44
+ * `EXIT_DEADLINE_MS` after arming. The deadline exists because a caller
45
+ * holding only the Bearer token can keep the quiet window from closing by
46
+ * sending secretless requests. The evidence is persisted, so a server
47
+ * restart does not reset it.
48
+ */
49
+ import fs from 'node:fs';
50
+ import path from 'node:path';
51
+ import { resolveDevAgentGate } from '../core/devAgentGate.js';
52
+ import { LifelineIngressSecretHolder, lifelineSecretMatches, LIFELINE_SECRET_HEADER, LIFELINE_INGRESS_CALLER_HEADER, LIFELINE_INGRESS_CALLER_SERVER_SELF, } from '../core/LifelineIngressSecret.js';
53
+ import { SafeFsExecutor } from '../core/SafeFsExecutor.js';
54
+ /** Observe exits after this long with zero would-refuse … */
55
+ export const EXIT_QUIET_MS = 24 * 60 * 60 * 1000;
56
+ /** … and at least this many verified Telegram requests inside that window. */
57
+ export const EXIT_MIN_VERIFIED = 10;
58
+ /** Hard ceiling: observe ends this long after arming, whatever the counts say. */
59
+ export const EXIT_DEADLINE_MS = 7 * 24 * 60 * 60 * 1000;
60
+ /**
61
+ * Minimum gap between restart-signal writes BEFORE the lifeline has proven
62
+ * itself (the rollout nudge for a lifeline still on older code). Two a day at
63
+ * most: lifeline restarts on this signal share a daily cap of three with
64
+ * genuine version-skew restarts, and must not be able to use it up.
65
+ */
66
+ export const SIGNAL_GAP_UNARMED_MS = 12 * 60 * 60 * 1000;
67
+ /**
68
+ * Minimum gap AFTER arming. A secretless request is then almost never a
69
+ * version problem (the lifeline has shown it sends the secret and re-reads a
70
+ * stale one by itself), so one write a day is the whole budget — a caller with
71
+ * only the Bearer token cannot drive lifeline restarts faster than that.
72
+ */
73
+ export const SIGNAL_GAP_ARMED_MS = 24 * 60 * 60 * 1000;
74
+ /** A lifeline that has not proven itself for this long is reported. */
75
+ export const UNARMED_REPORT_AFTER_MS = 60 * 60 * 1000;
76
+ /**
77
+ * In ENFORCE, how long an UNARMED guard keeps the pre-change behaviour
78
+ * (body-supplied sender trusted, taps accepted) for a lifeline still on older
79
+ * code. After it, an unproven message is delivered without sender trust and an
80
+ * unproven tap is refused, armed or not. In OBSERVE the allowance does not
81
+ * expire; passing this age only raises the operator attention item. Messages
82
+ * are never refused before arming.
83
+ */
84
+ export const PRE_ARM_TRUST_MS = 24 * 60 * 60 * 1000;
85
+ /**
86
+ * A current lifeline proves itself within one 15-second tick of the server
87
+ * being up. This long after construction, a guard that is still unarmed asks
88
+ * the lifeline to restart (one budgeted signal write) instead of waiting for
89
+ * the first Telegram message to do it.
90
+ */
91
+ export const BOOT_NUDGE_DELAY_MS = 60 * 1000;
92
+ /** The security attention item is raised at most this often (persisted). */
93
+ export const ATTENTION_GAP_MS = 24 * 60 * 60 * 1000;
94
+ /** A wrong secret BEFORE arming is reported once seen this many times in a row. */
95
+ export const MISMATCH_REPORT_AFTER = 3;
96
+ /** Unverified requests write through up to this many times per window … */
97
+ export const PERSIST_BURST = 5;
98
+ /** … then their counter writes are coalesced to one per this. */
99
+ export const PERSIST_COALESCE_MS = 1000;
100
+ /**
101
+ * The state file sits under the secret's own name prefix on purpose: that
102
+ * prefix is in KEY_MATERIAL_PATHS, so the file routes never serve or edit it,
103
+ * backups skip it and git ignores it — without those, the Bearer token alone
104
+ * could rewrite `armedAt` through the file editor.
105
+ */
106
+ export const LIFELINE_INGRESS_STATE_RELPATH = path.join('state', 'lifeline-ingress-secret.guard.json');
107
+ function freshState(nowIso) {
108
+ return {
109
+ version: 1,
110
+ observingSince: nowIso,
111
+ verified: 0,
112
+ verifiedServerSelf: 0,
113
+ wouldRefuse: 0,
114
+ refused: 0,
115
+ secretUnavailable: 0,
116
+ byReason: { missing: 0, mismatch: 0 },
117
+ byRoute: {
118
+ 'telegram-forward': { verified: 0, wouldRefuse: 0, refused: 0 },
119
+ 'telegram-callback': { verified: 0, wouldRefuse: 0, refused: 0 },
120
+ 'lifeline-proof': { verified: 0, wouldRefuse: 0, refused: 0 },
121
+ },
122
+ lastVerifiedAt: null,
123
+ lastWouldRefuseAt: null,
124
+ lastRefusedAt: null,
125
+ lastReason: null,
126
+ verifiedSinceLastWouldRefuse: 0,
127
+ armedAt: null,
128
+ armedBy: null,
129
+ enforceDeadlineAt: null,
130
+ graduatedAt: null,
131
+ graduatedBy: null,
132
+ lastSignalAt: null,
133
+ attentionAt: {},
134
+ };
135
+ }
136
+ /* @self-action-controller: lifeline-ingress-restart-signal */
137
+ export class LifelineIngressGuard {
138
+ opts;
139
+ holder;
140
+ statePath;
141
+ now;
142
+ log;
143
+ state;
144
+ stateCorruptAtLoad = false;
145
+ lastReloadAt = 0;
146
+ lastDegradationAt = new Map();
147
+ consecutiveMismatch = 0;
148
+ stateWriteFailures = 0;
149
+ lastPersistAt = 0;
150
+ burstWindowStart = 0;
151
+ burstCount = 0;
152
+ dirty = false;
153
+ flushTimer = null;
154
+ lastLogAt = 0;
155
+ suppressedLogLines = 0;
156
+ bootNudgeTimer = null;
157
+ constructor(opts) {
158
+ this.opts = opts;
159
+ this.now = opts.now ?? Date.now;
160
+ this.holder = opts.holder ?? new LifelineIngressSecretHolder(opts.stateDir, this.now);
161
+ this.statePath = path.join(opts.stateDir, LIFELINE_INGRESS_STATE_RELPATH);
162
+ this.log = opts.log ?? ((line) => console.warn(line));
163
+ this.state = this.loadState();
164
+ if (this.state.armedAt && !this.state.enforceDeadlineAt) {
165
+ this.state.enforceDeadlineAt = new Date(Date.parse(this.state.armedAt) + EXIT_DEADLINE_MS).toISOString();
166
+ }
167
+ if (this.stateCorruptAtLoad) {
168
+ // Absent evidence is the normal first boot and stays quiet. CORRUPT
169
+ // evidence may have un-armed or un-graduated this guard: say so.
170
+ this.degrade(this.now(), 'state-corrupt', 'the lifeline ingress guard state file was unreadable and has been reset', 'The guard starts unarmed and in its default mode until the lifeline proves itself again (seconds on a current lifeline).');
171
+ }
172
+ const delay = opts.bootNudgeDelayMs === undefined ? BOOT_NUDGE_DELAY_MS : opts.bootNudgeDelayMs;
173
+ if (delay !== null) {
174
+ this.bootNudgeTimer = setTimeout(() => { this.bootNudgeTimer = null; this.bootNudge(); }, delay);
175
+ this.bootNudgeTimer.unref?.();
176
+ }
177
+ }
178
+ /**
179
+ * Run once shortly after boot. Pins the clocks this guard relies on (the
180
+ * secret file and the persisted `observingSince`) so they do not slide
181
+ * across restarts, arms at once where there is no lifeline to wait for, and
182
+ * — if a lifeline is expected and still has not proven itself — asks it to
183
+ * restart, through the same persisted budget as every other signal write.
184
+ * Without this, a quiet machine would only be nudged by its first message.
185
+ */
186
+ bootNudge() {
187
+ try {
188
+ if (!fs.existsSync(this.opts.stateDir))
189
+ return; // torn-down fixture / removed agent
190
+ if (this.bypassed())
191
+ return;
192
+ const nowMs = this.now();
193
+ const nowIso = new Date(nowMs).toISOString();
194
+ this.holder.get(); // creates the secret if absent — dates the feature on this disk
195
+ this.armIfNoLifeline(nowMs, nowIso);
196
+ if (!this.state.armedAt && this.spendSignalBudget()) {
197
+ let wrote = false;
198
+ try {
199
+ wrote = this.opts.writeRestartSignal({ previousVersion: 'unknown' });
200
+ }
201
+ catch (err) {
202
+ // @silent-fallback-ok — logged, and the budget is released below so
203
+ // the first unverified request writes the signal instead.
204
+ this.log(`[lifeline-ingress] boot nudge could not write the lifeline-restart signal: ${err instanceof Error ? err.message : err}`);
205
+ wrote = false;
206
+ }
207
+ if (wrote === false)
208
+ this.state.lastSignalAt = null; // declined: budget unspent
209
+ }
210
+ this.persist(nowMs);
211
+ }
212
+ catch { /* the nudge is an optimisation; requests do the same work */ }
213
+ }
214
+ /** No lifeline to wait for on this machine: arm now. */
215
+ armIfNoLifeline(nowMs, nowIso) {
216
+ if (this.state.armedAt)
217
+ return;
218
+ if (this.opts.lifelineIsIngress && !this.opts.lifelineIsIngress())
219
+ this.arm(nowMs, nowIso, 'no-lifeline-ingress');
220
+ }
221
+ /**
222
+ * When the 24-hour pre-arm allowance started: the EARLIER of this guard's
223
+ * first recorded run and the secret file's own timestamp. The second clock
224
+ * survives a lost or corrupt state file, so resetting the state cannot buy a
225
+ * fresh allowance on a machine that has had this feature for longer.
226
+ */
227
+ allowanceStartMs() {
228
+ const observing = Date.parse(this.state.observingSince);
229
+ const secretAt = this.holder.fileMtimeMs();
230
+ return secretAt !== null && secretAt < observing ? secretAt : observing;
231
+ }
232
+ /** The configured intent, before the arm-on-proof rule. */
233
+ mode() {
234
+ const explicit = this.opts.explicitEnforce();
235
+ if (explicit !== undefined)
236
+ return explicit ? 'enforce' : 'observe';
237
+ if (resolveDevAgentGate(undefined, this.opts.config))
238
+ return 'enforce';
239
+ // Observe exists to watch a real lifeline's traffic before refusing it.
240
+ // Where there is no lifeline ingress there is nothing to watch.
241
+ if (this.state.armedBy === 'no-lifeline-ingress')
242
+ return 'enforce';
243
+ return this.state.graduatedAt ? 'enforce' : 'observe';
244
+ }
245
+ /** Is the full rollback (`bypass: true`) set? */
246
+ bypassed() {
247
+ try {
248
+ return this.opts.bypass?.() === true;
249
+ }
250
+ catch {
251
+ // @silent-fallback-ok — a config read fault must never switch the guard
252
+ // OFF: "not bypassed" is the protective direction, not a degraded one.
253
+ return false;
254
+ }
255
+ }
256
+ /**
257
+ * Guard-posture row (GUARD_MANIFEST key `telegramIngress.lifelineSecret`).
258
+ * `enabled` = the guard is actually protecting: armed (or there is no
259
+ * lifeline to wait for) and not bypassed. `dryRun` = observe.
260
+ */
261
+ guardStatus() {
262
+ const nowMs = this.now();
263
+ this.armIfNoLifeline(nowMs, new Date(nowMs).toISOString());
264
+ return {
265
+ enabled: !this.bypassed() && this.state.armedAt !== null,
266
+ // "Dry run" here means observe HELD by config. Default observe is not a
267
+ // dry run: once armed it already drops unproven sender identity and
268
+ // refuses unproven taps, and it ends by itself (the exit rule), so it
269
+ // cannot become watch-only-forever. A held observe can, and shows as one.
270
+ dryRun: this.opts.explicitEnforce() === false,
271
+ };
272
+ }
273
+ /** Why the mode is what it is — for /health. */
274
+ modeSource() {
275
+ if (this.opts.explicitEnforce() !== undefined)
276
+ return 'config';
277
+ if (resolveDevAgentGate(undefined, this.opts.config))
278
+ return 'development-agent';
279
+ if (this.state.armedBy === 'no-lifeline-ingress')
280
+ return 'no-lifeline-ingress';
281
+ return this.state.graduatedAt ? 'observe-exit-rule' : 'default-observe';
282
+ }
283
+ evaluate(headers, route, info = {}) {
284
+ const nowMs = this.now();
285
+ const nowIso = new Date(nowMs).toISOString();
286
+ const presented = headers[LIFELINE_SECRET_HEADER];
287
+ const serverSelf = headers[LIFELINE_INGRESS_CALLER_HEADER] === LIFELINE_INGRESS_CALLER_SERVER_SELF;
288
+ let expected = this.holder.get();
289
+ if (!expected) {
290
+ // OUR side is broken and this process has never held a secret. A message
291
+ // is still delivered (reachability) but with no sender trust; a tap is
292
+ // nothing but a privileged act, so it is refused until we can verify.
293
+ this.state.secretUnavailable++;
294
+ this.persistSoon(nowMs);
295
+ if (this.bypassed()) {
296
+ // The full rollback is total: prior behaviour, whatever our own state.
297
+ return {
298
+ action: 'accept', verified: false, mode: this.mode(), reason: 'secret-unavailable',
299
+ senderTrusted: true, staleSecretHint: false,
300
+ };
301
+ }
302
+ // Self-heal first: the holder retries the load every 30 s. Report only
303
+ // once a retry has failed too.
304
+ if (this.holder.failedLoads >= 2) {
305
+ this.degrade(nowMs, 'secret-unavailable', 'lifeline ingress secret file could not be read or created', 'Messages on the internal Telegram routes are delivered without proof of the sender, button taps are refused, and no operator is bound, until the file is readable.');
306
+ }
307
+ return {
308
+ action: route === 'telegram-callback' ? 'refuse' : 'accept',
309
+ verified: false, mode: this.mode(), reason: 'secret-unavailable',
310
+ senderTrusted: false, staleSecretHint: false,
311
+ };
312
+ }
313
+ let ok = lifelineSecretMatches(presented, expected);
314
+ if (!ok && typeof presented === 'string' && presented.length > 0 && nowMs - this.lastReloadAt > 5_000) {
315
+ // The other process may have replaced the file since we cached it. A
316
+ // failed re-read keeps our last good value (never fails open).
317
+ this.lastReloadAt = nowMs;
318
+ expected = this.holder.reload();
319
+ ok = lifelineSecretMatches(presented, expected);
320
+ }
321
+ if (ok) {
322
+ this.consecutiveMismatch = 0;
323
+ this.state.verified++;
324
+ this.state.byRoute[route].verified++;
325
+ this.state.lastVerifiedAt = nowIso;
326
+ if (serverSelf) {
327
+ // The label is read only AFTER the secret verified, and it can only
328
+ // REMOVE evidence (this request does not arm or count as traffic).
329
+ this.state.verifiedServerSelf++;
330
+ }
331
+ else {
332
+ // A startup proof arms the guard but is not Telegram traffic, so it
333
+ // does not count toward the quiet-window arm of the exit rule.
334
+ if (route !== 'lifeline-proof')
335
+ this.state.verifiedSinceLastWouldRefuse++;
336
+ this.arm(nowMs, nowIso, 'lifeline-proof');
337
+ this.maybeGraduate(nowMs, nowIso);
338
+ }
339
+ this.persist(nowMs); // verified traffic is lifeline-rate; write through
340
+ return { action: 'accept', verified: true, mode: this.mode(), senderTrusted: true, staleSecretHint: false };
341
+ }
342
+ const reason = typeof presented === 'string' && presented.length > 0 ? 'mismatch' : 'missing';
343
+ this.consecutiveMismatch = reason === 'mismatch' ? this.consecutiveMismatch + 1 : 0;
344
+ if (this.bypassed()) {
345
+ // Full rollback: handled exactly as before this change. Still counted,
346
+ // and reported — a bypassed guard is itself an incident.
347
+ this.state.byReason[reason]++;
348
+ this.state.lastReason = reason;
349
+ this.state.wouldRefuse++;
350
+ this.state.byRoute[route].wouldRefuse++;
351
+ this.state.lastWouldRefuseAt = nowIso;
352
+ this.state.verifiedSinceLastWouldRefuse = 0;
353
+ this.degrade(nowMs, 'bypassed', `request without a valid lifeline secret on ${route} accepted because the guard is bypassed by config`, 'telegramIngress.lifelineSecret.bypass is true: the internal Telegram routes are unprotected.');
354
+ this.attention(nowMs, nowIso, 'bypassed', 'My Telegram intake check is switched off', `A request without the lifeline secret reached the internal Telegram ${route === 'telegram-callback' ? 'button' : 'message'} route and was accepted, because the bypass setting is on. While it is on, a session can send a message or a button tap as you.`);
355
+ this.persistSoon(nowMs);
356
+ return { action: 'accept', verified: false, mode: this.mode(), reason, senderTrusted: true, staleSecretHint: false };
357
+ }
358
+ this.armIfNoLifeline(nowMs, nowIso);
359
+ this.maybeGraduate(nowMs, nowIso); // the deadline arm needs no verified request
360
+ const mode = this.mode();
361
+ const armed = this.state.armedAt !== null;
362
+ // Rule 2: once armed — or, in enforce, once the bounded pre-arm allowance
363
+ // has run out — an unproven request carries no sender trust and an
364
+ // unproven tap is refused. A MESSAGE is refused only when armed + enforce.
365
+ const allowanceOver = nowMs - this.allowanceStartMs() >= PRE_ARM_TRUST_MS;
366
+ const trustAllowed = !armed && (mode === 'observe' || !allowanceOver);
367
+ const refuse = route === 'telegram-callback' ? !trustAllowed : armed && mode === 'enforce';
368
+ this.state.byReason[reason]++;
369
+ this.state.lastReason = reason;
370
+ this.state.verifiedSinceLastWouldRefuse = 0;
371
+ if (refuse) {
372
+ this.state.refused++;
373
+ this.state.byRoute[route].refused++;
374
+ this.state.lastRefusedAt = nowIso;
375
+ }
376
+ else {
377
+ this.state.wouldRefuse++;
378
+ this.state.byRoute[route].wouldRefuse++;
379
+ this.state.lastWouldRefuseAt = nowIso;
380
+ }
381
+ this.logLimited(nowMs, `[lifeline-ingress] ${refuse ? 'refused' : 'would-refuse'} ${route}: ${reason} (mode=${mode}, armed=${armed})`);
382
+ if (armed) {
383
+ // SECURITY class — reported on the same request, no heal-first delay,
384
+ // whether the header was missing or wrong (the server's own re-read
385
+ // above already ruled out a stale copy on OUR side): after arming, a
386
+ // request without the valid secret is not a healthy lifeline.
387
+ const what = reason === 'missing' ? 'without the lifeline secret' : 'with a wrong lifeline secret';
388
+ const impact = refuse
389
+ ? 'The request was refused. Something other than the lifeline called the route with the ordinary token, or the lifeline cannot read the secret file (its messages stay queued).'
390
+ : 'The message was delivered (observe) with no sender identity. Something other than the lifeline called the route with the ordinary token.';
391
+ this.degrade(nowMs, 'unproven-after-arming', `request ${what} on ${route} after the guard armed`, impact);
392
+ // …and it reaches the operator.
393
+ this.attention(nowMs, nowIso, 'unproven-after-arming', 'Something other than the lifeline called my Telegram intake', `A request ${what} reached the internal Telegram ${route === 'telegram-callback' ? 'button' : 'message'} route. `
394
+ + (refuse ? 'It was refused. ' : 'It was delivered without a sender identity. ')
395
+ + 'If Telegram messages are also not getting through, the lifeline may be unable to read its secret file.');
396
+ }
397
+ else {
398
+ this.reportUnarmed(nowMs, nowIso, reason, allowanceOver, trustAllowed);
399
+ }
400
+ // Move a lifeline that is still running old code onto the current version.
401
+ // A lifeline already on the server's version clears the signal without
402
+ // restarting. The budget is persisted and deliberately small — see the
403
+ // SIGNAL_GAP_* constants.
404
+ const gap = armed ? SIGNAL_GAP_ARMED_MS : SIGNAL_GAP_UNARMED_MS;
405
+ const lastSignalMs = this.state.lastSignalAt ? Date.parse(this.state.lastSignalAt) : NaN;
406
+ if (!Number.isFinite(lastSignalMs) || nowMs - lastSignalMs >= gap) {
407
+ try {
408
+ const wrote = this.opts.writeRestartSignal({
409
+ previousVersion: typeof info.lifelineVersion === 'string' ? info.lifelineVersion.slice(0, 64) : 'unknown',
410
+ });
411
+ if (wrote !== false)
412
+ this.state.lastSignalAt = nowIso;
413
+ }
414
+ catch (err) {
415
+ this.log(`[lifeline-ingress] failed to write lifeline-restart signal: ${err instanceof Error ? err.message : err}`);
416
+ }
417
+ }
418
+ this.persistSoon(nowMs);
419
+ return {
420
+ action: refuse ? 'refuse' : 'accept',
421
+ verified: false,
422
+ mode,
423
+ reason,
424
+ senderTrusted: trustAllowed,
425
+ staleSecretHint: !refuse && reason === 'mismatch',
426
+ };
427
+ }
428
+ /**
429
+ * Spend one unit of the persisted restart-signal budget on behalf of
430
+ * another signal writer acting on an UNVERIFIED request (the route's 426
431
+ * path). True = the caller may write.
432
+ */
433
+ spendSignalBudget() {
434
+ const nowMs = this.now();
435
+ const gap = this.state.armedAt !== null ? SIGNAL_GAP_ARMED_MS : SIGNAL_GAP_UNARMED_MS;
436
+ const last = this.state.lastSignalAt ? Date.parse(this.state.lastSignalAt) : NaN;
437
+ if (Number.isFinite(last) && nowMs - last < gap)
438
+ return false;
439
+ this.state.lastSignalAt = new Date(nowMs).toISOString();
440
+ this.persistSoon(nowMs);
441
+ return true;
442
+ }
443
+ /** The block published on the authed /health. No secret material. */
444
+ health() {
445
+ const nowMs = this.now();
446
+ this.armIfNoLifeline(nowMs, new Date(nowMs).toISOString());
447
+ this.maybeGraduate(nowMs, new Date(nowMs).toISOString(), true);
448
+ const mode = this.mode();
449
+ const explicit = this.opts.explicitEnforce();
450
+ const quietSince = this.quietSinceMs();
451
+ const s = this.state;
452
+ const secretAvailable = this.holder.fileReadable(); // a read of the FILE, never a create
453
+ return {
454
+ mode,
455
+ modeSource: this.modeSource(),
456
+ armed: s.armedAt !== null,
457
+ armedBy: s.armedBy,
458
+ bypassedByConfig: this.bypassed(),
459
+ // Refusal needs a secret HELD in memory; `secretAvailable` is the file.
460
+ refusing: !this.bypassed() && mode === 'enforce' && s.armedAt !== null && this.holder.held,
461
+ // Taps are refused once armed (either mode), in enforce once the
462
+ // pre-arm allowance has run out, and whenever no secret is held.
463
+ refusingTaps: !this.bypassed() && (s.armedAt !== null || !this.holder.held
464
+ || (mode === 'enforce' && nowMs - this.allowanceStartMs() >= PRE_ARM_TRUST_MS)),
465
+ // Only enforce has an expiring pre-arm allowance; observe never degrades
466
+ // an unarmed machine's traffic.
467
+ preArmTrustEndsAt: s.armedAt === null && mode === 'enforce'
468
+ ? new Date(this.allowanceStartMs() + PRE_ARM_TRUST_MS).toISOString()
469
+ : null,
470
+ secretAvailable,
471
+ observeExit: {
472
+ rule: `zero would-refuse for ${EXIT_QUIET_MS / 3_600_000}h and at least ${EXIT_MIN_VERIFIED} verified Telegram requests from the lifeline in that time, or ${EXIT_DEADLINE_MS / 86_400_000} days after arming, whichever comes first`,
473
+ pinnedObserveByConfig: explicit === false,
474
+ deadlineAt: s.enforceDeadlineAt,
475
+ graduatedAt: s.graduatedAt,
476
+ graduatedBy: s.graduatedBy,
477
+ quietSince: new Date(quietSince).toISOString(),
478
+ quietHours: Math.max(0, Math.round(((nowMs - quietSince) / 3_600_000) * 10) / 10),
479
+ verifiedSinceLastWouldRefuse: s.verifiedSinceLastWouldRefuse,
480
+ },
481
+ observingSince: s.observingSince,
482
+ armedAt: s.armedAt,
483
+ verified: s.verified,
484
+ verifiedServerSelf: s.verifiedServerSelf,
485
+ wouldRefuse: s.wouldRefuse,
486
+ refused: s.refused,
487
+ secretUnavailable: s.secretUnavailable,
488
+ byReason: { ...s.byReason },
489
+ byRoute: {
490
+ 'telegram-forward': { ...s.byRoute['telegram-forward'] },
491
+ 'telegram-callback': { ...s.byRoute['telegram-callback'] },
492
+ 'lifeline-proof': { ...s.byRoute['lifeline-proof'] },
493
+ },
494
+ lastVerifiedAt: s.lastVerifiedAt,
495
+ lastWouldRefuseAt: s.lastWouldRefuseAt,
496
+ lastRefusedAt: s.lastRefusedAt,
497
+ lastReason: s.lastReason,
498
+ lastSignalAt: s.lastSignalAt,
499
+ stateCorruptAtLoad: this.stateCorruptAtLoad,
500
+ stateWriteFailures: this.stateWriteFailures,
501
+ };
502
+ }
503
+ /** Write any coalesced counters now and stop the timers (shutdown, tests). */
504
+ flush() {
505
+ if (this.bootNudgeTimer) {
506
+ clearTimeout(this.bootNudgeTimer);
507
+ this.bootNudgeTimer = null;
508
+ }
509
+ if (this.flushTimer) {
510
+ clearTimeout(this.flushTimer);
511
+ this.flushTimer = null;
512
+ }
513
+ if (this.dirty)
514
+ this.persist(this.now());
515
+ }
516
+ arm(nowMs, nowIso, by) {
517
+ if (this.state.armedAt)
518
+ return;
519
+ this.state.armedAt = nowIso;
520
+ this.state.armedBy = by;
521
+ this.state.enforceDeadlineAt = new Date(nowMs + EXIT_DEADLINE_MS).toISOString();
522
+ console.warn(by === 'lifeline-proof'
523
+ ? '[lifeline-ingress] armed: the lifeline proved it sends the ingress secret'
524
+ : '[lifeline-ingress] armed: the lifeline is not the Telegram ingress on this machine');
525
+ }
526
+ quietSinceMs() {
527
+ const candidates = [this.state.observingSince, this.state.lastWouldRefuseAt, this.state.lastRefusedAt]
528
+ .map((v) => (v ? Date.parse(v) : NaN))
529
+ .filter((n) => Number.isFinite(n));
530
+ return candidates.length > 0 ? Math.max(...candidates) : this.now();
531
+ }
532
+ maybeGraduate(nowMs, nowIso, persist = false) {
533
+ if (this.state.graduatedAt)
534
+ return;
535
+ if (!this.state.armedAt)
536
+ return;
537
+ const deadline = this.state.enforceDeadlineAt ? Date.parse(this.state.enforceDeadlineAt) : NaN;
538
+ const quiet = this.state.verifiedSinceLastWouldRefuse >= EXIT_MIN_VERIFIED &&
539
+ nowMs - this.quietSinceMs() >= EXIT_QUIET_MS;
540
+ const pastDeadline = Number.isFinite(deadline) && nowMs >= deadline;
541
+ if (!quiet && !pastDeadline)
542
+ return;
543
+ this.state.graduatedAt = nowIso;
544
+ this.state.graduatedBy = quiet ? 'quiet-window' : 'deadline';
545
+ console.warn(`[lifeline-ingress] observe exit rule met (${this.state.graduatedBy}) — the default mode is now enforce`);
546
+ if (persist)
547
+ this.persist(nowMs);
548
+ }
549
+ /**
550
+ * Reports for an unverified request while UNARMED. The three checks are
551
+ * independent: a persistently wrong secret must not hide the fact that the
552
+ * guard is still unarmed, or that a day has passed.
553
+ */
554
+ reportUnarmed(nowMs, nowIso, reason, allowanceOver, trustAllowed) {
555
+ if (reason === 'mismatch' && this.consecutiveMismatch >= MISMATCH_REPORT_AFTER) {
556
+ // RECOVERABLE — both sides re-read the file first (the server in
557
+ // evaluate, the lifeline on a refusal or a stale hint). Report only if
558
+ // it persists.
559
+ this.degrade(nowMs, 'secret-mismatch', `${this.consecutiveMismatch} requests in a row carried a lifeline secret that is not this server's`, 'The caller and the server disagree about the secret file — for example two state directories.');
560
+ }
561
+ if (nowMs - this.allowanceStartMs() >= UNARMED_REPORT_AFTER_MS) {
562
+ // RECOVERABLE — the restart signal is the heal and was written by the
563
+ // boot nudge (or the first such request). Still unarmed an hour later.
564
+ this.degrade(nowMs, 'unarmed', 'the lifeline has not yet proven it sends the ingress secret; requests without it are still being accepted', 'The internal Telegram routes are not yet protected on this machine. Restart the lifeline onto the current version.');
565
+ }
566
+ if (allowanceOver) {
567
+ // A day on, the lifeline still has not updated. Tell the operator.
568
+ this.attention(nowMs, nowIso, 'unarmed', 'My Telegram lifeline has not updated', trustAllowed
569
+ ? 'My lifeline is still running an older version a day after my server updated, so the check that Telegram messages really come through the lifeline is not active yet. Restarting the lifeline fixes it.'
570
+ : 'My lifeline is still running an older version a day after my server updated, so Telegram messages now reach me without a verified sender and button taps are refused. Restarting the lifeline fixes it.');
571
+ }
572
+ }
573
+ /**
574
+ * One operator attention item per kind per ATTENTION_GAP_MS at most. The
575
+ * throttle is persisted and the id is dated, so a later episode raises a
576
+ * NEW item even after the operator resolved an earlier one. If nothing
577
+ * reached the operator, the throttle is released.
578
+ */
579
+ attention(nowMs, nowIso, kind, title, summary) {
580
+ if (!this.opts.raiseAttention)
581
+ return;
582
+ const previous = this.state.attentionAt[kind];
583
+ const last = previous ? Date.parse(previous) : NaN;
584
+ if (Number.isFinite(last) && nowMs - last < ATTENTION_GAP_MS)
585
+ return;
586
+ this.state.attentionAt[kind] = nowIso;
587
+ const release = () => {
588
+ if (this.state.attentionAt[kind] !== nowIso)
589
+ return;
590
+ if (previous)
591
+ this.state.attentionAt[kind] = previous;
592
+ else
593
+ delete this.state.attentionAt[kind];
594
+ this.persistSoon(this.now());
595
+ };
596
+ try {
597
+ const result = this.opts.raiseAttention({ id: `telegram-ingress:${kind}:${nowIso.slice(0, 10)}`, title, summary });
598
+ if (result === false)
599
+ release();
600
+ else if (result && typeof result.then === 'function') {
601
+ result.then((ok) => { if (ok === false)
602
+ release(); }, () => release());
603
+ }
604
+ }
605
+ catch (err) {
606
+ // @silent-fallback-ok — logged; the throttle is released so the next
607
+ // occurrence tries again, and the degradation record already exists.
608
+ this.log(`[lifeline-ingress] attention item could not be raised (${kind}): ${err instanceof Error ? err.message : err}`);
609
+ release();
610
+ }
611
+ }
612
+ /** One report per kind per hour. */
613
+ degrade(nowMs, kind, reason, impact) {
614
+ const last = this.lastDegradationAt.get(kind);
615
+ if (last !== undefined && nowMs - last < 60 * 60 * 1000)
616
+ return;
617
+ this.lastDegradationAt.set(kind, nowMs);
618
+ try {
619
+ this.opts.reportDegradation?.({ kind, reason, impact });
620
+ }
621
+ catch { /* reporting is best-effort */ }
622
+ }
623
+ /** At most one log line per 10 s; the next line says how many were skipped. */
624
+ logLimited(nowMs, line) {
625
+ if (this.lastLogAt !== 0 && nowMs - this.lastLogAt < 10_000) {
626
+ this.suppressedLogLines++;
627
+ return;
628
+ }
629
+ const suffix = this.suppressedLogLines > 0 ? ` (+${this.suppressedLogLines} similar not logged)` : '';
630
+ this.suppressedLogLines = 0;
631
+ this.lastLogAt = nowMs;
632
+ this.log(line + suffix);
633
+ }
634
+ loadState() {
635
+ const base = freshState(new Date(this.now()).toISOString());
636
+ let raw;
637
+ try {
638
+ raw = fs.readFileSync(this.statePath, 'utf-8');
639
+ }
640
+ catch {
641
+ // @silent-fallback-ok — no evidence yet (first boot): a fresh window,
642
+ // nothing armed, nothing graduated, so nothing refused.
643
+ return base;
644
+ }
645
+ try {
646
+ const parsed = JSON.parse(raw);
647
+ if (parsed && parsed.version === 1 && typeof parsed.observingSince === 'string') {
648
+ return {
649
+ ...base,
650
+ ...parsed,
651
+ attentionAt: parsed.attentionAt && typeof parsed.attentionAt === 'object' ? { ...parsed.attentionAt } : {},
652
+ byReason: { ...base.byReason, ...(parsed.byReason ?? {}) },
653
+ byRoute: {
654
+ 'telegram-forward': { ...base.byRoute['telegram-forward'], ...(parsed.byRoute?.['telegram-forward'] ?? {}) },
655
+ 'telegram-callback': { ...base.byRoute['telegram-callback'], ...(parsed.byRoute?.['telegram-callback'] ?? {}) },
656
+ 'lifeline-proof': { ...base.byRoute['lifeline-proof'], ...(parsed.byRoute?.['lifeline-proof'] ?? {}) },
657
+ },
658
+ };
659
+ }
660
+ }
661
+ catch { /* falls through to the corrupt case */ }
662
+ this.stateCorruptAtLoad = true;
663
+ return base;
664
+ }
665
+ /**
666
+ * Unverified requests can arrive at any rate (the caller this guard exists
667
+ * for is a looping session), so their counter writes are bounded: the first
668
+ * PERSIST_BURST in a window write through, the rest are coalesced into one
669
+ * trailing write per PERSIST_COALESCE_MS.
670
+ */
671
+ persistSoon(nowMs) {
672
+ if (nowMs - this.burstWindowStart >= PERSIST_COALESCE_MS) {
673
+ this.burstWindowStart = nowMs;
674
+ this.burstCount = 0;
675
+ }
676
+ if (this.burstCount < PERSIST_BURST) {
677
+ this.burstCount++;
678
+ this.persist(nowMs);
679
+ return;
680
+ }
681
+ this.dirty = true;
682
+ if (!this.flushTimer) {
683
+ this.flushTimer = setTimeout(() => {
684
+ this.flushTimer = null;
685
+ if (this.dirty)
686
+ this.persist(this.now());
687
+ }, PERSIST_COALESCE_MS);
688
+ this.flushTimer.unref?.();
689
+ }
690
+ }
691
+ /** Single writer (the server process), synchronous, tmp + rename. */
692
+ persist(nowMs) {
693
+ this.lastPersistAt = nowMs;
694
+ this.dirty = false;
695
+ const tmp = `${this.statePath}.${process.pid}.tmp`;
696
+ try {
697
+ fs.mkdirSync(path.dirname(this.statePath), { recursive: true });
698
+ /* state-registry: lifeline-ingress-guard */
699
+ fs.writeFileSync(tmp, JSON.stringify(this.state), { mode: 0o600 });
700
+ fs.renameSync(tmp, this.statePath);
701
+ }
702
+ catch {
703
+ this.stateWriteFailures++;
704
+ try {
705
+ if (fs.existsSync(tmp)) {
706
+ SafeFsExecutor.safeUnlinkSync(tmp, { operation: 'src/server/LifelineIngressGuard.ts:persist-cleanup' });
707
+ }
708
+ }
709
+ catch { /* best-effort */ }
710
+ }
711
+ }
712
+ }
713
+ //# sourceMappingURL=LifelineIngressGuard.js.map