approval-md 0.3.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 (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  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 +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  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 +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -0,0 +1,709 @@
1
+ /**
2
+ * `approval channel telegram webhook` — the same channel, the other arrival
3
+ * (SPEC.md §10.3, APRV-424).
4
+ *
5
+ * `approval channel telegram listen` holds a socket open against `getUpdates`
6
+ * for as long as the gate exists. That works, and it costs one always-running
7
+ * process per bot and forbids a second poller on the same token. This verb
8
+ * registers a URL with `setWebhook` instead and serves the callback, which is
9
+ * what makes a sleep-when-idle host able to run a gate, and what lets an
10
+ * operator put the receiver behind a proxy they already own.
11
+ *
12
+ * **Everything below the arrival is unchanged, and deliberately not restated
13
+ * here.** The pending queue is still `dispatchPending`'s, re-derived from the
14
+ * verified log every cycle. The handlers are still {@link wireListener}'s, the
15
+ * same four function objects `listen` registers. A tap still becomes a
16
+ * decision through `channels/telegram.ts`'s `handleUpdate` and
17
+ * `channels/contract.ts`'s `recordChannelDecision`. This file resolves
18
+ * configuration, refuses what it must refuse, binds a port and keeps a cycle
19
+ * running; it decides nothing and it renders nothing.
20
+ *
21
+ * ## The three refusals that are the point of the task
22
+ *
23
+ * 1. **The secret comes from the launch environment, never from the tree.**
24
+ * `APPROVAL_TG_WEBHOOK_SECRET` (`core/telegram-config.ts` says why the name
25
+ * is conventional rather than policy-declared), refused when it is unset,
26
+ * when it is shorter than a value a host would generate, and when it holds
27
+ * a character Telegram will not accept. SPEC.md §11.1 invariant 7.
28
+ * 2. **A post without the matching header decides nothing.** That check is
29
+ * `channels/telegram-webhook.ts`'s, before the path, the method or the
30
+ * body, and its refusal is counted and complained about rather than
31
+ * appended: an endpoint the internet can reach must not be able to grow the
32
+ * tenant's log.
33
+ * 3. **One transport per bot.** The preflight asks the Bot API which webhook
34
+ * holds this bot and refuses a foreign one; `listen` and `approval up` ask
35
+ * the same question and refuse when any webhook holds it. Within one
36
+ * process the channel refuses the second claim outright.
37
+ *
38
+ * ## Where TLS is, and why the bind is loopback
39
+ *
40
+ * Telegram will only deliver to an HTTPS URL, and this process terminates no
41
+ * TLS and holds no certificate — exactly as `approval serve` does not
42
+ * (APRV-421, `cli/serve.ts`). The supported deployment is a loopback bind
43
+ * behind a proxy or a tunnel the operator owns, which is what `--url` names
44
+ * and what `docs/cli-reference.md#channel-telegram-webhook` states. A
45
+ * non-loopback bind takes two flags and prints a banner, because the secret
46
+ * arrives in a header and a cleartext hop hands it to whoever is on it.
47
+ */
48
+ import { isAbsolute, resolve as resolvePathSegments } from "node:path";
49
+ import { TelegramApiError } from "../channels/telegram.js";
50
+ import { serveTelegramWebhook, TELEGRAM_WEBHOOK_DEFAULT_HOST, TELEGRAM_WEBHOOK_DEFAULT_PORT, TELEGRAM_SECRET_HEADER, } from "../channels/telegram-webhook.js";
51
+ import { redactWebhookPath, redactWebhookUrl, TELEGRAM_WEBHOOK_SECRET_ENV, } from "../core/telegram-config.js";
52
+ import { declareResolvedDaemonIdentity, refreshDaemonAllowlistFrom, resolveDaemonId, } from "../core/daemon-host.js";
53
+ import { loadPolicy, parseDuration } from "../core/policy-load.js";
54
+ import { passphraseEnvFor } from "../core/vault.js";
55
+ import { boolFlag, parseFlags, stringFlag } from "./args.js";
56
+ import { claimListenerBot, dispatchPending, glossWiring, prepareListen, reportCycle, wireListener, } from "./channel-telegram.js";
57
+ import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
58
+ import { parseGlossOptions } from "./gloss-options.js";
59
+ import { TELEGRAM_WEBHOOK_HELP } from "./help.js";
60
+ import { isLoopbackHost, parseListen } from "./mcp.js";
61
+ import { DEFAULT_LOG_PATH, resolvePath } from "./paths.js";
62
+ import { usageErrorText } from "./usage.js";
63
+ const WEBHOOK_FLAGS = {
64
+ "--url": "string",
65
+ "--listen": "string",
66
+ "--port": "string",
67
+ "--path": "string",
68
+ "--allow-non-loopback": "boolean",
69
+ /** APRV-424 review finding 6: register over a webhook that already holds this bot. */
70
+ "--reclaim": "boolean",
71
+ "--cycle": "string",
72
+ "--log": "string",
73
+ "--policy": "string",
74
+ "--dir": "string",
75
+ "--as": "string",
76
+ "--payloads": "string",
77
+ "--api-base": "string",
78
+ "--allow-cross-instance": "boolean",
79
+ "--gloss": "boolean",
80
+ "--no-gloss": "boolean",
81
+ "--gloss-provider": "string",
82
+ "--gloss-model": "string",
83
+ "--json": "boolean",
84
+ "--help": "boolean",
85
+ "-h": "boolean",
86
+ };
87
+ /**
88
+ * The shortest `secret_token` this verb will start with.
89
+ *
90
+ * `serve/credentials.ts`'s floor, for its reason: not a password policy, a
91
+ * floor under the one mistake that makes the check meaningless, which is a
92
+ * memorable value somebody typed. The host generates this; the floor costs it
93
+ * nothing.
94
+ */
95
+ export const WEBHOOK_MIN_SECRET_LENGTH = 24;
96
+ /** What Telegram accepts in a `secret_token`: 1-256 of these characters. */
97
+ const SECRET_CHARSET = /^[A-Za-z0-9_-]+$/u;
98
+ /**
99
+ * The ports Telegram will deliver a webhook to.
100
+ *
101
+ * Telegram's own list, and it is the reason `--url` is refused rather than
102
+ * attempted when it names anything else: a `setWebhook` on port 9443 is
103
+ * accepted by this runtime, refused by the Bot API, and the operator reads a
104
+ * network error instead of the sentence below.
105
+ */
106
+ const TELEGRAM_WEBHOOK_PORTS = new Set([443, 80, 88, 8443]);
107
+ /** How often the dispatch cycle runs when the operator names no period. */
108
+ export const WEBHOOK_DEFAULT_CYCLE_MS = 30_000;
109
+ /**
110
+ * How long the stop path waits for `deleteWebhook` (APRV-424, second review,
111
+ * note 6).
112
+ *
113
+ * The channel's ordinary request timeout is thirty seconds, which is right for
114
+ * a delivery and wrong for a stop: on top of the receiver's drain it made a
115
+ * second Ctrl-C look swallowed for the better part of a minute. A removal that
116
+ * does not land in five seconds is reported, and the operator is told the
117
+ * webhook is still registered, which is a sentence they can act on.
118
+ */
119
+ export const WEBHOOK_STOP_DELETE_TIMEOUT_MS = 5_000;
120
+ /**
121
+ * Why a webhook runner could not be built. A closed union, distinct per
122
+ * repair, per SPEC.md §11.1 invariant 6.
123
+ *
124
+ * It sits BESIDE `LISTEN_REFUSAL_CODES` rather than inside it: every reason a
125
+ * listener cannot start is a reason this cannot either (it needs the same
126
+ * token, chat, identity, log and policy), and these are the reasons peculiar
127
+ * to a transport that binds a port and registers a URL.
128
+ */
129
+ export const WEBHOOK_REFUSAL_CODES = [
130
+ /** `APPROVAL_TG_WEBHOOK_SECRET` is unset or empty. */
131
+ "webhook-secret-missing",
132
+ /** It is shorter than {@link WEBHOOK_MIN_SECRET_LENGTH}. */
133
+ "webhook-secret-weak",
134
+ /** It holds a character Telegram will not accept in a `secret_token`. */
135
+ "webhook-secret-charset",
136
+ /** No `--url`: nothing to register, so nothing would ever be delivered. */
137
+ "webhook-url-missing",
138
+ /** `--url` is not a URL, or its scheme is not `https`. */
139
+ "webhook-url-insecure",
140
+ /** `--url` names a port Telegram does not deliver to. */
141
+ "webhook-url-port",
142
+ /**
143
+ * `--url` carries userinfo (`https://user:pw@host/hook`) (review finding 10).
144
+ *
145
+ * Refused rather than stripped. A credential in a url is a credential the
146
+ * operator put on a command line, and a runtime that quietly dropped it
147
+ * would register an endpoint that answers differently from the one they
148
+ * typed; a runtime that kept it would carry it into `setWebhook`, into the
149
+ * start-up banner and into every refusal that names the url.
150
+ */
151
+ "webhook-url-userinfo",
152
+ /**
153
+ * `--path` is not a servable path: it does not start with `/`, or it holds a
154
+ * `..` segment (review finding 3).
155
+ */
156
+ "webhook-path-invalid",
157
+ /**
158
+ * `--path` is a servable path, and not the one `--url` names (review finding
159
+ * 3).
160
+ *
161
+ * The pair used to be free to disagree, described as an override for a proxy
162
+ * that rewrites on the way. What it actually bought was a receiver that
163
+ * registered cleanly, verified the secret on every real delivery, and then
164
+ * answered all of them 404: `telegram/webhook` against a url path of
165
+ * `/telegram/webhook`, or `/hook/` against a url ending `/hook`, both start
166
+ * without a word. A path mismatch is now a startup refusal.
167
+ */
168
+ "webhook-path-mismatch",
169
+ /** `--cycle` was not a duration. */
170
+ "webhook-cycle",
171
+ /**
172
+ * `setWebhook` was refused by the Bot API.
173
+ *
174
+ * Emitted by {@link runWebhook} since the review of this task; before it the
175
+ * code was declared here and the failure was reported as a bare `io`, which
176
+ * made "Telegram will not take this registration" indistinguishable from
177
+ * "the log could not be read". The status and the Bot API's own description
178
+ * are carried, redacted of the token and the secret by the channel.
179
+ */
180
+ "webhook-registration-failed",
181
+ ];
182
+ function usageError(streams, json, message) {
183
+ if (json)
184
+ streams.err(`${JSON.stringify({ error: { code: "usage", message } })}\n`);
185
+ else
186
+ streams.err(usageErrorText(message, TELEGRAM_WEBHOOK_HELP));
187
+ return EXIT_USAGE;
188
+ }
189
+ function ioError(streams, json, message) {
190
+ if (json)
191
+ streams.err(`${JSON.stringify({ error: { code: "io", message } })}\n`);
192
+ else
193
+ streams.err(`approval: ${message}\n`);
194
+ return EXIT_IO;
195
+ }
196
+ /**
197
+ * A refusal that has its own code, rather than the transport-shaped `io`.
198
+ *
199
+ * Added for review finding 4: `webhook-registration-failed` was declared in
200
+ * the frozen union above and never emitted, so a `setWebhook` the Bot API
201
+ * turned down reached the operator as `{"error":{"code":"io"}}` — the same
202
+ * code an unreadable log produces, for a completely different repair. The exit
203
+ * code stays {@link EXIT_IO}, because what failed is still a call out of this
204
+ * process; what changes is that the machine-readable half names it.
205
+ */
206
+ function refusalError(streams, json, code, message) {
207
+ if (json)
208
+ streams.err(`${JSON.stringify({ error: { code, message } })}\n`);
209
+ else
210
+ streams.err(`approval: ${message}\n`);
211
+ return EXIT_IO;
212
+ }
213
+ function integrityError(streams, json, message) {
214
+ if (json)
215
+ streams.err(`${JSON.stringify({ error: { code: "integrity", message } })}\n`);
216
+ else
217
+ streams.err(`approval: ${message}\n`);
218
+ return EXIT_INTEGRITY;
219
+ }
220
+ function absolute(value, cwd) {
221
+ return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
222
+ }
223
+ /**
224
+ * Everything that can fail without touching the network, in order.
225
+ *
226
+ * {@link prepareListen} first, because a webhook runner that has no bot token,
227
+ * no chat and no human identity is not a webhook problem: the two verbs refuse
228
+ * those the same way, in the same sentences, for the reason `prepareListen`
229
+ * exists at all. Then the three this transport adds.
230
+ *
231
+ * It PRINTS NOTHING and CHOOSES NO EXIT CODE, exactly as `prepareListen` does.
232
+ */
233
+ export function prepareWebhook(request) {
234
+ const listen = prepareListen({
235
+ logPath: request.logPath,
236
+ policy: request.policy,
237
+ as: request.as,
238
+ payloads: request.payloads,
239
+ apiBase: request.apiBase,
240
+ // No `getUpdates` runs under this transport, so there is no long poll to
241
+ // time out. The channel keeps its default and never issues one.
242
+ pollTimeout: null,
243
+ once: false,
244
+ json: request.json,
245
+ ...(request.allowCrossInstance === undefined
246
+ ? {}
247
+ : { allowCrossInstance: request.allowCrossInstance }),
248
+ log: request.log,
249
+ ...(request.gloss === undefined ? {} : { gloss: request.gloss }),
250
+ });
251
+ if (!listen.ok)
252
+ return listen;
253
+ const environment = request.env ?? process.env;
254
+ // APRV-448. Which daemon this process writes as, resolved by the daemon
255
+ // loop's own function from this launch environment and this log. A declared
256
+ // id that is not an id refuses here, before the bot is claimed or a port is
257
+ // bound, which is the rule `approval up`, `approval daemon run` and `approval
258
+ // serve` follow: a process whose decisions are appends should not start in
259
+ // order to find out it cannot append.
260
+ const daemon = resolveDaemonId(request.logPath, environment);
261
+ if (!daemon.ok)
262
+ return { ok: false, code: daemon.code, message: daemon.message };
263
+ const raw = environment[TELEGRAM_WEBHOOK_SECRET_ENV];
264
+ const secret = raw === undefined ? "" : raw.trim();
265
+ if (secret.length === 0) {
266
+ return {
267
+ ok: false,
268
+ code: "webhook-secret-missing",
269
+ message: `no ${TELEGRAM_WEBHOOK_SECRET_ENV} in the environment. A webhook URL is a public endpoint and its only authentication is Telegram's secret_token, echoed on every delivery in ${TELEGRAM_SECRET_HEADER}; without one, any post that looked like a tap would be one. Generate it (\`openssl rand -hex 32\`) and set it in the environment this process is launched from — never in a file in the tree`,
270
+ };
271
+ }
272
+ if (!SECRET_CHARSET.test(secret) || secret.length > 256) {
273
+ return {
274
+ ok: false,
275
+ code: "webhook-secret-charset",
276
+ message: `${TELEGRAM_WEBHOOK_SECRET_ENV} holds a value Telegram will not accept as a secret_token: 1 to 256 characters of A-Z, a-z, 0-9, underscore and hyphen. Its value is not shown here and is in no message this process writes`,
277
+ };
278
+ }
279
+ if (secret.length < WEBHOOK_MIN_SECRET_LENGTH) {
280
+ return {
281
+ ok: false,
282
+ code: "webhook-secret-weak",
283
+ message: `${TELEGRAM_WEBHOOK_SECRET_ENV} is shorter than ${String(WEBHOOK_MIN_SECRET_LENGTH)} characters. Generate it (\`openssl rand -hex 32\`) rather than choosing it: this value is the whole of what separates a delivery from Telegram from a post by anyone who can reach the URL`,
284
+ };
285
+ }
286
+ if (request.url === null) {
287
+ return {
288
+ ok: false,
289
+ code: "webhook-url-missing",
290
+ message: "no --url: this verb registers a public HTTPS URL with setWebhook, and without one Telegram has nowhere to deliver a tap. Name the address your proxy or tunnel serves, not this process's bind",
291
+ };
292
+ }
293
+ let url;
294
+ try {
295
+ url = new URL(request.url);
296
+ }
297
+ catch {
298
+ return {
299
+ ok: false,
300
+ code: "webhook-url-insecure",
301
+ message: `--url ${JSON.stringify(request.url)} is not a URL`,
302
+ };
303
+ }
304
+ if (url.protocol !== "https:") {
305
+ return {
306
+ ok: false,
307
+ code: "webhook-url-insecure",
308
+ message: `--url ${JSON.stringify(request.url)} is not https. Telegram delivers to HTTPS only, and the secret travels in a header: terminate TLS in a proxy or a tunnel you own and give this process the loopback bind behind it`,
309
+ };
310
+ }
311
+ const urlPort = url.port === "" ? 443 : Number(url.port);
312
+ if (!TELEGRAM_WEBHOOK_PORTS.has(urlPort)) {
313
+ return {
314
+ ok: false,
315
+ code: "webhook-url-port",
316
+ message: `--url names port ${String(urlPort)}, and Telegram delivers a webhook only to ${[...TELEGRAM_WEBHOOK_PORTS].join(", ")}. That is the PUBLIC port your proxy answers on; this process's own bind is --port / --listen and can be anything`,
317
+ };
318
+ }
319
+ if (url.username !== "" || url.password !== "") {
320
+ // Refused, not stripped. See `webhook-url-userinfo`: the value is a
321
+ // credential on a command line, and this runtime neither carries it into
322
+ // `setWebhook` nor decides on the operator's behalf that they did not mean
323
+ // it. The message quotes the origin alone, so the refusal does not print
324
+ // the thing it is refusing.
325
+ return {
326
+ ok: false,
327
+ code: "webhook-url-userinfo",
328
+ message: `--url carries a username or a password (${url.origin} was given with userinfo in front of the host). Telegram's delivery would not use it, this runtime will not register it, and a credential in a url ends up in shell history, in a process listing and in every line that names the url. Put the authentication in the proxy in front of this process, and give --url the plain https address it answers on`,
329
+ };
330
+ }
331
+ // The path this process SERVES is the url's own, always (review finding 3).
332
+ // `--path` is an explicit restatement of it and nothing more: the two used
333
+ // to be free to disagree, and a disagreement produced a receiver that
334
+ // registered, verified the secret on every real delivery, and 404'd every
335
+ // one of them.
336
+ if (request.path !== null) {
337
+ const declared = request.path;
338
+ if (!declared.startsWith("/") || declared.split("/").includes("..")) {
339
+ return {
340
+ ok: false,
341
+ code: "webhook-path-invalid",
342
+ message: `--path ${JSON.stringify(declared)} is not a path this server can serve: it must begin with "/" and hold no ".." segment. It is the path Telegram will POST to, which is the path in --url (${url.pathname})`,
343
+ };
344
+ }
345
+ if (declared !== url.pathname) {
346
+ return {
347
+ ok: false,
348
+ code: "webhook-path-mismatch",
349
+ message: `--path ${JSON.stringify(declared)} is not the path --url names (${url.pathname}). Telegram posts to the url it was given, so a receiver listening on any other path verifies the secret on every real delivery and then answers it 404. Pass --path only to restate the url's own path, and put any rewriting in the proxy in front of this process`,
350
+ };
351
+ }
352
+ }
353
+ const cycleMs = request.cycle === null ? WEBHOOK_DEFAULT_CYCLE_MS : (parseDuration(request.cycle) ?? -1);
354
+ if (cycleMs <= 0) {
355
+ // `parseDuration` is the same parser `--interval` and `--timeout` use
356
+ // everywhere else, so a duration that works on one verb works on this one.
357
+ return {
358
+ ok: false,
359
+ code: "webhook-cycle",
360
+ message: `--cycle expects a duration like 30s, 5m, got ${JSON.stringify(request.cycle)}`,
361
+ };
362
+ }
363
+ return {
364
+ ok: true,
365
+ setup: {
366
+ listen: listen.setup,
367
+ secret,
368
+ url: request.url,
369
+ // The url's own path, checked against `--path` above when one was given.
370
+ // One path, one registration, one route: what Telegram posts to is what
371
+ // this process answers.
372
+ path: url.pathname,
373
+ host: request.host,
374
+ port: request.port,
375
+ cycleMs,
376
+ reclaim: request.reclaim ?? false,
377
+ daemon,
378
+ },
379
+ };
380
+ }
381
+ /**
382
+ * Where the listener's policy is, as the allowlist refresh loads it: the file
383
+ * the operator pinned, or the directory it is discovered from (APRV-448).
384
+ */
385
+ function policyWhereOf(listen) {
386
+ const policy = listen.gateOptions.policy ?? {};
387
+ const where = policy.file !== undefined ? { file: policy.file } : policy.dir !== undefined ? { dir: policy.dir } : {};
388
+ if (listen.gateOptions.schemaDir !== undefined)
389
+ where.schemaDir = listen.gateOptions.schemaDir;
390
+ return where;
391
+ }
392
+ export function resolveWebhookBind(listenFlag, portFlag, allowNonLoopback) {
393
+ if (listenFlag !== null && portFlag !== null) {
394
+ return {
395
+ ok: false,
396
+ message: "--listen and --port name the same thing; pass one. --listen <[host:]port> is the only way to bind a non-loopback interface",
397
+ };
398
+ }
399
+ let host = TELEGRAM_WEBHOOK_DEFAULT_HOST;
400
+ let port = TELEGRAM_WEBHOOK_DEFAULT_PORT;
401
+ if (listenFlag !== null) {
402
+ const bind = parseListen(listenFlag);
403
+ if (!bind.ok)
404
+ return { ok: false, message: bind.message };
405
+ host = bind.host;
406
+ port = bind.port;
407
+ }
408
+ else if (portFlag !== null) {
409
+ if (!/^\d+$/u.test(portFlag.trim())) {
410
+ return { ok: false, message: `--port expects a whole number, got ${JSON.stringify(portFlag)}` };
411
+ }
412
+ port = Number(portFlag.trim());
413
+ }
414
+ if (port === 0) {
415
+ return {
416
+ ok: false,
417
+ message: `the bind port may not be 0. Port 0 asks the kernel for an ephemeral port, and this transport is a fixed public url forwarded to a fixed local address: a receiver on a port nobody registered would verify nothing and answer nothing. Name the port your proxy forwards to (the default is ${String(TELEGRAM_WEBHOOK_DEFAULT_PORT)})`,
418
+ };
419
+ }
420
+ if (port < 1 || port > 65535) {
421
+ return { ok: false, message: `the bind port ${String(port)} is outside the TCP port range 1..65535` };
422
+ }
423
+ if (!isLoopbackHost(host) && !allowNonLoopback) {
424
+ return {
425
+ ok: false,
426
+ message: `--listen ${JSON.stringify(host)} is not the loopback interface, and this process speaks plain HTTP: the webhook secret arrives in a header and a cleartext hop hands it to whoever is on it. Add --allow-non-loopback if you meant it and TLS is terminated in front of this process`,
427
+ };
428
+ }
429
+ return { ok: true, host, port };
430
+ }
431
+ /**
432
+ * The banner a non-loopback bind prints, every time, on stderr.
433
+ *
434
+ * `cli/serve.ts`'s, with the one credential this surface has in place of that
435
+ * one's two. Reached only when the operator passed both `--listen` naming a
436
+ * routable interface and `--allow-non-loopback`, so it is not a warning about
437
+ * something that happened by accident.
438
+ */
439
+ export function webhookNonLoopbackBanner(host, port) {
440
+ return [
441
+ "",
442
+ " !! the telegram webhook receiver IS BOUND TO A NON-LOOPBACK INTERFACE !!",
443
+ ` ${host}:${String(port)} is reachable by anyone who can route to it, and this`,
444
+ " process speaks PLAIN HTTP: it terminates no TLS and holds no certificate.",
445
+ ` The ${TELEGRAM_SECRET_HEADER} header is what separates a delivery from`,
446
+ " Telegram from a forgery, and a cleartext hop hands it to whoever is on it.",
447
+ " Terminate TLS in a proxy you control and give this process a loopback bind.",
448
+ "",
449
+ "",
450
+ ].join("\n");
451
+ }
452
+ /**
453
+ * Run the receiver until a signal stops it.
454
+ *
455
+ * The order is the one `cli/serve.ts` argues for, with one change that is this
456
+ * transport's own: everything that can refuse refuses first, and the LOCAL
457
+ * bind happens before `setWebhook`, so there is no window in which Telegram is
458
+ * posting to a port nothing is listening on.
459
+ */
460
+ export async function runWebhook(setup, streams) {
461
+ const { listen } = setup;
462
+ const json = listen.json;
463
+ /** The url as it may be printed: origin plus a redacted path. */
464
+ const shownUrl = redactWebhookUrl(setup.url);
465
+ /**
466
+ * The bind, as it may be printed.
467
+ *
468
+ * The local address carries the SAME path as the public url, so printing it
469
+ * whole beside a redacted url would hand back what the redaction removed
470
+ * (APRV-424, second review, finding 2).
471
+ */
472
+ const shownPath = redactWebhookPath(setup.path);
473
+ for (const finding of listen.crossInstance) {
474
+ streams.err(`approval: --allow-cross-instance: starting anyway — ${finding.detail}\n`);
475
+ }
476
+ // Which bot is this, whose is it, and what is already receiving its updates.
477
+ // The same preflight `approval channel telegram listen` runs, told that THIS
478
+ // process is the webhook at this URL: without `--reclaim` any registration
479
+ // refuses, including one at this very url, because two hosts sharing one
480
+ // configuration both read that as their own restart (review finding 6). It
481
+ // also takes this gate's transport lease, which is what makes a poller
482
+ // already running in the same project a refusal rather than a double
483
+ // delivery (review finding 1).
484
+ const claimed = await claimListenerBot(listen, (message) => streams.err(`${message}\n`), {
485
+ webhookUrl: setup.url,
486
+ reclaim: setup.reclaim,
487
+ mode: "webhook",
488
+ });
489
+ if (!claimed.ok)
490
+ return integrityError(streams, json, claimed.message);
491
+ try {
492
+ const state = wireListener(listen, streams);
493
+ // APRV-448. Every record this process appends (a decision a tap delivers, a
494
+ // refusal of one, whatever a dispatch cycle writes) carries the id the
495
+ // preparation resolved, and is held to the attested `daemons` list at the
496
+ // write boundary. Declared before the first cycle can append. The list is
497
+ // refreshed here and at the top of every cycle, which is this process's
498
+ // tick: a narrowing takes effect within one cycle, as it does for the
499
+ // daemon, and a resolution that fails leaves the previous one standing.
500
+ // This process does NOT mark itself the daemon: it declares a name and a
501
+ // restriction and takes no route that mark would open.
502
+ declareResolvedDaemonIdentity(setup.daemon);
503
+ const policyWhere = policyWhereOf(listen);
504
+ refreshDaemonAllowlistFrom(listen.logPath, policyWhere);
505
+ // The startup cycle, before anything is bound: an operator who has just
506
+ // mistyped a token or pointed at an unreadable log should learn it here
507
+ // rather than watch a receiver sit on a port. Same call, same state and
508
+ // same fatal-failure rule as `startListener`'s.
509
+ const startup = await dispatchPending(listen, streams, state, new Date().toISOString());
510
+ if (startup.queueError !== undefined) {
511
+ return startup.queueError.code === "log-unreadable"
512
+ ? ioError(streams, json, startup.queueError.message)
513
+ : integrityError(streams, json, startup.queueError.message);
514
+ }
515
+ const firstFailure = startup.failed[0];
516
+ if (firstFailure !== undefined) {
517
+ return ioError(streams, json, `telegram sendMessage failed: ${firstFailure.message}`);
518
+ }
519
+ const cycle = async () => {
520
+ refreshDaemonAllowlistFrom(listen.logPath, policyWhere);
521
+ reportCycle(await dispatchPending(listen, streams, state, new Date().toISOString()), streams);
522
+ };
523
+ let handle;
524
+ try {
525
+ handle = await serveTelegramWebhook({
526
+ channel: listen.channel,
527
+ secret: setup.secret,
528
+ path: setup.path,
529
+ host: setup.host,
530
+ port: setup.port,
531
+ log: (message) => streams.err(`${message}\n`),
532
+ // The dispatch cycle, run after each update the channel accepted: this
533
+ // is where a paced listener's NEXT question goes out once the shown one
534
+ // is decided, and the poll loop gets the same thing from `beforePoll`.
535
+ afterUpdate: cycle,
536
+ });
537
+ }
538
+ catch (cause) {
539
+ return ioError(streams, json, `telegram webhook could not bind ${setup.host}:${String(setup.port)}: ${cause instanceof Error ? cause.message : String(cause)}`);
540
+ }
541
+ try {
542
+ await listen.channel.registerWebhook(setup.url, setup.secret);
543
+ }
544
+ catch (cause) {
545
+ await handle.close();
546
+ // Its own code since the review of this task (finding 4): "Telegram will
547
+ // not take this registration" and "the log could not be read" used to
548
+ // arrive as the same `io`, which is two different repairs under one
549
+ // name. The status and the Bot API's own description come through, both
550
+ // already scrubbed of the token and the secret by the channel.
551
+ const api = cause instanceof TelegramApiError ? cause : null;
552
+ const detail = api === null
553
+ ? cause instanceof Error
554
+ ? cause.message
555
+ : String(cause)
556
+ : [
557
+ api.status === null ? null : `HTTP ${String(api.status)}`,
558
+ api.description ?? api.message,
559
+ ]
560
+ .filter((part) => part !== null)
561
+ .join(": ");
562
+ return refusalError(streams, json, "webhook-registration-failed", `telegram setWebhook was refused for ${shownUrl} (${detail}); nothing is registered and this process is not listening`);
563
+ }
564
+ if (!isLoopbackHost(handle.host)) {
565
+ streams.err(webhookNonLoopbackBanner(handle.host, handle.port));
566
+ }
567
+ const started = `approval: telegram webhook registered ${shownUrl} and bound http://${handle.host}:${String(handle.port)}${shownPath} as ${listen.actor}, records stamped daemon ${setup.daemon.id} (${setup.daemon.source}). Every post must carry ${TELEGRAM_SECRET_HEADER}; TLS is your proxy's. Press Ctrl-C to stop, which removes the webhook.`;
568
+ if (json) {
569
+ streams.out(`${JSON.stringify({
570
+ event: "webhook_started",
571
+ // The origin and the served path, not the url as typed (review
572
+ // finding 10): a tunnel that puts a random token in the path should
573
+ // not have it copied into a log line nobody asked for.
574
+ url: shownUrl,
575
+ host: handle.host,
576
+ port: handle.port,
577
+ // Redacted for the url's reason: this is the same path, and a
578
+ // line that printed it here would undo the line above.
579
+ path: shownPath,
580
+ cycle_ms: setup.cycleMs,
581
+ // APRV-448: the id every record this process appends carries.
582
+ daemon: setup.daemon.id,
583
+ })}\n`);
584
+ }
585
+ streams.err(`${started}\n`);
586
+ // The cycle a poller gets from its poll loop. Serialized with delivery, so
587
+ // a dispatch never runs while an update is being handled: the channel's
588
+ // maps and its single ack slot are not reentrant.
589
+ const timer = setInterval(() => {
590
+ void handle.serialize(cycle).catch((cause) => {
591
+ streams.err(`approval: telegram webhook dispatch cycle failed (${cause instanceof Error ? cause.message : String(cause)}); retrying next cycle\n`);
592
+ });
593
+ }, setup.cycleMs);
594
+ // Nothing about a cycle should hold the process open on its own.
595
+ timer.unref?.();
596
+ return await new Promise((settle) => {
597
+ let stopping = false;
598
+ const stop = () => {
599
+ if (stopping)
600
+ return;
601
+ stopping = true;
602
+ clearInterval(timer);
603
+ void (async () => {
604
+ // THE ORDER IS THE POINT (review finding 2). The receiver stops
605
+ // accepting and then drains: an update mid-append keeps its socket,
606
+ // finishes its decision and writes its response. Only then does this
607
+ // process tell Telegram to stop delivering, and only then does it
608
+ // say it has stopped. The first version closed first and resolved in
609
+ // about a millisecond, so `deleteWebhook`, the stopped line and the
610
+ // exit all happened inside somebody's append.
611
+ //
612
+ // SIGINT stays hooked for the whole of it, so a second Ctrl-C is
613
+ // absorbed by the `stopping` guard rather than killing the process
614
+ // through Node's default handler halfway through the drain.
615
+ await handle.close();
616
+ // Best effort, and said out loud when it fails: a webhook left
617
+ // registered is a bot no poller can start against, which is exactly
618
+ // the refusal this task added.
619
+ try {
620
+ await listen.channel.deleteWebhook(WEBHOOK_STOP_DELETE_TIMEOUT_MS);
621
+ }
622
+ catch (cause) {
623
+ streams.err(`approval: telegram deleteWebhook failed (${cause instanceof Error ? cause.message : String(cause)}); the webhook is still registered, so getUpdates will refuse until it is removed\n`);
624
+ }
625
+ if (json) {
626
+ streams.out(`${JSON.stringify({
627
+ event: "stopped",
628
+ ...listen.channel.stats(),
629
+ webhook: handle.stats(),
630
+ })}\n`);
631
+ }
632
+ process.off("SIGINT", stop);
633
+ process.off("SIGTERM", stop);
634
+ settle(EXIT_OK);
635
+ })();
636
+ };
637
+ process.on("SIGINT", stop);
638
+ process.on("SIGTERM", stop);
639
+ });
640
+ }
641
+ finally {
642
+ // The transport lease goes back however this verb ends: a clean stop, a
643
+ // refused registration, a log that could not be read. The next process to
644
+ // start in this gate finds no lease rather than one it has to reclaim.
645
+ claimed.lease.release();
646
+ }
647
+ }
648
+ /** `approval channel telegram webhook [flags]`. */
649
+ export async function commandTelegramWebhook(argv, streams, cwd) {
650
+ const json = argv.includes("--json");
651
+ const parsed = parseFlags(argv, WEBHOOK_FLAGS);
652
+ if (!parsed.ok)
653
+ return usageError(streams, json, parsed.message);
654
+ if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
655
+ streams.out(`${TELEGRAM_WEBHOOK_HELP}\n`);
656
+ return EXIT_OK;
657
+ }
658
+ const extra = parsed.positionals[0];
659
+ if (extra !== undefined) {
660
+ return usageError(streams, json, `unexpected argument ${JSON.stringify(extra)}`);
661
+ }
662
+ const flags = parsed.flags;
663
+ const selectedGloss = parseGlossOptions(flags, true);
664
+ if (!selectedGloss.ok)
665
+ return usageError(streams, json, selectedGloss.message);
666
+ // The bind, parsed exactly as `approval serve` parses its own: `--port`
667
+ // never reaches a routable interface, `--listen` is the only thing that can,
668
+ // a non-loopback host needs a second flag on top, and since the review of
669
+ // this task port 0 is refused rather than turned into an ephemeral bind
670
+ // nothing has registered.
671
+ const bind = resolveWebhookBind(stringFlag(flags, "--listen"), stringFlag(flags, "--port"), boolFlag(flags, "--allow-non-loopback"));
672
+ if (!bind.ok)
673
+ return usageError(streams, json, bind.message);
674
+ const policyFlag = stringFlag(flags, "--policy");
675
+ const dirFlag = stringFlag(flags, "--dir");
676
+ const policy = policyFlag !== null
677
+ ? { file: absolute(policyFlag, cwd) }
678
+ : { dir: dirFlag === null ? cwd : absolute(dirFlag, cwd) };
679
+ const payloadsFlag = stringFlag(flags, "--payloads");
680
+ const prepared = prepareWebhook({
681
+ logPath: resolvePath(stringFlag(flags, "--log"), DEFAULT_LOG_PATH, cwd),
682
+ policy,
683
+ as: stringFlag(flags, "--as"),
684
+ payloads: payloadsFlag === null ? null : absolute(payloadsFlag, cwd),
685
+ apiBase: stringFlag(flags, "--api-base"),
686
+ json,
687
+ allowCrossInstance: boolFlag(flags, "--allow-cross-instance"),
688
+ url: stringFlag(flags, "--url"),
689
+ path: stringFlag(flags, "--path"),
690
+ host: bind.host,
691
+ port: bind.port,
692
+ cycle: stringFlag(flags, "--cycle"),
693
+ reclaim: boolFlag(flags, "--reclaim"),
694
+ log: (message) => streams.err(`${message}\n`),
695
+ ...glossWiring(flags, passphraseEnvFor(loadPolicy(policy)), {
696
+ diagnostic: (reason) => streams.err(`approval: Codex gloss unavailable (${reason}); continuing without it\n`),
697
+ }),
698
+ });
699
+ if (!prepared.ok) {
700
+ // The mapping `channel telegram listen` uses, extended by this verb's own:
701
+ // a path that could not be read is I/O, and everything else is a command
702
+ // line or an environment the operator can fix in one edit.
703
+ return prepared.code === "log-unreadable" || prepared.code === "payloads-unreadable"
704
+ ? ioError(streams, json, prepared.message)
705
+ : usageError(streams, json, prepared.message);
706
+ }
707
+ return await runWebhook(prepared.setup, streams);
708
+ }
709
+ //# sourceMappingURL=channel-telegram-webhook.js.map