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.
- package/dist/cli/switchroom.js +73 -62
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +2776 -2353
- package/telegram-plugin/gateway/auth-broker-client.ts +18 -8
- package/telegram-plugin/gateway/gateway.ts +319 -8
- package/telegram-plugin/gateway/microsoft-connect-flow.ts +226 -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 +22 -22
- package/telegram-plugin/tests/with-deadline.test.ts +61 -0
|
@@ -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
|
+
})
|
|
@@ -7,30 +7,30 @@ import {
|
|
|
7
7
|
} from "../gateway/obligation-store.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* REGRESSION GUARD — not the proof.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* The actual determinism argument is closed-form and lives WITH the code: the
|
|
13
|
+
* ledger is a finite FSM with a total transition function and a strictly-
|
|
14
|
+
* decreasing measure μ = (REPRESENT_MAX - representCount) + (ESCALATE_MAX -
|
|
15
|
+
* escalateAttempts) ⇒ every OPEN reaches a terminal (see the proof comment on
|
|
16
|
+
* obligationSweep in gateway.ts and the ledger methods in obligation-ledger.ts).
|
|
17
|
+
* A total state-machine proof also found — and a fix closed — the one liveness
|
|
18
|
+
* hole this kind of SAMPLING test structurally cannot reach: a hung escalation
|
|
19
|
+
* send leaking the in-flight flag (now bounded by withDeadline; guarded by
|
|
20
|
+
* with-deadline.test.ts). The lesson stands: a random-schedule test only
|
|
21
|
+
* exercises the behaviours its model encodes; it is evidence, never the proof.
|
|
16
22
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* Over thousands of random schedules it asserts the invariant holds and the
|
|
30
|
-
* engine always terminates (no infinite loop). The COALESCED PARTIAL-ANSWER
|
|
31
|
-
* residual is deliberately NOT modelled — it is the one honest hard limit (a
|
|
32
|
-
* turn-keyed ledger cannot see "answered half" without parsing model prose) and
|
|
33
|
-
* is mitigated by coalescing policy, not the ledger.
|
|
23
|
+
* What this file still earns its keep doing: drive the REAL ObligationLedger +
|
|
24
|
+
* REAL durable snapshot store over many random {model-behaviour × timing ×
|
|
25
|
+
* restart} schedules to catch a regression that breaks the FSM invariant
|
|
26
|
+
* (no silent drop, no double-ask of an answered message, bounded termination).
|
|
27
|
+
* It models the lifecycle SYNCHRONOUSLY (open at receipt; close at turn_end on a
|
|
28
|
+
* delivered answer; bounded represent→escalate; restart = hydrate from snapshot)
|
|
29
|
+
* — so it does NOT and cannot cover async/coupling liveness (hung send, gate
|
|
30
|
+
* never opening, drain wedging); those are proven/bounded in the code, not here.
|
|
31
|
+
* The coalesced PARTIAL-ANSWER residual is also out of model — the one honest
|
|
32
|
+
* hard limit (a turn-keyed ledger can't see "answered half" without parsing the
|
|
33
|
+
* model's prose), mitigated by coalescing policy, not the ledger.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
// Mirrors the gateway constants under test.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { withDeadline } from "../gateway/with-deadline.js";
|
|
3
|
+
|
|
4
|
+
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
|
|
6
|
+
describe("withDeadline — bounds the obligation escalation send so a hang can't leak the in-flight flag", () => {
|
|
7
|
+
it("resolves with the inner value when the promise settles before the deadline", async () => {
|
|
8
|
+
await expect(withDeadline(Promise.resolve("ok"), 1000, "timed out")).resolves.toBe("ok");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects with the inner error when the promise rejects before the deadline", async () => {
|
|
12
|
+
await expect(withDeadline(Promise.reject(new Error("boom")), 1000, "timed out")).rejects.toThrow(
|
|
13
|
+
"boom",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects with the timeout message when the promise NEVER settles (the hang case)", async () => {
|
|
18
|
+
// The whole point: a promise that never resolves/rejects (a stalled send)
|
|
19
|
+
// must still settle the chain so the caller's .finally clears the in-flight flag.
|
|
20
|
+
const neverSettles = new Promise<string>(() => {});
|
|
21
|
+
await expect(withDeadline(neverSettles, 20, "obligation escalation send timed out")).rejects.toThrow(
|
|
22
|
+
"obligation escalation send timed out",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("the .finally chained after it ALWAYS runs even when the inner promise hangs", async () => {
|
|
27
|
+
// This mirrors the gateway's obligationEscalateInFlight clear: it lives in a
|
|
28
|
+
// .finally on withDeadline(...), and must fire within the deadline regardless.
|
|
29
|
+
let flagCleared = false;
|
|
30
|
+
await withDeadline(new Promise<void>(() => {}), 20, "timed out")
|
|
31
|
+
.catch(() => {})
|
|
32
|
+
.finally(() => {
|
|
33
|
+
flagCleared = true;
|
|
34
|
+
});
|
|
35
|
+
expect(flagCleared).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("a hung-then-late-rejecting inner promise does not produce an unhandled rejection", async () => {
|
|
39
|
+
const seen: unknown[] = [];
|
|
40
|
+
const onUnhandled = (reason: unknown) => seen.push(reason);
|
|
41
|
+
process.on("unhandledRejection", onUnhandled);
|
|
42
|
+
try {
|
|
43
|
+
const lateReject = new Promise<void>((_, reject) => {
|
|
44
|
+
setTimeout(() => reject(new Error("late-zombie-rejection")), 30);
|
|
45
|
+
});
|
|
46
|
+
// withDeadline rejects at 10ms; the inner promise rejects later at 30ms.
|
|
47
|
+
await withDeadline(lateReject, 10, "timed out").catch(() => {});
|
|
48
|
+
await tick(60); // let the late rejection fire
|
|
49
|
+
} finally {
|
|
50
|
+
process.off("unhandledRejection", onUnhandled);
|
|
51
|
+
}
|
|
52
|
+
expect(seen.some((r) => r instanceof Error && r.message === "late-zombie-rejection")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("clears its timer on a fast settle (no dangling work keeps the loop alive)", async () => {
|
|
56
|
+
// Sanity: a fast resolve settles immediately, not after the deadline.
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
await withDeadline(Promise.resolve(1), 5000, "timed out");
|
|
59
|
+
expect(Date.now() - start).toBeLessThan(500);
|
|
60
|
+
});
|
|
61
|
+
});
|