switchroom 0.14.60 → 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.
@@ -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
+ }
@@ -38,6 +38,12 @@ export interface Obligation {
38
38
  readonly openedAt: number
39
39
  /** How many times it has been re-presented (0 on first open). */
40
40
  representCount: number
41
+ /** How many times the operator-escalation send has been ATTEMPTED (0 until
42
+ * the represent ladder is exhausted). Bounds escalation so a permanently-
43
+ * undeliverable nudge (dead/renumbered topic → Telegram 400, blocked bot)
44
+ * can't loop forever — and, because it is part of the durable snapshot,
45
+ * can't become a boot-surviving poison record either. */
46
+ escalateAttempts?: number
41
47
  }
42
48
 
43
49
  /** What the gateway should do for the oldest open obligation at an idle boundary. */
@@ -57,14 +63,33 @@ export interface ObligationInput {
57
63
  openedAt: number
58
64
  }
59
65
 
66
+ /** Side-effect hooks the gateway injects. Kept out of the pure decision logic
67
+ * so the ledger stays unit-testable (a capturing `onChange` in tests). */
68
+ export interface ObligationLedgerHooks {
69
+ /** Called after EVERY state mutation (open/close/represent/escalate-attempt)
70
+ * with the full open snapshot, so the gateway can durably persist it. NOT
71
+ * called by hydrate() — that IS the restoration of persisted state. */
72
+ onChange?: (snapshot: Obligation[]) => void
73
+ }
74
+
60
75
  export class ObligationLedger {
61
76
  private readonly open = new Map<string, Obligation>()
62
77
 
63
78
  /**
64
79
  * @param maxRepresents max re-presentations before escalating to an
65
80
  * operator-visible nudge instead of re-asking again. Default 2.
81
+ * @param hooks optional side-effect hooks (durable persistence). Omitted in
82
+ * pure unit tests; the gateway wires `onChange` to the snapshot store.
66
83
  */
67
- constructor(private readonly maxRepresents = 2) {}
84
+ constructor(
85
+ private readonly maxRepresents = 2,
86
+ private readonly hooks: ObligationLedgerHooks = {},
87
+ ) {}
88
+
89
+ /** Persist the current open set via the injected hook (no-op if unwired). */
90
+ private persist(): void {
91
+ this.hooks.onChange?.(this.list())
92
+ }
68
93
 
69
94
  /**
70
95
  * Open an obligation if not already tracked. Idempotent on originTurnId — a
@@ -75,13 +100,33 @@ export class ObligationLedger {
75
100
  openIfAbsent(input: ObligationInput): boolean {
76
101
  if (this.open.has(input.originTurnId)) return false
77
102
  this.open.set(input.originTurnId, { ...input, representCount: 0 })
103
+ this.persist()
78
104
  return true
79
105
  }
80
106
 
81
107
  /** Close by origin id. Returns true if an obligation was open and is now closed. */
82
108
  close(originTurnId: string | null | undefined): boolean {
83
109
  if (originTurnId == null) return false
84
- return this.open.delete(originTurnId)
110
+ const closed = this.open.delete(originTurnId)
111
+ if (closed) this.persist()
112
+ return closed
113
+ }
114
+
115
+ /**
116
+ * Re-populate from a persisted snapshot at boot — the restart-durability seam.
117
+ * Open obligations (with their representCount + escalateAttempts intact)
118
+ * survive a gateway/container restart, so the next idle sweep re-presents or
119
+ * re-escalates them rather than silently losing a delivered-but-unanswered
120
+ * message. Replaces current contents; does NOT fire onChange (this state is
121
+ * already what's on disk). Tolerates a malformed snapshot (skips bad rows).
122
+ */
123
+ hydrate(snapshot: readonly Obligation[]): void {
124
+ this.open.clear()
125
+ for (const o of snapshot) {
126
+ if (o != null && typeof o.originTurnId === 'string' && o.originTurnId.length > 0) {
127
+ this.open.set(o.originTurnId, { ...o })
128
+ }
129
+ }
85
130
  }
86
131
 
87
132
  isOpen(originTurnId: string): boolean {
@@ -156,8 +201,26 @@ export class ObligationLedger {
156
201
  const o = this.open.get(originTurnId)
157
202
  if (o === undefined) return 0
158
203
  o.representCount += 1
204
+ this.persist()
159
205
  return o.representCount
160
206
  }
207
+
208
+ /**
209
+ * Record an operator-escalation SEND attempt (bumps escalateAttempts).
210
+ * Returns the new count. The gateway calls this immediately before each
211
+ * escalation send and uses the count to bound retries: a transient send
212
+ * failure leaves the obligation OPEN and is retried next sweep, but after
213
+ * the bound the gateway closes best-effort so a permanently-undeliverable
214
+ * nudge can neither loop forever nor (now that the count is durable) re-enter
215
+ * the loop on every boot.
216
+ */
217
+ markEscalateAttempt(originTurnId: string): number {
218
+ const o = this.open.get(originTurnId)
219
+ if (o === undefined) return 0
220
+ o.escalateAttempts = (o.escalateAttempts ?? 0) + 1
221
+ this.persist()
222
+ return o.escalateAttempts
223
+ }
161
224
  }
162
225
 
163
226
  /** Original message preview length for re-presentation (mirrors resume builder). */
@@ -0,0 +1,107 @@
1
+ /**
2
+ * obligation-store.ts — durable snapshot for the obligation ledger.
3
+ *
4
+ * Why this exists: `ObligationLedger` is an in-memory Map. A gateway/container
5
+ * restart (switchroom update, agent restart, self-restart, OOM) empties it, so
6
+ * an inbound that was OPEN-but-unanswered when the process died loses its
7
+ * answer-obligation. The inbound-spool redelivers the MESSAGE on boot, but its
8
+ * replay bypasses `handleInbound` (the only place obligations OPEN), so the
9
+ * obligation is never reborn and a post-restart deferral is silently dropped —
10
+ * the determinism hole the systems analysis flagged.
11
+ *
12
+ * This makes the obligation guarantee SELF-CONTAINED across restart: every
13
+ * ledger mutation persists the full open set here; on boot the gateway hydrates
14
+ * the ledger from it, so OPEN/ESCALATING obligations survive WITH their
15
+ * representCount + escalateAttempts intact (the latter so a permanently-
16
+ * undeliverable escalation can't re-enter its retry loop on every boot).
17
+ *
18
+ * Shape choice — SNAPSHOT, not append-log. The open set is tiny and bounded
19
+ * (the count of currently-unanswered messages — normally 0–3), so rewriting the
20
+ * whole set on each change is trivially cheap and needs NO compaction,
21
+ * tombstones, or torn-tail replay. Crash-safety is a single write-tmp +
22
+ * atomic rename: a crash leaves EITHER the prior complete snapshot OR the new
23
+ * complete one — never a torn file. This is strictly simpler than the spool's
24
+ * append+ack+compact idiom, which that file needs only because its set is
25
+ * unbounded. PURE w.r.t. the injected fs seam ⇒ unit-testable.
26
+ */
27
+
28
+ import type { Obligation } from './obligation-ledger.js'
29
+
30
+ export interface ObligationStoreFsSeam {
31
+ readFileSync: (path: string) => string
32
+ writeFileSync: (path: string, data: string) => void
33
+ /** Atomic same-dir replace (POSIX rename) so a crash mid-write can't tear
34
+ * the snapshot. */
35
+ renameSync: (from: string, to: string) => void
36
+ existsSync: (path: string) => boolean
37
+ }
38
+
39
+ interface SnapshotEnvelope {
40
+ v: 1
41
+ obligations: Obligation[]
42
+ }
43
+
44
+ function isObligationRow(x: unknown): x is Obligation {
45
+ if (x == null || typeof x !== 'object') return false
46
+ const o = x as Record<string, unknown>
47
+ return (
48
+ typeof o.originTurnId === 'string' &&
49
+ o.originTurnId.length > 0 &&
50
+ typeof o.chatId === 'string' &&
51
+ typeof o.messageId === 'number' &&
52
+ typeof o.text === 'string' &&
53
+ typeof o.openedAt === 'number' &&
54
+ typeof o.representCount === 'number'
55
+ )
56
+ }
57
+
58
+ /**
59
+ * Load the persisted open set. Returns [] on a missing, unreadable, or
60
+ * malformed file (fail-open to empty: a corrupt snapshot must never crash boot;
61
+ * worst case we lose the cross-restart obligation guarantee for that boot and
62
+ * fall back to the spool's message redelivery — strictly no worse than today).
63
+ */
64
+ export function loadObligations(path: string, fs: ObligationStoreFsSeam): Obligation[] {
65
+ if (!fs.existsSync(path)) return []
66
+ let raw = ''
67
+ try {
68
+ raw = fs.readFileSync(path)
69
+ } catch {
70
+ return []
71
+ }
72
+ let parsed: unknown
73
+ try {
74
+ parsed = JSON.parse(raw)
75
+ } catch {
76
+ return []
77
+ }
78
+ if (parsed == null || typeof parsed !== 'object') return []
79
+ const env = parsed as Record<string, unknown>
80
+ if (env.v !== 1 || !Array.isArray(env.obligations)) return []
81
+ return env.obligations.filter(isObligationRow)
82
+ }
83
+
84
+ /**
85
+ * Persist the open set atomically (write sibling tmp → rename over the real
86
+ * path). Best-effort relative to fs availability: a write failure is logged but
87
+ * never thrown — a failing store degrades to in-memory-only (today's behaviour),
88
+ * it must not break live delivery.
89
+ */
90
+ export function persistObligations(
91
+ path: string,
92
+ fs: ObligationStoreFsSeam,
93
+ snapshot: readonly Obligation[],
94
+ log: (line: string) => void = (l) => process.stderr.write(l),
95
+ ): void {
96
+ const env: SnapshotEnvelope = { v: 1, obligations: [...snapshot] }
97
+ const tmp = path + '.tmp'
98
+ try {
99
+ fs.writeFileSync(tmp, JSON.stringify(env))
100
+ fs.renameSync(tmp, path)
101
+ } catch (err) {
102
+ log(
103
+ `obligation-store: persist FAILED path=${path}: ${(err as Error).message} — ` +
104
+ `durability degraded to in-memory\n`,
105
+ )
106
+ }
107
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * withDeadline — bound a promise so the chain off it ALWAYS settles within `ms`.
3
+ *
4
+ * Why this exists (the obligation-ledger determinism hole): the escalation send
5
+ * in `obligationSweep` is fire-and-forget and clears its in-flight guard
6
+ * (`obligationEscalateInFlight`) only in a `.finally` — which runs only if the
7
+ * awaited promise SETTLES. grammy's `bot.api` has no request timeout
8
+ * (`new Bot(TOKEN)`, no `client.timeoutSeconds`) and `retryApiCall`'s `await
9
+ * fn()` does not bound a hang (its retry cap applies to rejections, not to a
10
+ * promise that never resolves). So a stalled send (half-open TCP, unresponsive
11
+ * Telegram) would never settle → `.finally` never fires → the in-flight id is
12
+ * leaked forever → every later sweep early-returns at the guard → the
13
+ * obligation is stuck OPEN: never re-presented, never escalated, never closed.
14
+ * That is a silent loss of the "every inbound is answered-or-escalated"
15
+ * guarantee — the one liveness hole a total state-machine proof surfaced (a
16
+ * sampling test cannot, because its model never includes "send never settles").
17
+ *
18
+ * Racing the send against a deadline makes the wait bounded BY CONSTRUCTION:
19
+ * the returned promise settles in ≤ `ms`, so the caller's `.then/.catch/.finally`
20
+ * always run and the in-flight flag always clears. A hang becomes a bounded
21
+ * rejection that feeds the already-bounded escalate ladder
22
+ * (`escalateAttempts → OBLIGATION_ESCALATE_MAX`) to a terminal. The losing
23
+ * (still-pending) promise is given a no-op `.catch` so its eventual rejection
24
+ * is not an unhandled rejection, and the timer is cleared + unref'd so it
25
+ * neither leaks nor keeps the event loop alive.
26
+ *
27
+ * Pure (no gateway/Telegram coupling) ⇒ unit-testable; see
28
+ * tests/with-deadline.test.ts.
29
+ */
30
+ export function withDeadline<T>(p: Promise<T>, ms: number, timeoutMessage: string): Promise<T> {
31
+ // Swallow a late rejection from the loser after the race has already settled,
32
+ // so a hung-then-eventually-rejected send is never an unhandled rejection.
33
+ p.catch(() => {})
34
+ let timer: ReturnType<typeof setTimeout> | undefined
35
+ const deadline = new Promise<never>((_resolve, reject) => {
36
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), ms)
37
+ // Don't keep the process alive solely for this timer.
38
+ ;(timer as unknown as { unref?: () => void }).unref?.()
39
+ })
40
+ return Promise.race([p, deadline]).finally(() => {
41
+ if (timer !== undefined) clearTimeout(timer)
42
+ }) as Promise<T>
43
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ import {
4
+ startMicrosoftConnect,
5
+ runMicrosoftConnectPoll,
6
+ } from '../gateway/microsoft-connect-flow.js'
7
+ import { DEFAULT_MICROSOFT_CLIENT_ID } from '../../src/auth/default-oauth-clients.js'
8
+ import type {
9
+ MicrosoftDeviceCodeResponse,
10
+ MicrosoftOAuthClientConfig,
11
+ MicrosoftTokenResponse,
12
+ } from '../../src/microsoft/oauth.js'
13
+
14
+ const PERSONAL_MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad'
15
+
16
+ const DEVICE: MicrosoftDeviceCodeResponse = {
17
+ device_code: 'dc-abc',
18
+ user_code: 'ABCD-EFGH',
19
+ verification_uri: 'https://microsoft.com/devicelogin',
20
+ expires_in: 900,
21
+ interval: 5,
22
+ }
23
+
24
+ function fakeIdToken(payload: Record<string, unknown>): string {
25
+ const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
26
+ return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
27
+ }
28
+
29
+ describe('startMicrosoftConnect', () => {
30
+ it('uses the shipped default client_id + default scopes when there is no config', async () => {
31
+ let seen: MicrosoftOAuthClientConfig | undefined
32
+ const res = await startMicrosoftConnect({
33
+ requestDeviceCode: async (cfg) => {
34
+ seen = cfg
35
+ return DEVICE
36
+ },
37
+ })
38
+ expect(res.kind).toBe('started')
39
+ if (res.kind === 'started') {
40
+ expect(res.source).toBe('default')
41
+ expect(res.clientId).toBe(DEFAULT_MICROSOFT_CLIENT_ID)
42
+ expect(res.scopes).toContain('offline_access')
43
+ expect(res.device.user_code).toBe('ABCD-EFGH')
44
+ }
45
+ expect(seen?.client_id).toBe(DEFAULT_MICROSOFT_CLIENT_ID)
46
+ })
47
+
48
+ it('refuses a vaulted BYO client (gateway can\'t resolve vault refs)', async () => {
49
+ const res = await startMicrosoftConnect({
50
+ configClientId: 'vault:microsoft-oauth-client-id',
51
+ requestDeviceCode: async () => DEVICE,
52
+ })
53
+ expect(res.kind).toBe('byo-vault')
54
+ if (res.kind === 'byo-vault') {
55
+ expect(res.ref).toBe('vault:microsoft-oauth-client-id')
56
+ }
57
+ })
58
+
59
+ it('uses a literal BYO config client_id (source=config)', async () => {
60
+ const res = await startMicrosoftConnect({
61
+ configClientId: 'byo-literal-123',
62
+ requestDeviceCode: async () => DEVICE,
63
+ })
64
+ expect(res.kind).toBe('started')
65
+ if (res.kind === 'started') {
66
+ expect(res.source).toBe('config')
67
+ expect(res.clientId).toBe('byo-literal-123')
68
+ }
69
+ })
70
+
71
+ it('returns an error when the device-code request fails', async () => {
72
+ const res = await startMicrosoftConnect({
73
+ requestDeviceCode: async () => {
74
+ throw new Error('device_code request failed (400): boom')
75
+ },
76
+ })
77
+ expect(res.kind).toBe('error')
78
+ if (res.kind === 'error') expect(res.message).toContain('boom')
79
+ })
80
+ })
81
+
82
+ describe('runMicrosoftConnectPoll', () => {
83
+ const flow = {
84
+ device: DEVICE,
85
+ clientId: 'client-1',
86
+ scopes: ['offline_access', 'Mail.ReadWrite'],
87
+ cancelled: false,
88
+ }
89
+
90
+ function tokens(o: Partial<MicrosoftTokenResponse> = {}): MicrosoftTokenResponse {
91
+ return {
92
+ access_token: 'at',
93
+ refresh_token: 'rt',
94
+ expires_in: 3600,
95
+ token_type: 'Bearer',
96
+ scope: 'Mail.ReadWrite',
97
+ ...o,
98
+ }
99
+ }
100
+
101
+ it('connects: registers the account via the broker keyed by the authenticated email', async () => {
102
+ const id_token = fakeIdToken({
103
+ tid: PERSONAL_MSA_TID,
104
+ oid: 'oid-1',
105
+ preferred_username: 'Lisa@Outlook.com',
106
+ })
107
+ const calls: Array<{ label: string; opts: unknown; refreshToken: string }> = []
108
+ const res = await runMicrosoftConnectPoll(flow, {
109
+ pollDeviceToken: async () => tokens({ id_token }),
110
+ addAccount: async (label, creds, opts) => {
111
+ calls.push({
112
+ label,
113
+ opts,
114
+ refreshToken: creds.microsoftOauth.refreshToken,
115
+ })
116
+ return { label }
117
+ },
118
+ })
119
+ expect(res.kind).toBe('connected')
120
+ if (res.kind === 'connected') {
121
+ expect(res.account).toBe('lisa@outlook.com')
122
+ expect(res.accountType).toBe('personal')
123
+ }
124
+ expect(calls).toHaveLength(1)
125
+ expect(calls[0].label).toBe('lisa@outlook.com')
126
+ expect(calls[0].opts).toEqual({ provider: 'microsoft', replace: true })
127
+ expect(calls[0].refreshToken).toBe('rt')
128
+ })
129
+
130
+ it('cancelled: does NOT register if the flow was cancelled during the poll', async () => {
131
+ let added = false
132
+ const res = await runMicrosoftConnectPoll(
133
+ { ...flow, cancelled: true },
134
+ {
135
+ pollDeviceToken: async () => tokens(),
136
+ addAccount: async () => {
137
+ added = true
138
+ return { label: 'x' }
139
+ },
140
+ },
141
+ )
142
+ expect(res.kind).toBe('cancelled')
143
+ expect(added).toBe(false)
144
+ })
145
+
146
+ it('no-refresh-token: refuses to register an un-refreshable account', async () => {
147
+ let added = false
148
+ const res = await runMicrosoftConnectPoll(flow, {
149
+ pollDeviceToken: async () => tokens({ refresh_token: undefined }),
150
+ addAccount: async () => {
151
+ added = true
152
+ return { label: 'x' }
153
+ },
154
+ })
155
+ expect(res.kind).toBe('no-refresh-token')
156
+ expect(added).toBe(false)
157
+ })
158
+
159
+ it('failed: surfaces a poll error (e.g. a work account rejected at /common)', async () => {
160
+ const res = await runMicrosoftConnectPoll(flow, {
161
+ pollDeviceToken: async () => {
162
+ throw new Error('Token poll failed: AADSTS9001023 work account')
163
+ },
164
+ addAccount: async () => ({ label: 'x' }),
165
+ })
166
+ expect(res.kind).toBe('failed')
167
+ if (res.kind === 'failed') expect(res.message).toContain('AADSTS9001023')
168
+ })
169
+
170
+ it('failed: refuses to register when Microsoft returns no id_token (no account identity)', async () => {
171
+ let added = false
172
+ const res = await runMicrosoftConnectPoll(flow, {
173
+ // Has a refresh token but no id_token → no resolvable email to key
174
+ // the broker account by; must NOT register a label-less account.
175
+ pollDeviceToken: async () => tokens({ id_token: undefined }),
176
+ addAccount: async () => {
177
+ added = true
178
+ return { label: 'x' }
179
+ },
180
+ })
181
+ expect(res.kind).toBe('failed')
182
+ if (res.kind === 'failed') expect(res.message).toContain('account identity')
183
+ expect(added).toBe(false)
184
+ })
185
+ })