switchroom 0.14.61 → 0.14.62

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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { AuthBrokerClient as BrokerClient, type AddAccountCredentials } from '../../src/auth/broker/client.js'
14
+ import type { ProviderName } from '../../src/auth/broker/protocol.js'
14
15
  import type { AuthBrokerClient } from './auth-command.js'
15
16
 
16
17
  /**
@@ -56,21 +57,30 @@ export async function getAuthBrokerClient(
56
57
  }
57
58
 
58
59
  /**
59
- * Add an account via the broker. Used exclusively by the `/auth add`
60
- * chat flow — the narrow {@link AuthBrokerClient} surface in
61
- * `auth-command.ts` deliberately omits `addAccount` because the verb
62
- * is gateway-routed (not handler-routed). Constructs and closes a
63
- * one-shot {@link BrokerClient} so the gateway doesn't need a
64
- * long-lived handle just for this verb.
60
+ * Add an account via the broker. Used by the `/auth add` chat flow
61
+ * (Anthropic) and the `/connect microsoft` device-code flow — the narrow
62
+ * {@link AuthBrokerClient} surface in `auth-command.ts` deliberately
63
+ * omits `addAccount` because the verb is gateway-routed (not
64
+ * handler-routed). Constructs and closes a one-shot {@link BrokerClient}
65
+ * so the gateway doesn't need a long-lived handle just for this verb.
66
+ *
67
+ * `provider` defaults to Anthropic (back-compat with the `/auth add`
68
+ * caller, which omits it). The device-code flow passes `"microsoft"`
69
+ * with a `MicrosoftAddAccountCredentials` payload.
65
70
  */
66
71
  export async function addAccountViaBroker(
67
72
  label: string,
68
73
  credentials: AddAccountCredentials,
69
- opts: { replace?: boolean } = {},
74
+ opts: { replace?: boolean; provider?: ProviderName } = {},
70
75
  ): Promise<{ label: string; expiresAt?: number }> {
71
76
  const broker = new BrokerClient()
72
77
  try {
73
- return await broker.addAccount(label, credentials, opts.replace)
78
+ return await broker.addAccount(
79
+ label,
80
+ credentials,
81
+ opts.replace,
82
+ opts.provider,
83
+ )
74
84
  } finally {
75
85
  await broker.close()
76
86
  }
@@ -116,6 +116,11 @@ import {
116
116
  import type { AuthBrokerClient } from './auth-command.js'
117
117
  import type { ListStateData } from './auth-line.js'
118
118
  import { getAuthBrokerClient, addAccountViaBroker } from './auth-broker-client.js'
119
+ import {
120
+ pendingMicrosoftConnectFlows,
121
+ startMicrosoftConnect,
122
+ runMicrosoftConnectPoll,
123
+ } from './microsoft-connect-flow.js'
119
124
  import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
120
125
  import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
121
126
  import {
@@ -284,6 +289,7 @@ import {
284
289
  obligationEscalationText,
285
290
  } from './obligation-ledger.js'
286
291
  import { loadObligations, persistObligations } from './obligation-store.js'
292
+ import { withDeadline } from './with-deadline.js'
287
293
  import { createInboundSpool } from './inbound-spool.js'
288
294
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
289
295
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
@@ -1406,6 +1412,17 @@ const OBLIGATION_SWEEP_MS = 5_000
1406
1412
  // attempts the gateway closes best-effort (loud log): the user is genuinely
1407
1413
  // unreachable, so a bounded give-up beats an infinite/poison loop.
1408
1414
  const OBLIGATION_ESCALATE_MAX = 3
1415
+ // Deadline for a single escalation send. grammy/fetch impose NO request timeout,
1416
+ // and the in-flight guard (obligationEscalateInFlight) is cleared only in the
1417
+ // send's `.finally` — which never runs if the send hangs. Without a bound, one
1418
+ // stalled send leaks the in-flight flag forever and the obligation is stuck OPEN
1419
+ // (never re-escalated, never closed) — the sole liveness hole a total proof
1420
+ // found. Racing the send against this deadline makes the wait bounded BY
1421
+ // CONSTRUCTION (see with-deadline.ts): the chain always settles, `.finally`
1422
+ // always clears the flag, and a hang becomes a bounded reject that feeds the
1423
+ // bounded escalate ladder to a terminal. 45s comfortably exceeds robustApiCall's
1424
+ // 3-attempt network backoff so a legitimate slow send isn't cut short.
1425
+ const OBLIGATION_ESCALATE_SEND_DEADLINE_MS = 45_000
1409
1426
  // Durable snapshot of the open obligation set on the persistent per-agent
1410
1427
  // volume (STATE_DIR = /state/agent/telegram in prod). Closes the restart hole:
1411
1428
  // the in-memory ledger alone empties on restart and the spool's boot-replay
@@ -3745,6 +3762,17 @@ const pendingStateReaper = setInterval(() => {
3745
3762
  for (const [k, v] of awaitingAuthCodeAt) {
3746
3763
  if (now - v > AUTH_CODE_CONTEXT_TTL_MS) awaitingAuthCodeAt.delete(k)
3747
3764
  }
3765
+ // Microsoft connect flows self-expire at the device code's own expiry
3766
+ // (~15 min) — sweep past that + grace so an abandoned card doesn't pin
3767
+ // its key. Setting cancelled makes any still-running poll bail. Placed
3768
+ // AFTER the OAuth-code cluster above, which secret-detect-oauth-code.
3769
+ // test.ts pins as contiguous within the first 800 chars of the reaper.
3770
+ for (const [k, v] of pendingMicrosoftConnectFlows) {
3771
+ if (now - v.startedAt > v.device.expires_in * 1000 + 30_000) {
3772
+ v.cancelled = true
3773
+ pendingMicrosoftConnectFlows.delete(k)
3774
+ }
3775
+ }
3748
3776
  // Auth-refresh throttle entries decay quickly (5s window); sweep
3749
3777
  // anything older than 60s so abandoned snapshot messages don't pin
3750
3778
  // their key forever.
@@ -4874,16 +4902,39 @@ const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
4874
4902
  // OPEN via meta.source; reuses tested delivery). Bounded: after maxRepresents,
4875
4903
  // escalate to ONE operator-visible "did I miss this?" and close — no loop.
4876
4904
  // No-op unless the flag is on; gated on the same idle predicate as the drains.
4905
+ // DETERMINISM (closed-form, not sampled). The obligation ledger itself
4906
+ // (obligation-ledger.ts) is a finite FSM with a total transition function and a
4907
+ // strictly-decreasing measure μ = (REPRESENT_MAX - representCount) +
4908
+ // (ESCALATE_MAX - escalateAttempts): every markRepresented/markEscalateAttempt
4909
+ // decreases μ, both terms are bounded below, and at the floor the only move is
4910
+ // close → terminal. So on the FSM, every OPEN reaches answered | escalation-
4911
+ // delivered | bounded give-up (no cycle re-increments a counter). This sweep is
4912
+ // the FSM's driver; its termination rests on three liveness facts, all bounded:
4913
+ // (1) the 5s setInterval keeps firing;
4914
+ // (2) the gate (turnInFlightForGate) opens — claudeBusyKeys is cleared at
4915
+ // turn-end (purgeReactionTracking / releaseTurnBufferGate), on bridge
4916
+ // disconnect (disconnect-flush.ts), and by the 300s silence-poke watchdog;
4917
+ // (3) the escalation send settles — bounded BY CONSTRUCTION via withDeadline
4918
+ // below (grammy has no request timeout, so an unbounded send was the one
4919
+ // way an obligation could get stuck OPEN forever — now closed).
4920
+ // The only residual liveness assumption is the bridge eventually reconnecting /
4921
+ // the process restarting, which the entire gateway's inbound delivery already
4922
+ // depends on and which durable spool + boot-replay make self-healing.
4877
4923
  function obligationSweep(): void {
4878
4924
  if (!OBLIGATION_LEDGER_ENABLED) return
4879
4925
  if (!obligationLedger.hasOpen()) return
4880
4926
  if (turnInFlightForGate()) return // a turn is running — let it finish/answer
4881
4927
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
4882
- if (pendingInboundBuffer.depth(agent) > 0) return // existing drain runs first; avoids double-present
4883
4928
  const decision = obligationLedger.decideAtIdle()
4884
4929
  const o = decision.obligation
4885
4930
  if (decision.action === 'none' || o == null) return
4886
4931
  if (decision.action === 'represent') {
4932
+ // Re-present goes through the bridge → buffer. Only the represent path is
4933
+ // gated on an empty buffer (let the existing drain run first, avoid
4934
+ // double-presenting). Escalation below is NOT gated on the buffer — it is a
4935
+ // direct Telegram send, independent of the bridge, so a represent stranded
4936
+ // behind a dead bridge can never block the operator nudge.
4937
+ if (pendingInboundBuffer.depth(agent) > 0) return
4887
4938
  pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
4888
4939
  const attempt = obligationLedger.markRepresented(o.originTurnId)
4889
4940
  process.stderr.write(
@@ -4909,13 +4960,22 @@ function obligationSweep(): void {
4909
4960
  // retryWithThreadFallback: a stale/renumbered topic returns THREAD_NOT_FOUND;
4910
4961
  // retry WITHOUT the thread so the nudge still lands in the chat (the #2096
4911
4962
  // pattern) instead of being permanently undeliverable to a dead topic.
4912
- void retryWithThreadFallback(
4913
- robustApiCall,
4914
- (tid) =>
4915
- bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
4916
- ...(tid != null ? { message_thread_id: tid } : {}),
4917
- }),
4918
- { threadId: o.threadId, chat_id: o.chatId, verb: 'obligation.escalate' },
4963
+ // withDeadline: grammy/fetch impose no request timeout and `.finally` (which
4964
+ // clears the in-flight flag) only runs on settle — so a hung send would leak
4965
+ // the flag forever and wedge this obligation OPEN. Racing against a deadline
4966
+ // guarantees the chain settles, the flag always clears, and a hang becomes a
4967
+ // bounded reject handled exactly like any other failed attempt.
4968
+ void withDeadline(
4969
+ retryWithThreadFallback(
4970
+ robustApiCall,
4971
+ (tid) =>
4972
+ bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
4973
+ ...(tid != null ? { message_thread_id: tid } : {}),
4974
+ }),
4975
+ { threadId: o.threadId, chat_id: o.chatId, verb: 'obligation.escalate' },
4976
+ ),
4977
+ OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
4978
+ 'obligation escalation send timed out',
4919
4979
  )
4920
4980
  .then(() => {
4921
4981
  obligationLedger.close(escId)
@@ -14385,6 +14445,226 @@ async function runQuotaWatch(): Promise<void> {
14385
14445
  }
14386
14446
  }
14387
14447
 
14448
+ /**
14449
+ * Edit a Microsoft connect card from the BACKGROUND device-code poll
14450
+ * (no `ctx` — we hold the chat+message id). Wrapped in robustApiCall;
14451
+ * swallows failures (a deleted card / closed topic must not crash the
14452
+ * poll). RFC #1873 Phase 2.
14453
+ */
14454
+ async function editMicrosoftConnectCard(
14455
+ chatId: number | string,
14456
+ messageId: number,
14457
+ html: string,
14458
+ ): Promise<void> {
14459
+ await robustApiCall(
14460
+ // allow-raw-bot-api: background connect-card edit by message id (no thread_id; not the reply chunk loop)
14461
+ () => bot.api.editMessageText(chatId, messageId, html, {
14462
+ parse_mode: 'HTML',
14463
+ link_preview_options: { is_disabled: true },
14464
+ }),
14465
+ { chat_id: String(chatId), verb: 'microsoftConnectCard' },
14466
+ ).catch(() => {})
14467
+ }
14468
+
14469
+ /**
14470
+ * Background half of `/connect microsoft`: poll Microsoft for consent,
14471
+ * register the account with the broker, then edit the card to the
14472
+ * outcome. Fire-and-forget from the command handler.
14473
+ */
14474
+ async function finalizeMicrosoftConnect(key: string): Promise<void> {
14475
+ const flow = pendingMicrosoftConnectFlows.get(key)
14476
+ if (!flow) return
14477
+ const agentName = getMyAgentName()
14478
+ const result = await runMicrosoftConnectPoll(flow)
14479
+ // A `/connect cancel` (command or button) between consent and write
14480
+ // already edited the card and dropped the entry — don't clobber it.
14481
+ if (result.kind === 'cancelled' || !pendingMicrosoftConnectFlows.has(key)) {
14482
+ pendingMicrosoftConnectFlows.delete(key)
14483
+ return
14484
+ }
14485
+ pendingMicrosoftConnectFlows.delete(key)
14486
+
14487
+ let html: string
14488
+ if (result.kind === 'connected') {
14489
+ html =
14490
+ `✅ <b>Connected</b> <code>${escapeHtmlForTg(result.account)}</code> ` +
14491
+ `(${result.accountType}).\n\n` +
14492
+ `To let <b>${escapeHtmlForTg(agentName)}</b> use it, run on the host:\n` +
14493
+ `<code>switchroom auth microsoft enable ${escapeHtmlForTg(result.account)} ${escapeHtmlForTg(agentName)}</code>\n` +
14494
+ `then restart ${escapeHtmlForTg(agentName)}.`
14495
+ } else if (result.kind === 'no-refresh-token') {
14496
+ html =
14497
+ `⚠ Microsoft returned no refresh token (the account would expire in ~1h), ` +
14498
+ `so it was not registered. Try <code>/connect microsoft</code> again, or ` +
14499
+ `connect from the host CLI.`
14500
+ } else {
14501
+ html =
14502
+ `❌ <b>Connect failed:</b> ${escapeHtmlForTg(result.message)}\n\n` +
14503
+ `<i>Work/school accounts can't use the phone flow at /common — connect those ` +
14504
+ `from the host: <code>switchroom auth microsoft account add &lt;email&gt;</code>.</i>`
14505
+ }
14506
+ await editMicrosoftConnectCard(flow.cardChatId, flow.cardMessageId, html)
14507
+ }
14508
+
14509
+ /**
14510
+ * `/connect microsoft` — Telegram-native device-code connect for a
14511
+ * Microsoft account (RFC #1873 Phase 2). The user signs in on their
14512
+ * phone; we register the account with the auth-broker (shipped default
14513
+ * app unless the operator BYO'd one). Admin-gated like `/auth add`.
14514
+ * `/connect cancel` aborts a pending flow. Google stays host-CLI.
14515
+ */
14516
+ bot.command('connect', async ctx => {
14517
+ // Credential-plane admin is OPERATOR-PRIVATE (WS7-F2 / #1408), exactly
14518
+ // like `/auth`: honor `/connect` ONLY in a private chat from a strict
14519
+ // `access.allowFrom` sender — never the group-permissive
14520
+ // `isAuthorizedSender` (an empty group `allowFrom` = allow-all, so a
14521
+ // forum member could otherwise bind THEIR OWN Microsoft account as the
14522
+ // agent's credential). The agent-`admin:true` flag check below is
14523
+ // orthogonal and the broker enforces it server-side on add-account.
14524
+ const senderId = String(ctx.from?.id ?? '')
14525
+ const operatorPrivate =
14526
+ ctx.chat?.type === 'private' && loadAccess().allowFrom.includes(senderId)
14527
+ if (!operatorPrivate) {
14528
+ if (ctx.chat?.type !== 'private') {
14529
+ process.stderr.write(
14530
+ `telegram gateway: /connect refused (not operator-private) agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${senderId}\n`,
14531
+ )
14532
+ await switchroomReply(
14533
+ ctx,
14534
+ `⚠️ <code>/connect</code> links account credentials — it is <b>operator-private</b>. ` +
14535
+ `Send it as a direct message to me from your operator account (a private chat where ` +
14536
+ `your Telegram ID is on the access allowlist), not in a group or forum.`,
14537
+ { html: true },
14538
+ ).catch(() => {})
14539
+ }
14540
+ return
14541
+ }
14542
+
14543
+ const arg = String(ctx.match ?? '').trim().toLowerCase()
14544
+ const chatId = String(ctx.chat?.id ?? '')
14545
+ const key = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
14546
+
14547
+ // Agent-`admin:true` gate (orthogonal to operator-private above; same
14548
+ // source as /auth + the broker's server-side add-account enforcement).
14549
+ let isAdmin = false
14550
+ try {
14551
+ const cfg = loadSwitchroomConfig()
14552
+ const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })
14553
+ ?.agents?.[getMyAgentName()]
14554
+ isAdmin = me?.admin === true
14555
+ } catch { /* non-admin is the safe default */ }
14556
+ if (!isAuthAdmin({ isAdmin })) {
14557
+ await switchroomReply(
14558
+ ctx,
14559
+ `<b>Not authorized.</b> <code>/connect</code> requires this agent to have ` +
14560
+ `<code>admin: true</code> in switchroom.yaml.`,
14561
+ { html: true },
14562
+ )
14563
+ return
14564
+ }
14565
+
14566
+ if (arg === 'cancel') {
14567
+ const existing = pendingMicrosoftConnectFlows.get(key)
14568
+ if (!existing) {
14569
+ await switchroomReply(ctx, '<i>No pending connect flow in this chat.</i>', { html: true })
14570
+ return
14571
+ }
14572
+ existing.cancelled = true
14573
+ pendingMicrosoftConnectFlows.delete(key)
14574
+ await switchroomReply(ctx, 'Cancelled.', { html: true })
14575
+ return
14576
+ }
14577
+
14578
+ if (arg !== '' && arg !== 'microsoft' && arg !== 'ms') {
14579
+ await switchroomReply(
14580
+ ctx,
14581
+ `<b>Usage:</b> <code>/connect microsoft</code> to link a Microsoft account ` +
14582
+ `(Outlook / Office 365), or <code>/connect cancel</code>.\n` +
14583
+ `<i>Google accounts are connected from the host CLI.</i>`,
14584
+ { html: true },
14585
+ )
14586
+ return
14587
+ }
14588
+
14589
+ if (pendingMicrosoftConnectFlows.has(key)) {
14590
+ await switchroomReply(
14591
+ ctx,
14592
+ '<i>A Microsoft connect flow is already in progress here. Finish it on your phone, ' +
14593
+ 'or send <code>/connect cancel</code>.</i>',
14594
+ { html: true },
14595
+ )
14596
+ return
14597
+ }
14598
+
14599
+ let configClientId: string | undefined
14600
+ let orgMode = false
14601
+ try {
14602
+ const cfg = loadSwitchroomConfig() as unknown as {
14603
+ microsoft_workspace?: { microsoft_client_id?: string; org_mode?: boolean }
14604
+ }
14605
+ configClientId = cfg?.microsoft_workspace?.microsoft_client_id
14606
+ orgMode = cfg?.microsoft_workspace?.org_mode === true
14607
+ } catch { /* fall back to the shipped default */ }
14608
+
14609
+ const started = await startMicrosoftConnect({ configClientId, orgMode })
14610
+ if (started.kind === 'byo-vault') {
14611
+ await switchroomReply(
14612
+ ctx,
14613
+ `<b>Can't connect from chat:</b> this install uses a vaulted custom Microsoft ` +
14614
+ `app (<code>${escapeHtmlForTg(started.ref)}</code>) only the host CLI can read. ` +
14615
+ `Run <code>switchroom auth microsoft account add &lt;email&gt;</code> on the host.`,
14616
+ { html: true },
14617
+ )
14618
+ return
14619
+ }
14620
+ if (started.kind === 'error') {
14621
+ await switchroomReply(
14622
+ ctx,
14623
+ `<b>/connect failed:</b> ${escapeHtmlForTg(started.message)}`,
14624
+ { html: true },
14625
+ )
14626
+ return
14627
+ }
14628
+
14629
+ const appNote =
14630
+ started.source === 'default'
14631
+ ? "<i>Using switchroom's shipped Microsoft app.</i>"
14632
+ : '<i>Using your configured Microsoft app.</i>'
14633
+ const keyboard = new InlineKeyboard()
14634
+ .url('🔐 Sign in to Microsoft', started.device.verification_uri)
14635
+ .row()
14636
+ .text('✖ Cancel', `cn:cancel:${key}`)
14637
+ const sent = await ctx.reply(
14638
+ `🔗 <b>Connect a Microsoft account</b>\n\n` +
14639
+ `1. Tap <b>Sign in to Microsoft</b> below.\n` +
14640
+ `2. Enter this code: <code>${escapeHtmlForTg(started.device.user_code)}</code>\n` +
14641
+ `3. Approve the requested permissions (Mail, Calendar, Files).\n\n` +
14642
+ `I'll confirm here once it's connected (within ~15 min).\n${appNote}`,
14643
+ {
14644
+ parse_mode: 'HTML',
14645
+ link_preview_options: { is_disabled: true },
14646
+ reply_markup: keyboard,
14647
+ ...(ctx.message?.message_thread_id != null
14648
+ ? { message_thread_id: ctx.message.message_thread_id }
14649
+ : {}),
14650
+ },
14651
+ )
14652
+
14653
+ pendingMicrosoftConnectFlows.set(key, {
14654
+ initiatedBy: String(ctx.from?.id ?? ''),
14655
+ cardChatId: ctx.chat!.id,
14656
+ cardMessageId: sent.message_id,
14657
+ device: started.device,
14658
+ clientId: started.clientId,
14659
+ scopes: started.scopes,
14660
+ startedAt: Date.now(),
14661
+ cancelled: false,
14662
+ })
14663
+
14664
+ // Background: poll Microsoft, register the account, edit the card.
14665
+ void finalizeMicrosoftConnect(key)
14666
+ })
14667
+
14388
14668
  bot.command("auth", async ctx => {
14389
14669
  // sec WS7-F2b (#1394): `/auth` drives the auth-broker credential
14390
14670
  // lifecycle (`/auth add` mints/attaches an Anthropic account token,
@@ -17130,6 +17410,37 @@ bot.on('callback_query:data', async ctx => {
17130
17410
  return
17131
17411
  }
17132
17412
 
17413
+ // `cn:cancel:<key>` — cancel a pending Microsoft connect flow (the
17414
+ // Cancel button on the /connect card). RFC #1873 Phase 2.
17415
+ if (data.startsWith('cn:')) {
17416
+ const access = loadAccess()
17417
+ const senderId = String(ctx.from?.id ?? '')
17418
+ if (!access.allowFrom.includes(senderId)) {
17419
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
17420
+ return
17421
+ }
17422
+ const rest = data.slice('cn:'.length)
17423
+ const sep = rest.indexOf(':')
17424
+ const action = sep >= 0 ? rest.slice(0, sep) : rest
17425
+ const flowKey = sep >= 0 ? rest.slice(sep + 1) : ''
17426
+ if (action === 'cancel') {
17427
+ const pending = pendingMicrosoftConnectFlows.get(flowKey)
17428
+ if (pending) {
17429
+ pending.cancelled = true
17430
+ pendingMicrosoftConnectFlows.delete(flowKey)
17431
+ }
17432
+ await ctx.answerCallbackQuery({ text: 'Connect cancelled.' })
17433
+ await ctx
17434
+ .editMessageText('Microsoft connect cancelled.', {
17435
+ reply_markup: { inline_keyboard: [] },
17436
+ })
17437
+ .catch(() => {})
17438
+ } else {
17439
+ await ctx.answerCallbackQuery()
17440
+ }
17441
+ return
17442
+ }
17443
+
17133
17444
  // RFC B §6.1: apv:<request_id>:<choice>[:<param>] — approval kernel taps.
17134
17445
  // Routed through the generic kernel handler so any surface that uses
17135
17446
  // buildApprovalCard inherits consume → record → confirmation UX without
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Telegram-native Microsoft connect — device-code flow (RFC #1873 /
3
+ * out-of-box, Phase 2).
4
+ *
5
+ * The headline "connect from your phone" path: a user runs
6
+ * `/connect microsoft`, the gateway shows a card with a Microsoft
7
+ * sign-in link + a short code, the user approves on their phone, and the
8
+ * gateway registers the resulting account with the auth-broker — no host
9
+ * shell, no Azure portal (the shipped default app is used unless the
10
+ * operator BYO'd one).
11
+ *
12
+ * This module is the framework-agnostic core: it talks to Microsoft's
13
+ * device-code endpoints (RFC 8628, engine in `src/microsoft/oauth.ts`)
14
+ * and the auth-broker, and returns plain data. The gateway owns the
15
+ * Telegram surface (card rendering, edits, callbacks). All network +
16
+ * broker boundaries are injectable so the flow is testable without
17
+ * hitting Microsoft or a live broker, and it contains NO raw bot.api
18
+ * calls (the bot-api-wrapping lint trap lives only in the gateway).
19
+ *
20
+ * Mirrors `auth-add-flow.ts` (the Anthropic `/auth add` template) but
21
+ * needs no child subprocess and no pasted code: device-code consent
22
+ * happens entirely on Microsoft's domain, so nothing secret is ever
23
+ * pasted into chat (strictly better than paste-back — no redaction
24
+ * needed). Personal Microsoft accounts (outlook.com/hotmail) are the
25
+ * clean case at `/common`; a work/school account that fails device-code
26
+ * at `/common` surfaces a clear "use the host CLI" error (the documented
27
+ * "personal-first, work best-effort" boundary).
28
+ */
29
+
30
+ import {
31
+ requestDeviceCode as realRequestDeviceCode,
32
+ pollDeviceToken as realPollDeviceToken,
33
+ type MicrosoftDeviceCodeResponse,
34
+ type MicrosoftOAuthClientConfig,
35
+ } from '../../src/microsoft/oauth.js'
36
+ import { selectMicrosoftScopes } from '../../src/microsoft/scopes.js'
37
+ import { buildMicrosoftCredentials } from '../../src/microsoft/credentials.js'
38
+ import { resolveMicrosoftClientId } from '../../src/auth/default-oauth-clients.js'
39
+ import { isVaultReference } from '../../src/vault/resolver.js'
40
+ import { addAccountViaBroker } from './auth-broker-client.js'
41
+ import type { MicrosoftAddAccountCredentials } from '../../src/auth/broker/client.js'
42
+
43
+ /** A connect flow in flight, keyed by `chatKey(chatId, threadId)`. */
44
+ export interface PendingMicrosoftConnectFlow {
45
+ /** Telegram user id that started the flow (consent owner; Phase 3). */
46
+ initiatedBy: string
47
+ /** Card we posted, so the poll loop can edit it on completion. */
48
+ cardChatId: number | string
49
+ cardMessageId: number
50
+ device: MicrosoftDeviceCodeResponse
51
+ clientId: string
52
+ scopes: string[]
53
+ startedAt: number
54
+ /** Flipped by cancel so the in-flight poll bails without writing. */
55
+ cancelled: boolean
56
+ }
57
+
58
+ export const pendingMicrosoftConnectFlows = new Map<
59
+ string,
60
+ PendingMicrosoftConnectFlow
61
+ >()
62
+
63
+ export interface MicrosoftConnectDeps {
64
+ /** `config.microsoft_workspace?.microsoft_client_id` (may be a vault: ref). */
65
+ configClientId?: string
66
+ orgMode?: boolean
67
+ requestDeviceCode?: (
68
+ cfg: MicrosoftOAuthClientConfig,
69
+ ) => Promise<MicrosoftDeviceCodeResponse>
70
+ pollDeviceToken?: typeof realPollDeviceToken
71
+ addAccount?: (
72
+ label: string,
73
+ credentials: MicrosoftAddAccountCredentials,
74
+ opts: { replace?: boolean; provider: 'microsoft' },
75
+ ) => Promise<{ label: string; expiresAt?: number }>
76
+ now?: () => number
77
+ }
78
+
79
+ export type StartResult =
80
+ | {
81
+ kind: 'started'
82
+ device: MicrosoftDeviceCodeResponse
83
+ clientId: string
84
+ scopes: string[]
85
+ /** 'default' = shipped app; 'config'/'env' = BYO. */
86
+ source: 'env' | 'config' | 'default'
87
+ }
88
+ | {
89
+ // The operator BYO'd a Microsoft client via a vault: reference,
90
+ // which the gateway can't resolve in-process — host CLI only.
91
+ kind: 'byo-vault'
92
+ ref: string
93
+ }
94
+ | { kind: 'error'; message: string }
95
+
96
+ /**
97
+ * Request a device code and return the data the gateway needs to render
98
+ * the connect card. Does NOT mutate the pending map — the gateway stores
99
+ * the pending entry (with the card message id) after it posts the card.
100
+ */
101
+ export async function startMicrosoftConnect(
102
+ deps: MicrosoftConnectDeps = {},
103
+ ): Promise<StartResult> {
104
+ const resolved = resolveMicrosoftClientId(deps.configClientId)
105
+
106
+ // A vaulted BYO client_id can't be resolved from the gateway process
107
+ // (the gateway has no passphrase / vault-broker read path here). The
108
+ // shipped default and literal config values are fine.
109
+ if (isVaultReference(resolved.clientId)) {
110
+ return { kind: 'byo-vault', ref: resolved.clientId }
111
+ }
112
+
113
+ const scopes = selectMicrosoftScopes(deps.orgMode ?? false)
114
+ const cfg: MicrosoftOAuthClientConfig = {
115
+ client_id: resolved.clientId,
116
+ scopes,
117
+ }
118
+ try {
119
+ const device = await (deps.requestDeviceCode ?? realRequestDeviceCode)(cfg)
120
+ return {
121
+ kind: 'started',
122
+ device,
123
+ clientId: resolved.clientId,
124
+ scopes,
125
+ source: resolved.source,
126
+ }
127
+ } catch (err) {
128
+ return { kind: 'error', message: (err as Error).message }
129
+ }
130
+ }
131
+
132
+ export type PollResult =
133
+ | {
134
+ kind: 'connected'
135
+ account: string
136
+ accountType: 'personal' | 'work'
137
+ expiresAt: number
138
+ }
139
+ | { kind: 'cancelled' }
140
+ | { kind: 'no-refresh-token' }
141
+ | { kind: 'failed'; message: string }
142
+
143
+ /**
144
+ * Poll Microsoft for consent completion, then register the account with
145
+ * the broker. Blocks (with the device-code `interval`) up to the
146
+ * device's `expires_in`. Returns a discriminated result the gateway
147
+ * turns into a card edit. Reads `flow.cancelled` after the (potentially
148
+ * long) poll so a `/connect cancel` between consent and write is
149
+ * honored.
150
+ */
151
+ export async function runMicrosoftConnectPoll(
152
+ flow: Pick<
153
+ PendingMicrosoftConnectFlow,
154
+ 'device' | 'clientId' | 'scopes' | 'cancelled'
155
+ >,
156
+ deps: MicrosoftConnectDeps = {},
157
+ ): Promise<PollResult> {
158
+ const now = deps.now ?? Date.now
159
+ const cfg: MicrosoftOAuthClientConfig = {
160
+ client_id: flow.clientId,
161
+ scopes: flow.scopes,
162
+ }
163
+
164
+ let tokens
165
+ try {
166
+ tokens = await (deps.pollDeviceToken ?? realPollDeviceToken)(
167
+ cfg,
168
+ flow.device,
169
+ { now },
170
+ )
171
+ } catch (err) {
172
+ return { kind: 'failed', message: (err as Error).message }
173
+ }
174
+
175
+ if (flow.cancelled) return { kind: 'cancelled' }
176
+
177
+ const built = buildMicrosoftCredentials({
178
+ tokens,
179
+ clientId: flow.clientId,
180
+ accountEmail: '', // device-code learns the email from the id_token
181
+ fallbackScope: flow.scopes.join(' '),
182
+ now,
183
+ })
184
+
185
+ // offline_access is requested, so a refresh token is expected; without
186
+ // one the account dies at the first access-token expiry — fail loud
187
+ // rather than register an un-refreshable account.
188
+ if (!built.credentials.microsoftOauth.refreshToken) {
189
+ return { kind: 'no-refresh-token' }
190
+ }
191
+
192
+ const account = built.resolvedEmail
193
+ if (!account) {
194
+ return {
195
+ kind: 'failed',
196
+ message: 'Microsoft did not return an account identity (no id_token).',
197
+ }
198
+ }
199
+
200
+ const addAccount = deps.addAccount ?? defaultAddAccount
201
+ try {
202
+ await addAccount(account, built.credentials as MicrosoftAddAccountCredentials, {
203
+ provider: 'microsoft',
204
+ // replace:true so reconnecting an already-linked account just
205
+ // refreshes its tokens rather than erroring.
206
+ replace: true,
207
+ })
208
+ } catch (err) {
209
+ return { kind: 'failed', message: (err as Error).message }
210
+ }
211
+
212
+ return {
213
+ kind: 'connected',
214
+ account,
215
+ accountType: built.credentials.microsoftOauth.accountType,
216
+ expiresAt: built.credentials.microsoftOauth.expiresAt,
217
+ }
218
+ }
219
+
220
+ function defaultAddAccount(
221
+ label: string,
222
+ credentials: MicrosoftAddAccountCredentials,
223
+ opts: { replace?: boolean; provider: 'microsoft' },
224
+ ): Promise<{ label: string; expiresAt?: number }> {
225
+ return addAccountViaBroker(label, credentials, opts)
226
+ }