Files
ironclaw/src/code_challenge.rs
Henry Park 79c1b0fd7e Improve channel onboarding and Telegram pairing flow (#2103)
* Improve channel onboarding and Telegram pairing flow

* fix: remove dead restart_required code, fix review findings, and stabilize polling E2E test

- Remove restart_required/needs_restart dead code from 6 files (no real
  extension uses it; all channels hot-activate at runtime)
- Remove dead extensions.configuredRestart i18n key from all 3 locales
- Fix pairing test asserting wrong upsert semantics (test expected
  idempotent behavior but impl always rotates codes)
- Fix pairing test using expired code for approval (req.code -> req_again.code)
- Fix missing i18n fallback for auth.extensionTokenPlaceholder
- Validate setup_url scheme (https?://) before assigning to <a>.href
- Replace hardcoded English "Approve"/"Pairing code is required" with i18n keys
- Demote misleading "bot is open to all users" Telegram log from Warn to Debug
- Move polling E2E test to run first (polling loop dies during refresh_active_channel)
- Add poll_interval_ms config field to Telegram WASM channel
- Fix conversations.rs compilation (missing ? operator)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address remaining review comments (i18n regressions)

- Remove hardcoded pairing_instructions() function from server.rs;
  use onboarding metadata from ExtensionManager instead (fixes i18n
  regression where pairing instructions were always English)
- Restore i18n calls for stepper labels in renderWasmChannelStepper
  (was using hardcoded English strings instead of missions.step* keys)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restart polling loop on channel refresh, address Copilot review

- Add WasmChannel::ensure_polling() that stops any stale polling task
  and starts a fresh one from the on_start config
- Call ensure_polling() in refresh_active_channel after re-running
  on_start, fixing the root cause of the dead polling loop in E2E tests
- Move polling test back to its original position (no longer order-dependent)
- Fix requires_pairing in channel_onboarding_for_state to use
  channel_requires_pairing() instead of legacy owner_id-only check
- Add rel='noopener noreferrer' to all setup_url target=_blank links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: collapse nested if-let to satisfy clippy collapsible_if

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: return promise from approvePairing, stop polling unconditionally in ensure_polling

- Add missing `return` before apiFetch in approvePairing() so callers
  can await/chain the result
- Move poll_shutdown_tx.take() before the enabled check in
  ensure_polling() so switching from polling to webhook stops the old
  polling task

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove trailing commas in JSON test fixtures after restart_required removal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:26:26 -07:00

104 lines
3.0 KiB
Rust

//! Shared helpers for one-time code verification flows.
//!
//! This module centralizes the common pieces used by code-based flows such as
//! DM pairing and any future manual verification flows:
//! - one-time code generation
//! - challenge presentation
//! - submission normalization
//! - pending challenge bookkeeping
use rand::Rng;
use serde::{Deserialize, Serialize};
/// User-facing payload for a code-based verification flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerificationChallenge {
/// One-time code the user must send back to the integration.
pub code: String,
/// Human-readable instructions for completing verification.
pub instructions: String,
/// Deep-link or shortcut URL that prefills the verification payload when supported.
#[serde(skip_serializing_if = "Option::is_none")]
pub deep_link: Option<String>,
}
/// Pending one-time challenge plus flow-specific metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingCodeChallenge<M> {
pub code: String,
pub meta: M,
pub expires_at_unix: u64,
}
impl<M> PendingCodeChallenge<M> {
pub fn new(code: String, meta: M, expires_at_unix: u64) -> Self {
Self {
code,
meta,
expires_at_unix,
}
}
pub fn is_expired(&self, now_unix: u64) -> bool {
self.expires_at_unix <= now_unix
}
}
/// Shared seam for code-driven verification flows.
pub trait CodeChallengeFlow {
type Meta: Clone;
/// Issue a new one-time code for this flow.
fn issue_code(&self) -> String;
/// Render user-facing instructions for a pending challenge.
fn render_challenge(&self, pending: &PendingCodeChallenge<Self::Meta>)
-> VerificationChallenge;
/// Normalize a submitted code before validation.
fn normalize_submission(&self, submission: &str) -> Option<String> {
normalize_submitted_code(submission)
}
/// Validate whether a submission satisfies the pending challenge.
fn matches_submission(
&self,
pending: &PendingCodeChallenge<Self::Meta>,
submission: &str,
) -> bool;
/// Build a pending challenge with a flow-generated code.
fn issue_challenge(
&self,
meta: Self::Meta,
expires_at_unix: u64,
) -> PendingCodeChallenge<Self::Meta> {
PendingCodeChallenge::new(self.issue_code(), meta, expires_at_unix)
}
}
/// Trim user input and reject blank codes before hitting storage.
pub fn normalize_submitted_code(submission: &str) -> Option<String> {
let trimmed = submission.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// Generate a fixed-length code from the provided alphabet.
pub fn generate_code(len: usize, alphabet: &[u8]) -> String {
if len == 0 || alphabet.is_empty() {
return String::new();
}
let mut rng = rand::thread_rng();
(0..len)
.map(|_| {
let idx = rng.gen_range(0..alphabet.len());
alphabet[idx] as char
})
.collect()
}