@kin-tio/cli 0.6.1 → 0.7.0
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/.env.example +4 -2
- package/CHANGELOG.md +39 -0
- package/README.md +55 -16
- package/README.zh-CN.md +36 -10
- package/assets/avatar.svg +32 -0
- package/assets/logo.svg +22 -0
- package/dist/src/cli.js +245 -4
- package/dist/src/config.js +72 -17
- package/dist/src/ilink/cli-accounts.js +66 -0
- package/dist/src/ilink/cli-login.js +563 -0
- package/dist/src/ilink/cli-start.js +57 -0
- package/dist/src/ilink/enrollment.js +24 -0
- package/dist/src/ilink/login-manager.js +102 -30
- package/dist/src/ilink/login-store.js +78 -29
- package/dist/src/ilink/qr.js +67 -3
- package/dist/src/ilink/secret-box.js +73 -0
- package/dist/src/ilink/sqlite-store.js +211 -13
- package/dist/src/mcp/ilink-login-server.js +160 -0
- package/dist/src/mcp/ipc-host.js +4 -1
- package/dist/src/mcp/ipc-protocol.js +22 -0
- package/dist/src/runtime.js +226 -92
- package/dist/src/services/codex-agent.js +57 -27
- package/dist/src/services/codex-app-server.js +4 -2
- package/dist/src/services/conversation-processor.js +8 -2
- package/dist/src/state/sqlite-store.js +151 -10
- package/dist/src/version.js +1 -1
- package/package.json +3 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { IlinkLoginStore } from './login-store.js';
|
|
2
|
-
import { IlinkClient, IlinkProtocolError, normalizeIlinkBaseUrl, } from './protocol/client.js';
|
|
3
|
-
import {
|
|
1
|
+
import { IlinkLoginStore, } from './login-store.js';
|
|
2
|
+
import { DEFAULT_ILINK_BASE_URL, IlinkClient, IlinkProtocolError, normalizeIlinkBaseUrl, } from './protocol/client.js';
|
|
3
|
+
import { assertIlinkQrContent } from './qr.js';
|
|
4
4
|
import { IlinkSecretBox } from './secret-box.js';
|
|
5
5
|
import { IlinkSqliteStore } from './sqlite-store.js';
|
|
6
|
-
import { createIlinkAccountKey } from './store-types.js';
|
|
6
|
+
import { createIlinkAccountKey, } from './store-types.js';
|
|
7
7
|
const POLL_INTERVAL_MS = 1_000;
|
|
8
|
+
const MAX_EXPIRY_TIMER_MS = 10 * 60 * 1_000;
|
|
8
9
|
function sleep(milliseconds, signal) {
|
|
9
10
|
if (signal.aborted)
|
|
10
11
|
return Promise.reject(signal.reason);
|
|
@@ -30,16 +31,18 @@ export class IlinkLoginManager {
|
|
|
30
31
|
#client;
|
|
31
32
|
#logger;
|
|
32
33
|
#maxAccounts;
|
|
34
|
+
#baseUrl;
|
|
33
35
|
#clock;
|
|
34
36
|
#onAccountsChanged;
|
|
35
37
|
#sleep;
|
|
36
38
|
#running = new Map();
|
|
37
39
|
#closed = false;
|
|
38
|
-
constructor({ offers, accounts, secretBox, client = new IlinkClient(), maxAccounts = 20, clock = Date.now, onAccountsChanged = () => undefined, logger = console, sleep: sleepFunction = sleep, }) {
|
|
40
|
+
constructor({ offers, accounts, secretBox, client = new IlinkClient(), baseUrl = DEFAULT_ILINK_BASE_URL, maxAccounts = 20, clock = Date.now, onAccountsChanged = () => undefined, logger = console, sleep: sleepFunction = sleep, }) {
|
|
39
41
|
this.#offers = offers;
|
|
40
42
|
this.#accounts = accounts;
|
|
41
43
|
this.#secrets = secretBox;
|
|
42
44
|
this.#client = client;
|
|
45
|
+
this.#baseUrl = normalizeIlinkBaseUrl(baseUrl);
|
|
43
46
|
this.#maxAccounts = maxAccounts;
|
|
44
47
|
this.#clock = clock;
|
|
45
48
|
this.#onAccountsChanged = onAccountsChanged;
|
|
@@ -49,58 +52,88 @@ export class IlinkLoginManager {
|
|
|
49
52
|
async start() {
|
|
50
53
|
if (this.#closed)
|
|
51
54
|
throw new Error('iLink login manager is closed');
|
|
52
|
-
for (const offer of this.#offers.listActive())
|
|
53
|
-
|
|
55
|
+
for (const offer of this.#offers.listActive()) {
|
|
56
|
+
if (offer.initiatorKind === 'local_operator') {
|
|
57
|
+
this.#offers.finish(offer.offerId, 'cancelled');
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
this.#startPolling(offer);
|
|
61
|
+
}
|
|
54
62
|
}
|
|
55
|
-
async offer(
|
|
63
|
+
async offer(source, options = {}) {
|
|
56
64
|
if (this.#closed)
|
|
57
65
|
throw new Error('iLink login manager is closed');
|
|
58
|
-
if (this.#offers.
|
|
66
|
+
if (this.#offers.find(source)) {
|
|
59
67
|
throw new Error('An iLink login offer is already pending');
|
|
60
68
|
}
|
|
61
|
-
if (
|
|
62
|
-
this.#
|
|
69
|
+
if (source.kind !== 'terminal' &&
|
|
70
|
+
this.#accounts.listActiveAccounts().length +
|
|
71
|
+
this.#offers.listActive().length >= this.#maxAccounts) {
|
|
63
72
|
throw new Error('iLink account limit reached');
|
|
64
73
|
}
|
|
65
|
-
const
|
|
66
|
-
.slice(-10)
|
|
67
|
-
.reverse()
|
|
74
|
+
const localAccounts = this.#accounts.listActiveAccountsWithSecrets()
|
|
75
|
+
.slice(source.kind === 'terminal' ? -1 : -10)
|
|
76
|
+
.reverse();
|
|
77
|
+
const localTokens = localAccounts
|
|
68
78
|
.map(({ account, secret }) => this.#secrets.open(secret.sealedBotToken, {
|
|
69
79
|
secretKind: 'bot_token',
|
|
70
80
|
accountId: account.accountKey,
|
|
71
81
|
peerId: account.ownerPeerId,
|
|
72
82
|
generation: secret.accountGeneration,
|
|
73
83
|
}));
|
|
74
|
-
const created = await this.#client.createQr({ local_token_list: localTokens });
|
|
75
|
-
if (
|
|
76
|
-
|
|
84
|
+
const created = await this.#client.createQr({ local_token_list: localTokens }, options.signal ? { signal: options.signal } : {});
|
|
85
|
+
if (options.signal?.aborted)
|
|
86
|
+
throw options.signal.reason;
|
|
87
|
+
assertIlinkQrContent(created.qrcode_img_content);
|
|
88
|
+
if (source.kind !== 'terminal' &&
|
|
89
|
+
this.#accounts.listActiveAccounts().length +
|
|
90
|
+
this.#offers.listActive().length >= this.#maxAccounts) {
|
|
77
91
|
throw new Error('iLink account limit reached');
|
|
78
92
|
}
|
|
79
93
|
const offer = this.#offers.create({
|
|
80
|
-
|
|
94
|
+
source,
|
|
81
95
|
qrCode: created.qrcode,
|
|
82
|
-
apiBaseUrl:
|
|
96
|
+
apiBaseUrl: this.#baseUrl,
|
|
97
|
+
candidateAccountKeys: source.kind === 'terminal'
|
|
98
|
+
? localAccounts.map(({ account }) => account.accountKey)
|
|
99
|
+
: [],
|
|
83
100
|
});
|
|
84
|
-
let png;
|
|
85
|
-
try {
|
|
86
|
-
png = await renderIlinkQrPng(created.qrcode_img_content);
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
this.#offers.finish(offer.offerId, 'failed');
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
101
|
this.#startPolling(offer);
|
|
93
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
offerId: offer.offerId,
|
|
104
|
+
qrContent: created.qrcode_img_content,
|
|
105
|
+
expiresAt: offer.expiresAt,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
status(offerId) {
|
|
109
|
+
const status = this.#offers.status(offerId);
|
|
110
|
+
if (status.status !== 'waiting' && status.status !== 'scanned') {
|
|
111
|
+
this.#running.get(offerId)?.controller.abort();
|
|
112
|
+
}
|
|
113
|
+
return status;
|
|
94
114
|
}
|
|
95
115
|
cancel(offerId) {
|
|
96
116
|
this.#running.get(offerId)?.controller.abort();
|
|
97
|
-
this.#offers.finish(offerId, 'cancelled');
|
|
117
|
+
return this.#offers.finish(offerId, 'cancelled');
|
|
98
118
|
}
|
|
99
119
|
#startPolling(offer) {
|
|
100
120
|
if (this.#running.has(offer.offerId) || this.#closed)
|
|
101
121
|
return;
|
|
102
122
|
const controller = new AbortController();
|
|
123
|
+
const expiryTimer = setTimeout(() => {
|
|
124
|
+
try {
|
|
125
|
+
this.#offers.finish(offer.offerId, 'expired');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
this.#logger.warn?.('[ilink-login] expired offer cleanup failed');
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
controller.abort(new Error('iLink login offer expired'));
|
|
132
|
+
}
|
|
133
|
+
}, Math.max(0, Math.min(MAX_EXPIRY_TIMER_MS, offer.expiresAt - Number(this.#clock()))));
|
|
134
|
+
expiryTimer.unref();
|
|
103
135
|
const task = this.#poll(offer, controller.signal).finally(() => {
|
|
136
|
+
clearTimeout(expiryTimer);
|
|
104
137
|
if (this.#running.get(offer.offerId)?.controller === controller) {
|
|
105
138
|
this.#running.delete(offer.offerId);
|
|
106
139
|
}
|
|
@@ -125,7 +158,36 @@ export class IlinkLoginManager {
|
|
|
125
158
|
return;
|
|
126
159
|
}
|
|
127
160
|
if (result.status === 'binded_redirect') {
|
|
128
|
-
|
|
161
|
+
if (offer.initiatorKind === 'local_operator') {
|
|
162
|
+
const candidates = offer.candidateAccountKeys;
|
|
163
|
+
let reported;
|
|
164
|
+
try {
|
|
165
|
+
if (result.ilink_bot_id) {
|
|
166
|
+
reported = createIlinkAccountKey(String(result.ilink_bot_id));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
reported = undefined;
|
|
171
|
+
}
|
|
172
|
+
const accountKey = reported && candidates.includes(reported)
|
|
173
|
+
? reported
|
|
174
|
+
: candidates.length === 1
|
|
175
|
+
? candidates[0]
|
|
176
|
+
: undefined;
|
|
177
|
+
if (!accountKey) {
|
|
178
|
+
this.#offers.finish(offer.offerId, 'failed');
|
|
179
|
+
this.#logger.warn?.('[ilink-login] already-connected account could not be identified');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.#accounts.confirmExistingEnrollment({
|
|
183
|
+
offerId: offer.offerId,
|
|
184
|
+
accountKey,
|
|
185
|
+
now: Number(this.#clock()),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
this.#offers.finish(offer.offerId, 'already_connected');
|
|
190
|
+
}
|
|
129
191
|
this.#logger.info?.('[ilink-login] account is already connected');
|
|
130
192
|
return;
|
|
131
193
|
}
|
|
@@ -138,6 +200,11 @@ export class IlinkLoginManager {
|
|
|
138
200
|
else if (result.status === 'scaned') {
|
|
139
201
|
offer = this.#offers.update(offer.offerId, { status: 'scanned' });
|
|
140
202
|
}
|
|
203
|
+
else if (result.status === 'need_verifycode' ||
|
|
204
|
+
result.status === 'verify_code_blocked') {
|
|
205
|
+
this.#offers.finish(offer.offerId, 'verification_required');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
141
208
|
else if (result.status !== 'wait') {
|
|
142
209
|
this.#offers.finish(offer.offerId, result.status === 'expired' ? 'expired' : 'failed');
|
|
143
210
|
return;
|
|
@@ -194,5 +261,10 @@ export class IlinkLoginManager {
|
|
|
194
261
|
running.controller.abort();
|
|
195
262
|
await Promise.allSettled([...this.#running.values()].map(({ task }) => task));
|
|
196
263
|
this.#running.clear();
|
|
264
|
+
for (const offer of this.#offers.listActive()) {
|
|
265
|
+
if (offer.initiatorKind === 'local_operator') {
|
|
266
|
+
this.#offers.finish(offer.offerId, 'cancelled');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
197
269
|
}
|
|
198
270
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { normalizeIlinkBaseUrl } from './protocol/client.js';
|
|
3
3
|
import { IlinkSecretBox } from './secret-box.js';
|
|
4
|
-
|
|
4
|
+
import { assertIlinkAccountKey, } from './store-types.js';
|
|
5
|
+
const ILINK_LOGIN_TTL_MS = 5 * 60 * 1_000;
|
|
5
6
|
const MAX_TTL_MS = 10 * 60 * 1_000;
|
|
7
|
+
const TERMINAL_ACCOUNT_ID = 'local';
|
|
8
|
+
const TERMINAL_PEER_ID = 'operator';
|
|
6
9
|
function sha256(value) {
|
|
7
10
|
return createHash('sha256').update(value).digest('hex');
|
|
8
11
|
}
|
|
@@ -15,9 +18,7 @@ function rowAs(value) {
|
|
|
15
18
|
function mapped(row) {
|
|
16
19
|
return {
|
|
17
20
|
offerId: row.offer_id,
|
|
18
|
-
|
|
19
|
-
sourceOpenKfId: row.source_open_kfid,
|
|
20
|
-
sourceExternalUserId: row.source_external_userid,
|
|
21
|
+
initiatorKind: row.initiator_kind,
|
|
21
22
|
apiBaseUrl: row.api_base_url,
|
|
22
23
|
status: row.status,
|
|
23
24
|
expiresAt: Number(row.expires_at),
|
|
@@ -57,20 +58,52 @@ export class IlinkLoginStore {
|
|
|
57
58
|
`).get(offerId));
|
|
58
59
|
}
|
|
59
60
|
#runtime(row) {
|
|
61
|
+
const candidates = JSON.parse(row.candidate_account_keys_json);
|
|
62
|
+
if (!Array.isArray(candidates) || candidates.length > 10) {
|
|
63
|
+
throw new Error('Invalid iLink login candidate accounts');
|
|
64
|
+
}
|
|
65
|
+
for (const accountKey of candidates) {
|
|
66
|
+
if (typeof accountKey !== 'string') {
|
|
67
|
+
throw new Error('Invalid iLink login candidate account');
|
|
68
|
+
}
|
|
69
|
+
assertIlinkAccountKey(accountKey);
|
|
70
|
+
}
|
|
60
71
|
return {
|
|
61
72
|
...mapped(row),
|
|
73
|
+
candidateAccountKeys: Object.freeze([...candidates]),
|
|
62
74
|
qrCode: this.#secrets.open({
|
|
63
75
|
nonce: row.nonce,
|
|
64
76
|
ciphertext: row.ciphertext,
|
|
65
77
|
authTag: row.auth_tag,
|
|
66
78
|
}, {
|
|
67
79
|
secretKind: 'qr_token',
|
|
68
|
-
accountId: row.
|
|
69
|
-
peerId: row.
|
|
80
|
+
accountId: row.source_account_id,
|
|
81
|
+
peerId: row.source_peer_id,
|
|
70
82
|
generation: row.secret_generation,
|
|
71
83
|
}),
|
|
72
84
|
};
|
|
73
85
|
}
|
|
86
|
+
#source(source) {
|
|
87
|
+
if (source.kind === 'terminal') {
|
|
88
|
+
return {
|
|
89
|
+
initiatorKind: 'local_operator',
|
|
90
|
+
channel: source.kind,
|
|
91
|
+
messageKey: '',
|
|
92
|
+
accountId: TERMINAL_ACCOUNT_ID,
|
|
93
|
+
peerId: TERMINAL_PEER_ID,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const session = this.#store.getAgentSession(source.sessionToken);
|
|
97
|
+
if (session.channel !== 'wechat_kf')
|
|
98
|
+
throw new Error('Wrong channel for iLink offer');
|
|
99
|
+
return {
|
|
100
|
+
initiatorKind: 'remote_adapter',
|
|
101
|
+
channel: source.kind,
|
|
102
|
+
messageKey: session.messageKey,
|
|
103
|
+
accountId: session.accountKey,
|
|
104
|
+
peerId: session.peerId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
74
107
|
#expire(now = Number(this.#clock())) {
|
|
75
108
|
const expired = this.#database.prepare(`
|
|
76
109
|
SELECT * FROM ilink_login_offers
|
|
@@ -82,43 +115,46 @@ export class IlinkLoginStore {
|
|
|
82
115
|
#finishRow(row, result, accountKey, now) {
|
|
83
116
|
this.#database.prepare(`
|
|
84
117
|
INSERT INTO ilink_enrollment_audit (
|
|
85
|
-
offer_id,
|
|
86
|
-
|
|
87
|
-
|
|
118
|
+
offer_id, initiator_kind, source_channel, source_message_key,
|
|
119
|
+
source_account_id, source_peer_id,
|
|
120
|
+
account_key, result, offered_at, completed_at
|
|
121
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
88
122
|
ON CONFLICT(offer_id) DO NOTHING
|
|
89
|
-
`).run(row.offer_id, row.source_message_key, row.
|
|
123
|
+
`).run(row.offer_id, row.initiator_kind, row.source_channel, row.source_message_key, row.source_account_id, row.source_peer_id, accountKey, result, row.created_at, now);
|
|
90
124
|
this.#database.prepare(`
|
|
91
125
|
DELETE FROM ilink_login_offers WHERE offer_id = ?
|
|
92
126
|
`).run(row.offer_id);
|
|
93
127
|
}
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
if (session.channel !== 'wechat_kf')
|
|
97
|
-
throw new Error('Wrong channel for iLink offer');
|
|
128
|
+
find(source) {
|
|
129
|
+
const identity = this.#source(source);
|
|
98
130
|
return this.#transaction(() => {
|
|
99
131
|
this.#expire();
|
|
100
132
|
const row = rowAs(this.#database.prepare(`
|
|
101
133
|
SELECT * FROM ilink_login_offers
|
|
102
|
-
WHERE
|
|
134
|
+
WHERE source_channel = ? AND source_account_id = ? AND source_peer_id = ?
|
|
103
135
|
AND status IN ('waiting', 'scanned')
|
|
104
|
-
`).get(
|
|
136
|
+
`).get(identity.channel, identity.accountId, identity.peerId));
|
|
105
137
|
return row ? mapped(row) : undefined;
|
|
106
138
|
});
|
|
107
139
|
}
|
|
108
|
-
create({
|
|
109
|
-
const
|
|
110
|
-
if (session.channel !== 'wechat_kf')
|
|
111
|
-
throw new Error('Wrong channel for iLink offer');
|
|
140
|
+
create({ source, qrCode, apiBaseUrl, ttlMs = ILINK_LOGIN_TTL_MS, candidateAccountKeys = [], }) {
|
|
141
|
+
const identity = this.#source(source);
|
|
112
142
|
if (!qrCode || Buffer.byteLength(qrCode, 'utf8') > 8_192) {
|
|
113
143
|
throw new Error('Invalid iLink QR token');
|
|
114
144
|
}
|
|
115
145
|
const lifetime = Math.max(1_000, Math.min(Number(ttlMs) || 0, MAX_TTL_MS));
|
|
116
146
|
const offerId = `qo_${randomBytes(20).toString('base64url')}`;
|
|
117
147
|
const generation = secretGeneration(offerId);
|
|
148
|
+
if (candidateAccountKeys.length > 10) {
|
|
149
|
+
throw new Error('Too many iLink login candidate accounts');
|
|
150
|
+
}
|
|
151
|
+
for (const accountKey of candidateAccountKeys)
|
|
152
|
+
assertIlinkAccountKey(accountKey);
|
|
153
|
+
const candidates = JSON.stringify([...new Set(candidateAccountKeys)]);
|
|
118
154
|
const sealed = this.#secrets.seal(qrCode, {
|
|
119
155
|
secretKind: 'qr_token',
|
|
120
|
-
accountId:
|
|
121
|
-
peerId:
|
|
156
|
+
accountId: identity.accountId,
|
|
157
|
+
peerId: identity.peerId,
|
|
122
158
|
generation,
|
|
123
159
|
});
|
|
124
160
|
const now = Number(this.#clock());
|
|
@@ -126,19 +162,20 @@ export class IlinkLoginStore {
|
|
|
126
162
|
this.#expire(now);
|
|
127
163
|
const existing = this.#database.prepare(`
|
|
128
164
|
SELECT 1 FROM ilink_login_offers
|
|
129
|
-
WHERE
|
|
165
|
+
WHERE source_channel = ? AND source_account_id = ? AND source_peer_id = ?
|
|
130
166
|
AND status IN ('waiting', 'scanned')
|
|
131
|
-
`).get(
|
|
167
|
+
`).get(identity.channel, identity.accountId, identity.peerId);
|
|
132
168
|
if (existing)
|
|
133
169
|
throw new Error('An iLink login offer is already pending');
|
|
134
170
|
this.#database.prepare(`
|
|
135
171
|
INSERT INTO ilink_login_offers (
|
|
136
|
-
offer_id,
|
|
137
|
-
|
|
138
|
-
nonce, ciphertext, auth_tag,
|
|
172
|
+
offer_id, initiator_kind, source_channel, source_message_key,
|
|
173
|
+
source_account_id, source_peer_id, secret_generation,
|
|
174
|
+
candidate_account_keys_json, nonce, ciphertext, auth_tag,
|
|
175
|
+
api_base_url, status,
|
|
139
176
|
expires_at, last_polled_at, created_at, updated_at
|
|
140
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'waiting', ?, 0, ?, ?)
|
|
141
|
-
`).run(offerId,
|
|
177
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'waiting', ?, 0, ?, ?)
|
|
178
|
+
`).run(offerId, identity.initiatorKind, identity.channel, identity.messageKey, identity.accountId, identity.peerId, generation, candidates, sealed.nonce, sealed.ciphertext, sealed.authTag, normalizeIlinkBaseUrl(apiBaseUrl), now + lifetime, now, now);
|
|
142
179
|
return this.#runtime(this.#row(offerId));
|
|
143
180
|
});
|
|
144
181
|
}
|
|
@@ -158,6 +195,18 @@ export class IlinkLoginStore {
|
|
|
158
195
|
AND expires_at > ?
|
|
159
196
|
`).get(offerId, Number(this.#clock())));
|
|
160
197
|
}
|
|
198
|
+
status(offerId) {
|
|
199
|
+
return this.#transaction(() => {
|
|
200
|
+
this.#expire();
|
|
201
|
+
const active = this.#row(offerId);
|
|
202
|
+
if (active)
|
|
203
|
+
return { status: active.status };
|
|
204
|
+
const audit = rowAs(this.#database.prepare(`
|
|
205
|
+
SELECT result FROM ilink_enrollment_audit WHERE offer_id = ?
|
|
206
|
+
`).get(offerId));
|
|
207
|
+
return { status: audit?.result || 'unknown' };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
161
210
|
update(offerId, input) {
|
|
162
211
|
const now = Number(this.#clock());
|
|
163
212
|
return this.#transaction(() => {
|
package/dist/src/ilink/qr.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { PNG } from 'pngjs';
|
|
5
|
-
import { create } from 'qrcode';
|
|
5
|
+
import { create, toBuffer } from 'qrcode';
|
|
6
6
|
const MAX_QR_CONTENT_BYTES = 2_048;
|
|
7
7
|
const MAX_QR_PNG_BYTES = 512 * 1_024;
|
|
8
8
|
const CARD_WIDTH = 720;
|
|
@@ -11,10 +11,13 @@ const QR_REGION_X = 120;
|
|
|
11
11
|
const QR_REGION_Y = 220;
|
|
12
12
|
const QR_REGION_SIZE = 480;
|
|
13
13
|
const QUIET_ZONE_MODULES = 4;
|
|
14
|
+
const RAW_QR_SCALE = 6;
|
|
14
15
|
const QR_DARK = Object.freeze([0x11, 0x18, 0x14, 0xff]);
|
|
15
16
|
const PNG_SIGNATURE = Buffer.from([
|
|
16
17
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
17
18
|
]);
|
|
19
|
+
const TERMINAL_INK = '\u001b[47m\u001b[30m';
|
|
20
|
+
const TERMINAL_RESET = '\u001b[0m';
|
|
18
21
|
function templatePath() {
|
|
19
22
|
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
20
23
|
const sourcePath = path.resolve(moduleDirectory, '../../assets/ilink-login-card.png');
|
|
@@ -42,7 +45,7 @@ export class IlinkQrRenderError extends Error {
|
|
|
42
45
|
this.code = code;
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
|
-
function
|
|
48
|
+
export function assertIlinkQrContent(content) {
|
|
46
49
|
if (typeof content !== 'string' ||
|
|
47
50
|
content.trim().length === 0 ||
|
|
48
51
|
Buffer.byteLength(content, 'utf8') > MAX_QR_CONTENT_BYTES) {
|
|
@@ -91,7 +94,7 @@ function renderCard(content) {
|
|
|
91
94
|
});
|
|
92
95
|
}
|
|
93
96
|
export async function renderIlinkQrPng(content) {
|
|
94
|
-
|
|
97
|
+
assertIlinkQrContent(content);
|
|
95
98
|
let png;
|
|
96
99
|
try {
|
|
97
100
|
png = renderCard(content);
|
|
@@ -107,3 +110,64 @@ export async function renderIlinkQrPng(content) {
|
|
|
107
110
|
}
|
|
108
111
|
return png;
|
|
109
112
|
}
|
|
113
|
+
export async function renderIlinkRawQrPng(content) {
|
|
114
|
+
assertIlinkQrContent(content);
|
|
115
|
+
let png;
|
|
116
|
+
try {
|
|
117
|
+
png = await toBuffer(content, {
|
|
118
|
+
errorCorrectionLevel: 'M',
|
|
119
|
+
margin: QUIET_ZONE_MODULES,
|
|
120
|
+
scale: RAW_QR_SCALE,
|
|
121
|
+
type: 'png',
|
|
122
|
+
color: {
|
|
123
|
+
dark: '#111814ff',
|
|
124
|
+
light: '#ffffffff',
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new IlinkQrRenderError('qr_render_failed', 'Unable to render iLink QR image');
|
|
130
|
+
}
|
|
131
|
+
if (png.length < PNG_SIGNATURE.length || !png.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
132
|
+
throw new IlinkQrRenderError('invalid_qr_png', 'Rendered iLink QR image is invalid');
|
|
133
|
+
}
|
|
134
|
+
if (png.length > MAX_QR_PNG_BYTES) {
|
|
135
|
+
throw new IlinkQrRenderError('qr_png_too_large', 'Rendered iLink QR image is too large');
|
|
136
|
+
}
|
|
137
|
+
return png;
|
|
138
|
+
}
|
|
139
|
+
export function renderIlinkQrTerminal(content) {
|
|
140
|
+
assertIlinkQrContent(content);
|
|
141
|
+
let qr;
|
|
142
|
+
try {
|
|
143
|
+
qr = create(content, { errorCorrectionLevel: 'M' });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new IlinkQrRenderError('qr_render_failed', 'Unable to render iLink QR code');
|
|
147
|
+
}
|
|
148
|
+
const matrixSize = qr.modules.size;
|
|
149
|
+
const columns = matrixSize + QUIET_ZONE_MODULES * 2;
|
|
150
|
+
const rows = Math.ceil(columns / 2);
|
|
151
|
+
const dark = (row, column) => {
|
|
152
|
+
const matrixRow = row - QUIET_ZONE_MODULES;
|
|
153
|
+
const matrixColumn = column - QUIET_ZONE_MODULES;
|
|
154
|
+
return (matrixRow >= 0 && matrixRow < matrixSize &&
|
|
155
|
+
matrixColumn >= 0 && matrixColumn < matrixSize &&
|
|
156
|
+
Boolean(qr.modules.get(matrixRow, matrixColumn)));
|
|
157
|
+
};
|
|
158
|
+
const lines = [];
|
|
159
|
+
for (let row = 0; row < columns; row += 2) {
|
|
160
|
+
let line = TERMINAL_INK;
|
|
161
|
+
for (let column = 0; column < columns; column += 1) {
|
|
162
|
+
const top = dark(row, column);
|
|
163
|
+
const bottom = dark(row + 1, column);
|
|
164
|
+
line += top ? (bottom ? '█' : '▀') : (bottom ? '▄' : ' ');
|
|
165
|
+
}
|
|
166
|
+
lines.push(`${line}${TERMINAL_RESET}`);
|
|
167
|
+
}
|
|
168
|
+
return Object.freeze({
|
|
169
|
+
text: `${lines.join('\n')}\n`,
|
|
170
|
+
columns,
|
|
171
|
+
rows,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createSecretKey, randomBytes, } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { assertTrustedDirectory, ensurePrivateDirectory, } from '../lib/private-directory.js';
|
|
2
5
|
const KEY_BYTES = 32;
|
|
3
6
|
const NONCE_BYTES = 12;
|
|
4
7
|
const AUTH_TAG_BYTES = 16;
|
|
@@ -27,6 +30,76 @@ function decodeConfiguredKey(configuredKey) {
|
|
|
27
30
|
}
|
|
28
31
|
return decoded;
|
|
29
32
|
}
|
|
33
|
+
export function readOrCreateIlinkStorageKey(filePath, { allowCreate }) {
|
|
34
|
+
const target = path.resolve(filePath);
|
|
35
|
+
const parent = ensurePrivateDirectory(path.dirname(target));
|
|
36
|
+
assertTrustedDirectory(parent, 'iLink storage key directory', true);
|
|
37
|
+
try {
|
|
38
|
+
const stat = fs.lstatSync(target);
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
40
|
+
throw new Error(`iLink storage key is not a regular file: ${target}`);
|
|
41
|
+
}
|
|
42
|
+
if (process.platform !== 'win32') {
|
|
43
|
+
const uid = process.getuid?.();
|
|
44
|
+
if ((uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
|
|
45
|
+
throw new Error(`iLink storage key has unsafe permissions: ${target}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const configuredKey = fs.readFileSync(target, 'utf8').trim();
|
|
49
|
+
const decoded = decodeConfiguredKey(configuredKey);
|
|
50
|
+
decoded.fill(0);
|
|
51
|
+
return configuredKey;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!allowCreate) {
|
|
59
|
+
throw new Error('iLink storage key is missing for existing encrypted state');
|
|
60
|
+
}
|
|
61
|
+
const configuredKey = randomBytes(KEY_BYTES).toString('base64url');
|
|
62
|
+
let descriptor;
|
|
63
|
+
let created = false;
|
|
64
|
+
try {
|
|
65
|
+
descriptor = fs.openSync(target, 'wx', 0o600);
|
|
66
|
+
created = true;
|
|
67
|
+
fs.writeFileSync(descriptor, `${configuredKey}\n`, 'utf8');
|
|
68
|
+
fs.fsyncSync(descriptor);
|
|
69
|
+
fs.closeSync(descriptor);
|
|
70
|
+
descriptor = undefined;
|
|
71
|
+
if (process.platform !== 'win32') {
|
|
72
|
+
const parentDescriptor = fs.openSync(parent, 'r');
|
|
73
|
+
try {
|
|
74
|
+
fs.fsyncSync(parentDescriptor);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
fs.closeSync(parentDescriptor);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return configuredKey;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (descriptor !== undefined) {
|
|
84
|
+
try {
|
|
85
|
+
fs.closeSync(descriptor);
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
}
|
|
89
|
+
if (created) {
|
|
90
|
+
try {
|
|
91
|
+
fs.unlinkSync(target);
|
|
92
|
+
}
|
|
93
|
+
catch (cleanupError) {
|
|
94
|
+
throw new AggregateError([error, cleanupError], 'iLink storage key creation and cleanup both failed');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
98
|
+
return readOrCreateIlinkStorageKey(target, { allowCreate });
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
30
103
|
function boundedScopeText(value) {
|
|
31
104
|
return typeof value === 'string' &&
|
|
32
105
|
value.length > 0 &&
|