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.
- package/README.md +85 -877
- package/SPEC.md +150 -19
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +53 -1
- package/dist/src/channels/contract.js +304 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +394 -12
- package/dist/src/channels/telegram.js +621 -68
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +564 -34
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +177 -13
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +763 -28
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +10 -1
- package/dist/src/cli/channel.js +14 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +41 -6
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +1322 -122
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +366 -22
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +14 -2
- package/dist/src/cli/help.js +395 -82
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +828 -4
- package/dist/src/cli/hook.js +2972 -142
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +56 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +7 -1
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +134 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +510 -47
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +234 -2
- package/dist/src/core/attest.js +342 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +73 -3
- package/dist/src/core/audit.js +65 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +205 -0
- package/dist/src/core/command-class.js +1433 -28
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +392 -6
- package/dist/src/core/gate.js +852 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +181 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +89 -22
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +17 -1
- package/dist/src/core/policy-explain.js +48 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +65 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +81 -0
- package/dist/src/core/policy-match.js +87 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +75 -0
- package/dist/src/core/policy-proposal.js +110 -4
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +182 -0
- package/dist/src/core/read-scope.js +256 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +501 -0
- package/dist/src/core/sender-identity.js +609 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +307 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +30 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +2563 -73
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +6 -2
- package/schema/event.schema.json +595 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +100 -4
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -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
|