crewhaus 0.1.8 → 0.2.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 (136) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +172 -0
  25. package/dist/dataset-mine.js +403 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11601 -964
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/scaffold-evals.d.ts +138 -0
  107. package/dist/scaffold-evals.js +410 -0
  108. package/dist/scope-audit-drift.d.ts +139 -0
  109. package/dist/scope-audit-drift.js +260 -0
  110. package/dist/security-corpus.d.ts +237 -0
  111. package/dist/security-corpus.js +516 -0
  112. package/dist/security-digest.d.ts +173 -0
  113. package/dist/security-digest.js +650 -0
  114. package/dist/sessions-index.d.ts +27 -0
  115. package/dist/sessions-index.js +51 -0
  116. package/dist/slo-doctor.d.ts +67 -0
  117. package/dist/slo-doctor.js +119 -0
  118. package/dist/slo-sink.d.ts +96 -0
  119. package/dist/slo-sink.js +107 -0
  120. package/dist/spec-changelog.d.ts +102 -0
  121. package/dist/spec-changelog.js +237 -0
  122. package/dist/state-backup.d.ts +223 -0
  123. package/dist/state-backup.js +648 -0
  124. package/dist/tools-cli.d.ts +170 -0
  125. package/dist/tools-cli.js +298 -0
  126. package/dist/triage.d.ts +202 -0
  127. package/dist/triage.js +403 -0
  128. package/dist/upgrade.d.ts +57 -0
  129. package/dist/upgrade.js +113 -0
  130. package/dist/version.d.ts +6 -0
  131. package/dist/version.js +27 -0
  132. package/dist/voice-eval.d.ts +138 -0
  133. package/dist/voice-eval.js +309 -0
  134. package/dist/watch.d.ts +58 -0
  135. package/dist/watch.js +97 -0
  136. package/package.json +89 -65
@@ -0,0 +1,881 @@
1
+ /**
2
+ * Item 61 — `crewhaus channel provision|verify`: one-command channel app
3
+ * setup + scope doctor, factored out of the entry file `index.ts` (which
4
+ * runs a top-level argv switch and so cannot be imported by a test without
5
+ * executing the CLI). Side-effect-free on import and directly unit-testable,
6
+ * mirroring `audit-verify.ts` / `security-digest.ts` / `scope-audit-drift.ts`.
7
+ *
8
+ * The problem this closes: channel-bot harnesses assume platform-side
9
+ * configuration the user had to hand-build — a Slack app with the right
10
+ * scopes/event subscriptions, a Telegram webhook registered with the secret
11
+ * the adapter verifies (`channel-adapter-telegram` documents the
12
+ * `setWebhook(secret_token=...)` expectation but nothing performed it), a
13
+ * Discord application pointed at the daemon's interactions endpoint.
14
+ *
15
+ * Everything here is DERIVED from the adapters + the compiled daemon, not
16
+ * guessed (per-platform derivations are documented on the builders below).
17
+ * The one shared fact: the generated gateway routes webhooks by path —
18
+ * `/^\/([^/]+)\/events$/` in target-channel-bot's `renderGateway` — so the
19
+ * platform-side URL is always `<base-url>/<adapterId>/events`.
20
+ *
21
+ * Design decisions:
22
+ * - Slack is EMIT-AND-INSTRUCT only (no `--apply`). Slack's manifest API
23
+ * (`apps.manifest.create`) authenticates with an app *configuration*
24
+ * token; the adapter/spec carry only the bot token (`xoxb`, Bearer for
25
+ * chat.postMessage/reactions.add), the signing secret (HMAC verify), and
26
+ * the reserved Socket-Mode `appToken` (`xapp`) — none of which can call
27
+ * the manifest API, and inventing a new env var for a token family the
28
+ * runtime never uses was ruled out.
29
+ * - Discord's adapter is interactions-endpoint-based (Ed25519-verified
30
+ * webhooks; types 1/2/3/5), NOT gateway-websocket-based — so provision
31
+ * PATCHes `applications/@me` `interactions_endpoint_url` and prints the
32
+ * invite URL with permission bits derived from the adapter's REST usage.
33
+ * Application *commands* are not registered: the spec declares no
34
+ * command list and the adapter accepts ANY command name (it renders
35
+ * `/<name> <options>` into the agent turn), so command names cannot be
36
+ * derived without guesswork — provision prints the registration
37
+ * instruction instead.
38
+ * - All network calls go through an injectable `fetchImpl` so tests are
39
+ * credential-free, and every network-performing path has a dry-run
40
+ * counterpart (`display` plans / `describeVerifyProbes`) that renders
41
+ * env refs as `$NAME` and literals as `[redacted]` — never the value.
42
+ */
43
+ // ---------------------------------------------------------------------------
44
+ // Shared plumbing
45
+ // ---------------------------------------------------------------------------
46
+ /** Platforms `channel provision|verify` supports. whatsapp (Meta app config
47
+ * lives in the Meta developer console) and imessage (host-local chat.db —
48
+ * there is no platform side to provision) are out of item-61 scope; when a
49
+ * spec configures them the CLI prints a note instead of failing. */
50
+ export const CHANNEL_PLATFORMS = ["slack", "telegram", "discord"];
51
+ /** Placeholder for an inline-literal secret in printable output — mirrors
52
+ * `packages/ir/src/redact.ts`'s `REDACTED_VALUE` convention. */
53
+ export const REDACTED_LITERAL = "[redacted]";
54
+ /** Thrown on a malformed/unconfigured `--platform` value. The CLI entry file
55
+ * catches it and routes the message through `die()`. */
56
+ export class InvalidPlatformFlagError extends Error {
57
+ name = "InvalidPlatformFlagError";
58
+ }
59
+ /** Thrown when a secret env-ref (`$VAR_NAME` in the spec) is unset at the
60
+ * moment a REAL platform call needs its value. Dry-run paths never throw
61
+ * this — they print the `$VAR_NAME` placeholder instead. */
62
+ export class ChannelEnvRefError extends Error {
63
+ label;
64
+ envName;
65
+ name = "ChannelEnvRefError";
66
+ constructor(label, envName) {
67
+ super(`${label} references $${envName} but it is unset — export ${envName} (or inline a literal in the spec)`);
68
+ this.label = label;
69
+ this.envName = envName;
70
+ }
71
+ }
72
+ /** Thrown on a failed platform API call (non-2xx or a logical `ok: false`).
73
+ * Messages are built from REDACTED endpoints — never the token-bearing URL. */
74
+ export class ChannelApiError extends Error {
75
+ name = "ChannelApiError";
76
+ }
77
+ /** Thrown on a malformed `--base-url`. */
78
+ export class InvalidBaseUrlError extends Error {
79
+ name = "InvalidBaseUrlError";
80
+ constructor(value, detail) {
81
+ super(`invalid --base-url "${value}" — ${detail} (expected the daemon's publicly reachable origin, e.g. https://bot.example.com)`);
82
+ }
83
+ }
84
+ /**
85
+ * Resolve the `--platform` flag against the spec's configured channels.
86
+ * `all` (or an absent flag) selects every configured platform this command
87
+ * supports; an explicit platform must actually be configured in the spec —
88
+ * provisioning a channel the daemon will never serve is a footgun, not a
89
+ * convenience. `unsupported` lists configured channels outside item-61
90
+ * scope (whatsapp/imessage) so the CLI can print a note.
91
+ */
92
+ export function resolvePlatformsFlag(flagValue, channels) {
93
+ const configured = CHANNEL_PLATFORMS.filter((p) => channels[p] !== undefined);
94
+ const unsupported = ["whatsapp", "imessage"].filter((p) => channels[p] !== undefined);
95
+ const value = flagValue ?? "all";
96
+ if (value === "all") {
97
+ if (configured.length === 0) {
98
+ throw new InvalidPlatformFlagError(`the spec's channels block configures none of: ${CHANNEL_PLATFORMS.join(", ")}${unsupported.length > 0
99
+ ? ` (${unsupported.join(", ")} ${unsupported.length === 1 ? "is" : "are"} configured but not supported by this command)`
100
+ : ""}`);
101
+ }
102
+ return { platforms: configured, unsupported };
103
+ }
104
+ if (!CHANNEL_PLATFORMS.includes(value)) {
105
+ throw new InvalidPlatformFlagError(`invalid --platform "${value}" — expected one of: ${CHANNEL_PLATFORMS.join(", ")}, all`);
106
+ }
107
+ const platform = value;
108
+ if (channels[platform] === undefined) {
109
+ throw new InvalidPlatformFlagError(`--platform ${platform} but the spec configures no channels.${platform} block`);
110
+ }
111
+ return { platforms: [platform], unsupported };
112
+ }
113
+ /** Resolve a lowered secret ref to its value. Env-refs read `env`; an unset
114
+ * (or empty) env var throws {@link ChannelEnvRefError} — matching the
115
+ * compiled daemon's own startup check, which exits non-zero on the same
116
+ * condition rather than serving webhooks with an empty secret. */
117
+ export function resolveSecretRef(label, ref, env) {
118
+ if (ref.kind === "literal")
119
+ return ref.value;
120
+ const value = env[ref.name];
121
+ if (value === undefined || value === "")
122
+ throw new ChannelEnvRefError(label, ref.name);
123
+ return value;
124
+ }
125
+ /** Printable form of a secret ref: `$NAME` for env-refs (the indirection is
126
+ * not a secret), {@link REDACTED_LITERAL} for inline literals. */
127
+ export function describeSecretRef(ref) {
128
+ return ref.kind === "env" ? `$${ref.name}` : REDACTED_LITERAL;
129
+ }
130
+ /** The daemon route the platform must deliver webhooks to. Single source:
131
+ * target-channel-bot's gateway matches `/^\/([^/]+)\/events$/` and looks the
132
+ * captured segment up in its adapter map keyed by adapter id. */
133
+ export function channelEventsPath(platform) {
134
+ return `/${platform}/events`;
135
+ }
136
+ /**
137
+ * Join `--base-url` with a daemon route. Validates the base URL parses and
138
+ * is http(s); trailing slashes are normalized so `https://x/` and `https://x`
139
+ * produce the same webhook URL. (Slack, Telegram, and Discord all require a
140
+ * publicly reachable https URL in production — http is tolerated here for
141
+ * local tunnels/tests; the platform rejects it server-side if unsupported.)
142
+ */
143
+ export function joinBaseUrl(baseUrl, path) {
144
+ let parsed;
145
+ try {
146
+ parsed = new URL(baseUrl);
147
+ }
148
+ catch {
149
+ throw new InvalidBaseUrlError(baseUrl, "not a parseable URL");
150
+ }
151
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
152
+ throw new InvalidBaseUrlError(baseUrl, `unsupported protocol "${parsed.protocol}"`);
153
+ }
154
+ const origin = parsed.origin + parsed.pathname.replace(/\/+$/, "");
155
+ return `${origin}${path}`;
156
+ }
157
+ function collectMissing(refs, env) {
158
+ const missing = [];
159
+ for (const { label, ref } of refs) {
160
+ if (ref.kind === "env" && (env[ref.name] === undefined || env[ref.name] === "")) {
161
+ missing.push({ label, envName: ref.name });
162
+ }
163
+ }
164
+ return missing;
165
+ }
166
+ /**
167
+ * The secret fields a platform's daemon REQUIRES at boot, per
168
+ * target-channel-bot's `requiredEnvNames`: slack botToken + signingSecret
169
+ * (appToken is reserved for a future Socket-Mode path — parsed but unused,
170
+ * so not required), telegram botToken + secretToken, discord applicationId +
171
+ * botToken + publicKeyHex. Shared by the doctor channel check and `verify`.
172
+ */
173
+ export function platformSecretRefs(platform, channels) {
174
+ if (platform === "slack") {
175
+ const slack = channels.slack;
176
+ if (slack === undefined)
177
+ return [];
178
+ return [
179
+ { label: "channels.slack.botToken", ref: slack.botToken },
180
+ { label: "channels.slack.signingSecret", ref: slack.signingSecret },
181
+ ];
182
+ }
183
+ if (platform === "telegram") {
184
+ const telegram = channels.telegram;
185
+ if (telegram === undefined)
186
+ return [];
187
+ return [
188
+ { label: "channels.telegram.botToken", ref: telegram.botToken },
189
+ { label: "channels.telegram.secretToken", ref: telegram.secretToken },
190
+ ];
191
+ }
192
+ const discord = channels.discord;
193
+ if (discord === undefined)
194
+ return [];
195
+ return [
196
+ { label: "channels.discord.applicationId", ref: discord.applicationId },
197
+ { label: "channels.discord.botToken", ref: discord.botToken },
198
+ { label: "channels.discord.publicKeyHex", ref: discord.publicKeyHex },
199
+ ];
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Slack — app manifest generation (emit-and-instruct; see header for the
203
+ // no-`--apply` decision)
204
+ // ---------------------------------------------------------------------------
205
+ export const SLACK_MANIFEST_FILENAME = "slack-app-manifest.yaml";
206
+ const DEFAULT_SLACK_API_BASE_URL = "https://slack.com/api";
207
+ /**
208
+ * Bot scopes derived from `@crewhaus/channel-adapter-slack`'s ACTUAL API
209
+ * usage and parsed event types — not from a generic bot template:
210
+ * - `sendReply` POSTs `chat.postMessage` → chat:write
211
+ * - `react` POSTs `reactions.add` (the generated session-router always
212
+ * attempts 👀/✅/⚠️ status acks) → reactions:write
213
+ * - `parseInbound` handles `app_mention` → app_mentions:read
214
+ * - `parseInbound` handles `message` in any
215
+ * conversation type, so each `message.*` event
216
+ * subscription needs its history scope → channels/groups/im/mpim:history
217
+ * - `parseInbound` normalises `reaction_added` into 👍/👎 `user_feedback`
218
+ * (the v0.1.8 ratings feature), but the generated gateway only
219
+ * dispatches it when the spec sets `feedback.channelReactions` — so
220
+ * reactions:read is requested exactly then → reactions:read
221
+ */
222
+ export function deriveSlackScopes(opts) {
223
+ const scopes = [
224
+ { scope: "app_mentions:read", reason: "receive app_mention events (adapter parseInbound)" },
225
+ { scope: "channels:history", reason: "receive message.channels events (public channels)" },
226
+ { scope: "groups:history", reason: "receive message.groups events (private channels)" },
227
+ { scope: "im:history", reason: "receive message.im events (DMs)" },
228
+ { scope: "mpim:history", reason: "receive message.mpim events (group DMs)" },
229
+ { scope: "chat:write", reason: "sendReply → chat.postMessage" },
230
+ { scope: "reactions:write", reason: "react → reactions.add (👀/✅/⚠️ status acks)" },
231
+ ];
232
+ if (opts.channelReactions) {
233
+ scopes.push({
234
+ scope: "reactions:read",
235
+ reason: "receive reaction_added events (feedback.channelReactions 👍/👎 ratings)",
236
+ });
237
+ }
238
+ return scopes;
239
+ }
240
+ /** Event subscriptions matching {@link deriveSlackScopes} — the event types
241
+ * the adapter's `parseInbound` consumes (everything else parses to skip). */
242
+ export function deriveSlackBotEvents(opts) {
243
+ const events = [
244
+ "app_mention",
245
+ "message.channels",
246
+ "message.groups",
247
+ "message.im",
248
+ "message.mpim",
249
+ ];
250
+ if (opts.channelReactions)
251
+ events.push("reaction_added");
252
+ return events;
253
+ }
254
+ /**
255
+ * Build the complete Slack app manifest for a channel spec. The request URL
256
+ * is the compiled daemon's slack route under `--base-url`; `socket_mode` is
257
+ * off because the daemon is a webhook server (the spec's `appToken` is
258
+ * documented as reserved for a future Socket-Mode path).
259
+ */
260
+ export function buildSlackManifest(spec, baseUrl) {
261
+ const reactions = { channelReactions: spec.channelReactions };
262
+ return {
263
+ display_information: {
264
+ // Slack limits: name ≤ 35 chars, description ≤ 140.
265
+ name: spec.name.slice(0, 35),
266
+ description: `CrewHaus channel bot "${spec.name}" (generated by crewhaus channel provision)`.slice(0, 140),
267
+ },
268
+ features: {
269
+ bot_user: { display_name: spec.name.slice(0, 35), always_online: true },
270
+ },
271
+ oauth_config: {
272
+ scopes: { bot: deriveSlackScopes(reactions).map((s) => s.scope) },
273
+ },
274
+ settings: {
275
+ event_subscriptions: {
276
+ request_url: joinBaseUrl(baseUrl, channelEventsPath("slack")),
277
+ bot_events: deriveSlackBotEvents(reactions),
278
+ },
279
+ org_deploy_enabled: false,
280
+ socket_mode_enabled: false,
281
+ token_rotation_enabled: false,
282
+ },
283
+ };
284
+ }
285
+ /** Minimal YAML renderer for the manifest shape (nested maps, string arrays,
286
+ * scalars). String scalars are always double-quoted via JSON.stringify —
287
+ * valid YAML, and immune to `:`/`#`/leading-symbol edge cases in URLs. */
288
+ function renderYamlValue(value, indent) {
289
+ const pad = " ".repeat(indent);
290
+ if (Array.isArray(value)) {
291
+ return value
292
+ .map((item) => `${pad}- ${typeof item === "string" ? JSON.stringify(item) : String(item)}`)
293
+ .join("\n");
294
+ }
295
+ if (typeof value === "object" && value !== null) {
296
+ return Object.entries(value)
297
+ .map(([key, v]) => {
298
+ if (typeof v === "object" && v !== null) {
299
+ return `${pad}${key}:\n${renderYamlValue(v, indent + 1)}`;
300
+ }
301
+ return `${pad}${key}: ${typeof v === "string" ? JSON.stringify(v) : String(v)}`;
302
+ })
303
+ .join("\n");
304
+ }
305
+ return `${pad}${typeof value === "string" ? JSON.stringify(value) : String(value)}`;
306
+ }
307
+ export function renderSlackManifestYaml(manifest) {
308
+ return `${renderYamlValue(manifest, 0)}\n`;
309
+ }
310
+ /** Post-manifest instructions. Slack app creation from a manifest is a
311
+ * console flow (no `--apply` — see the module header for why), so the CLI
312
+ * prints exactly what to do with the file it just wrote. */
313
+ export function slackNextSteps(cfg, manifestRef) {
314
+ return [
315
+ `1. open https://api.slack.com/apps → "Create New App" → "From an app manifest",`,
316
+ ` pick the workspace, and paste ${manifestRef}`,
317
+ `2. "Install to Workspace", then copy the credentials into the spec's env refs:`,
318
+ ` Bot User OAuth Token (xoxb-…) → ${describeSecretRef(cfg.botToken)}`,
319
+ ` Signing Secret → ${describeSecretRef(cfg.signingSecret)}`,
320
+ "3. start the daemon (bun daemon.ts) BEFORE saving the request URL — Slack",
321
+ " sends a url_verification challenge the gateway must answer",
322
+ "4. re-run `crewhaus channel verify --platform slack` to confirm granted scopes",
323
+ "(no --apply: Slack's manifest API needs an app *configuration* token — the",
324
+ " adapter only uses the xoxb bot token, signing secret, and reserved xapp token)",
325
+ ];
326
+ }
327
+ // ---------------------------------------------------------------------------
328
+ // Telegram — setWebhook (the call the adapter documents but nothing performed)
329
+ // ---------------------------------------------------------------------------
330
+ const DEFAULT_TELEGRAM_API_BASE_URL = "https://api.telegram.org";
331
+ /** Update kinds the adapter's `parseInbound` actually consumes — its three
332
+ * populated slots are `message`, `edited_message`, and `callback_query`;
333
+ * everything else returns skip, so the webhook shouldn't receive it. */
334
+ export const TELEGRAM_ALLOWED_UPDATES = ["message", "edited_message", "callback_query"];
335
+ /**
336
+ * Build the `setWebhook` call: url = the daemon's `/telegram/events` route
337
+ * under `--base-url`, secret_token = the spec's `secretToken` — the SAME
338
+ * value the adapter compares (timing-safely) against the
339
+ * `X-Telegram-Bot-Api-Secret-Token` header on every inbound POST —
340
+ * allowed_updates = the adapter's three consumed update kinds. The api base
341
+ * honours `TELEGRAM_API_BASE_URL` exactly like the adapter does.
342
+ */
343
+ export function buildTelegramProvision(cfg, baseUrl, env) {
344
+ const apiBase = env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_TELEGRAM_API_BASE_URL;
345
+ const webhookUrl = joinBaseUrl(baseUrl, channelEventsPath("telegram"));
346
+ const display = {
347
+ endpoint: `${apiBase}/bot${describeSecretRef(cfg.botToken)}/setWebhook`,
348
+ payload: {
349
+ url: webhookUrl,
350
+ secret_token: describeSecretRef(cfg.secretToken),
351
+ allowed_updates: [...TELEGRAM_ALLOWED_UPDATES],
352
+ },
353
+ };
354
+ const missingEnv = collectMissing([
355
+ { label: "channels.telegram.botToken", ref: cfg.botToken },
356
+ { label: "channels.telegram.secretToken", ref: cfg.secretToken },
357
+ ], env);
358
+ if (missingEnv.length > 0)
359
+ return { display, missingEnv };
360
+ return {
361
+ display,
362
+ missingEnv,
363
+ request: {
364
+ endpoint: `${apiBase}/bot${resolveSecretRef("channels.telegram.botToken", cfg.botToken, env)}/setWebhook`,
365
+ payload: {
366
+ url: webhookUrl,
367
+ secret_token: resolveSecretRef("channels.telegram.secretToken", cfg.secretToken, env),
368
+ allowed_updates: [...TELEGRAM_ALLOWED_UPDATES],
369
+ },
370
+ },
371
+ };
372
+ }
373
+ /** Perform the setWebhook POST. Errors carry the REDACTED endpoint (the real
374
+ * one embeds the bot token in its path). Returns Telegram's description. */
375
+ export async function performTelegramSetWebhook(provision, fetchImpl = fetch) {
376
+ const request = provision.request;
377
+ if (request === undefined) {
378
+ throw new ChannelApiError(`setWebhook not performed — unset env: ${provision.missingEnv.map((m) => `$${m.envName}`).join(", ")}`);
379
+ }
380
+ let res;
381
+ try {
382
+ res = await fetchImpl(request.endpoint, {
383
+ method: "POST",
384
+ headers: { "Content-Type": "application/json" },
385
+ body: JSON.stringify(request.payload),
386
+ });
387
+ }
388
+ catch (err) {
389
+ throw new ChannelApiError(`POST ${provision.display.endpoint} unreachable: ${err.message}`);
390
+ }
391
+ if (!res.ok) {
392
+ throw new ChannelApiError(`POST ${provision.display.endpoint} failed: ${res.status} ${res.statusText}`);
393
+ }
394
+ const body = (await res.json());
395
+ if (body.ok !== true) {
396
+ throw new ChannelApiError(`setWebhook error: ${body.description ?? "unknown"} (endpoint ${provision.display.endpoint})`);
397
+ }
398
+ return body.description ?? "webhook set";
399
+ }
400
+ // ---------------------------------------------------------------------------
401
+ // Discord — interactions endpoint registration + invite URL
402
+ // ---------------------------------------------------------------------------
403
+ const DEFAULT_DISCORD_API_BASE_URL = "https://discord.com/api/v10";
404
+ /**
405
+ * Invite-URL permission bits derived from the adapter's REST usage — the
406
+ * adapter never opens a gateway websocket, so no presence/guild intents:
407
+ * - `sendReply` POSTs `channels/<id>/messages` → View Channel + Send Messages
408
+ * - threads are first-class (`threadTs` from `channel.parent_id`), and the
409
+ * same POST into a thread channel → Send Messages in Threads
410
+ * - `setTyping` POSTs `channels/<id>/typing` → covered by Send Messages
411
+ */
412
+ export const DISCORD_PERMISSIONS = [
413
+ { name: "View Channel", bit: 1024 }, // 1 << 10
414
+ { name: "Send Messages", bit: 2048 }, // 1 << 11
415
+ { name: "Send Messages in Threads", bit: 274_877_906_944 }, // 1 << 38
416
+ ];
417
+ export const DISCORD_PERMISSION_BITS = DISCORD_PERMISSIONS.reduce((sum, p) => sum + p.bit, 0);
418
+ function discordInviteUrl(applicationId) {
419
+ // bot → the REST sends; applications.commands → slash commands invokable.
420
+ // No percent-encoding: real ids are numeric snowflakes, and the dry-run
421
+ // placeholder ($DISCORD_APPLICATION_ID) must stay readable.
422
+ return `https://discord.com/oauth2/authorize?client_id=${applicationId}&scope=bot+applications.commands&permissions=${DISCORD_PERMISSION_BITS}`;
423
+ }
424
+ /**
425
+ * Build the interactions-endpoint registration: PATCH `applications/@me`
426
+ * (Bot-token auth — the same token the adapter uses for its REST sends)
427
+ * setting `interactions_endpoint_url` to the daemon's `/discord/events`
428
+ * route. Discord VALIDATES the URL during the PATCH (it sends a PING plus a
429
+ * bad-signature probe), so the daemon must be live at `--base-url` first.
430
+ */
431
+ export function buildDiscordProvision(cfg, baseUrl, env) {
432
+ const apiBase = env["DISCORD_API_BASE_URL"] ?? DEFAULT_DISCORD_API_BASE_URL;
433
+ const endpoint = `${apiBase}/applications/@me`;
434
+ const payload = { interactions_endpoint_url: joinBaseUrl(baseUrl, channelEventsPath("discord")) };
435
+ const display = {
436
+ method: "PATCH",
437
+ endpoint,
438
+ payload,
439
+ authorization: `Bot ${describeSecretRef(cfg.botToken)}`,
440
+ inviteUrl: discordInviteUrl(describeSecretRef(cfg.applicationId)),
441
+ };
442
+ const missingEnv = collectMissing([
443
+ { label: "channels.discord.applicationId", ref: cfg.applicationId },
444
+ { label: "channels.discord.botToken", ref: cfg.botToken },
445
+ ], env);
446
+ if (missingEnv.length > 0)
447
+ return { display, missingEnv };
448
+ return {
449
+ display,
450
+ missingEnv,
451
+ request: {
452
+ endpoint,
453
+ payload,
454
+ authorization: `Bot ${resolveSecretRef("channels.discord.botToken", cfg.botToken, env)}`,
455
+ },
456
+ inviteUrl: discordInviteUrl(resolveSecretRef("channels.discord.applicationId", cfg.applicationId, env)),
457
+ };
458
+ }
459
+ /**
460
+ * Perform the registration, read-before-write (adversarial-review F5): GET
461
+ * `applications/@me` first, and if an `interactions_endpoint_url` is already
462
+ * set to something OTHER than the daemon route, refuse to overwrite it
463
+ * unless `force` is set — silently clobbering it would detach whatever
464
+ * service the application currently points at (the same stance as `state
465
+ * restore`, which refuses to overwrite without --force). A 4xx on the PATCH
466
+ * usually means Discord's live endpoint validation failed — surfaced with
467
+ * that hint.
468
+ */
469
+ export async function performDiscordProvision(provision, fetchImpl = fetch, opts = {}) {
470
+ const request = provision.request;
471
+ if (request === undefined) {
472
+ throw new ChannelApiError(`interactions endpoint not registered — unset env: ${provision.missingEnv.map((m) => `$${m.envName}`).join(", ")}`);
473
+ }
474
+ let getRes;
475
+ try {
476
+ getRes = await fetchImpl(request.endpoint, {
477
+ method: "GET",
478
+ headers: { Authorization: request.authorization },
479
+ });
480
+ }
481
+ catch (err) {
482
+ throw new ChannelApiError(`GET ${request.endpoint} unreachable: ${err.message}`);
483
+ }
484
+ if (!getRes.ok) {
485
+ throw new ChannelApiError(`GET ${request.endpoint} failed: ${getRes.status} ${getRes.statusText} — cannot read the existing interactions endpoint, so no overwrite was attempted`);
486
+ }
487
+ const app = (await getRes.json().catch(() => ({})));
488
+ const previousEndpoint = typeof app.interactions_endpoint_url === "string" && app.interactions_endpoint_url !== ""
489
+ ? app.interactions_endpoint_url
490
+ : undefined;
491
+ const target = request.payload.interactions_endpoint_url;
492
+ const wouldReplace = previousEndpoint !== undefined && previousEndpoint !== target;
493
+ if (wouldReplace && opts.force !== true) {
494
+ throw new ChannelApiError(`interactions_endpoint_url is already set to "${previousEndpoint}" — refusing to overwrite it with "${target}" (that would detach whatever currently serves it); re-run with --force to replace`);
495
+ }
496
+ let res;
497
+ try {
498
+ res = await fetchImpl(request.endpoint, {
499
+ method: "PATCH",
500
+ headers: {
501
+ "Content-Type": "application/json",
502
+ Authorization: request.authorization,
503
+ },
504
+ body: JSON.stringify(request.payload),
505
+ });
506
+ }
507
+ catch (err) {
508
+ throw new ChannelApiError(`PATCH ${request.endpoint} unreachable: ${err.message}`);
509
+ }
510
+ if (!res.ok) {
511
+ let detail = `${res.status} ${res.statusText}`;
512
+ try {
513
+ const body = (await res.json());
514
+ if (typeof body.message === "string")
515
+ detail = `${detail} — ${body.message}`;
516
+ }
517
+ catch {
518
+ // non-JSON error body — status line is all we have
519
+ }
520
+ throw new ChannelApiError(`PATCH ${request.endpoint} failed: ${detail} (Discord validates the interactions endpoint live — the daemon must be running and reachable at the --base-url when you provision)`);
521
+ }
522
+ return {
523
+ ...(previousEndpoint !== undefined ? { previousEndpoint } : {}),
524
+ replacedPrevious: wouldReplace,
525
+ };
526
+ }
527
+ /** Post-registration instructions: the invite URL plus the command-
528
+ * registration note (see the module header for why commands aren't
529
+ * auto-registered: the spec declares none and the adapter accepts any). */
530
+ export function discordNextSteps(inviteUrl) {
531
+ return [
532
+ `1. invite the bot with the derived permission bits (${DISCORD_PERMISSIONS.map((p) => p.name).join(", ")}):`,
533
+ ` ${inviteUrl}`,
534
+ "2. register the slash command(s) your users should invoke — the adapter routes",
535
+ ' ANY command to the agent (rendered as "/<name> <options>"), and the spec',
536
+ " declares no command list, so names are yours to choose:",
537
+ ` PUT ${DEFAULT_DISCORD_API_BASE_URL}/applications/<application-id>/commands`,
538
+ "3. re-run `crewhaus channel verify --platform discord` to confirm the endpoint",
539
+ ];
540
+ }
541
+ function envRefCheck(label, fieldLabel, ref, env) {
542
+ if (ref.kind === "literal") {
543
+ return {
544
+ label,
545
+ pass: true,
546
+ warn: true,
547
+ reason: `${fieldLabel} is an inline literal — prefer a $ENV ref so the value stays out of the spec`,
548
+ };
549
+ }
550
+ const set = env[ref.name] !== undefined && env[ref.name] !== "";
551
+ return set
552
+ ? { label, pass: true }
553
+ : {
554
+ label,
555
+ pass: false,
556
+ reason: `${fieldLabel} references $${ref.name} but it is unset — the daemon exits at boot without it`,
557
+ };
558
+ }
559
+ /**
560
+ * Slack probes: `auth.test` with the bot token (identity + reachability),
561
+ * then the token's GRANTED scopes — Slack returns them on every Web API
562
+ * response in the `x-oauth-scopes` header — diffed against the needed set
563
+ * from {@link deriveSlackScopes}. The signing secret is only checked for
564
+ * presence: Slack never exposes it back over the API.
565
+ */
566
+ export async function verifySlackChannel(cfg, spec, opts) {
567
+ const fetchImpl = opts.fetchImpl ?? fetch;
568
+ const apiBase = opts.env["SLACK_API_BASE_URL"] ?? DEFAULT_SLACK_API_BASE_URL;
569
+ const checks = [];
570
+ checks.push(envRefCheck("Slack signing secret", "channels.slack.signingSecret", cfg.signingSecret, opts.env));
571
+ let botToken;
572
+ try {
573
+ botToken = resolveSecretRef("channels.slack.botToken", cfg.botToken, opts.env);
574
+ }
575
+ catch (err) {
576
+ checks.push({ label: "Slack bot token", pass: false, reason: err.message });
577
+ return checks;
578
+ }
579
+ let res;
580
+ try {
581
+ res = await fetchImpl(`${apiBase}/auth.test`, {
582
+ method: "POST",
583
+ headers: { Authorization: `Bearer ${botToken}` },
584
+ });
585
+ }
586
+ catch (err) {
587
+ checks.push({
588
+ label: "Slack auth.test",
589
+ pass: false,
590
+ reason: `Slack API unreachable: ${err.message}`,
591
+ });
592
+ return checks;
593
+ }
594
+ const body = (await res.json().catch(() => ({})));
595
+ if (body.ok !== true) {
596
+ checks.push({
597
+ label: "Slack auth.test",
598
+ pass: false,
599
+ reason: `token rejected: ${body.error ?? `${res.status} ${res.statusText}`}`,
600
+ });
601
+ return checks;
602
+ }
603
+ checks.push({
604
+ label: `Slack auth.test (team ${body.team ?? "?"}, bot user ${body.user ?? "?"})`,
605
+ pass: true,
606
+ });
607
+ const needed = deriveSlackScopes(spec).map((s) => s.scope);
608
+ const grantedHeader = res.headers.get("x-oauth-scopes");
609
+ if (grantedHeader === null) {
610
+ checks.push({
611
+ label: "Slack bot scopes",
612
+ pass: true,
613
+ warn: true,
614
+ reason: "Slack did not return x-oauth-scopes — granted scopes could not be compared",
615
+ });
616
+ return checks;
617
+ }
618
+ const granted = new Set(grantedHeader
619
+ .split(",")
620
+ .map((s) => s.trim())
621
+ .filter((s) => s !== ""));
622
+ const missing = needed.filter((s) => !granted.has(s));
623
+ checks.push(missing.length === 0
624
+ ? { label: `Slack bot scopes (${needed.length} needed, all granted)`, pass: true }
625
+ : {
626
+ label: "Slack bot scopes",
627
+ pass: false,
628
+ reason: `missing: ${missing.join(", ")} — update the app from the manifest (crewhaus channel provision --platform slack) and re-install`,
629
+ });
630
+ return checks;
631
+ }
632
+ /**
633
+ * Telegram probes: `getWebhookInfo`, compared against the daemon route.
634
+ * Telegram never returns the configured secret_token (write-only by design),
635
+ * so the secret is checked for presence on the daemon side — the adapter
636
+ * rejects every inbound POST if the two ever diverge, which surfaces as
637
+ * pending updates + a 401 last_error here.
638
+ */
639
+ export async function verifyTelegramChannel(cfg, opts) {
640
+ const fetchImpl = opts.fetchImpl ?? fetch;
641
+ const apiBase = opts.env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_TELEGRAM_API_BASE_URL;
642
+ const checks = [];
643
+ checks.push(envRefCheck("Telegram secret token (daemon side; Telegram never returns it)", "channels.telegram.secretToken", cfg.secretToken, opts.env));
644
+ let botToken;
645
+ try {
646
+ botToken = resolveSecretRef("channels.telegram.botToken", cfg.botToken, opts.env);
647
+ }
648
+ catch (err) {
649
+ checks.push({ label: "Telegram bot token", pass: false, reason: err.message });
650
+ return checks;
651
+ }
652
+ const redactedEndpoint = `${apiBase}/bot${describeSecretRef(cfg.botToken)}/getWebhookInfo`;
653
+ let res;
654
+ try {
655
+ res = await fetchImpl(`${apiBase}/bot${botToken}/getWebhookInfo`, { method: "GET" });
656
+ }
657
+ catch (err) {
658
+ checks.push({
659
+ label: "Telegram getWebhookInfo",
660
+ pass: false,
661
+ reason: `${redactedEndpoint} unreachable: ${err.message}`,
662
+ });
663
+ return checks;
664
+ }
665
+ const body = (await res.json().catch(() => ({})));
666
+ if (body.ok !== true || body.result === undefined) {
667
+ checks.push({
668
+ label: "Telegram getWebhookInfo",
669
+ pass: false,
670
+ reason: `token rejected: ${body.description ?? `${res.status} ${res.statusText}`}`,
671
+ });
672
+ return checks;
673
+ }
674
+ checks.push({ label: "Telegram getWebhookInfo", pass: true });
675
+ const info = body.result;
676
+ const url = info.url ?? "";
677
+ if (url === "") {
678
+ checks.push({
679
+ label: "Telegram webhook url",
680
+ pass: false,
681
+ reason: "no webhook registered — run `crewhaus channel provision --platform telegram`",
682
+ });
683
+ }
684
+ else if (opts.baseUrl !== undefined) {
685
+ const expected = joinBaseUrl(opts.baseUrl, channelEventsPath("telegram"));
686
+ checks.push(url === expected
687
+ ? { label: `Telegram webhook url (${url})`, pass: true }
688
+ : {
689
+ label: "Telegram webhook url",
690
+ pass: false,
691
+ reason: `registered "${url}" but this daemon expects "${expected}" — re-run provision`,
692
+ });
693
+ }
694
+ else {
695
+ checks.push({
696
+ label: "Telegram webhook url",
697
+ pass: true,
698
+ warn: true,
699
+ reason: `registered as ${url} — pass --base-url to assert it matches this daemon`,
700
+ });
701
+ }
702
+ const allowedUpdates = info.allowed_updates;
703
+ if (allowedUpdates !== undefined) {
704
+ const missing = TELEGRAM_ALLOWED_UPDATES.filter((u) => !allowedUpdates.includes(u));
705
+ if (missing.length > 0) {
706
+ checks.push({
707
+ label: "Telegram allowed_updates",
708
+ pass: true,
709
+ warn: true,
710
+ reason: `webhook does not deliver: ${missing.join(", ")} — the adapter consumes them; re-run provision to restore`,
711
+ });
712
+ }
713
+ else {
714
+ checks.push({ label: "Telegram allowed_updates", pass: true });
715
+ }
716
+ }
717
+ const pending = info.pending_update_count ?? 0;
718
+ if (pending > 0) {
719
+ checks.push({
720
+ label: "Telegram pending updates",
721
+ pass: true,
722
+ warn: true,
723
+ reason: `${pending} update(s) queued — the daemon may be unreachable at the webhook url`,
724
+ });
725
+ }
726
+ if (info.last_error_message !== undefined && info.last_error_message !== "") {
727
+ const at = info.last_error_date !== undefined
728
+ ? ` at ${new Date(info.last_error_date * 1000).toISOString()}`
729
+ : "";
730
+ checks.push({
731
+ label: "Telegram last delivery error",
732
+ pass: true,
733
+ warn: true,
734
+ reason: `${info.last_error_message}${at}`,
735
+ });
736
+ }
737
+ return checks;
738
+ }
739
+ /**
740
+ * Discord probes: `GET applications/@me` with the bot token, asserting the
741
+ * three facts the adapter depends on — the token belongs to the declared
742
+ * `applicationId`, the application's `verify_key` equals the spec's
743
+ * `publicKeyHex` (a mismatch means every inbound webhook fails Ed25519
744
+ * verification with a 401), and `interactions_endpoint_url` points at the
745
+ * daemon's `/discord/events` route.
746
+ */
747
+ export async function verifyDiscordChannel(cfg, opts) {
748
+ const fetchImpl = opts.fetchImpl ?? fetch;
749
+ const apiBase = opts.env["DISCORD_API_BASE_URL"] ?? DEFAULT_DISCORD_API_BASE_URL;
750
+ const checks = [];
751
+ let botToken;
752
+ try {
753
+ botToken = resolveSecretRef("channels.discord.botToken", cfg.botToken, opts.env);
754
+ }
755
+ catch (err) {
756
+ checks.push({ label: "Discord bot token", pass: false, reason: err.message });
757
+ return checks;
758
+ }
759
+ let res;
760
+ try {
761
+ res = await fetchImpl(`${apiBase}/applications/@me`, {
762
+ method: "GET",
763
+ headers: { Authorization: `Bot ${botToken}` },
764
+ });
765
+ }
766
+ catch (err) {
767
+ checks.push({
768
+ label: "Discord application fetch",
769
+ pass: false,
770
+ reason: `${apiBase}/applications/@me unreachable: ${err.message}`,
771
+ });
772
+ return checks;
773
+ }
774
+ if (!res.ok) {
775
+ checks.push({
776
+ label: "Discord application fetch",
777
+ pass: false,
778
+ reason: `token rejected: ${res.status} ${res.statusText}`,
779
+ });
780
+ return checks;
781
+ }
782
+ const app = (await res.json().catch(() => ({})));
783
+ checks.push({ label: `Discord application fetch (${app.name ?? "?"})`, pass: true });
784
+ try {
785
+ const declaredId = resolveSecretRef("channels.discord.applicationId", cfg.applicationId, opts.env);
786
+ checks.push(app.id === declaredId
787
+ ? { label: `Discord application id (${declaredId})`, pass: true }
788
+ : {
789
+ label: "Discord application id",
790
+ pass: false,
791
+ reason: `bot token belongs to application ${app.id ?? "?"} but the spec declares ${declaredId}`,
792
+ });
793
+ }
794
+ catch (err) {
795
+ checks.push({ label: "Discord application id", pass: false, reason: err.message });
796
+ }
797
+ try {
798
+ const declaredKey = resolveSecretRef("channels.discord.publicKeyHex", cfg.publicKeyHex, opts.env);
799
+ checks.push(app.verify_key === declaredKey
800
+ ? { label: "Discord public key matches verify_key", pass: true }
801
+ : {
802
+ label: "Discord public key",
803
+ pass: false,
804
+ reason: "spec publicKeyHex differs from the application's verify_key — every inbound webhook will fail Ed25519 verification (401)",
805
+ });
806
+ }
807
+ catch (err) {
808
+ checks.push({ label: "Discord public key", pass: false, reason: err.message });
809
+ }
810
+ const endpointUrl = app.interactions_endpoint_url ?? "";
811
+ if (endpointUrl === "") {
812
+ checks.push({
813
+ label: "Discord interactions endpoint",
814
+ pass: false,
815
+ reason: "not set — the adapter is webhook-based; run `crewhaus channel provision --platform discord`",
816
+ });
817
+ }
818
+ else if (opts.baseUrl !== undefined) {
819
+ const expected = joinBaseUrl(opts.baseUrl, channelEventsPath("discord"));
820
+ checks.push(endpointUrl === expected
821
+ ? { label: `Discord interactions endpoint (${endpointUrl})`, pass: true }
822
+ : {
823
+ label: "Discord interactions endpoint",
824
+ pass: false,
825
+ reason: `set to "${endpointUrl}" but this daemon expects "${expected}" — re-run provision`,
826
+ });
827
+ }
828
+ else {
829
+ checks.push({
830
+ label: "Discord interactions endpoint",
831
+ pass: true,
832
+ warn: true,
833
+ reason: `set to ${endpointUrl} — pass --base-url to assert it matches this daemon`,
834
+ });
835
+ }
836
+ return checks;
837
+ }
838
+ /** Redacted one-liners describing what `verify` would call — the dry-run
839
+ * counterpart for the probes (network never happens under --dry-run). */
840
+ export function describeVerifyProbes(platform, channels, env) {
841
+ if (platform === "slack" && channels.slack !== undefined) {
842
+ const apiBase = env["SLACK_API_BASE_URL"] ?? DEFAULT_SLACK_API_BASE_URL;
843
+ return [
844
+ `would POST ${apiBase}/auth.test (Authorization: Bearer ${describeSecretRef(channels.slack.botToken)})`,
845
+ "would compare the x-oauth-scopes response header against the derived scope set",
846
+ ];
847
+ }
848
+ if (platform === "telegram" && channels.telegram !== undefined) {
849
+ const apiBase = env["TELEGRAM_API_BASE_URL"] ?? DEFAULT_TELEGRAM_API_BASE_URL;
850
+ return [
851
+ `would GET ${apiBase}/bot${describeSecretRef(channels.telegram.botToken)}/getWebhookInfo`,
852
+ ];
853
+ }
854
+ if (platform === "discord" && channels.discord !== undefined) {
855
+ const apiBase = env["DISCORD_API_BASE_URL"] ?? DEFAULT_DISCORD_API_BASE_URL;
856
+ return [
857
+ `would GET ${apiBase}/applications/@me (Authorization: Bot ${describeSecretRef(channels.discord.botToken)})`,
858
+ ];
859
+ }
860
+ return [];
861
+ }
862
+ /** Render checks as the doctor's ✓/~/✗ lines + the exit code — non-zero on
863
+ * any hard failure (missing scopes, mismatched webhook, bad credentials);
864
+ * warns are informational. Mirrors audit-verify's `summarizeVerifyResult`. */
865
+ export function summarizeChannelChecks(checks) {
866
+ const lines = checks.map((c) => {
867
+ if (c.warn === true && c.pass)
868
+ return `~ ${c.label}: ${c.reason ?? "informational"}`;
869
+ if (c.pass)
870
+ return `✓ ${c.label}`;
871
+ return `✗ ${c.label}: ${c.reason ?? "failed"}`;
872
+ });
873
+ const failed = checks.filter((c) => !c.pass).length;
874
+ return {
875
+ lines: [
876
+ ...lines,
877
+ `${checks.length} check(s), ${failed} failed${failed > 0 ? "" : " — channel wiring looks healthy"}`,
878
+ ],
879
+ exitCode: failed > 0 ? 1 : 0,
880
+ };
881
+ }