@rikaidev/yomi 0.3.1 → 0.4.1

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/PRIVACY.md CHANGED
@@ -19,6 +19,20 @@ The only data that leaves your device is whatever the agent surfaces in its
19
19
  replies to you — and in a cloud assistant, that surfaced text is processed by
20
20
  that assistant's model. Yomi itself makes no outbound copy of your messages.
21
21
 
22
+ ## The one non-LINE request Yomi makes
23
+
24
+ At startup Yomi asks the public npm registry whether a newer version has been
25
+ published, and tells the agent if so. This exists because a Desktop Extension
26
+ (`.mcpb`) installed by hand never auto-updates — without the check you could
27
+ sit on an old build, security fixes included, and never know.
28
+
29
+ It sends **no message content and no account identifier**: it is a plain GET of
30
+ a public package document. What it does reveal to the registry is that your IP
31
+ address is running Yomi, and roughly which version. Nothing is uploaded.
32
+
33
+ Set **`YOMI_NO_UPDATE_CHECK=1`** to turn it off; Yomi then makes no outbound
34
+ request except to LINE itself.
35
+
22
36
  ## Excluding conversations
23
37
 
24
38
  You can exclude any conversation at any time, in plain language to the agent
package/README.md CHANGED
@@ -36,6 +36,9 @@ reply, who ties your world together, and what has gone overdue.
36
36
  No official API, no bot account, no webhook. Yomi logs in as a secondary device
37
37
  on your own account.
38
38
 
39
+ Guides: **[What is a LINE MCP server?](https://rikaidev.github.io/yomi/line-mcp/)**
40
+ · **[LINE MCP 是什麼?Yomi、官方 Bot 與桌面自動化的差異](https://rikaidev.github.io/yomi/zh-tw/line-mcp/)**.
41
+
39
42
  ![license](https://img.shields.io/badge/license-MIT-blue) ![runtime](https://img.shields.io/badge/runtime-node%20%7C%20bun-black) ![protocol](https://img.shields.io/badge/MCP-stdio-green) ![npm](https://img.shields.io/badge/npm-@rikaidev%2Fyomi-red)
40
43
 
41
44
  > **Unofficial.** Yomi is an independent personal project, not affiliated with or
@@ -16,6 +16,15 @@
16
16
  * KeychainService service name: see keychain.ts — writes go to Yomi's own
17
17
  * canonical namespace; a legacy namespace from before this split is read
18
18
  * as a fallback and migrated forward, never written to or deleted.
19
+ *
20
+ * The Keychain is machine-global: it is NOT scoped by YOMI_DATA_DIR, so on
21
+ * macOS an automated run that only redirected the data dir would still find —
22
+ * and act on — the developer's real LINE session (a stored login certificate
23
+ * is enough for `login` to start a device sign-in against the live account).
24
+ * `YOMI_NO_KEYCHAIN=1` forces the file backend so tests and smoke runs are
25
+ * genuinely isolated by pointing YOMI_DATA_DIR somewhere disposable. It is a
26
+ * testing escape hatch: setting it in normal use downgrades credential
27
+ * storage from the Keychain to a plain JSON file.
19
28
  */
20
29
  import { dirname, join } from 'node:path';
21
30
  import { getKeychainService } from './keychain.js';
@@ -188,7 +197,8 @@ export class CredentialStore {
188
197
  this.filePath = fallbackFilePath;
189
198
  this.cache = new Map();
190
199
  this.loaded = false;
191
- this.secureStorageEnabled = process.platform === 'darwin';
200
+ this.secureStorageEnabled =
201
+ process.platform === 'darwin' && process.env.YOMI_NO_KEYCHAIN !== '1';
192
202
  this.keychainService = this.secureStorageEnabled
193
203
  ? getKeychainService()
194
204
  : null;
@@ -0,0 +1,55 @@
1
+ import { CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
2
+ /**
3
+ * The capabilities the CURRENT request's client declared.
4
+ *
5
+ * @param server - MCP Server instance (the legacy, handshake-scoped source).
6
+ * @param ctx - Request handler context (the modern, per-request source).
7
+ * @returns The declared capabilities, or undefined when the client declared
8
+ * none and no envelope carried any.
9
+ */
10
+ export function clientCapabilitiesOf(server, ctx) {
11
+ // The envelope type is opaque (`RequestMetaEnvelope = {}`) — the SDK models
12
+ // it as a bag of reserved keys, so reaching the declared capabilities off it
13
+ // needs an index-signature view rather than a property access.
14
+ const envelope = ctx?.mcpReq.envelope;
15
+ return (envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.getClientCapabilities());
16
+ }
17
+ /**
18
+ * Does the connected client accept form-mode elicitation (`elicitation/create`
19
+ * carrying a `requestedSchema`)?
20
+ *
21
+ * @param capabilities - Result of `server.getClientCapabilities()`.
22
+ * @returns True when the client declared `elicitation.form`, or declared a
23
+ * bare `elicitation` with no mode sub-keys at all (pre-2026-07-28 shape).
24
+ */
25
+ export function supportsFormElicitation(capabilities) {
26
+ const elicitation = capabilities?.elicitation;
27
+ if (!elicitation || typeof elicitation !== 'object') {
28
+ return false;
29
+ }
30
+ if ('form' in elicitation && elicitation.form) {
31
+ return true;
32
+ }
33
+ // A client that named `url` and not `form` is url-only; one that named
34
+ // neither predates the split and can only have meant form.
35
+ return !('url' in elicitation && elicitation.url);
36
+ }
37
+ /**
38
+ * Does the connected client accept url-mode elicitation (`elicitation/create`
39
+ * with `mode: "url"`, answered out-of-band and closed by a
40
+ * `notifications/elicitation/complete`)?
41
+ *
42
+ * Not used by any flow yet — url mode has no observed client support, the
43
+ * same trap ./ui/login-app.ts documents for MCP Apps. Exported so a future
44
+ * url-mode path can gate on the real declaration rather than assume it.
45
+ *
46
+ * @param capabilities - Result of `server.getClientCapabilities()`.
47
+ * @returns True only when the client explicitly declared `elicitation.url`.
48
+ */
49
+ export function supportsUrlElicitation(capabilities) {
50
+ const elicitation = capabilities?.elicitation;
51
+ if (!elicitation || typeof elicitation !== 'object') {
52
+ return false;
53
+ }
54
+ return 'url' in elicitation && Boolean(elicitation.url);
55
+ }
@@ -0,0 +1,200 @@
1
+ import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
2
+ /** LINE region codes offered on the login form. */
3
+ export const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
4
+ /** Where the toggle lives on the primary phone, in LINE's own words. */
5
+ export const PRIMARY_DEVICE_SETTING_PATH = '設定 > 我的帳號 > 允許自其他裝置登入';
6
+ /**
7
+ * The prerequisite as a single line, for texts that report on a login
8
+ * already in flight (the PIN and cert steps) rather than gating one.
9
+ */
10
+ const PRIMARY_DEVICE_SETTING_NOTE = `0. This only works if the primary phone has ${PRIMARY_DEVICE_SETTING_PATH} ` +
11
+ '(Settings > Account > allow logging in from other devices; the label is ' +
12
+ 'localized) enabled — without it LINE will not offer this device a sign-in ' +
13
+ 'prompt at all.\n';
14
+ /**
15
+ * Step-by-step instructions for turning the setting on, returned when the
16
+ * human says it is off or that they do not know where it is.
17
+ */
18
+ const PRIMARY_DEVICE_SETTING_STEPS = 'On the PRIMARY phone (the one already logged in to this LINE account):\n\n' +
19
+ '1. Open LINE and go to the 主頁 / Home tab.\n' +
20
+ '2. Tap the gear icon in the top corner to open 設定 (Settings).\n' +
21
+ '3. Open 我的帳號 (Account).\n' +
22
+ '4. Turn ON 允許自其他裝置登入 (the toggle allowing login from other ' +
23
+ 'devices — the exact wording is localized).\n\n' +
24
+ 'This toggle is what lets LINE prompt a second device to sign in. With it ' +
25
+ 'off, nothing at all appears on the phone and the login simply never ' +
26
+ 'completes.';
27
+ /** Answers offered by the pre-flight prerequisite check. */
28
+ const PREREQ_ANSWERS = {
29
+ enabled: 'enabled',
30
+ notEnabled: 'not_enabled',
31
+ unsure: 'unsure',
32
+ };
33
+ /**
34
+ * The prerequisite question, in the 2026-07-28 `oneOf`-with-`title`
35
+ * single-select shape (`TitledSingleSelectEnumSchema`) so each choice carries
36
+ * human-facing text of its own, rather than an enum of bare machine values
37
+ * the client has to render raw.
38
+ */
39
+ const PREREQ_SCHEMA_PROPERTY = {
40
+ type: 'string',
41
+ title: 'Is 允許自其他裝置登入 enabled on your primary phone?',
42
+ description: `On the phone already logged in to LINE: ${PRIMARY_DEVICE_SETTING_PATH}. ` +
43
+ 'Without it, LINE never prompts this device and the login cannot finish.',
44
+ oneOf: [
45
+ { const: PREREQ_ANSWERS.enabled, title: 'Yes — it is already on' },
46
+ { const: PREREQ_ANSWERS.notEnabled, title: 'No / not yet — show me how' },
47
+ {
48
+ const: PREREQ_ANSWERS.unsure,
49
+ title: "I don't know where that setting is",
50
+ },
51
+ ],
52
+ };
53
+ /**
54
+ * Build the one form shown before a login starts: the prerequisite, plus
55
+ * `phone`/`region` when the caller did not supply them.
56
+ *
57
+ * Both live in ONE form on purpose — the human answers a single prompt, and
58
+ * the prerequisite is raised while it can still change the outcome.
59
+ *
60
+ * @param phone - Phone already supplied by the tool call, if any.
61
+ * @param region - Region already supplied by the tool call, if any.
62
+ * @returns The elicitation body, for `elicitInput` or `inputRequired.elicit`.
63
+ */
64
+ export function buildPreflightForm(phone, region) {
65
+ const needsCredentials = !phone || !region;
66
+ const properties = {
67
+ primaryDeviceLogin: PREREQ_SCHEMA_PROPERTY,
68
+ };
69
+ const required = ['primaryDeviceLogin'];
70
+ if (needsCredentials) {
71
+ properties.phone = {
72
+ type: 'string',
73
+ title: 'Phone number',
74
+ description: 'E.164 form, e.g. +8869XXXXXXXX.',
75
+ ...(phone ? { default: phone } : {}),
76
+ };
77
+ properties.region = {
78
+ type: 'string',
79
+ title: 'Region',
80
+ enum: LOGIN_REGIONS,
81
+ ...(region ? { default: region } : {}),
82
+ };
83
+ required.push('phone', 'region');
84
+ }
85
+ return {
86
+ message: 'Yomi signs in as a SECOND LINE device. That requires ' +
87
+ `${PRIMARY_DEVICE_SETTING_PATH} to be enabled on the phone already ` +
88
+ 'logged in — confirm that below' +
89
+ (needsCredentials
90
+ ? ', along with your number and region.'
91
+ : ' before the login starts.'),
92
+ requestedSchema: { type: 'object', properties, required },
93
+ };
94
+ }
95
+ /**
96
+ * Read an accepted pre-flight form back. Shared by every path so the answer
97
+ * is interpreted identically no matter how it was collected.
98
+ *
99
+ * @param content - The accepted form content (untrusted client input).
100
+ * @param phone - Phone supplied by the tool call, used when the form did not
101
+ * carry the field because it was already known.
102
+ * @param region - Region, same rule.
103
+ * @returns What the login should do next.
104
+ */
105
+ export function readPreflightForm(content, phone, region) {
106
+ const answer = String(content?.primaryDeviceLogin ?? '');
107
+ if (answer !== PREREQ_ANSWERS.enabled) {
108
+ return { ok: false, reason: 'prerequisite', answer };
109
+ }
110
+ const collectedPhone = String(content?.phone ?? phone ?? '');
111
+ const collectedRegion = String(content?.region ?? region ?? '');
112
+ if (!collectedPhone || !collectedRegion) {
113
+ return { ok: false, reason: 'cancelled' };
114
+ }
115
+ return { ok: true, phone: collectedPhone, region: collectedRegion };
116
+ }
117
+ /**
118
+ * The numbered steps for acting on an issued PIN. Step 0 repeats the
119
+ * prerequisite even though the pre-flight form already asked about it: a
120
+ * human who answered "yes" without checking finds out here, while the code
121
+ * is still valid.
122
+ *
123
+ * @param pin - The PIN LINE just issued.
124
+ * @returns The steps, ending in a blank line.
125
+ */
126
+ export function pinSteps(pin) {
127
+ const lifetimeSeconds = Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000);
128
+ return (`LINE PIN:\n\n ${pin}\n\n` +
129
+ PRIMARY_DEVICE_SETTING_NOTE +
130
+ '1. Open LINE on the primary phone. If a verification prompt does not ' +
131
+ 'appear immediately, bringing the app to the foreground surfaces it.\n' +
132
+ `2. Enter this PIN and tick the device that is signing in, then tap ` +
133
+ `「用戶確認」 — LINE gives you ${lifetimeSeconds} seconds (about 3 ` +
134
+ 'minutes) from when the code was shown to do this.\n' +
135
+ '3. LINE will then ask that same phone to approve this new device ' +
136
+ 'signing in — approve it there.\n\n');
137
+ }
138
+ /**
139
+ * The steps when a stored login certificate skipped the PIN entirely — the
140
+ * flow is already waiting on phone approval of this device.
141
+ *
142
+ * @returns The steps, ending in a blank line.
143
+ */
144
+ export function certSteps() {
145
+ return ('No PIN needed — LINE recognized a stored login certificate from a ' +
146
+ 'previous login, so the PIN step was skipped.\n\n' +
147
+ PRIMARY_DEVICE_SETTING_NOTE +
148
+ '1. Bring the LINE app to the foreground on your primary phone — it ' +
149
+ 'should prompt you to approve this new device signing in.\n' +
150
+ '2. Approve the device from there.\n\n');
151
+ }
152
+ /**
153
+ * The acknowledgement form shown alongside an issued PIN (or alongside the
154
+ * cert-skipped approval prompt).
155
+ *
156
+ * @param pin - The PIN, or `null` when a stored certificate skipped it.
157
+ * @returns The elicitation body.
158
+ */
159
+ export function buildAcknowledgementForm(pin) {
160
+ return {
161
+ message: (pin ? pinSteps(pin) : certSteps()) +
162
+ 'Acknowledge below once you have done this.',
163
+ requestedSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ entered: {
167
+ type: 'boolean',
168
+ title: pin
169
+ ? 'I entered the PIN and approved the device on my phone'
170
+ : 'I approved the device on my phone',
171
+ },
172
+ },
173
+ },
174
+ };
175
+ }
176
+ /**
177
+ * Build the tool result returned when the human has not confirmed the
178
+ * prerequisite. Deliberately NOT a tool error: nothing failed, the login was
179
+ * simply never started, and the human has one concrete thing to do.
180
+ *
181
+ * @param answer - Which non-confirming answer they gave, for wording.
182
+ * @returns MCP tool result carrying the enabling steps and the retry.
183
+ */
184
+ export function prerequisiteNotMetResult(answer) {
185
+ const lead = answer === PREREQ_ANSWERS.unsure
186
+ ? 'Login was not started — here is where that setting lives.\n\n'
187
+ : 'Login was not started, because LINE would never prompt the phone.\n\n';
188
+ return {
189
+ content: [
190
+ {
191
+ type: 'text',
192
+ text: lead +
193
+ PRIMARY_DEVICE_SETTING_STEPS +
194
+ '\n\nOnce the toggle is on, call `login` again. Relay these steps ' +
195
+ 'to the human as they are — do not summarize the settings path ' +
196
+ 'away, it is the whole point.',
197
+ },
198
+ ],
199
+ };
200
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `login` on a MCP 2026-07-28 connection: the multi-round-trip (MRTR) path.
3
+ *
4
+ * That revision removed the server→client request channel — `elicitInput`
5
+ * throws on a modern-era instance before any wire traffic (SDK:
6
+ * `_assertPushApiInServedEra`, "the 2026-07-28 revision has no server→client
7
+ * request channel"). The same conversation is instead carried by
8
+ * `input_required` RESULTS: the handler returns the elicitation it needs,
9
+ * the client fulfils it through its own `elicitation/create` handler, then
10
+ * RE-CALLS `login` with the answers in `inputResponses`.
11
+ *
12
+ * So this file is ./login.ts's pushed elicitation flow turned inside out —
13
+ * same three beats, one tool call each:
14
+ *
15
+ * round 1 no responses → ask the pre-flight form
16
+ * round 2 `preflight` answered → start the login, ask for PIN ack
17
+ * round 3 `ack` answered → await completion, return the profile
18
+ *
19
+ * The in-flight login survives between rounds in ./login-session.ts, exactly
20
+ * as it survives between `login` and `login_complete` on the fallback path.
21
+ *
22
+ * `requestState` (the other half of MRTR) is deliberately NOT used: it
23
+ * round-trips through the client and comes back attacker-controlled, so the
24
+ * spec requires integrity-protecting anything that influences behavior. The
25
+ * only state this flow needs is the pending login, which never leaves the
26
+ * server.
27
+ */
28
+ import { acceptedContent, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
29
+ import { createCliLogger } from '../../util/log.js';
30
+ import { buildAcknowledgementForm, buildPreflightForm, prerequisiteNotMetResult, readPreflightForm, } from './login-copy.js';
31
+ import { finishPendingLogin, getLivePendingLogin, getPendingLogin, PIN_WAIT_TIMEOUT_MS, startPendingLogin, waitForPin, } from './login-session.js';
32
+ import { jsonText, toolError } from './shared.js';
33
+ const log = createCliLogger('Yomi');
34
+ /** Keys this flow assigns to its embedded input requests. */
35
+ const PREFLIGHT_KEY = 'preflight';
36
+ const ACK_KEY = 'ack';
37
+ /**
38
+ * Round 3 — the human says they have acted on their phone. Await the login
39
+ * `startPendingLogin` began in round 2.
40
+ *
41
+ * @returns The logged-in profile, or an honest error.
42
+ */
43
+ async function completeRound() {
44
+ const pending = getPendingLogin();
45
+ if (!pending) {
46
+ return toolError('No login is pending — the attempt this acknowledgement belongs to has already finished or ' +
47
+ 'expired (LINE gives the human about 3 minutes to act on a shown code). Calling `login` ' +
48
+ 'again is safe and starts a fresh attempt.');
49
+ }
50
+ try {
51
+ const { mid, displayName } = await finishPendingLogin(pending);
52
+ return {
53
+ content: [
54
+ {
55
+ type: 'text',
56
+ text: jsonText({ loggedIn: true, mid, displayName }),
57
+ },
58
+ ],
59
+ };
60
+ }
61
+ catch (error) {
62
+ return toolError(error?.message ?? String(error));
63
+ }
64
+ }
65
+ /**
66
+ * Round 2 — the pre-flight form came back accepted and confirming. Start
67
+ * (or reuse) the passwordless login and ask the human to act on the PIN.
68
+ *
69
+ * @param service - LineProtocolService (not yet authenticated).
70
+ * @param phone - Confirmed phone number.
71
+ * @param region - Confirmed region code.
72
+ * @returns An `input_required` result carrying the PIN acknowledgement form,
73
+ * or an honest error.
74
+ */
75
+ async function startRound(service, phone, region) {
76
+ const active = getLivePendingLogin(Date.now());
77
+ if (active && (active.phone !== phone || active.region !== region)) {
78
+ // A live login for a DIFFERENT number is still in flight. Never hand
79
+ // back its PIN under this call's identity. Name nothing sensitive.
80
+ return toolError('A login for a different phone number is still in progress. Wait for its login code to expire ' +
81
+ '(LINE gives the human about 3 minutes to act on it) before starting a login for another number.');
82
+ }
83
+ const pending = active ?? startPendingLogin(service, phone, region);
84
+ const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
85
+ if (!pin && !pending.certSkippedPin) {
86
+ return toolError('LINE has not issued a login PIN within 20s. The attempt is still running in the background ' +
87
+ '(do not worry, nothing was lost) — call `login` again shortly to check for the PIN.');
88
+ }
89
+ log.warn('login.pin', {
90
+ action: pin ? 'returned_in_input_required' : 'skipped_cert_valid',
91
+ });
92
+ // The PIN travels inside the elicitation the client is about to render,
93
+ // and the login keeps running while the human acts on it — this handler
94
+ // returns immediately rather than blocking through LINE's ~3-minute
95
+ // window, so the human's clock starts now and not after a timeout.
96
+ return inputRequired({
97
+ inputRequests: {
98
+ [ACK_KEY]: inputRequired.elicit(buildAcknowledgementForm(pin)),
99
+ },
100
+ });
101
+ }
102
+ /**
103
+ * Handle `login` on a modern-era connection. Which round this call is comes
104
+ * from the responses it carries: none means round 1.
105
+ *
106
+ * @param service - LineProtocolService (not yet authenticated).
107
+ * @param args - Tool arguments.
108
+ * @param inputResponses - `ctx.mcpReq.inputResponses` from a retried call.
109
+ * Untrusted client input — read only through the SDK's response readers.
110
+ * @returns An `input_required` result, or a terminal tool result.
111
+ */
112
+ export async function handleLoginMrtr(service, args, inputResponses) {
113
+ // Round 3 first: a client that accumulates responses across rounds would
114
+ // send both keys, and the later beat is the one to act on.
115
+ const ack = inputResponse(inputResponses, ACK_KEY);
116
+ if (ack.kind === 'elicit') {
117
+ if (ack.action !== 'accept') {
118
+ return toolError('Login was cancelled by the user.');
119
+ }
120
+ return await completeRound();
121
+ }
122
+ const preflight = inputResponse(inputResponses, PREFLIGHT_KEY);
123
+ if (preflight.kind === 'elicit') {
124
+ if (preflight.action !== 'accept') {
125
+ return toolError('Login was cancelled by the user.');
126
+ }
127
+ const outcome = readPreflightForm(acceptedContent(inputResponses, PREFLIGHT_KEY), args.phone, args.region);
128
+ if (!outcome.ok) {
129
+ if (outcome.reason === 'prerequisite') {
130
+ log.warn('login.prerequisite', { answer: outcome.answer });
131
+ return prerequisiteNotMetResult(outcome.answer);
132
+ }
133
+ return toolError('Login was cancelled by the user.');
134
+ }
135
+ return await startRound(service, outcome.phone, outcome.region);
136
+ }
137
+ // Round 1. Persisted credentials still pre-fill the form, so a returning
138
+ // user only confirms rather than retypes.
139
+ const phone = args.phone ??
140
+ (await service.credentialStore?.get?.('line_phone')) ??
141
+ undefined;
142
+ const region = args.region ??
143
+ (await service.credentialStore?.get?.('line_region')) ??
144
+ undefined;
145
+ return inputRequired({
146
+ inputRequests: {
147
+ [PREFLIGHT_KEY]: inputRequired.elicit(buildPreflightForm(phone, region)),
148
+ },
149
+ });
150
+ }
@@ -0,0 +1,132 @@
1
+ import { runPwlessLogin } from '../../cli/login.js';
2
+ import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
3
+ import { createCliLogger } from '../../util/log.js';
4
+ const log = createCliLogger('Yomi');
5
+ /**
6
+ * How long a live pending login may be reused by a later `login` call for
7
+ * the same phone/region, before treating it as stale and starting fresh.
8
+ *
9
+ * This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
10
+ * an external fact from LINE Help Center, ~3 minutes) — NOT to how long
11
+ * yomi's client is willing to keep polling (~16 minutes). A login started
12
+ * 90 seconds ago is still very much alive and its PIN is still valid; the
13
+ * only thing that actually kills a pending login early is LINE's own code
14
+ * expiring, so that is the correct bound for "is this still worth reusing".
15
+ */
16
+ const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
17
+ /** How long a `login` call waits for LINE to issue a PIN before giving up. */
18
+ export const PIN_WAIT_TIMEOUT_MS = 20000;
19
+ let pendingLogin = null;
20
+ /** @returns The in-flight login, or null when none is pending. */
21
+ export function getPendingLogin() {
22
+ return pendingLogin;
23
+ }
24
+ /**
25
+ * The in-flight login only if it is still worth reusing: started within
26
+ * LINE's PIN lifetime.
27
+ *
28
+ * @param now - Current epoch millis.
29
+ * @returns The live pending login, or null.
30
+ */
31
+ export function getLivePendingLogin(now) {
32
+ if (!pendingLogin) {
33
+ return null;
34
+ }
35
+ return now - pendingLogin.startedAt < PIN_WINDOW_MS ? pendingLogin : null;
36
+ }
37
+ /**
38
+ * Start a new passwordless login in the background and register it as the
39
+ * one in-flight pending login.
40
+ *
41
+ * The stored promise is the actual `runPwlessLogin` promise, so a later call
42
+ * can await it and see the real success/failure. A `.catch()` is attached
43
+ * immediately (its result discarded, not stored) so an abandoned login — one
44
+ * nobody ever finishes — never surfaces as an unhandled rejection; this does
45
+ * not affect what `promise` itself resolves/rejects with.
46
+ *
47
+ * @param service - LineProtocolService (not yet authenticated).
48
+ * @param phone - Phone number in E.164 form.
49
+ * @param region - Region code.
50
+ * @returns The newly registered pending-login record.
51
+ */
52
+ export function startPendingLogin(service, phone, region) {
53
+ let resolvePinReady = () => { };
54
+ const pinReady = new Promise((resolve) => {
55
+ resolvePinReady = resolve;
56
+ });
57
+ const pending = {
58
+ phone,
59
+ region,
60
+ pin: null,
61
+ certSkippedPin: false,
62
+ startedAt: Date.now(),
63
+ pinReady,
64
+ };
65
+ const onPin = (pin) => {
66
+ pending.pin = pin;
67
+ resolvePinReady();
68
+ };
69
+ const onBiometric = () => {
70
+ log.warn('login.waiting_biometric', {
71
+ action: 'approve the new device on your phone',
72
+ });
73
+ // Reaching biometric wait with no PIN ever set means a valid stored
74
+ // certificate skipped the PIN step entirely (see LinePwlessLogin.login,
75
+ // step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
76
+ // its full timeout waiting for a PIN that will never arrive.
77
+ if (!pending.pin) {
78
+ pending.certSkippedPin = true;
79
+ resolvePinReady();
80
+ }
81
+ };
82
+ const promise = runPwlessLogin(service, phone, region, {
83
+ onPin,
84
+ onWaitingBiometric: onBiometric,
85
+ });
86
+ promise.catch(() => {
87
+ // Swallow here only to prevent an unhandled-rejection warning for a
88
+ // login nobody completes; the real error still surfaces to whoever
89
+ // awaits `promise` below.
90
+ });
91
+ pending.promise = promise;
92
+ pendingLogin = pending;
93
+ return pendingLogin;
94
+ }
95
+ /**
96
+ * Wait for a pending login's PIN to arrive, bounded by a timeout.
97
+ *
98
+ * @param pending - The pending login to wait on.
99
+ * @param timeoutMs - Maximum time to wait.
100
+ * @returns The PIN, or `null` if it did not arrive within `timeoutMs` (also
101
+ * the answer when a stored certificate skipped the PIN — check
102
+ * `certSkippedPin` to tell the two apart).
103
+ */
104
+ export async function waitForPin(pending, timeoutMs) {
105
+ if (pending.pin) {
106
+ return pending.pin;
107
+ }
108
+ return await Promise.race([
109
+ pending.pinReady.then(() => pending.pin ?? null),
110
+ new Promise((resolve) => {
111
+ setTimeout(() => resolve(null), timeoutMs);
112
+ }),
113
+ ]);
114
+ }
115
+ /**
116
+ * Await the in-flight login to completion and clear it either way. Never
117
+ * touches credentials on the failure path — `runPwlessLogin` owns that.
118
+ *
119
+ * @param pending - The pending login to finish.
120
+ * @returns The logged-in profile.
121
+ * @throws Whatever `runPwlessLogin` failed with.
122
+ */
123
+ export async function finishPendingLogin(pending) {
124
+ try {
125
+ const result = await pending.promise;
126
+ log.info('login.complete', { mid: result.mid });
127
+ return result;
128
+ }
129
+ finally {
130
+ pendingLogin = null;
131
+ }
132
+ }