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.
- package/dist/cli/switchroom.js +73 -62
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +2586 -2061
- package/telegram-plugin/gateway/auth-broker-client.ts +18 -8
- package/telegram-plugin/gateway/gateway.ts +401 -14
- package/telegram-plugin/gateway/microsoft-connect-flow.ts +226 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +65 -2
- package/telegram-plugin/gateway/obligation-store.ts +107 -0
- package/telegram-plugin/gateway/with-deadline.ts +43 -0
- package/telegram-plugin/tests/microsoft-connect-flow.test.ts +185 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +241 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +69 -0
- package/telegram-plugin/tests/obligation-store.test.ts +117 -0
- package/telegram-plugin/tests/with-deadline.test.ts +61 -0
|
@@ -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(
|
|
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
|
-
|
|
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
|
+
})
|