@rikaidev/yomi 0.2.6

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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +81 -0
  3. package/PRIVACY.md +39 -0
  4. package/README.md +659 -0
  5. package/dist/auth/credential-store.js +395 -0
  6. package/dist/auth/keychain.js +256 -0
  7. package/dist/cli/login.js +142 -0
  8. package/dist/line/auth/protocol/e2ee-bootstrap.js +87 -0
  9. package/dist/line/auth/protocol/index.js +3 -0
  10. package/dist/line/auth/protocol/login-metadata.js +159 -0
  11. package/dist/line/auth/protocol/pwless-login-flow.js +83 -0
  12. package/dist/line/auth/pwless/index.js +163 -0
  13. package/dist/line/auth/pwless/steps.js +213 -0
  14. package/dist/line/client/auth-service/client.js +240 -0
  15. package/dist/line/client/e2ee-service/client.js +69 -0
  16. package/dist/line/client/e2ee-service/requests.js +92 -0
  17. package/dist/line/client/e2ee-service/shared-key-log.js +24 -0
  18. package/dist/line/client/e2ee-service/shared-key-payload.js +35 -0
  19. package/dist/line/client/index.js +213 -0
  20. package/dist/line/client/obs-media-client.js +140 -0
  21. package/dist/line/client/parsers.js +69 -0
  22. package/dist/line/client/shop-service/client.js +63 -0
  23. package/dist/line/client/sticker-meta.js +46 -0
  24. package/dist/line/client/sync-service/client.js +317 -0
  25. package/dist/line/client/talk-service/chat-query.js +122 -0
  26. package/dist/line/client/talk-service/command-client.js +242 -0
  27. package/dist/line/client/talk-service/contact-query.js +77 -0
  28. package/dist/line/client/talk-service/message-box-query.js +137 -0
  29. package/dist/line/client/talk-service/message-id-query.js +241 -0
  30. package/dist/line/client/talk-service/query-client.js +219 -0
  31. package/dist/line/client/talk-service/requests.js +383 -0
  32. package/dist/line/client/transport.js +138 -0
  33. package/dist/line/core/auth-session-logout.js +55 -0
  34. package/dist/line/core/auth-session-runtime.js +231 -0
  35. package/dist/line/core/auth-session-service.js +85 -0
  36. package/dist/line/core/chat-runtime-service.js +343 -0
  37. package/dist/line/core/client-runtime.js +17 -0
  38. package/dist/line/core/config.js +67 -0
  39. package/dist/line/core/constants.js +56 -0
  40. package/dist/line/core/contact-query-service.js +20 -0
  41. package/dist/line/core/directory-runtime-service.js +83 -0
  42. package/dist/line/core/e2ee/bootstrap/keychain.js +41 -0
  43. package/dist/line/core/e2ee/crypto/crypto-primitives.js +98 -0
  44. package/dist/line/core/e2ee/index.js +6 -0
  45. package/dist/line/core/e2ee/keys/decrypt.js +110 -0
  46. package/dist/line/core/e2ee/keys/encrypt.js +117 -0
  47. package/dist/line/core/e2ee/keys/group-key.js +260 -0
  48. package/dist/line/core/e2ee/keys/key-manager.js +231 -0
  49. package/dist/line/core/e2ee/keys/key-payload.js +130 -0
  50. package/dist/line/core/e2ee/keys/key-types.js +1 -0
  51. package/dist/line/core/e2ee/keys/message-crypto.js +146 -0
  52. package/dist/line/core/e2ee/keys/message-decrypt.js +390 -0
  53. package/dist/line/core/e2ee/keys/peer-key.js +191 -0
  54. package/dist/line/core/e2ee/recovery/bootstrap-keys.js +161 -0
  55. package/dist/line/core/e2ee/types.js +4 -0
  56. package/dist/line/core/group-query-service.js +20 -0
  57. package/dist/line/core/media-decrypt.js +78 -0
  58. package/dist/line/core/media-encrypt.js +97 -0
  59. package/dist/line/core/mention.js +89 -0
  60. package/dist/line/core/message-command-service.js +454 -0
  61. package/dist/line/core/message-query-service.js +161 -0
  62. package/dist/line/core/service.js +134 -0
  63. package/dist/line/core/session-state/index.js +243 -0
  64. package/dist/line/core/session-state/persist.js +192 -0
  65. package/dist/line/core/store.js +8 -0
  66. package/dist/line/core/thrift/binary/binary-reader.js +151 -0
  67. package/dist/line/core/thrift/binary/binary-writer.js +157 -0
  68. package/dist/line/core/thrift/binary/type-codec-dsl.js +42 -0
  69. package/dist/line/core/thrift/compact/compact-reader.js +180 -0
  70. package/dist/line/core/thrift/compact/compact-writer.js +161 -0
  71. package/dist/line/core/thrift/compact/type-codec-dsl.js +66 -0
  72. package/dist/line/core/thrift/fields/builders.js +124 -0
  73. package/dist/line/core/thrift/index.js +12 -0
  74. package/dist/line/core/thrift/message/decoder.js +55 -0
  75. package/dist/line/core/thrift/message/encoder.js +25 -0
  76. package/dist/line/core/thrift/primitives/varint.js +64 -0
  77. package/dist/line/core/thrift/struct/reader.js +172 -0
  78. package/dist/line/core/thrift/struct/writer.js +189 -0
  79. package/dist/line/core/thrift/types.js +23 -0
  80. package/dist/line/core/video-thumbnail.js +104 -0
  81. package/dist/mcp/handlers/contacts.js +166 -0
  82. package/dist/mcp/handlers/conversations.js +193 -0
  83. package/dist/mcp/handlers/groups.js +123 -0
  84. package/dist/mcp/handlers/index.js +15 -0
  85. package/dist/mcp/handlers/login.js +473 -0
  86. package/dist/mcp/handlers/media.js +69 -0
  87. package/dist/mcp/handlers/message-actions.js +62 -0
  88. package/dist/mcp/handlers/scope.js +120 -0
  89. package/dist/mcp/handlers/search.js +196 -0
  90. package/dist/mcp/handlers/send.js +523 -0
  91. package/dist/mcp/handlers/shared.js +84 -0
  92. package/dist/mcp/handlers/stickers.js +87 -0
  93. package/dist/mcp/media.js +228 -0
  94. package/dist/mcp/names.js +133 -0
  95. package/dist/mcp/phi-guard.js +105 -0
  96. package/dist/mcp/policy.js +34 -0
  97. package/dist/mcp/server.js +301 -0
  98. package/dist/mcp/tools.js +761 -0
  99. package/dist/mcp/ui/capability.js +26 -0
  100. package/dist/mcp/ui/login-app-protocol.js +118 -0
  101. package/dist/mcp/ui/login-app-styles.js +142 -0
  102. package/dist/mcp/ui/login-app-view.js +361 -0
  103. package/dist/mcp/ui/login-app.js +72 -0
  104. package/dist/mcp/ui/login-structured-content.js +1 -0
  105. package/dist/mcp/ui/resource.js +35 -0
  106. package/dist/mcp/ui/tools-with-ui.js +40 -0
  107. package/dist/search/bigram.js +86 -0
  108. package/dist/search/capture.js +140 -0
  109. package/dist/search/collector.js +176 -0
  110. package/dist/search/default-embedder.js +101 -0
  111. package/dist/search/embedder.js +13 -0
  112. package/dist/search/scope.js +142 -0
  113. package/dist/search/sqlite.js +141 -0
  114. package/dist/search/store.js +469 -0
  115. package/dist/util/data-dir.js +55 -0
  116. package/dist/util/log.js +86 -0
  117. package/dist/version.js +7 -0
  118. package/package.json +63 -0
  119. package/run.mjs +71 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Shared LINE passwordless-login core, plus the TTY front-end for it.
3
+ *
4
+ * `runPwlessLogin` is the ONE login sequence: subscribe to `pinCreated`/
5
+ * `waitingForBiometric`, drive `service.startPwlessLogin`, unsubscribe in a
6
+ * `finally`, and persist the phone/region that succeeded. Every front-end
7
+ * calls it — the MCP `login`/`login_complete` tools (see
8
+ * ../mcp/handlers-login.ts; surfaces the PIN via elicitation on clients
9
+ * that support it, or as a tool-result string with a follow-up
10
+ * `login_complete` call on clients that don't) and `cliLogin` below
11
+ * (surfaces the PIN on stdout, for the guaranteed-to-work `npx @rikaidev/yomi
12
+ * login` path that needs no MCP client at all). No front-end duplicates
13
+ * the sequence itself.
14
+ */
15
+ import readline from 'node:readline/promises';
16
+ import { LineProtocolService } from '../line/core/service.js';
17
+ /**
18
+ * Drive the passwordless (secondary-device) LINE login flow to completion.
19
+ *
20
+ * This is the single login sequence shared by every front-end — do not
21
+ * re-subscribe to `pinCreated`/`waitingForBiometric` or call
22
+ * `service.startPwlessLogin` anywhere else.
23
+ *
24
+ * @param service - LineProtocolService (not yet authenticated).
25
+ * @param phone - Phone number in E.164 form.
26
+ * @param region - Region code (e.g. TW, JP).
27
+ * @param hooks - Front-end callbacks for surfacing the PIN/biometric wait.
28
+ * @returns The resulting profile identity.
29
+ */
30
+ export async function runPwlessLogin(service, phone, region, hooks) {
31
+ const onBiometric = () => hooks.onWaitingBiometric?.();
32
+ service.on('pinCreated', hooks.onPin);
33
+ service.on('waitingForBiometric', onBiometric);
34
+ try {
35
+ await service.startPwlessLogin(phone, region);
36
+ const profile = service.profile ?? null;
37
+ // Persist phone/region once, here in the shared core, so every
38
+ // front-end's next login is argument-free (and the phone number stops
39
+ // appearing in the tool-call transcript).
40
+ await service.credentialStore?.set?.('line_phone', phone);
41
+ await service.credentialStore?.set?.('line_region', region);
42
+ return {
43
+ mid: profile?.mid ?? null,
44
+ displayName: profile?.displayName ?? null,
45
+ };
46
+ }
47
+ finally {
48
+ service.off('pinCreated', hooks.onPin);
49
+ service.off('waitingForBiometric', onBiometric);
50
+ }
51
+ }
52
+ /**
53
+ * Prompt on stdin for a value when it was not supplied on the command line.
54
+ *
55
+ * @param rl - Open readline interface.
56
+ * @param label - Prompt label.
57
+ * @returns Trimmed user input.
58
+ */
59
+ async function promptFor(rl, label) {
60
+ const answer = await rl.question(`${label}: `);
61
+ return answer.trim();
62
+ }
63
+ /**
64
+ * Parse `--phone`/`--region` flags out of CLI args.
65
+ *
66
+ * @param argv - Arguments following `login`.
67
+ * @returns Parsed phone/region (undefined when not supplied).
68
+ */
69
+ function parseLoginArgs(argv) {
70
+ let phone;
71
+ let region;
72
+ for (let i = 0; i < argv.length; i++) {
73
+ if (argv[i] === '--phone') {
74
+ phone = argv[i + 1];
75
+ i++;
76
+ }
77
+ else if (argv[i] === '--region') {
78
+ region = argv[i + 1];
79
+ i++;
80
+ }
81
+ }
82
+ return { phone, region };
83
+ }
84
+ /**
85
+ * TTY front-end: `npx @rikaidev/yomi login [--phone +886...] [--region TW]`.
86
+ *
87
+ * Prints the PIN prominently to stdout (not stderr — there is no MCP
88
+ * client here to hide it from). Falls back to a previously persisted
89
+ * `line_phone`/`line_region` when the corresponding flag is absent, and
90
+ * only prompts on stdin for whatever is still missing after that. Blocks
91
+ * until the flow completes.
92
+ *
93
+ * @param argv - Arguments following `login` on the command line.
94
+ * @returns Process exit code (0 on success).
95
+ */
96
+ export async function cliLogin(argv) {
97
+ const parsed = parseLoginArgs(argv);
98
+ const rl = readline.createInterface({
99
+ input: process.stdin,
100
+ output: process.stdout,
101
+ });
102
+ const service = new LineProtocolService();
103
+ // A Node EventEmitter with zero 'error' listeners turns any emit('error')
104
+ // into an uncaught exception that kills this process (see pwless-login-flow.ts,
105
+ // auth-session-service.ts). This listener must exist for the lifetime of the
106
+ // service so a login/session error surfaces as a log line, not a crash — the
107
+ // real failure still reaches this function through the normal throw/rejection path.
108
+ service.on('error', (error) => console.error(`[Yomi] service error: ${error?.message ?? String(error)}`));
109
+ try {
110
+ const phone = parsed.phone ||
111
+ (await service.credentialStore?.get?.('line_phone')) ||
112
+ (await promptFor(rl, 'LINE phone number (E.164, e.g. +8869XXXXXXXX)'));
113
+ const region = parsed.region ||
114
+ (await service.credentialStore?.get?.('line_region')) ||
115
+ (await promptFor(rl, 'Region code (e.g. TW, JP, TH, ID, US)'));
116
+ if (!phone || !region) {
117
+ console.error('[Yomi] phone and region are required.');
118
+ return 1;
119
+ }
120
+ console.log('[Yomi] Starting passwordless login. Enter the PIN on your primary phone within ' +
121
+ "about 3 minutes of it appearing — that is LINE's own deadline; this CLI itself will keep " +
122
+ 'waiting well beyond that.');
123
+ const result = await runPwlessLogin(service, phone, region, {
124
+ onPin: (pin) => {
125
+ console.log(`[Yomi] PIN: ${pin}`);
126
+ console.log('[Yomi] Enter this PIN in the LINE app on your primary phone, then approve the new device.');
127
+ },
128
+ onWaitingBiometric: () => {
129
+ console.log('[Yomi] Waiting for you to approve the new device on your phone...');
130
+ },
131
+ });
132
+ console.log(`[Yomi] Login successful. mid=${result.mid ?? 'unknown'} displayName=${result.displayName ?? 'unknown'}`);
133
+ return 0;
134
+ }
135
+ catch (error) {
136
+ console.error(`[Yomi] Login failed: ${error?.message ?? String(error)}`);
137
+ return 1;
138
+ }
139
+ finally {
140
+ rl.close();
141
+ }
142
+ }
@@ -0,0 +1,87 @@
1
+ import { decryptKeyChain } from '../../core/e2ee/index.js';
2
+ /**
3
+ * Resolve the active LINE logger.
4
+ *
5
+ * @param service - LINE protocol service instance.
6
+ * @returns Logger-like object.
7
+ */
8
+ function getLineLog(service) {
9
+ return service?.startupFlowLogger || service?.logger || console;
10
+ }
11
+ /**
12
+ * Build a missing-bootstrap result and emit the matching warning.
13
+ *
14
+ * @param service - LINE protocol service instance.
15
+ * @param loginResult - Login result.
16
+ * @param e2eeInfo - Normalized E2EE info.
17
+ * @returns Failed bootstrap result.
18
+ */
19
+ function buildMissingBootstrapResult(service, loginResult, e2eeInfo) {
20
+ const reason = 'missing_login_e2ee_bootstrap';
21
+ getLineLog(service).warn?.('e2ee.init.skipped', {
22
+ has_keychain: Boolean(e2eeInfo?.encryptedKeyChain),
23
+ has_secret_key: Boolean(loginResult.secretKey),
24
+ has_server_public_key: Boolean(e2eeInfo?.serverPublicKey),
25
+ reason,
26
+ });
27
+ service.e2eeWarning = true;
28
+ service.emit?.('e2eeWarning', { active: true, reason });
29
+ return { keyCount: 0, reason, success: false };
30
+ }
31
+ /**
32
+ * Import decoded self keys into the active service.
33
+ *
34
+ * @param service - LINE protocol service instance.
35
+ * @param loginResult - Login result.
36
+ * @param keys - Decoded self keys.
37
+ */
38
+ async function applyDecodedKeys(service, loginResult, keys) {
39
+ service.e2eeManager.importKeys(keys);
40
+ if (loginResult.mid) {
41
+ service.e2eeManager.bindSelfKeysToMid(loginResult.mid);
42
+ }
43
+ await service.sessionState.saveE2EEKeys(keys);
44
+ service.e2eeWarning = false;
45
+ service.emit?.('e2eeWarning', { active: false, reason: null });
46
+ }
47
+ /**
48
+ * Initializes end-to-end encryption with login credentials.
49
+ *
50
+ * @param service - The LINE protocol service instance
51
+ * @param loginResult - The login result containing encryption keys
52
+ * @returns E2EE bootstrap result
53
+ */
54
+ export async function initE2EE(service, loginResult) {
55
+ const lineLog = getLineLog(service);
56
+ const e2eeInfo = loginResult.e2eeInfo || null;
57
+ if (!e2eeInfo?.encryptedKeyChain ||
58
+ !e2eeInfo?.serverPublicKey ||
59
+ !loginResult.secretKey) {
60
+ return buildMissingBootstrapResult(service, loginResult, e2eeInfo);
61
+ }
62
+ try {
63
+ // This step only imports self keys delivered during login. It is allowed to fail
64
+ // without invalidating the just-issued auth token. Message decryption during scan
65
+ // and polling uses additional peer/group material and can still fail later even
66
+ // when this bootstrap succeeds, so callers must treat E2EE bootstrap and login
67
+ // validation as separate failure domains.
68
+ const keys = decryptKeyChain(e2eeInfo.encryptedKeyChain, e2eeInfo.serverPublicKey, loginResult.secretKey).map((key) => ({
69
+ ...key,
70
+ mid: loginResult.mid || null,
71
+ }));
72
+ await applyDecodedKeys(service, loginResult, keys);
73
+ lineLog.info?.('e2ee.init.complete', {
74
+ key_count: keys.length,
75
+ key_id: e2eeInfo.keyId ?? null,
76
+ version: e2eeInfo.e2eeVersion ?? null,
77
+ });
78
+ return { keyCount: keys.length, reason: null, success: keys.length > 0 };
79
+ }
80
+ catch (e) {
81
+ const reason = 'keychain_decrypt_failed';
82
+ lineLog.warn?.('e2ee.init.failed', { error: e?.message, reason });
83
+ service.e2eeWarning = true;
84
+ service.emit?.('e2eeWarning', { active: true, reason });
85
+ return { keyCount: 0, reason, success: false };
86
+ }
87
+ }
@@ -0,0 +1,3 @@
1
+ export { initE2EE } from './e2ee-bootstrap.js';
2
+ export { extractE2EEInfo } from './login-metadata.js';
3
+ export { performPwlessLogin } from './pwless-login-flow.js';
@@ -0,0 +1,159 @@
1
+ import { Buffer } from 'node:buffer';
2
+ /**
3
+ * Decode a base64-encoded value into a Buffer when present.
4
+ *
5
+ * @param value - Buffer or base64 string from a login response.
6
+ * @returns Decoded Buffer or null.
7
+ */
8
+ function decodeMaybeBase64(value) {
9
+ if (!value) {
10
+ return null;
11
+ }
12
+ if (Buffer.isBuffer(value)) {
13
+ return value;
14
+ }
15
+ if (typeof value === 'string') {
16
+ return Buffer.from(value, 'base64');
17
+ }
18
+ return null;
19
+ }
20
+ /**
21
+ * Convert a mixed login metadata value into a number when possible.
22
+ *
23
+ * @param value - Raw numeric value or numeric string.
24
+ * @returns Parsed number or null.
25
+ */
26
+ function toMaybeNumber(value) {
27
+ if (typeof value === 'number' && Number.isFinite(value)) {
28
+ return value;
29
+ }
30
+ if (typeof value === 'string' && value.trim() !== '') {
31
+ const parsed = Number(value);
32
+ return Number.isFinite(parsed) ? parsed : null;
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Resolve the metadata payload from a raw login response.
38
+ *
39
+ * @param raw - Raw login response or nested E2EE payload.
40
+ * @returns Metadata object or null.
41
+ */
42
+ function resolveE2EEMetadata(raw) {
43
+ if (!raw || typeof raw !== 'object') {
44
+ return null;
45
+ }
46
+ const nested = raw.metadata || raw.metaData || raw[10]?.metadata || raw[10]?.metaData;
47
+ if (nested && typeof nested === 'object') {
48
+ return nested;
49
+ }
50
+ return raw;
51
+ }
52
+ /**
53
+ * Check whether a metadata object has the minimum E2EE bootstrap fields.
54
+ *
55
+ * @param metadata - Metadata candidate.
56
+ * @returns Whether the candidate carries keychain and public key.
57
+ */
58
+ function hasE2EEBootstrapFields(metadata) {
59
+ return Boolean(metadata &&
60
+ typeof metadata === 'object' &&
61
+ (metadata.encryptedKeyChain || metadata[6] || metadata[3]) &&
62
+ (metadata.serverPublicKey || metadata.publicKey || metadata[4]));
63
+ }
64
+ /**
65
+ * Search child object values for a nested E2EE payload.
66
+ *
67
+ * @param metadata - Metadata object.
68
+ * @param depth - Remaining search depth.
69
+ * @returns Payload-like object or null.
70
+ */
71
+ function findNestedE2EEMetadata(metadata, depth) {
72
+ for (const child of Object.values(metadata)) {
73
+ if (child && typeof child === 'object') {
74
+ const found = findE2EEMetadata(child, depth - 1);
75
+ if (found && hasE2EEBootstrapFields(found)) {
76
+ return found;
77
+ }
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ /**
83
+ * Recursively find a nested E2EE payload.
84
+ *
85
+ * @param value - Candidate payload.
86
+ * @param depth - Remaining search depth.
87
+ * @returns Payload-like object or null.
88
+ */
89
+ function findE2EEMetadata(value, depth = 3) {
90
+ const metadata = resolveE2EEMetadata(value);
91
+ if (!metadata || typeof metadata !== 'object' || depth < 0) {
92
+ return null;
93
+ }
94
+ if (hasE2EEBootstrapFields(metadata)) {
95
+ return metadata;
96
+ }
97
+ return findNestedE2EEMetadata(metadata, depth) || metadata;
98
+ }
99
+ /**
100
+ * Resolve the encrypted keychain field from known LINE metadata shapes.
101
+ *
102
+ * @param metadata - E2EE metadata payload.
103
+ * @returns Encrypted keychain value.
104
+ */
105
+ function getEncryptedKeyChainField(metadata) {
106
+ return metadata.encryptedKeyChain || metadata[6] || metadata[3] || null;
107
+ }
108
+ /**
109
+ * Resolve the server public key field from known LINE metadata shapes.
110
+ *
111
+ * @param metadata - E2EE metadata payload.
112
+ * @returns Server public key value.
113
+ */
114
+ function getServerPublicKeyField(metadata) {
115
+ return metadata.serverPublicKey || metadata.publicKey || metadata[4] || null;
116
+ }
117
+ /**
118
+ * Resolve the E2EE key id field from known LINE metadata shapes.
119
+ *
120
+ * @param metadata - E2EE metadata payload.
121
+ * @returns Key id value.
122
+ */
123
+ function getKeyIdField(metadata) {
124
+ return metadata.keyId || metadata.e2EEPublicKeyId || metadata[2] || null;
125
+ }
126
+ /**
127
+ * Resolve the E2EE version field from known LINE metadata shapes.
128
+ *
129
+ * @param metadata - E2EE metadata payload.
130
+ * @returns Version value.
131
+ */
132
+ function getE2EEVersionField(metadata) {
133
+ return metadata.e2eeVersion || metadata.version || metadata[1] || null;
134
+ }
135
+ /**
136
+ * Normalize LINE login E2EE metadata into a common structure.
137
+ *
138
+ * @param raw - Raw E2EE metadata struct from LINE login APIs.
139
+ * @returns Normalized encrypted keychain payload or null when incomplete.
140
+ */
141
+ export function extractE2EEInfo(raw) {
142
+ const metadata = findE2EEMetadata(raw);
143
+ if (!metadata) {
144
+ return null;
145
+ }
146
+ const encryptedKeyChain = decodeMaybeBase64(getEncryptedKeyChainField(metadata));
147
+ const serverPublicKey = decodeMaybeBase64(getServerPublicKeyField(metadata));
148
+ const keyId = toMaybeNumber(getKeyIdField(metadata));
149
+ const e2eeVersion = toMaybeNumber(getE2EEVersionField(metadata));
150
+ if (!encryptedKeyChain || !serverPublicKey) {
151
+ return null;
152
+ }
153
+ return {
154
+ encryptedKeyChain,
155
+ serverPublicKey,
156
+ keyId,
157
+ e2eeVersion,
158
+ };
159
+ }
@@ -0,0 +1,83 @@
1
+ import { LineClient } from '../../client/index.js';
2
+ import { LinePwlessLogin } from '../pwless/index.js';
3
+ import { initE2EE } from './e2ee-bootstrap.js';
4
+ /**
5
+ * Perform passwordless login and initialize the service client.
6
+ *
7
+ * @param service - The LINE protocol service instance
8
+ * @param phone - E.164 phone number
9
+ * @param region - Region code (TW, JP)
10
+ * @param loggingInState - State to set during login
11
+ * @param connectedState - State to set on successful login
12
+ * @param errorState - State to set on login failure
13
+ * @returns Promise resolving to login result
14
+ */
15
+ export async function performPwlessLogin(service, phone, region, loggingInState, connectedState, errorState) {
16
+ // Never write login progress to stdout: under the MCP stdio transport
17
+ // stdout IS the JSON-RPC channel, and this login path runs inside the MCP
18
+ // server. Route through the service logger, which writes to stderr.
19
+ const log = service.startupFlowLogger ?? service.logger;
20
+ service.setState(loggingInState);
21
+ service.pwlessLogin = new LinePwlessLogin(service.config);
22
+ for (const evt of [
23
+ 'pinCreated',
24
+ 'waitingForBiometric',
25
+ 'biometricVerified',
26
+ 'error',
27
+ ]) {
28
+ service.pwlessLogin.on(evt, (...args) => service.emit(evt, ...args));
29
+ }
30
+ try {
31
+ const savedCert = await service.credentialStore.get('line_certificate');
32
+ const result = await service.pwlessLogin.login(phone, region, savedCert || undefined);
33
+ log?.info?.('login.ok', {
34
+ refreshToken: result.refreshToken ? 'yes' : 'no',
35
+ });
36
+ service.client = new LineClient(result.authToken, {
37
+ ...service.config,
38
+ logger: service.logger,
39
+ startupFlowLogger: service.startupFlowLogger,
40
+ });
41
+ // `service.client` is its own EventEmitter (see sync-service/client.ts's
42
+ // `runtime.emit('error', ...)` in the poll loop) — a zero-listener 'error'
43
+ // emit there throws uncaught and kills the process just like on `service`
44
+ // itself. Attach here, at construction, for the client's lifetime.
45
+ service.client.on('error', (error) => {
46
+ const message = error?.message ?? String(error);
47
+ if (service.startupFlowLogger?.warn) {
48
+ service.startupFlowLogger.warn('client.error', { error: message });
49
+ }
50
+ else {
51
+ // stderr, not stdout — stdout is the MCP JSON-RPC channel.
52
+ console.error(`[LINE] client error: ${message}`);
53
+ }
54
+ });
55
+ service.sessionState.bindClient(service.client);
56
+ await service.sessionState.initializeFromLogin(result);
57
+ // Keep login on one auth boundary: one persisted login result,
58
+ // one E2EE bootstrap, one getProfile validation.
59
+ // Any later per-message E2EE failures are runtime content issues, not a reason
60
+ // to mutate auth state, refresh tokens aggressively, or mark the login invalid.
61
+ const e2eeBootstrap = await initE2EE(service, result);
62
+ if (!e2eeBootstrap.success) {
63
+ service.startupFlowLogger?.warn?.('session.login_e2ee_unavailable', {
64
+ reason: e2eeBootstrap.reason,
65
+ });
66
+ }
67
+ service.profile = await service.client.getProfile();
68
+ // Display name only — do not print the mid alongside it.
69
+ log?.info?.('profile.ok', {
70
+ displayName: service.profile?.displayName ?? null,
71
+ });
72
+ service.loginRequired = false;
73
+ service.loginReason = null;
74
+ service.setState(connectedState);
75
+ service.emit('loginComplete', { profile: service.profile });
76
+ return result;
77
+ }
78
+ catch (err) {
79
+ service.setState(errorState);
80
+ service.emit('error', err);
81
+ throw err;
82
+ }
83
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * LINE Passwordless Login — Orchestrator
3
+ *
4
+ * Flow:
5
+ * 1. createSession(phone, region) → sessionId
6
+ * 2. verifyLoginCertificate → skip PIN if cert valid
7
+ * 3. requestPinCodeVerif → PIN displayed to user
8
+ * 4. checkPinCodeVerified → long-poll wait
9
+ * 5. putExchangeKey → NaCl X25519 key exchange
10
+ * 6. requestPaakAuth + checkPaakAuthenticated → biometric
11
+ * 7. loginV2 → authToken + certificate + mid
12
+ */
13
+ import { Buffer } from 'node:buffer';
14
+ import { EventEmitter } from 'node:events';
15
+ import nacl from 'tweetnacl';
16
+ import { sendRequest } from '../../client/transport.js';
17
+ import { LINE_APP_CONFIG } from '../../core/config.js';
18
+ import { extractE2EEInfo } from '../protocol/login-metadata.js';
19
+ import { checkPaakAuthenticated, checkPinCodeVerified, createSession, getE2eeKey, loginV2, PAAK_AUTH_POLL_ATTEMPTS, PIN_VERIFY_POLL_ATTEMPTS, putExchangeKey, requestPaakAuth, requestPinCodeVerif, verifyLoginCertificate, } from './steps.js';
20
+ /**
21
+ * How long a single long-poll hop is bounded to before LINE's long-poll
22
+ * endpoint returns (or times out). This is NOT the human's window for
23
+ * anything — `checkPinCodeVerified`/`checkPaakAuthenticated` each retry this
24
+ * many times in a loop (see steps.ts), so the real client-side ceiling for
25
+ * each step is this value multiplied by that step's attempt count — see
26
+ * `PIN_VERIFY_CLIENT_CEILING_MS` / `PAAK_AUTH_CLIENT_CEILING_MS` below.
27
+ */
28
+ export const PWLESS_POLL_TIMEOUT_MS = 65000;
29
+ /**
30
+ * Real client-side ceiling for the PIN-verification long-poll loop: how long
31
+ * yomi keeps waiting for the human to enter the PIN before giving up.
32
+ * Derived, not hardcoded — `PWLESS_POLL_TIMEOUT_MS × PIN_VERIFY_POLL_ATTEMPTS`
33
+ * = 65000 × 15 ≈ 16 minutes.
34
+ */
35
+ export const PIN_VERIFY_CLIENT_CEILING_MS = PWLESS_POLL_TIMEOUT_MS * PIN_VERIFY_POLL_ATTEMPTS;
36
+ /**
37
+ * Real client-side ceiling for the PAAK (device-approval) long-poll loop: how
38
+ * long yomi keeps waiting for the human to approve the new device before
39
+ * giving up. Derived, not hardcoded —
40
+ * `PWLESS_POLL_TIMEOUT_MS × PAAK_AUTH_POLL_ATTEMPTS` = 65000 × 12 ≈ 13 minutes.
41
+ */
42
+ export const PAAK_AUTH_CLIENT_CEILING_MS = PWLESS_POLL_TIMEOUT_MS * PAAK_AUTH_POLL_ATTEMPTS;
43
+ /**
44
+ * LINE's own server-side lifetime for a passwordless login code: the human
45
+ * must enter the code and confirm the device on the primary phone within 3
46
+ * minutes of the code being displayed, or the code is dead — regardless of
47
+ * how long yomi itself is still willing to poll.
48
+ *
49
+ * This is an EXTERNAL FACT, not derived from any of our code or protocol
50
+ * traffic. Source: LINE Help Center (zh-Hant),
51
+ * https://help.line.me/line/IOSSecondary/?contentId=20018574&lang=zh-Hant
52
+ * — on the primary device, tick the device signing in and tap 「用戶確認」
53
+ * within 3 minutes of the code being displayed.
54
+ */
55
+ export const LINE_PIN_CODE_LIFETIME_MS = 3 * 60 * 1000;
56
+ const DEFAULT_CONFIG = {
57
+ ...LINE_APP_CONFIG,
58
+ pollTimeout: PWLESS_POLL_TIMEOUT_MS,
59
+ };
60
+ /**
61
+ * Handles the LINE passwordless login flow.
62
+ * Emits events: session, pinCreated, waitingForBiometric, loginComplete, error
63
+ */
64
+ export class LinePwlessLogin extends EventEmitter {
65
+ config;
66
+ sessionId;
67
+ secretKey;
68
+ publicKey;
69
+ certificate;
70
+ aborted;
71
+ seq;
72
+ constructor(config = {}) {
73
+ super();
74
+ this.config = { ...DEFAULT_CONFIG, ...config };
75
+ this.sessionId = null;
76
+ this.secretKey = null;
77
+ this.publicKey = null;
78
+ this.certificate = null;
79
+ this.aborted = false;
80
+ this.seq = 1;
81
+ }
82
+ /**
83
+ * Abort the login process.
84
+ */
85
+ abort() {
86
+ this.aborted = true;
87
+ this.emit('abort');
88
+ }
89
+ /**
90
+ * Run the full pwless login flow.
91
+ *
92
+ * @param phone - E.164 phone number
93
+ * @param region - Region code (TW, JP, etc.)
94
+ * @param savedCertificate - Previously saved certificate for skip-PIN
95
+ * @returns Login result with authToken and credentials
96
+ */
97
+ async login(phone, region, savedCertificate) {
98
+ this.aborted = false;
99
+ this.certificate = savedCertificate || null;
100
+ // Step 1: Create session
101
+ this.sessionId = await createSession(this, phone, region);
102
+ this.emit('session', this.sessionId);
103
+ // Step 2: Check certificate
104
+ const certOk = await verifyLoginCertificate(this, this.certificate || '');
105
+ // Step 3: PIN verification (if cert invalid)
106
+ if (!certOk) {
107
+ const pin = await requestPinCodeVerif(this);
108
+ this.emit('pinCreated', pin);
109
+ if (!(await checkPinCodeVerified(this))) {
110
+ throw new Error('PIN verification failed or timed out');
111
+ }
112
+ this.emit('pinVerified');
113
+ }
114
+ else {
115
+ this.emit('certificateVerified');
116
+ }
117
+ // Step 4: E2EE key exchange
118
+ const kp = nacl.box.keyPair();
119
+ this.secretKey = Buffer.from(kp.secretKey);
120
+ this.publicKey = Buffer.from(kp.publicKey);
121
+ await putExchangeKey(this, this.publicKey.toString('base64'));
122
+ // Step 5-6: PAAK (biometric) authentication
123
+ await requestPaakAuth(this);
124
+ this.emit('waitingForBiometric');
125
+ if (!(await checkPaakAuthenticated(this))) {
126
+ console.warn('[LINE] PAAK not confirmed, attempting login anyway');
127
+ }
128
+ else {
129
+ this.emit('biometricVerified');
130
+ }
131
+ // Step 7: Retrieve E2EE key (required to complete session)
132
+ const e2eeKeyResult = await getE2eeKey(this);
133
+ // Step 8: Final login
134
+ const loginResult = await loginV2(this, this.config.systemName);
135
+ const result = {
136
+ ...loginResult,
137
+ secretKey: this.secretKey,
138
+ publicKey: this.publicKey,
139
+ e2eeKeyChain: e2eeKeyResult?.encryptedKeyChain ?? null,
140
+ e2eeInfo: extractE2EEInfo(e2eeKeyResult),
141
+ nonce: null,
142
+ };
143
+ this.emit('loginComplete', result);
144
+ return result;
145
+ }
146
+ // ─── Transport (used by steps via PwlessContext interface) ─────
147
+ /**
148
+ * Send a pwless request.
149
+ * @param data - TCompact-encoded body
150
+ * @returns Promise resolving to decoded response
151
+ */
152
+ sendPwless(data) {
153
+ return sendRequest(this.config.host, this.config.pwlessPath, data, {}, 30000, { logger: this.config?.startupFlowLogger || this.config?.logger });
154
+ }
155
+ /**
156
+ * Send a long-poll pwless request.
157
+ * @param data - TCompact-encoded body
158
+ * @returns Promise resolving to decoded response
159
+ */
160
+ sendPwlessLongPoll(data) {
161
+ return sendRequest(this.config.host, this.config.pwlessLongPollPath, data, { 'X-Line-Access': this.sessionId, 'x-lst': '60000' }, this.config.pollTimeout, { logger: this.config?.startupFlowLogger || this.config?.logger });
162
+ }
163
+ }