approval-md 0.2.0 → 0.4.0

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 (378) hide show
  1. package/README.md +85 -877
  2. package/SPEC.md +150 -19
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +53 -1
  8. package/dist/src/channels/contract.js +304 -7
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +394 -12
  22. package/dist/src/channels/telegram.js +621 -68
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/channels/web.d.ts +9 -0
  25. package/dist/src/channels/web.js +17 -0
  26. package/dist/src/channels/web.js.map +1 -1
  27. package/dist/src/cli/amend.js +564 -34
  28. package/dist/src/cli/amend.js.map +1 -1
  29. package/dist/src/cli/attest.d.ts +9 -0
  30. package/dist/src/cli/attest.js +177 -13
  31. package/dist/src/cli/attest.js.map +1 -1
  32. package/dist/src/cli/channel-relay.d.ts +108 -0
  33. package/dist/src/cli/channel-relay.js +334 -0
  34. package/dist/src/cli/channel-relay.js.map +1 -0
  35. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  36. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  37. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  38. package/dist/src/cli/channel-telegram.d.ts +347 -28
  39. package/dist/src/cli/channel-telegram.js +763 -28
  40. package/dist/src/cli/channel-telegram.js.map +1 -1
  41. package/dist/src/cli/channel.d.ts +10 -1
  42. package/dist/src/cli/channel.js +14 -1
  43. package/dist/src/cli/channel.js.map +1 -1
  44. package/dist/src/cli/codex-bridge.d.ts +819 -0
  45. package/dist/src/cli/codex-bridge.js +1607 -0
  46. package/dist/src/cli/codex-bridge.js.map +1 -0
  47. package/dist/src/cli/codex.d.ts +1 -1
  48. package/dist/src/cli/codex.js +304 -7
  49. package/dist/src/cli/codex.js.map +1 -1
  50. package/dist/src/cli/daemon.js +41 -6
  51. package/dist/src/cli/daemon.js.map +1 -1
  52. package/dist/src/cli/doctor.d.ts +63 -0
  53. package/dist/src/cli/doctor.js +1322 -122
  54. package/dist/src/cli/doctor.js.map +1 -1
  55. package/dist/src/cli/execute.d.ts +17 -1
  56. package/dist/src/cli/execute.js +366 -22
  57. package/dist/src/cli/execute.js.map +1 -1
  58. package/dist/src/cli/exit-codes.d.ts +12 -0
  59. package/dist/src/cli/exit-codes.js +13 -0
  60. package/dist/src/cli/exit-codes.js.map +1 -1
  61. package/dist/src/cli/gate.d.ts +2 -0
  62. package/dist/src/cli/gate.js +233 -2
  63. package/dist/src/cli/gate.js.map +1 -1
  64. package/dist/src/cli/help.d.ts +14 -2
  65. package/dist/src/cli/help.js +395 -82
  66. package/dist/src/cli/help.js.map +1 -1
  67. package/dist/src/cli/hook-codex.d.ts +49 -1
  68. package/dist/src/cli/hook-codex.js +60 -1
  69. package/dist/src/cli/hook-codex.js.map +1 -1
  70. package/dist/src/cli/hook.d.ts +828 -4
  71. package/dist/src/cli/hook.js +2972 -142
  72. package/dist/src/cli/hook.js.map +1 -1
  73. package/dist/src/cli/import.js +1 -1
  74. package/dist/src/cli/import.js.map +1 -1
  75. package/dist/src/cli/init.js +16 -7
  76. package/dist/src/cli/init.js.map +1 -1
  77. package/dist/src/cli/instructions.js +24 -3
  78. package/dist/src/cli/instructions.js.map +1 -1
  79. package/dist/src/cli/log-anchor.d.ts +55 -0
  80. package/dist/src/cli/log-anchor.js +199 -60
  81. package/dist/src/cli/log-anchor.js.map +1 -1
  82. package/dist/src/cli/main.d.ts +0 -12
  83. package/dist/src/cli/main.js +56 -3
  84. package/dist/src/cli/main.js.map +1 -1
  85. package/dist/src/cli/muse.d.ts +2 -0
  86. package/dist/src/cli/muse.js +62 -0
  87. package/dist/src/cli/muse.js.map +1 -0
  88. package/dist/src/cli/payload.js +79 -1
  89. package/dist/src/cli/payload.js.map +1 -1
  90. package/dist/src/cli/policy-apply.d.ts +195 -0
  91. package/dist/src/cli/policy-apply.js +573 -0
  92. package/dist/src/cli/policy-apply.js.map +1 -0
  93. package/dist/src/cli/policy.js +14 -1
  94. package/dist/src/cli/policy.js.map +1 -1
  95. package/dist/src/cli/preflight.d.ts +151 -13
  96. package/dist/src/cli/preflight.js +398 -41
  97. package/dist/src/cli/preflight.js.map +1 -1
  98. package/dist/src/cli/sandbox.js +17 -1
  99. package/dist/src/cli/sandbox.js.map +1 -1
  100. package/dist/src/cli/scaffold.d.ts +1 -1
  101. package/dist/src/cli/scaffold.js +7 -1
  102. package/dist/src/cli/scaffold.js.map +1 -1
  103. package/dist/src/cli/serve.d.ts +53 -0
  104. package/dist/src/cli/serve.js +270 -0
  105. package/dist/src/cli/serve.js.map +1 -0
  106. package/dist/src/cli/setup-channel.d.ts +9 -0
  107. package/dist/src/cli/setup-channel.js +28 -1
  108. package/dist/src/cli/setup-channel.js.map +1 -1
  109. package/dist/src/cli/setup-common.d.ts +3 -1
  110. package/dist/src/cli/setup-common.js +3 -2
  111. package/dist/src/cli/setup-common.js.map +1 -1
  112. package/dist/src/cli/setup.d.ts +2 -0
  113. package/dist/src/cli/setup.js +94 -2
  114. package/dist/src/cli/setup.js.map +1 -1
  115. package/dist/src/cli/up.js +134 -51
  116. package/dist/src/cli/up.js.map +1 -1
  117. package/dist/src/cli/values.js +3 -4
  118. package/dist/src/cli/values.js.map +1 -1
  119. package/dist/src/cli/verb-registry.d.ts +2 -0
  120. package/dist/src/cli/verb-registry.js +510 -47
  121. package/dist/src/cli/verb-registry.js.map +1 -1
  122. package/dist/src/cli/wordmark.d.ts +2 -2
  123. package/dist/src/cli/wordmark.js +2 -2
  124. package/dist/src/codex/broker.d.ts +229 -0
  125. package/dist/src/codex/broker.js +548 -0
  126. package/dist/src/codex/broker.js.map +1 -0
  127. package/dist/src/codex/runner.d.ts +178 -0
  128. package/dist/src/codex/runner.js +231 -0
  129. package/dist/src/codex/runner.js.map +1 -0
  130. package/dist/src/codex/serve.d.ts +56 -0
  131. package/dist/src/codex/serve.js +98 -0
  132. package/dist/src/codex/serve.js.map +1 -0
  133. package/dist/src/codex/workspace-commit.d.ts +219 -0
  134. package/dist/src/codex/workspace-commit.js +549 -0
  135. package/dist/src/codex/workspace-commit.js.map +1 -0
  136. package/dist/src/core/advance-cycle.d.ts +51 -0
  137. package/dist/src/core/advance-cycle.js +66 -2
  138. package/dist/src/core/advance-cycle.js.map +1 -1
  139. package/dist/src/core/agents-md.d.ts +20 -18
  140. package/dist/src/core/agents-md.js +33 -31
  141. package/dist/src/core/agents-md.js.map +1 -1
  142. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  143. package/dist/src/core/attest-bootstrap.js +135 -0
  144. package/dist/src/core/attest-bootstrap.js.map +1 -0
  145. package/dist/src/core/attest.d.ts +234 -2
  146. package/dist/src/core/attest.js +342 -7
  147. package/dist/src/core/attest.js.map +1 -1
  148. package/dist/src/core/audit.d.ts +73 -3
  149. package/dist/src/core/audit.js +65 -5
  150. package/dist/src/core/audit.js.map +1 -1
  151. package/dist/src/core/channel-lease.d.ts +322 -0
  152. package/dist/src/core/channel-lease.js +663 -0
  153. package/dist/src/core/channel-lease.js.map +1 -0
  154. package/dist/src/core/channel-owner.d.ts +213 -0
  155. package/dist/src/core/channel-owner.js +358 -0
  156. package/dist/src/core/channel-owner.js.map +1 -0
  157. package/dist/src/core/command-class.d.ts +205 -0
  158. package/dist/src/core/command-class.js +1433 -28
  159. package/dist/src/core/command-class.js.map +1 -1
  160. package/dist/src/core/commit-guard.d.ts +272 -0
  161. package/dist/src/core/commit-guard.js +424 -0
  162. package/dist/src/core/commit-guard.js.map +1 -0
  163. package/dist/src/core/daemon-actor.d.ts +45 -0
  164. package/dist/src/core/daemon-actor.js +54 -0
  165. package/dist/src/core/daemon-actor.js.map +1 -0
  166. package/dist/src/core/daemon-host.d.ts +178 -0
  167. package/dist/src/core/daemon-host.js +228 -0
  168. package/dist/src/core/daemon-host.js.map +1 -0
  169. package/dist/src/core/daemon-identity.d.ts +238 -0
  170. package/dist/src/core/daemon-identity.js +252 -0
  171. package/dist/src/core/daemon-identity.js.map +1 -0
  172. package/dist/src/core/dark-session.d.ts +109 -8
  173. package/dist/src/core/dark-session.js +266 -82
  174. package/dist/src/core/dark-session.js.map +1 -1
  175. package/dist/src/core/decision-refusal.d.ts +23 -2
  176. package/dist/src/core/decision-refusal.js +24 -2
  177. package/dist/src/core/decision-refusal.js.map +1 -1
  178. package/dist/src/core/env-file.d.ts +5 -0
  179. package/dist/src/core/env-file.js +60 -1
  180. package/dist/src/core/env-file.js.map +1 -1
  181. package/dist/src/core/execute.d.ts +15 -2
  182. package/dist/src/core/execute.js +15 -2
  183. package/dist/src/core/execute.js.map +1 -1
  184. package/dist/src/core/gate.d.ts +392 -6
  185. package/dist/src/core/gate.js +852 -24
  186. package/dist/src/core/gate.js.map +1 -1
  187. package/dist/src/core/gesture-refusal.d.ts +166 -0
  188. package/dist/src/core/gesture-refusal.js +188 -0
  189. package/dist/src/core/gesture-refusal.js.map +1 -0
  190. package/dist/src/core/harness-version.d.ts +128 -4
  191. package/dist/src/core/harness-version.js +181 -12
  192. package/dist/src/core/harness-version.js.map +1 -1
  193. package/dist/src/core/harness-wait.d.ts +82 -3
  194. package/dist/src/core/harness-wait.js +89 -3
  195. package/dist/src/core/harness-wait.js.map +1 -1
  196. package/dist/src/core/instance.d.ts +59 -2
  197. package/dist/src/core/instance.js +113 -0
  198. package/dist/src/core/instance.js.map +1 -1
  199. package/dist/src/core/log-subscribe.d.ts +21 -0
  200. package/dist/src/core/log-subscribe.js +16 -6
  201. package/dist/src/core/log-subscribe.js.map +1 -1
  202. package/dist/src/core/log-write-layer.d.ts +55 -0
  203. package/dist/src/core/log-write-layer.js +91 -0
  204. package/dist/src/core/log-write-layer.js.map +1 -0
  205. package/dist/src/core/log.d.ts +89 -22
  206. package/dist/src/core/log.js +197 -19
  207. package/dist/src/core/log.js.map +1 -1
  208. package/dist/src/core/payload-census.d.ts +51 -0
  209. package/dist/src/core/payload-census.js +125 -2
  210. package/dist/src/core/payload-census.js.map +1 -1
  211. package/dist/src/core/payload-store.d.ts +31 -0
  212. package/dist/src/core/payload-store.js +113 -8
  213. package/dist/src/core/payload-store.js.map +1 -1
  214. package/dist/src/core/payload.d.ts +18 -3
  215. package/dist/src/core/payload.js +20 -4
  216. package/dist/src/core/payload.js.map +1 -1
  217. package/dist/src/core/policy-diff.d.ts +10 -0
  218. package/dist/src/core/policy-diff.js +10 -2
  219. package/dist/src/core/policy-diff.js.map +1 -1
  220. package/dist/src/core/policy-explain.d.ts +17 -1
  221. package/dist/src/core/policy-explain.js +48 -1
  222. package/dist/src/core/policy-explain.js.map +1 -1
  223. package/dist/src/core/policy-load.d.ts +65 -1
  224. package/dist/src/core/policy-load.js +21 -3
  225. package/dist/src/core/policy-load.js.map +1 -1
  226. package/dist/src/core/policy-match.d.ts +81 -0
  227. package/dist/src/core/policy-match.js +87 -0
  228. package/dist/src/core/policy-match.js.map +1 -1
  229. package/dist/src/core/policy-proposal.d.ts +75 -0
  230. package/dist/src/core/policy-proposal.js +110 -4
  231. package/dist/src/core/policy-proposal.js.map +1 -1
  232. package/dist/src/core/protected-path-guard.d.ts +117 -4
  233. package/dist/src/core/protected-path-guard.js +362 -48
  234. package/dist/src/core/protected-path-guard.js.map +1 -1
  235. package/dist/src/core/question-preempted.d.ts +141 -0
  236. package/dist/src/core/question-preempted.js +152 -0
  237. package/dist/src/core/question-preempted.js.map +1 -0
  238. package/dist/src/core/read-scope.d.ts +182 -0
  239. package/dist/src/core/read-scope.js +256 -0
  240. package/dist/src/core/read-scope.js.map +1 -0
  241. package/dist/src/core/run-payload.d.ts +160 -0
  242. package/dist/src/core/run-payload.js +304 -0
  243. package/dist/src/core/run-payload.js.map +1 -0
  244. package/dist/src/core/sandbox.d.ts +81 -0
  245. package/dist/src/core/sandbox.js +190 -1
  246. package/dist/src/core/sandbox.js.map +1 -1
  247. package/dist/src/core/sender-identity.d.ts +501 -0
  248. package/dist/src/core/sender-identity.js +609 -0
  249. package/dist/src/core/sender-identity.js.map +1 -0
  250. package/dist/src/core/shlex.d.ts +102 -0
  251. package/dist/src/core/shlex.js +159 -0
  252. package/dist/src/core/shlex.js.map +1 -0
  253. package/dist/src/core/state.d.ts +52 -1
  254. package/dist/src/core/state.js +52 -6
  255. package/dist/src/core/state.js.map +1 -1
  256. package/dist/src/core/telegram-config.d.ts +79 -0
  257. package/dist/src/core/telegram-config.js +108 -0
  258. package/dist/src/core/telegram-config.js.map +1 -1
  259. package/dist/src/core/values.d.ts +18 -8
  260. package/dist/src/core/values.js +36 -1
  261. package/dist/src/core/values.js.map +1 -1
  262. package/dist/src/core/verify.d.ts +23 -1
  263. package/dist/src/core/verify.js +20 -2
  264. package/dist/src/core/verify.js.map +1 -1
  265. package/dist/src/daemon/advance.d.ts +10 -0
  266. package/dist/src/daemon/advance.js +25 -4
  267. package/dist/src/daemon/advance.js.map +1 -1
  268. package/dist/src/daemon/audit.d.ts +80 -5
  269. package/dist/src/daemon/audit.js +126 -6
  270. package/dist/src/daemon/audit.js.map +1 -1
  271. package/dist/src/daemon/daemon.d.ts +174 -2
  272. package/dist/src/daemon/daemon.js +307 -7
  273. package/dist/src/daemon/daemon.js.map +1 -1
  274. package/dist/src/daemon/git-evidence.d.ts +2 -2
  275. package/dist/src/daemon/git-evidence.js +1 -1
  276. package/dist/src/daemon/projection.d.ts +13 -2
  277. package/dist/src/daemon/projection.js +39 -6
  278. package/dist/src/daemon/projection.js.map +1 -1
  279. package/dist/src/mcp/server.d.ts +39 -3
  280. package/dist/src/mcp/server.js +30 -3
  281. package/dist/src/mcp/server.js.map +1 -1
  282. package/dist/src/muse/server.d.ts +16 -0
  283. package/dist/src/muse/server.js +238 -0
  284. package/dist/src/muse/server.js.map +1 -0
  285. package/dist/src/serve/archive.d.ts +158 -0
  286. package/dist/src/serve/archive.js +354 -0
  287. package/dist/src/serve/archive.js.map +1 -0
  288. package/dist/src/serve/arguments.d.ts +93 -0
  289. package/dist/src/serve/arguments.js +228 -0
  290. package/dist/src/serve/arguments.js.map +1 -0
  291. package/dist/src/serve/credentials.d.ts +91 -0
  292. package/dist/src/serve/credentials.js +143 -0
  293. package/dist/src/serve/credentials.js.map +1 -0
  294. package/dist/src/serve/follow.d.ts +86 -0
  295. package/dist/src/serve/follow.js +101 -0
  296. package/dist/src/serve/follow.js.map +1 -0
  297. package/dist/src/serve/hook-thread.d.ts +134 -0
  298. package/dist/src/serve/hook-thread.js +333 -0
  299. package/dist/src/serve/hook-thread.js.map +1 -0
  300. package/dist/src/serve/hook-worker.d.ts +134 -0
  301. package/dist/src/serve/hook-worker.js +173 -0
  302. package/dist/src/serve/hook-worker.js.map +1 -0
  303. package/dist/src/serve/server.d.ts +412 -0
  304. package/dist/src/serve/server.js +1251 -0
  305. package/dist/src/serve/server.js.map +1 -0
  306. package/docs/README-extended.md +766 -0
  307. package/docs/cli-reference.md +2563 -73
  308. package/docs/codex-enforced-session.md +75 -2
  309. package/docs/codex-workspace-broker.md +118 -0
  310. package/package.json +6 -2
  311. package/schema/event.schema.json +595 -9
  312. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  313. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  314. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  315. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  316. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  317. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  318. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  319. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  320. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  321. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  322. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  323. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  324. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  325. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  326. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  327. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  328. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  329. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  330. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  331. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  332. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  333. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  334. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  335. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  336. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  337. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  338. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  339. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  340. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  341. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  342. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  343. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  344. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  345. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  346. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  347. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  348. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  349. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  350. package/schema/fixtures/policy/valid/canonical.json +1 -1
  351. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  352. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  353. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  354. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  355. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  356. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  357. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  358. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  359. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  360. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  361. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  362. package/schema/fixtures/values/invalid/version-float.json +1 -0
  363. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  364. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  365. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  366. package/schema/fixtures/values/valid/full.json +5 -7
  367. package/schema/fixtures/values/valid/minimal.json +1 -1
  368. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  369. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  370. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  371. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  372. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  373. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  374. package/schema/fixtures/values-md/valid/absent.md +1 -1
  375. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  376. package/schema/policy.schema.json +100 -4
  377. package/schema/values.schema.json +7 -11
  378. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -34,22 +34,59 @@
34
34
  * notify, long-poll, callbacks, failure modes — against a local mock Bot API
35
35
  * server and never touches the real network.
36
36
  *
37
+ * ## Two transports, one update path (APRV-424)
38
+ *
39
+ * An update reaches this channel either because it long-polled for it
40
+ * ({@link TelegramChannel.pollOnce}) or because Telegram posted it to a URL the
41
+ * runtime registered ({@link TelegramChannel.deliverUpdate}, fed by
42
+ * `channels/telegram-webhook.ts`). That is the WHOLE of the difference. Both
43
+ * entry points hand the update to the same {@link TelegramChannel.handleUpdate}
44
+ * and therefore to the same chat check, the same `senderOf`, the same
45
+ * resolution ladder, the same `onDecision` handler and the same
46
+ * annotate-after-decision edit; a decision does not know which door it came
47
+ * through, and there is no second copy of any of it to drift.
48
+ *
49
+ * The two are mutually exclusive per bot, and the Bot API says so first:
50
+ * `getUpdates` is refused outright while a webhook is set.
51
+ * {@link TelegramChannel.claimTransport} refuses the in-process form of the
52
+ * mistake, and the verbs above ask `getWebhookInfo` and the ownership registry
53
+ * about the cross-process one.
54
+ *
37
55
  * ## Config-declared identity — SPEC.md §11
38
56
  *
39
57
  * > Human identity in v0.1 is config-declared (an environment variable or
40
58
  * > flag); the trust boundary is the local machine, and anyone who can set that
41
59
  * > configuration and write to the log is inside it.
42
60
  *
43
- * This channel does **not** authenticate the person who tapped the button. It
44
- * checks that the callback arrived from the configured chat id, and the
45
- * decision is then recorded against the human actor the *runtime* was
46
- * configured with (`APPROVAL_HUMAN` / `--as`), not against anything the
47
- * callback carried. So the guarantee is "someone with access to the configured
48
- * chat, on a runtime configured by someone with local control, tapped Approve"
49
- * — not "alice tapped Approve". Anyone in that chat can approve as the
50
- * configured actor. Use a private chat with the bot, and treat the chat's
51
- * membership as part of the trust boundary. Cryptographic identity is future
52
- * work and is not a v0.1 claim.
61
+ * What this channel authenticates, exactly: that the callback arrived **from
62
+ * the configured chat id**, and which **Telegram account** the Bot API
63
+ * attributes the tap to (`callback_query.from.id`). It authenticates no
64
+ * person, because no transport can: an account id is evidence about an
65
+ * account.
66
+ *
67
+ * What is done with the second fact is the operator's to decide, and since
68
+ * APRV-324 there are two settings:
69
+ *
70
+ * - **No `senders` block in the policy.** Nothing changes from every build
71
+ * before it. The decision is recorded against the human actor the *runtime*
72
+ * was configured with (`APPROVAL_HUMAN` / `--as`), so the guarantee is
73
+ * "someone with access to the configured chat, on a runtime configured by
74
+ * someone with local control, tapped Approve" — not "alice tapped Approve".
75
+ * Anyone in that chat can approve as the configured actor. Use a private chat
76
+ * with the bot, and treat the chat's membership as part of the trust
77
+ * boundary.
78
+ * - **A `senders` block mapping account ids to approvers.** The decision is
79
+ * recorded against the person the operator attested that account to, and a
80
+ * tap from an account the policy does not name is REFUSED rather than
81
+ * recorded under the listener's identity. The guarantee becomes "the account
82
+ * the operator attested to alice tapped Approve", which is a statement about
83
+ * Telegram's session handling and the operator's assertion, and still not a
84
+ * proof of personhood. Cryptographic identity is future work and is not a
85
+ * v0.1 claim.
86
+ *
87
+ * The channel itself resolves nothing either way. It reports the account it
88
+ * saw; `channels/contract.ts` resolves it against the attested policy, and
89
+ * `core/gate.ts` decides. See `design/channel-sender-identity.md`.
53
90
  *
54
91
  * ## Formatting: HTML, not MarkdownV2 — a deliberate choice
55
92
  *
@@ -370,6 +407,13 @@ const DEFAULT_MAX_BACKOFF_MS = 30_000;
370
407
  export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
371
408
  /** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
372
409
  export const TELEGRAM_SWEEP_INTERVAL_MS = 60_000;
410
+ /**
411
+ * How many refused cards' nonces a channel remembers as dead (APRV-442).
412
+ *
413
+ * A bound on memory and nothing else: a nonce that falls off the end reaches
414
+ * the action-reference fallback, and the gate judges that tap in full.
415
+ */
416
+ export const TELEGRAM_REFUSED_NONCE_CAP = 1024;
373
417
  // ---------------------------------------------------------------------------
374
418
  // Anomalies
375
419
  // ---------------------------------------------------------------------------
@@ -1206,6 +1250,42 @@ export function parseCheckpointCallback(data) {
1206
1250
  return null;
1207
1251
  return { sign: verb === "k", nonce };
1208
1252
  }
1253
+ /**
1254
+ * The account the Bot API attributes a callback to (APRV-324, amended SPEC.md
1255
+ * §10.3).
1256
+ *
1257
+ * `callback_query.from.id` and nothing else. Telegram assembles the `from`
1258
+ * object itself, from the session the tap arrived on, which is why it is the
1259
+ * one field on an update this channel treats as evidence about a person. Three
1260
+ * neighbours are deliberately not read:
1261
+ *
1262
+ * - **`from.username`.** Mutable and reusable, so a mapping keyed on it would
1263
+ * hand an identity over with a handle. Never a key here and never recorded
1264
+ * (`core/sender-identity.ts` states the argument in full).
1265
+ * - **`message.from`.** The author of the message the button sits on — this bot
1266
+ * — rather than the person who pressed it.
1267
+ * - **`data`, and any text.** What the sender says about themselves, which
1268
+ * §11.1 invariant 4 says may raise scrutiny and never lower it. A callback
1269
+ * whose payload names a user id names it about itself; the id here comes from
1270
+ * the transport, and the two disagreeing changes nothing.
1271
+ *
1272
+ * `undefined` when the update carries no usable id: a sender this channel could
1273
+ * not read is not a sender it may guess at, and the decision then travels with
1274
+ * none, which the contract reads as today's configured attribution.
1275
+ */
1276
+ export function senderOf(query, channel) {
1277
+ const from = query["from"];
1278
+ if (typeof from !== "object" || from === null)
1279
+ return undefined;
1280
+ const id = from["id"];
1281
+ // Telegram sends a JSON number; a string is accepted for the same reason the
1282
+ // chat check accepts one, and nothing else is: an object or an array here is
1283
+ // a shape this channel does not understand rather than an id to stringify.
1284
+ if (typeof id !== "number" && typeof id !== "string")
1285
+ return undefined;
1286
+ const text = String(id);
1287
+ return text.length === 0 ? undefined : { channel, id: text };
1288
+ }
1209
1289
  const CALLBACK_VERBS = {
1210
1290
  g: { decision: "grant", scope: "one" },
1211
1291
  r: { decision: "reject", scope: "one" },
@@ -1494,6 +1574,60 @@ export function isMessageNotModified(cause) {
1494
1574
  cause.description !== null &&
1495
1575
  TELEGRAM_NOT_MODIFIED.test(cause.description));
1496
1576
  }
1577
+ /**
1578
+ * Telegram's wording for "somebody else is already long-polling this bot".
1579
+ *
1580
+ * Matched on the description as well as the status, for the same reason
1581
+ * {@link TELEGRAM_NOT_MODIFIED} is: 409 is a conflict, and the conflict this
1582
+ * project cares about is specifically the terminated-by-other-getUpdates one.
1583
+ */
1584
+ const TELEGRAM_POLL_CONFLICT = /terminated by other getupdates|make sure that only one bot/iu;
1585
+ /**
1586
+ * Whether a failed poll is the Bot API saying another process holds this bot
1587
+ * (APRV-390).
1588
+ *
1589
+ * It is the one poll failure that is not transient and not the network's
1590
+ * fault: retrying it forever produces an identical line every few seconds and
1591
+ * never recovers, because the other poller is not going to stop. {@link
1592
+ * TelegramChannel.listen} reports it once, with whatever the caller knows
1593
+ * about who the other process is, and then stops repeating itself.
1594
+ */
1595
+ export function isPollConflict(cause) {
1596
+ return (cause instanceof TelegramApiError &&
1597
+ cause.method === "getUpdates" &&
1598
+ (cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
1599
+ }
1600
+ /** A fresh, empty batch report. One shape for both transports (APRV-424). */
1601
+ function emptyPollResult() {
1602
+ return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [] };
1603
+ }
1604
+ /**
1605
+ * Why a transport claim was refused. Frozen, per SPEC §11.1 invariant 6.
1606
+ *
1607
+ * One code, because there is one fact: this channel is already receiving
1608
+ * updates by the other transport.
1609
+ */
1610
+ export const TELEGRAM_TRANSPORT_REFUSAL_CODES = ["transport-conflict"];
1611
+ /**
1612
+ * The in-process half of "long-poll and webhook are mutually exclusive"
1613
+ * (APRV-424).
1614
+ *
1615
+ * It is a belt on the braces and says so. The exclusion that matters is per
1616
+ * BOT and lives above this class, where a process can ask the Bot API
1617
+ * (`getWebhookInfo`) and the per-machine ownership registry who else is
1618
+ * receiving this bot's updates; a channel object knows only about itself.
1619
+ * What this rules out is the one failure a caller could cause here and
1620
+ * nowhere else: feeding one channel instance from both a poll loop and an
1621
+ * HTTP handler, where `handleUpdate`'s single ack slot and the delivery maps
1622
+ * would be written by two arrivals nobody ordered.
1623
+ */
1624
+ export class TelegramTransportError extends Error {
1625
+ code = "transport-conflict";
1626
+ constructor(message) {
1627
+ super(message);
1628
+ this.name = "TelegramTransportError";
1629
+ }
1630
+ }
1497
1631
  export class TelegramChannel {
1498
1632
  name = "telegram";
1499
1633
  token;
@@ -1569,11 +1703,60 @@ export class TelegramChannel {
1569
1703
  digests = new Map();
1570
1704
  /** "All" nonce -> the digest message it was issued for. */
1571
1705
  allNonces = new Map();
1706
+ /**
1707
+ * Action keys whose card this process disarmed on a tap the gate did NOT
1708
+ * settle (APRV-442): a refusal (`sender-unmapped`, a budget, an unattested
1709
+ * policy, anything `ok: false`) or a handler that threw. Drained by
1710
+ * {@link takeReleased}.
1711
+ *
1712
+ * The disarm is right and stays, and {@link refusedNonces} keeps it final:
1713
+ * the refused card's bytes resolve to nothing, not even through the
1714
+ * action-reference fallback to the card that replaces it. What the disarm
1715
+ * must not do is leave the listener believing the REQUEST is on the
1716
+ * approver's phone, which is what `DispatchState.delivered` goes on saying
1717
+ * until something tells it otherwise. This set is that something: it names
1718
+ * a card, never an outcome, and the listener re-derives from the verified
1719
+ * log whether the request is still pending before it offers a new card.
1720
+ * Nothing here decides, and losing it to a restart costs nothing, because a
1721
+ * restarted listener re-sends everything pending anyway.
1722
+ */
1723
+ released = new Set();
1724
+ /**
1725
+ * Nonces of cards a refused or failed tap disarmed (APRV-442), oldest first,
1726
+ * capped at {@link TELEGRAM_REFUSED_NONCE_CAP}.
1727
+ *
1728
+ * Without this, a released request's fresh card would give the refused
1729
+ * card's bytes somewhere to go: the nonce misses, the action reference
1730
+ * resolves to the new live card, and a Telegram redelivery of the refused
1731
+ * tap (a webhook retry) or a client replaying the removed buttons would be
1732
+ * refused again, append again, disarm the new card and cause another
1733
+ * re-send, once per replay. A nonce in this set never takes the fallback, so
1734
+ * a refused card stays exactly as dead as it was before APRV-442 and only
1735
+ * the REQUEST is offered again. Forgetting an entry past the cap degrades to
1736
+ * the fallback, whose tap the gate still judges in full: volume, never a
1737
+ * decision.
1738
+ */
1739
+ refusedNonces = new Set();
1572
1740
  rendered = [];
1573
1741
  offset = 0;
1574
1742
  counter = 0;
1575
1743
  stopped = false;
1576
1744
  inFlight = null;
1745
+ /**
1746
+ * The transport this channel has started receiving updates by, or `null`
1747
+ * before the first one (APRV-424). Sticky for the life of the object: a
1748
+ * process that stopped polling has not stopped being a poller, and the
1749
+ * question this answers is which arrival path owns the maps below.
1750
+ */
1751
+ transport = null;
1752
+ /**
1753
+ * Everything {@link redact} scrubs out of anything that leaves this class.
1754
+ *
1755
+ * The bot token from construction, plus any secret a later call was handed:
1756
+ * {@link registerWebhook} adds the `secret_token` before it sends it, so a
1757
+ * Bot API error quoting the request cannot put it on an operator's terminal.
1758
+ */
1759
+ secrets;
1577
1760
  counters = {
1578
1761
  notified: 0,
1579
1762
  updates: 0,
@@ -1593,6 +1776,8 @@ export class TelegramChannel {
1593
1776
  };
1594
1777
  constructor(config) {
1595
1778
  this.token = config.token;
1779
+ this.secrets =
1780
+ config.token.length === 0 ? [] : [{ value: config.token, label: "<token redacted>" }];
1596
1781
  this.chatId = String(config.chatId);
1597
1782
  this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
1598
1783
  this.fetchImpl = config.fetch ?? globalThis.fetch;
@@ -1855,8 +2040,15 @@ export class TelegramChannel {
1855
2040
  async deliverDigest(members, batchDeliveryId, stale = null) {
1856
2041
  const allNonce = this.makeNonce();
1857
2042
  const deliveredAtMs = this.now();
2043
+ // The digest's window is the widest member's: it may be forgotten only when
2044
+ // every member is past deciding, and a member nothing bounds keeps it.
2045
+ const windows = members.map((member) => this.retentionWindowMs(member));
2046
+ const windowMs = windows.includes(null)
2047
+ ? null
2048
+ : Math.max(...windows.filter((window) => window !== null));
1858
2049
  const state = {
1859
2050
  deliveredAtMs,
2051
+ windowMs,
1860
2052
  // Assigned once the message exists; nothing consults it before then.
1861
2053
  deliveryId: "",
1862
2054
  batchDeliveryId,
@@ -1916,13 +2108,14 @@ export class TelegramChannel {
1916
2108
  state.deliveryId = deliveryId;
1917
2109
  // Armed only now, and all at once: until the message with the buttons on it
1918
2110
  // exists there is nothing a callback could legitimately answer.
1919
- for (const member of state.members) {
2111
+ for (const [index, member] of state.members.entries()) {
1920
2112
  this.deliveries.set(member.nonce, {
1921
2113
  actionKey: member.actionKey,
1922
2114
  actionRef: actionRefOf(member.actionKey),
1923
2115
  deliveryId,
1924
2116
  batchDeliveryId,
1925
2117
  deliveredAtMs,
2118
+ windowMs: windows[index] ?? null,
1926
2119
  });
1927
2120
  }
1928
2121
  this.digests.set(deliveryId, state);
@@ -2014,6 +2207,7 @@ export class TelegramChannel {
2014
2207
  actionRef: actionRefOf(actionKey),
2015
2208
  deliveryId: sent.deliveryId,
2016
2209
  deliveredAtMs: this.now(),
2210
+ windowMs: this.retentionWindowMs(request),
2017
2211
  ...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
2018
2212
  });
2019
2213
  return {
@@ -2054,6 +2248,31 @@ export class TelegramChannel {
2054
2248
  }
2055
2249
  return actionKey;
2056
2250
  }
2251
+ /**
2252
+ * How long after delivery the request behind a prompt can still be decided,
2253
+ * on this process's clock, or `null` when nothing bounds it (APRV-423, second
2254
+ * review pass).
2255
+ *
2256
+ * The SHORTER of two bounds, each of which is at or past the gate's own
2257
+ * lapse, so the sweep never forgets a button a decision could still reach:
2258
+ *
2259
+ * - the request's `ttl_remaining_ms`, computed by the listener from the
2260
+ * verified log against the window the gate judges by (the policy's TTL
2261
+ * narrowed by the harness cap the requesting hook declared). It was
2262
+ * measured at tagging time, and delivery is at or after tagging, so
2263
+ * counting it from delivery ends at or after the real lapse;
2264
+ * - the policy's own TTL ({@link TelegramConfig.approvalTtlMs}), counted from
2265
+ * delivery, which is at or after the `approval.requested` it really runs
2266
+ * from. This was the only bound before APRV-423 and it stays as one.
2267
+ *
2268
+ * A request neither bounds is decidable forever, and its entry is dropped
2269
+ * only on an observed settlement. This channel still reads no policy and no
2270
+ * log: both numbers arrive computed, by the verb and by the tagger.
2271
+ */
2272
+ retentionWindowMs(request) {
2273
+ const bounds = [request.ttl_remaining_ms.value, this.approvalTtlMs].filter((bound) => bound !== null);
2274
+ return bounds.length === 0 ? null : Math.min(...bounds);
2275
+ }
2057
2276
  /**
2058
2277
  * Drop the delivery bookkeeping no callback can still be honoured against
2059
2278
  * (APRV-135).
@@ -2088,17 +2307,23 @@ export class TelegramChannel {
2088
2307
  */
2089
2308
  sweep(nowMs = this.now()) {
2090
2309
  this.lastSweepMs = nowMs;
2091
- const retention = this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
2092
- const expired = (deliveredAtMs) => nowMs - deliveredAtMs >= retention;
2093
- // Past the approval TTL the gate refuses every decision, so the request is
2094
- // terminal whether or not this process saw it settle. With no TTL declared
2095
- // nothing expires, and only an observed settlement makes an entry droppable.
2096
- const lapsed = (deliveredAtMs) => this.approvalTtlMs !== null && nowMs - deliveredAtMs >= this.approvalTtlMs;
2310
+ // APRV-423 (second review pass): both halves read the ENTRY's own window,
2311
+ // which is the effective one the gate judges its request by, and fall back
2312
+ // to the policy's TTL and then the default only for an entry that carries
2313
+ // none (a review card, which has no request).
2314
+ const retentionOf = (windowMs) => windowMs ?? this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
2315
+ const expired = (deliveredAtMs, windowMs) => nowMs - deliveredAtMs >= retentionOf(windowMs);
2316
+ // Past its window the gate refuses every decision on the request, so it is
2317
+ // terminal whether or not this process saw it settle. With nothing bounding
2318
+ // it nothing expires, and only an observed settlement makes it droppable.
2319
+ const lapsed = (deliveredAtMs, windowMs) => windowMs !== null && nowMs - deliveredAtMs >= windowMs;
2097
2320
  let digests = 0;
2098
2321
  for (const [deliveryId, digest] of this.digests) {
2099
2322
  const terminal = digest.members.every((member) => member.settled !== null);
2100
- if (!(terminal || lapsed(digest.deliveredAtMs)) || !expired(digest.deliveredAtMs))
2323
+ if (!(terminal || lapsed(digest.deliveredAtMs, digest.windowMs)) ||
2324
+ !expired(digest.deliveredAtMs, digest.windowMs)) {
2101
2325
  continue;
2326
+ }
2102
2327
  this.digests.delete(deliveryId);
2103
2328
  this.allNonces.delete(digest.allNonce);
2104
2329
  digests += 1;
@@ -2110,8 +2335,10 @@ export class TelegramChannel {
2110
2335
  // decides, and it was just judged above.
2111
2336
  if (this.digests.has(delivery.deliveryId))
2112
2337
  continue;
2113
- if (!lapsed(delivery.deliveredAtMs) || !expired(delivery.deliveredAtMs))
2338
+ if (!lapsed(delivery.deliveredAtMs, delivery.windowMs) ||
2339
+ !expired(delivery.deliveredAtMs, delivery.windowMs)) {
2114
2340
  continue;
2341
+ }
2115
2342
  this.deliveries.delete(nonce);
2116
2343
  deliveries += 1;
2117
2344
  }
@@ -2123,7 +2350,7 @@ export class TelegramChannel {
2123
2350
  // this map holds, so keeping the buttons alive costs a map entry and
2124
2351
  // dropping them early would cost a human their thumb.
2125
2352
  for (const [deliveryId, card] of this.reviewCards) {
2126
- if (card.settled === null || !expired(card.deliveredAtMs))
2353
+ if (card.settled === null || !expired(card.deliveredAtMs, null))
2127
2354
  continue;
2128
2355
  this.reviewCards.delete(deliveryId);
2129
2356
  this.reviewNonces.delete(card.nonce);
@@ -2233,6 +2460,51 @@ export class TelegramChannel {
2233
2460
  async retract(deliveryId, reason, actionKey) {
2234
2461
  await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
2235
2462
  }
2463
+ /**
2464
+ * The action keys whose card a refused or failed tap disarmed since the last
2465
+ * call, and forget them (APRV-442).
2466
+ *
2467
+ * The listener's dispatch cycle calls this once per cycle and offers a fresh
2468
+ * card for every key the verified log still calls pending. It is a statement
2469
+ * about this process's cards and nothing else: it carries no decision, no
2470
+ * sender and no outcome, and a key in it that the log has since settled is
2471
+ * simply not offered.
2472
+ */
2473
+ takeReleased() {
2474
+ const keys = [...this.released];
2475
+ this.released.clear();
2476
+ return keys;
2477
+ }
2478
+ /**
2479
+ * Mark `delivery`'s card dead and its request released, BEFORE the
2480
+ * annotation that disarms it (APRV-442).
2481
+ *
2482
+ * Every nonce still armed for this request on this message is recorded in
2483
+ * {@link refusedNonces}, together with the nonce the tap carried (which
2484
+ * differs when the tap came from an earlier copy through the fallback), so
2485
+ * none of those bytes can later resolve to the fresh card. On a digest only
2486
+ * this member's nonce is taken: its siblings stay armed on the digest.
2487
+ */
2488
+ release(delivery, tapNonce) {
2489
+ const dead = [tapNonce];
2490
+ for (const [nonce, entry] of this.deliveries) {
2491
+ if (entry.deliveryId === delivery.deliveryId && entry.actionKey === delivery.actionKey) {
2492
+ dead.push(nonce);
2493
+ }
2494
+ }
2495
+ for (const nonce of dead) {
2496
+ // Re-inserted so a nonce marked again counts as the newest.
2497
+ this.refusedNonces.delete(nonce);
2498
+ this.refusedNonces.add(nonce);
2499
+ }
2500
+ while (this.refusedNonces.size > TELEGRAM_REFUSED_NONCE_CAP) {
2501
+ const oldest = this.refusedNonces.values().next().value;
2502
+ if (oldest === undefined)
2503
+ break;
2504
+ this.refusedNonces.delete(oldest);
2505
+ }
2506
+ this.released.add(delivery.actionKey);
2507
+ }
2236
2508
  /**
2237
2509
  * Send one plain message that carries no question (APRV-196).
2238
2510
  *
@@ -2255,6 +2527,32 @@ export class TelegramChannel {
2255
2527
  return String(result.message_id);
2256
2528
  }
2257
2529
  // -------------------------------------------------------------------------
2530
+ // Identity
2531
+ // -------------------------------------------------------------------------
2532
+ /**
2533
+ * Which bot this token is, from one `getMe` (APRV-390).
2534
+ *
2535
+ * `getMe` is the only Bot API call that answers it, and it is the call
2536
+ * `approval doctor` and `approval setup channel telegram` already make for
2537
+ * the same reason: it mutates nothing, sends nothing, and acknowledges
2538
+ * nothing. In particular it is NOT `getUpdates`, so asking who this bot is
2539
+ * consumes no update and steals no pending tap from a listener that is
2540
+ * already running — which matters here above all, because the answer is
2541
+ * what decides whether this process is allowed to poll at all.
2542
+ *
2543
+ * Throws {@link TelegramApiError} like every other call; the caller decides
2544
+ * whether an unreachable Bot API is a refusal or a shrug.
2545
+ */
2546
+ async identify() {
2547
+ const result = await this.call("getMe", {});
2548
+ const id = result["id"];
2549
+ const username = result["username"];
2550
+ return {
2551
+ id: typeof id === "number" || typeof id === "string" ? String(id) : "",
2552
+ username: typeof username === "string" ? `@${username}` : "the bot",
2553
+ };
2554
+ }
2555
+ // -------------------------------------------------------------------------
2258
2556
  // Long polling
2259
2557
  // -------------------------------------------------------------------------
2260
2558
  /**
@@ -2275,8 +2573,19 @@ export class TelegramChannel {
2275
2573
  * decision", not "deliver once at startup, then wait forever".
2276
2574
  */
2277
2575
  async listen(options = {}) {
2576
+ this.claimOrThrow("poll");
2278
2577
  this.stopped = false;
2279
2578
  let backoff = this.backoffMs;
2579
+ /**
2580
+ * APRV-390. The last failure reported, and how many identical ones have
2581
+ * been swallowed since. A 409 does not recover: the other poller is not
2582
+ * going to stop because this one asked again, so the pre-APRV-390 loop
2583
+ * printed the same sentence every few seconds until somebody read the
2584
+ * terminal. The retry itself stays — a listener that stops listening is
2585
+ * the failure this loop rules out — and only the COMPLAINING is collapsed.
2586
+ */
2587
+ let lastComplaint = null;
2588
+ let repeats = 0;
2280
2589
  while (!this.stopped) {
2281
2590
  try {
2282
2591
  if (options.beforePoll !== undefined) {
@@ -2286,6 +2595,11 @@ export class TelegramChannel {
2286
2595
  }
2287
2596
  await this.pollOnce();
2288
2597
  backoff = this.backoffMs;
2598
+ if (repeats > 0) {
2599
+ this.complain(`approval: telegram getUpdates recovered after ${String(repeats)} further identical failure(s)`);
2600
+ }
2601
+ lastComplaint = null;
2602
+ repeats = 0;
2289
2603
  if (options.once === true)
2290
2604
  return;
2291
2605
  }
@@ -2293,7 +2607,26 @@ export class TelegramChannel {
2293
2607
  if (this.stopped)
2294
2608
  return;
2295
2609
  this.counters.pollErrors += 1;
2296
- this.complain(`approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`);
2610
+ const conflict = isPollConflict(cause);
2611
+ // The 409's own sentence, said once. It names the fact ("another
2612
+ // process is polling this bot") rather than the HTTP status, because
2613
+ // the status is what a reader was already staring at, and it carries
2614
+ // whatever the caller knows about which two gates are involved.
2615
+ const advice = conflict ? (options.conflictAdvice?.() ?? null) : null;
2616
+ const message = conflict
2617
+ ? `approval: telegram getUpdates: another process is polling this bot${advice === null ? "" : ` — ${advice}`}; this listener keeps retrying and will not receive a tap until the other one stops`
2618
+ : `approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`;
2619
+ if (message === lastComplaint) {
2620
+ repeats += 1;
2621
+ }
2622
+ else {
2623
+ if (repeats > 0) {
2624
+ this.complain(`approval: telegram getUpdates: the previous line repeated ${String(repeats)} more time(s)`);
2625
+ }
2626
+ this.complain(message);
2627
+ lastComplaint = message;
2628
+ repeats = 0;
2629
+ }
2297
2630
  await sleep(backoff);
2298
2631
  backoff = Math.min(backoff * 2, this.maxBackoffMs);
2299
2632
  }
@@ -2309,6 +2642,7 @@ export class TelegramChannel {
2309
2642
  * what {@link listen} catches and retries.
2310
2643
  */
2311
2644
  async pollOnce() {
2645
+ this.claimOrThrow("poll");
2312
2646
  // APRV-135. Before the long poll, not after: this is where the loop is
2313
2647
  // about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
2314
2648
  // the block would be a sweep that never runs on a quiet chat. Rate-limited
@@ -2320,35 +2654,163 @@ export class TelegramChannel {
2320
2654
  const updates = await this.call("getUpdates", {
2321
2655
  offset: this.offset,
2322
2656
  timeout: this.pollTimeoutSeconds,
2323
- // APRV-216. `message` is asked for only while a command handler is
2324
- // registered: see {@link onCommand} for why a listener that reads
2325
- // messages nobody asked for would break `approval setup channel
2326
- // telegram`'s chat discovery.
2327
- // APRV-299 adds the second reason to read messages: a `loved` or
2328
- // `disliked` on a review card collects the human's words as a REPLY,
2329
- // because an inline keyboard has no text input.
2330
- allowed_updates: this.commandHandler === null && this.reviewHandler === null
2331
- ? ["callback_query"]
2332
- : ["callback_query", "message"],
2657
+ allowed_updates: this.allowedUpdates(),
2333
2658
  }, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
2334
- const result = {
2335
- updates: 0,
2336
- outcomes: [],
2337
- ignored: [],
2338
- commands: [],
2339
- reviews: [],
2340
- };
2341
- for (const raw of updates) {
2342
- const update = (raw ?? {});
2343
- const id = update["update_id"];
2344
- if (typeof id === "number")
2345
- this.offset = Math.max(this.offset, id + 1);
2346
- this.counters.updates += 1;
2347
- result.updates += 1;
2348
- await this.handleUpdate(update, result);
2349
- }
2659
+ const result = emptyPollResult();
2660
+ for (const raw of updates)
2661
+ await this.intake(raw, result);
2350
2662
  return result;
2351
2663
  }
2664
+ /**
2665
+ * Which update types this channel asks for, on either transport (APRV-424).
2666
+ *
2667
+ * APRV-216. `message` is asked for only while a command handler is
2668
+ * registered: see {@link onCommand} for why a listener that reads messages
2669
+ * nobody asked for would break `approval setup channel telegram`'s chat
2670
+ * discovery. APRV-299 adds the second reason to read messages: a `loved` or
2671
+ * `disliked` on a review card collects the human's words as a REPLY, because
2672
+ * an inline keyboard has no text input.
2673
+ *
2674
+ * One function since APRV-424, because `setWebhook` takes the same list as
2675
+ * `getUpdates` and two spellings of it would be two answers to "what does
2676
+ * this listener read" — with the webhook's answer frozen at registration
2677
+ * time, where nobody would look for it.
2678
+ */
2679
+ allowedUpdates() {
2680
+ return this.commandHandler === null && this.reviewHandler === null
2681
+ ? ["callback_query"]
2682
+ : ["callback_query", "message"];
2683
+ }
2684
+ /**
2685
+ * One update, from whichever transport carried it (APRV-424).
2686
+ *
2687
+ * The whole of what the webhook adds is the ARRIVAL. Everything a tap then
2688
+ * passes through — the chat check, `senderOf`, the checkpoint and review
2689
+ * parsers, the resolution ladder, the early ack, `this.handler` and the
2690
+ * annotation written from the record the gate appended — is
2691
+ * {@link handleUpdate}, called from here and from {@link pollOnce}, and
2692
+ * there is no second copy of any of it. `tests/telegram-webhook.test.ts`
2693
+ * drives one callback through both entry points and compares the records the
2694
+ * log ends up holding.
2695
+ *
2696
+ * The sweep is `pollOnce`'s, for `pollOnce`'s reason: this is the moment the
2697
+ * process is about to be idle, and a channel that only ever wakes for taps
2698
+ * would otherwise never run one.
2699
+ */
2700
+ async deliverUpdate(update) {
2701
+ this.claimOrThrow("webhook");
2702
+ const nowMs = this.now();
2703
+ if (nowMs - this.lastSweepMs >= TELEGRAM_SWEEP_INTERVAL_MS)
2704
+ this.sweep(nowMs);
2705
+ const result = emptyPollResult();
2706
+ await this.intake(update, result);
2707
+ return result;
2708
+ }
2709
+ /** The per-update bookkeeping both transports do, before `handleUpdate`. */
2710
+ async intake(raw, result) {
2711
+ const update = (raw ?? {});
2712
+ const id = update["update_id"];
2713
+ // Meaningless on the webhook, where Telegram acknowledges by the HTTP
2714
+ // response, and harmless: the offset is read by `getUpdates` alone, and a
2715
+ // channel that has claimed the webhook transport never issues one.
2716
+ if (typeof id === "number")
2717
+ this.offset = Math.max(this.offset, id + 1);
2718
+ this.counters.updates += 1;
2719
+ result.updates += 1;
2720
+ await this.handleUpdate(update, result);
2721
+ }
2722
+ /**
2723
+ * Bind this channel to one transport, or say which one already holds it
2724
+ * (APRV-424).
2725
+ *
2726
+ * Idempotent for the transport already claimed, so a webhook server that
2727
+ * claims at start-up and then delivers, or a driver calling `pollOnce` in a
2728
+ * loop, pays nothing. Not released by {@link stop}: a process that stopped
2729
+ * polling has not stopped being a poller, and re-pointing a live channel's
2730
+ * delivery maps at a second arrival path is the state this exists to refuse.
2731
+ */
2732
+ claimTransport(transport) {
2733
+ if (this.transport === null || this.transport === transport) {
2734
+ this.transport = transport;
2735
+ return { ok: true, transport };
2736
+ }
2737
+ return {
2738
+ ok: false,
2739
+ code: "transport-conflict",
2740
+ message: `this channel is already receiving updates by ${this.transport}, so it cannot also receive them by ${transport}. Long polling and a webhook are alternatives per bot: the Bot API refuses getUpdates outright while a webhook is set, and a process running both would answer a tap from whichever half reached it first`,
2741
+ };
2742
+ }
2743
+ /** {@link claimTransport}, for the paths where a refusal is a caller's bug. */
2744
+ claimOrThrow(transport) {
2745
+ const claim = this.claimTransport(transport);
2746
+ if (!claim.ok)
2747
+ throw new TelegramTransportError(claim.message);
2748
+ }
2749
+ // -------------------------------------------------------------------------
2750
+ // Webhook registration (APRV-424)
2751
+ // -------------------------------------------------------------------------
2752
+ /**
2753
+ * Point this bot's updates at `url`, and require `secret` on every post.
2754
+ *
2755
+ * Three properties, and each is a refusal to be clever:
2756
+ *
2757
+ * - **The secret is Telegram's own mechanism**, `secret_token`, echoed back
2758
+ * on every delivery in the `X-Telegram-Bot-Api-Secret-Token` header. The
2759
+ * receiver compares it and refuses anything else, so a URL that leaks (a
2760
+ * proxy log, a referrer, a screenshot) is a URL that still decides
2761
+ * nothing.
2762
+ * - **It is redacted from this moment on.** The value is added to the
2763
+ * channel's secret list BEFORE the call, so a Bot API error that quotes
2764
+ * the request cannot print it.
2765
+ * - **Pending updates are kept.** `drop_pending_updates` is left false:
2766
+ * taps that arrived while the process was down are the approver's answers,
2767
+ * and the gate is what decides whether they are still honourable.
2768
+ */
2769
+ async registerWebhook(url, secret) {
2770
+ if (secret.length > 0 && !this.secrets.some((held) => held.value === secret)) {
2771
+ this.secrets.push({ value: secret, label: "<webhook secret redacted>" });
2772
+ }
2773
+ await this.call("setWebhook", {
2774
+ url,
2775
+ secret_token: secret,
2776
+ allowed_updates: this.allowedUpdates(),
2777
+ drop_pending_updates: false,
2778
+ });
2779
+ }
2780
+ /**
2781
+ * Take this bot's webhook away, so `getUpdates` works again.
2782
+ *
2783
+ * Called on a clean stop of the webhook runner. Pending updates are kept for
2784
+ * {@link registerWebhook}'s reason: the next process to start — a poller or
2785
+ * another webhook — is entitled to the taps that arrived in between.
2786
+ *
2787
+ * `timeoutMs` is what the stop path passes, and it is shorter than this
2788
+ * channel's ordinary request timeout on purpose (APRV-424, second review,
2789
+ * note 6). A stop already waits for the receiver to drain, and an operator
2790
+ * pressing Ctrl-C a second time is asking for the process to end: half a
2791
+ * minute of an unreachable Bot API on top of that reads as a hang, and the
2792
+ * webhook it failed to remove is reported rather than silently retried.
2793
+ */
2794
+ async deleteWebhook(timeoutMs) {
2795
+ await this.call("deleteWebhook", { drop_pending_updates: false }, ...(timeoutMs === undefined ? [] : [timeoutMs]));
2796
+ }
2797
+ /**
2798
+ * What the Bot API says is receiving this bot's updates right now.
2799
+ *
2800
+ * The one authority on the question, and the reason it is asked at start-up
2801
+ * by both transports: the ownership registry knows about processes on THIS
2802
+ * machine, and a webhook registered from a laptop in another timezone is
2803
+ * still a webhook swallowing every tap this poller is waiting for.
2804
+ */
2805
+ async webhookInfo() {
2806
+ const result = await this.call("getWebhookInfo", {});
2807
+ const url = result["url"];
2808
+ const pending = result["pending_update_count"];
2809
+ return {
2810
+ url: typeof url === "string" ? url : "",
2811
+ pendingUpdateCount: typeof pending === "number" ? pending : 0,
2812
+ };
2813
+ }
2352
2814
  /**
2353
2815
  * Exactly one `answerCallbackQuery` per callback query, on every path
2354
2816
  * (APRV-196).
@@ -2478,17 +2940,37 @@ export class TelegramChannel {
2478
2940
  const chat = (message["chat"] ?? {});
2479
2941
  const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
2480
2942
  // (a) Not our chat. Counted, answered, never decided, never logged.
2943
+ //
2944
+ // APRV-324 leaves this check exactly where it is, FIRST and ahead of the
2945
+ // sender, on purpose: the mapping widens WHO may decide and never WHERE
2946
+ // from. A callback carrying a perfectly mapped `from.id` but arriving in a
2947
+ // chat this listener does not answer to is ignored here, before anything
2948
+ // reads a sender, because a bot that accepted it would be one an operator
2949
+ // could not scope by conversation.
2481
2950
  if (chatId !== this.chatId) {
2482
2951
  await this.ignore(result, callbackId, "foreign-chat", `callback from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`, "This bot only accepts decisions from its configured approval chat.");
2483
2952
  return;
2484
2953
  }
2954
+ // APRV-324. The one field on this update that identifies a PERSON rather
2955
+ // than a conversation, read once, here, directly after the chat check and
2956
+ // BEFORE every branch below. Its position is the fix for the follow-up
2957
+ // review: read later, three callback families — checkpoint signatures,
2958
+ // review cards and the decision ladder — would each have had their own
2959
+ // chance to keep the pre-mapping behaviour, and two of them are privileged
2960
+ // gestures. `callback_query.from` is the Bot API's own attribution of the
2961
+ // tap to an account; nothing inside `message`, `data` or any text is
2962
+ // consulted, because those are what the sender says about themselves. It is
2963
+ // an observation and nothing more: this channel resolves no identity and
2964
+ // chooses no actor, it reports what it saw and the runtime's handlers
2965
+ // resolve it against the attested policy.
2966
+ const sender = senderOf(query, this.name);
2485
2967
  // APRV-257, before the decision vocabulary and in a parser of its own. A
2486
2968
  // checkpoint button decides no request, so it must never reach the ladder
2487
2969
  // below — where an unresolved nonce falls back to an action reference, and
2488
2970
  // a signature gesture would start looking for something to approve.
2489
2971
  const checkpoint = parseCheckpointCallback(query["data"]);
2490
2972
  if (checkpoint !== null) {
2491
- await this.handleCheckpointTap(checkpoint, callbackId, result);
2973
+ await this.handleCheckpointTap(checkpoint, callbackId, result, sender);
2492
2974
  return;
2493
2975
  }
2494
2976
  // APRV-299, before the decision vocabulary and in a parser of its own, for
@@ -2498,7 +2980,7 @@ export class TelegramChannel {
2498
2980
  // happened would start looking for something to approve.
2499
2981
  const review = parseReviewCallback(query["data"]);
2500
2982
  if (review !== null) {
2501
- await this.handleReviewTap(review, callbackId, result);
2983
+ await this.handleReviewTap(review, callbackId, result, sender);
2502
2984
  return;
2503
2985
  }
2504
2986
  const parsed = parseCallbackData(query["data"]);
@@ -2510,7 +2992,7 @@ export class TelegramChannel {
2510
2992
  // decides is whatever is still open on that delivery right now, which this
2511
2993
  // process knows and the callback bytes deliberately do not say.
2512
2994
  if (parsed.scope === "all") {
2513
- await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result);
2995
+ await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result, sender);
2514
2996
  return;
2515
2997
  }
2516
2998
  // The resolution ladder (APRV-196). A tap is answered by the nonce when
@@ -2518,7 +3000,12 @@ export class TelegramChannel {
2518
3000
  // the log when neither is holding the action open.
2519
3001
  let delivery = this.deliveries.get(parsed.nonce);
2520
3002
  let viaStaleCopy = false;
2521
- if (delivery === undefined && parsed.actionRef !== null) {
3003
+ // APRV-442. A card a refused tap disarmed stays dead: its bytes take no
3004
+ // fallback to the fresh card the listener sends for the same request, so
3005
+ // a redelivered or replayed refused tap reaches neither the gate nor the
3006
+ // new card, exactly as it did before that card existed.
3007
+ const refusedCard = delivery === undefined && this.refusedNonces.has(parsed.nonce);
3008
+ if (delivery === undefined && parsed.actionRef !== null && !refusedCard) {
2522
3009
  // The pre-restart copy. Its nonce died with the process that issued it,
2523
3010
  // but the request it names is one THIS process has since re-delivered, so
2524
3011
  // the tap decides that request — on the live copy's message, which is
@@ -2529,18 +3016,21 @@ export class TelegramChannel {
2529
3016
  viaStaleCopy = delivery !== undefined;
2530
3017
  }
2531
3018
  if (delivery === undefined) {
2532
- if (parsed.actionRef !== null) {
3019
+ if (parsed.actionRef !== null && !refusedCard) {
2533
3020
  // Nothing open here for that action. Say what the log says, which is
2534
3021
  // the only thing that knows: decided, lapsed, withdrawn, or unknown.
2535
3022
  const described = this.describeAction?.(parsed.actionRef) ?? null;
2536
3023
  await this.ignore(result, callbackId, "stale-copy", `no open delivery for action ref ${JSON.stringify(parsed.actionRef)} (an earlier copy of a request this listener is not holding open)`, described ?? TELEGRAM_STALE_UNKNOWN);
2537
3024
  return;
2538
3025
  }
2539
- await this.ignore(result, callbackId, "unknown-callback", `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
2540
- // Two ways to get here, and the reply has to serve both: a button this
2541
- // process never issued (a restart forgot it), and a button on a message
2542
- // this process has already annotated (APRV-113 forgets the nonce with
2543
- // the edit). Either way the message text is the thing to read.
3026
+ await this.ignore(result, callbackId, "unknown-callback", refusedCard
3027
+ ? `nonce ${JSON.stringify(parsed.nonce)} is on a card a refused tap disarmed (APRV-442); the request, if still pending, is on a newer card`
3028
+ : `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
3029
+ // Three ways to get here, and the reply has to serve all of them: a
3030
+ // button this process never issued (a restart forgot it), a button on a
3031
+ // message this process has already annotated (APRV-113 forgets the
3032
+ // nonce with the edit), and a button on a card a refused tap disarmed
3033
+ // (APRV-442). Either way the message text is the thing to read.
2544
3034
  "This button is no longer live — read the message for the outcome, or the newest message for the request.");
2545
3035
  return;
2546
3036
  }
@@ -2552,6 +3042,7 @@ export class TelegramChannel {
2552
3042
  action_key: delivery.actionKey,
2553
3043
  decision: parsed.decision,
2554
3044
  deliveryId: delivery.deliveryId,
3045
+ ...(sender === undefined ? {} : { sender }),
2555
3046
  ...(delivery.batchDeliveryId === undefined
2556
3047
  ? {}
2557
3048
  : { batchDeliveryId: delivery.batchDeliveryId }),
@@ -2580,6 +3071,9 @@ export class TelegramChannel {
2580
3071
  outcome = this.handler(decision);
2581
3072
  }
2582
3073
  catch (cause) {
3074
+ // APRV-442: the card is about to be disarmed and nothing settled the
3075
+ // request. Marked BEFORE the annotation, which forgets the nonces.
3076
+ this.release(delivery, parsed.nonce);
2583
3077
  await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2584
3078
  TELEGRAM_HANDLER_FAILED,
2585
3079
  ]);
@@ -2604,6 +3098,18 @@ export class TelegramChannel {
2604
3098
  // message, which is right in both directions: a terminal request has no
2605
3099
  // decision left to collect, and a still-pending one is re-delivered as a
2606
3100
  // fresh prompt by the next dispatch cycle.
3101
+ //
3102
+ // APRV-442. That re-delivery used to be a promise nothing kept: the
3103
+ // disarm forgets the card's nonces, but the listener's own bookkeeping
3104
+ // went on saying the request was on the phone, so the cycle never sent
3105
+ // it again and the original card's action-reference fallback found
3106
+ // nothing live. A wrong account's tap made the prompt dead until a
3107
+ // restart. Releasing names this CARD as gone and nothing else; the cycle
3108
+ // re-derives from the verified log whether the request is still pending
3109
+ // before it offers a new one. Marked BEFORE the annotation, which forgets
3110
+ // the nonces it records. The refusal itself, its record and its wording
3111
+ // are untouched.
3112
+ this.release(delivery, parsed.nonce);
2607
3113
  await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
2608
3114
  this.answerFor(outcome),
2609
3115
  ]);
@@ -2643,7 +3149,11 @@ export class TelegramChannel {
2643
3149
  * the same message would show the approver their own decisions arriving one
2644
3150
  * at a time, and would spend N Bot API calls to end in the same place.
2645
3151
  */
2646
- async handleDigestAll(decision, nonce, callbackId, result) {
3152
+ async handleDigestAll(decision, nonce, callbackId, result,
3153
+ // APRV-324: one gesture, one sender, on every member it decides. An "all"
3154
+ // tap is N decisions by one person, so each of the N carries the account
3155
+ // the one tap came from.
3156
+ sender) {
2647
3157
  const deliveryId = this.allNonces.get(nonce);
2648
3158
  const digest = deliveryId === undefined ? undefined : this.digests.get(deliveryId);
2649
3159
  if (digest === undefined) {
@@ -2667,6 +3177,7 @@ export class TelegramChannel {
2667
3177
  decision,
2668
3178
  deliveryId: digest.deliveryId,
2669
3179
  batchDeliveryId: digest.batchDeliveryId,
3180
+ ...(sender === undefined ? {} : { sender }),
2670
3181
  ...(decision === "reject"
2671
3182
  ? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
2672
3183
  : {}),
@@ -2771,7 +3282,11 @@ export class TelegramChannel {
2771
3282
  * and appends to a log, and a spinner that lasted a decision long is what
2772
3283
  * that task removed.
2773
3284
  */
2774
- async handleCheckpointTap(tap, callbackId, result) {
3285
+ async handleCheckpointTap(tap, callbackId, result,
3286
+ // APRV-324 follow-up. A checkpoint signature is a human-only act and says
3287
+ // this log's head is what this person saw, so it carries the account the
3288
+ // tap came from exactly as a decision does.
3289
+ sender) {
2775
3290
  const held = this.checkpointNonces.get(tap.nonce);
2776
3291
  if (held === undefined) {
2777
3292
  await this.ignore(result, callbackId, "unknown-callback", `checkpoint nonce ${JSON.stringify(tap.nonce)} was not issued by this process`, "This checkpoint prompt is from an earlier run. A fresh one is offered when the next is due.");
@@ -2784,7 +3299,11 @@ export class TelegramChannel {
2784
3299
  return;
2785
3300
  }
2786
3301
  await this.safeAnswer(callbackId, tap.sign ? "Heard — signing. The message will say what the log recorded." : "Not now.");
2787
- const response = await handler({ sign: tap.sign, head: held.head });
3302
+ const response = await handler({
3303
+ sign: tap.sign,
3304
+ head: held.head,
3305
+ ...(sender === undefined ? {} : { sender }),
3306
+ });
2788
3307
  // Edited here rather than through `annotate`, which renders an action key
2789
3308
  // under its headline. A checkpoint has none, and an empty `<code></code>`
2790
3309
  // where a request's key belongs would be this channel implying a request.
@@ -2817,7 +3336,7 @@ export class TelegramChannel {
2817
3336
  * re-implement the rule. The one thing this method owns is the ARMING, which
2818
3337
  * is process memory that appends nothing.
2819
3338
  */
2820
- async handleReviewTap(tap, callbackId, result) {
3339
+ async handleReviewTap(tap, callbackId, result, sender) {
2821
3340
  const deliveryId = this.reviewNonces.get(tap.nonce);
2822
3341
  const state = deliveryId === undefined ? undefined : this.reviewCards.get(deliveryId);
2823
3342
  if (state === undefined || state.settled !== null) {
@@ -2845,7 +3364,11 @@ export class TelegramChannel {
2845
3364
  const chosen = tap.choice === "deny" ? "denied" : "ok";
2846
3365
  state.denyArmed = chosen === "denied";
2847
3366
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
2848
- await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict: chosen }, result);
3367
+ await this.recordReview(state, {
3368
+ sampleSeq: state.card.sampleSeq,
3369
+ verdict: chosen,
3370
+ ...(sender === undefined ? {} : { sender }),
3371
+ }, result);
2849
3372
  return;
2850
3373
  }
2851
3374
  const reaction = tap.choice;
@@ -2858,11 +3381,16 @@ export class TelegramChannel {
2858
3381
  const conflicts = verdict === "denied" && (reaction === "liked" || reaction === "loved");
2859
3382
  if (!conflicts && (reaction === "loved" || reaction === "disliked")) {
2860
3383
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
2861
- await this.askForNote(state, verdict, reaction);
3384
+ await this.askForNote(state, verdict, reaction, sender);
2862
3385
  return;
2863
3386
  }
2864
3387
  await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
2865
- await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict, reaction }, result);
3388
+ await this.recordReview(state, {
3389
+ sampleSeq: state.card.sampleSeq,
3390
+ verdict,
3391
+ reaction,
3392
+ ...(sender === undefined ? {} : { sender }),
3393
+ }, result);
2866
3394
  }
2867
3395
  /**
2868
3396
  * Send the ForceReply prompt a `loved` or `disliked` needs, and remember what
@@ -2879,7 +3407,7 @@ export class TelegramChannel {
2879
3407
  * one card, and the older prompt stops resolving so a late reply to it lands
2880
3408
  * nowhere rather than recording a grade the human moved on from.
2881
3409
  */
2882
- async askForNote(state, verdict, reaction) {
3410
+ async askForNote(state, verdict, reaction, sender) {
2883
3411
  if (state.awaitingNote !== null)
2884
3412
  this.reviewNotePrompts.delete(state.awaitingNote.promptId);
2885
3413
  const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
@@ -2893,7 +3421,7 @@ export class TelegramChannel {
2893
3421
  reply_markup: { force_reply: true },
2894
3422
  });
2895
3423
  const promptId = String(sent.message_id);
2896
- state.awaitingNote = { promptId, verdict, reaction };
3424
+ state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
2897
3425
  this.reviewNotePrompts.set(promptId, state.deliveryId);
2898
3426
  }
2899
3427
  /**
@@ -2928,6 +3456,19 @@ export class TelegramChannel {
2928
3456
  }
2929
3457
  const state = this.reviewCards.get(deliveryId);
2930
3458
  const pending = state?.awaitingNote ?? null;
3459
+ // APRV-324 follow-up, and checked BEFORE the prompt is forgotten, so a
3460
+ // reply from the wrong account costs the right one nothing. A note prompt
3461
+ // is a ForceReply addressed to the person who armed the grade, and its
3462
+ // words are recorded as that person's; a different account replying to it
3463
+ // is answering a question nobody asked them.
3464
+ const sender = senderOf(message, this.name);
3465
+ if (pending !== null &&
3466
+ pending.promptId === promptId &&
3467
+ pending.sender !== undefined &&
3468
+ (sender === undefined || sender.id !== pending.sender.id)) {
3469
+ this.complain("approval: telegram ignored a review note: it replied to a prompt issued for a different account, and the prompt is still open for the account that armed it");
3470
+ return true;
3471
+ }
2931
3472
  this.reviewNotePrompts.delete(promptId);
2932
3473
  if (state === undefined || pending === null || pending.promptId !== promptId)
2933
3474
  return true;
@@ -2937,6 +3478,7 @@ export class TelegramChannel {
2937
3478
  verdict: pending.verdict,
2938
3479
  reaction: pending.reaction,
2939
3480
  note: text,
3481
+ ...(sender === undefined ? {} : { sender }),
2940
3482
  }, result);
2941
3483
  return true;
2942
3484
  }
@@ -3079,9 +3621,20 @@ export class TelegramChannel {
3079
3621
  // -------------------------------------------------------------------------
3080
3622
  // Transport
3081
3623
  // -------------------------------------------------------------------------
3082
- /** Replace the token with a placeholder anywhere it appears in `text`. */
3624
+ /**
3625
+ * Replace every secret this channel holds with a placeholder in `text`.
3626
+ *
3627
+ * The bot token always, and since APRV-424 the webhook `secret_token` too,
3628
+ * from the moment {@link registerWebhook} is handed it. Both are values that
3629
+ * only ever travel TO the Bot API, so anything coming back that quotes one
3630
+ * is a diagnostic about to print a credential.
3631
+ */
3083
3632
  redact(text) {
3084
- return this.token.length === 0 ? text : text.split(this.token).join("<token redacted>");
3633
+ let redacted = text;
3634
+ for (const secret of this.secrets) {
3635
+ redacted = redacted.split(secret.value).join(secret.label);
3636
+ }
3637
+ return redacted;
3085
3638
  }
3086
3639
  describe(cause) {
3087
3640
  return this.redact(cause instanceof Error ? cause.message : String(cause));