@soyeht/soyeht 0.2.0 → 0.3.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.
@@ -5,7 +5,7 @@
5
5
  ],
6
6
  "name": "Soyeht",
7
7
  "description": "Channel plugin for the Soyeht Flutter mobile app",
8
- "version": "0.2.0",
8
+ "version": "0.3.0",
9
9
  "configSchema": {
10
10
  "type": "object",
11
11
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soyeht/soyeht",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/qr.ts ADDED
@@ -0,0 +1,448 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Minimal QR Code encoder — byte mode, EC level M, versions 1-20
3
+ // Zero external dependencies. Used for terminal rendering on service start.
4
+ // ---------------------------------------------------------------------------
5
+
6
+ // Galois Field GF(2^8) with primitive polynomial 0x11d
7
+ const EXP = new Uint8Array(256);
8
+ const LOG = new Uint8Array(256);
9
+ (function initGF() {
10
+ let v = 1;
11
+ for (let i = 0; i < 255; i++) {
12
+ EXP[i] = v;
13
+ LOG[v] = i;
14
+ v <<= 1;
15
+ if (v >= 256) v ^= 0x11d;
16
+ }
17
+ EXP[255] = EXP[0];
18
+ })();
19
+
20
+ function gfMul(a: number, b: number): number {
21
+ if (a === 0 || b === 0) return 0;
22
+ return EXP[(LOG[a] + LOG[b]) % 255];
23
+ }
24
+
25
+ function rsGenPoly(n: number): number[] {
26
+ let g = [1];
27
+ for (let i = 0; i < n; i++) {
28
+ const ng = new Array(g.length + 1).fill(0);
29
+ for (let j = 0; j < g.length; j++) {
30
+ ng[j] ^= g[j];
31
+ ng[j + 1] ^= gfMul(g[j], EXP[i]);
32
+ }
33
+ g = ng;
34
+ }
35
+ return g;
36
+ }
37
+
38
+ function rsEncode(data: number[], ecLen: number): number[] {
39
+ const gen = rsGenPoly(ecLen);
40
+ const msg = new Array(data.length + ecLen).fill(0);
41
+ for (let i = 0; i < data.length; i++) msg[i] = data[i];
42
+ for (let i = 0; i < data.length; i++) {
43
+ const coef = msg[i];
44
+ if (coef !== 0) {
45
+ for (let j = 0; j < gen.length; j++) {
46
+ msg[i + j] ^= gfMul(gen[j], coef);
47
+ }
48
+ }
49
+ }
50
+ return msg.slice(data.length);
51
+ }
52
+
53
+ // Version table: [totalCodewords, ecPerBlock, blocks1, dataPerBlock1, blocks2, dataPerBlock2]
54
+ const VERSIONS: (number[] | null)[] = [
55
+ null,
56
+ [26, 10, 1, 16, 0, 0],
57
+ [44, 16, 1, 28, 0, 0],
58
+ [70, 26, 1, 44, 0, 0],
59
+ [100, 18, 2, 32, 0, 0],
60
+ [134, 24, 2, 43, 0, 0],
61
+ [172, 16, 4, 27, 0, 0],
62
+ [196, 18, 4, 31, 0, 0],
63
+ [242, 22, 4, 38, 0, 0],
64
+ [292, 22, 2, 36, 2, 37],
65
+ [346, 26, 4, 43, 1, 44],
66
+ [404, 30, 1, 50, 4, 51],
67
+ [466, 22, 6, 36, 2, 37],
68
+ [532, 22, 8, 37, 1, 38],
69
+ [581, 24, 4, 40, 5, 41],
70
+ [655, 24, 5, 41, 5, 42],
71
+ [733, 28, 7, 45, 3, 46],
72
+ [815, 28, 10, 46, 1, 47],
73
+ [901, 26, 9, 43, 4, 44],
74
+ [991, 26, 3, 44, 11, 45],
75
+ [1085, 28, 3, 41, 13, 42],
76
+ ];
77
+
78
+ const DATA_CAP = VERSIONS.map((v) => {
79
+ if (!v) return 0;
80
+ return v[2] * v[3] + (v[4] ? v[4] * v[5] : 0);
81
+ });
82
+
83
+ function chooseVersion(dataLen: number): number {
84
+ for (let v = 1; v <= 20; v++) {
85
+ if (DATA_CAP[v] >= dataLen) return v;
86
+ }
87
+ return -1;
88
+ }
89
+
90
+ function encodeToBytes(str: string): number[] {
91
+ return Array.from(Buffer.from(str, "utf8"));
92
+ }
93
+
94
+ function buildBitstream(dataBytes: number[], version: number): number[] {
95
+ const bits: number[] = [];
96
+ function push(val: number, len: number) {
97
+ for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
98
+ }
99
+ push(4, 4); // byte mode
100
+ const ccBits = version <= 9 ? 8 : 16;
101
+ push(dataBytes.length, ccBits);
102
+ for (const b of dataBytes) push(b, 8);
103
+ const totalBits = DATA_CAP[version] * 8;
104
+ push(0, Math.min(4, totalBits - bits.length));
105
+ while (bits.length % 8 !== 0) bits.push(0);
106
+ const padBytes = [0xec, 0x11];
107
+ let pi = 0;
108
+ while (bits.length < totalBits) {
109
+ push(padBytes[pi], 8);
110
+ pi = (pi + 1) % 2;
111
+ }
112
+ const out: number[] = [];
113
+ for (let i = 0; i < bits.length; i += 8) {
114
+ let b = 0;
115
+ for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
116
+ out.push(b);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function interleaveBlocks(data: number[], version: number): number[] {
122
+ const vInfo = VERSIONS[version]!;
123
+ const ecPerBlock = vInfo[1];
124
+ const blocks1 = vInfo[2], dPerBlock1 = vInfo[3];
125
+ const blocks2 = vInfo[4] || 0, dPerBlock2 = vInfo[5] || 0;
126
+ const dataBlocks: number[][] = [];
127
+ const ecBlocks: number[][] = [];
128
+ let offset = 0;
129
+ for (let i = 0; i < blocks1; i++) {
130
+ const block = data.slice(offset, offset + dPerBlock1);
131
+ dataBlocks.push(block);
132
+ ecBlocks.push(rsEncode(block, ecPerBlock));
133
+ offset += dPerBlock1;
134
+ }
135
+ for (let i = 0; i < blocks2; i++) {
136
+ const block = data.slice(offset, offset + dPerBlock2);
137
+ dataBlocks.push(block);
138
+ ecBlocks.push(rsEncode(block, ecPerBlock));
139
+ offset += dPerBlock2;
140
+ }
141
+ const result: number[] = [];
142
+ const maxDataLen = Math.max(dPerBlock1, dPerBlock2 || 0);
143
+ for (let i = 0; i < maxDataLen; i++) {
144
+ for (const block of dataBlocks) {
145
+ if (i < block.length) result.push(block[i]);
146
+ }
147
+ }
148
+ for (let i = 0; i < ecPerBlock; i++) {
149
+ for (const block of ecBlocks) {
150
+ if (i < block.length) result.push(block[i]);
151
+ }
152
+ }
153
+ return result;
154
+ }
155
+
156
+ const SIZE_FOR_VERSION = (v: number) => 17 + v * 4;
157
+
158
+ function createMatrix(version: number): number[][] {
159
+ const size = SIZE_FOR_VERSION(version);
160
+ return Array.from({ length: size }, () => new Array(size).fill(-1));
161
+ }
162
+
163
+ function setMod(m: number[][], r: number, c: number, val: number) {
164
+ if (r >= 0 && r < m.length && c >= 0 && c < m.length) m[r][c] = val;
165
+ }
166
+
167
+ function placeFinderPattern(m: number[][], row: number, col: number) {
168
+ for (let r = -1; r <= 7; r++) {
169
+ for (let c = -1; c <= 7; c++) {
170
+ let val: number;
171
+ if (r === -1 || r === 7 || c === -1 || c === 7) val = 0;
172
+ else if (r === 0 || r === 6 || c === 0 || c === 6) val = 1;
173
+ else if (r >= 2 && r <= 4 && c >= 2 && c <= 4) val = 1;
174
+ else val = 0;
175
+ setMod(m, row + r, col + c, val);
176
+ }
177
+ }
178
+ }
179
+
180
+ const ALIGN_POS: (number[] | null)[] = [
181
+ null, [],
182
+ [6, 18], [6, 22], [6, 26], [6, 30], [6, 34],
183
+ [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50],
184
+ [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66],
185
+ [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78],
186
+ [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90],
187
+ ];
188
+
189
+ function placeAlignmentPatterns(m: number[][], version: number) {
190
+ if (version < 2) return;
191
+ const pos = ALIGN_POS[version]!;
192
+ for (const r of pos) {
193
+ for (const c of pos) {
194
+ if (m[r][c] !== -1) continue;
195
+ for (let dr = -2; dr <= 2; dr++) {
196
+ for (let dc = -2; dc <= 2; dc++) {
197
+ const val = (Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0)) ? 1 : 0;
198
+ setMod(m, r + dr, c + dc, val);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ function placeTimingPatterns(m: number[][]) {
206
+ const size = m.length;
207
+ for (let i = 8; i < size - 8; i++) {
208
+ if (m[6][i] === -1) m[6][i] = (i % 2 === 0) ? 1 : 0;
209
+ if (m[i][6] === -1) m[i][6] = (i % 2 === 0) ? 1 : 0;
210
+ }
211
+ }
212
+
213
+ function reserveFormatBits(m: number[][]) {
214
+ const size = m.length;
215
+ for (let i = 0; i <= 8; i++) {
216
+ if (m[8][i] === -1) m[8][i] = 0;
217
+ if (m[i][8] === -1) m[i][8] = 0;
218
+ }
219
+ for (let i = 0; i <= 7; i++) {
220
+ if (m[8][size - 1 - i] === -1) m[8][size - 1 - i] = 0;
221
+ }
222
+ for (let i = 0; i <= 7; i++) {
223
+ if (m[size - 1 - i][8] === -1) m[size - 1 - i][8] = 0;
224
+ }
225
+ }
226
+
227
+ function reserveVersionBits(m: number[][], version: number) {
228
+ if (version < 7) return;
229
+ const size = m.length;
230
+ for (let i = 0; i < 6; i++) {
231
+ for (let j = 0; j < 3; j++) {
232
+ if (m[i][size - 11 + j] === -1) m[i][size - 11 + j] = 0;
233
+ if (m[size - 11 + j][i] === -1) m[size - 11 + j][i] = 0;
234
+ }
235
+ }
236
+ }
237
+
238
+ function placeDataBits(m: number[][], dataBits: number[]) {
239
+ const size = m.length;
240
+ let bitIdx = 0;
241
+ let upward = true;
242
+ for (let right = size - 1; right >= 1; right -= 2) {
243
+ if (right === 6) right = 5;
244
+ for (let cnt = 0; cnt < size; cnt++) {
245
+ const row = upward ? (size - 1 - cnt) : cnt;
246
+ for (let dx = 0; dx <= 1; dx++) {
247
+ const col = right - dx;
248
+ if (col < 0 || col >= size) continue;
249
+ if (m[row][col] !== -1) continue;
250
+ m[row][col] = (bitIdx < dataBits.length) ? dataBits[bitIdx] : 0;
251
+ bitIdx++;
252
+ }
253
+ }
254
+ upward = !upward;
255
+ }
256
+ }
257
+
258
+ const MASK_FNS: ((r: number, c: number) => boolean)[] = [
259
+ (r, c) => (r + c) % 2 === 0,
260
+ (r) => r % 2 === 0,
261
+ (_, c) => c % 3 === 0,
262
+ (r, c) => (r + c) % 3 === 0,
263
+ (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
264
+ (r, c) => (r * c) % 2 + (r * c) % 3 === 0,
265
+ (r, c) => ((r * c) % 2 + (r * c) % 3) % 2 === 0,
266
+ (r, c) => ((r + c) % 2 + (r * c) % 3) % 2 === 0,
267
+ ];
268
+
269
+ function applyMask(m: number[][], reserved: number[][], maskIdx: number) {
270
+ const fn = MASK_FNS[maskIdx];
271
+ const size = m.length;
272
+ for (let r = 0; r < size; r++) {
273
+ for (let c = 0; c < size; c++) {
274
+ if (reserved[r][c] === -1 && fn(r, c)) m[r][c] ^= 1;
275
+ }
276
+ }
277
+ }
278
+
279
+ function scorePenalty(m: number[][]): number {
280
+ const size = m.length;
281
+ let score = 0;
282
+ for (let r = 0; r < size; r++) {
283
+ let run = 1;
284
+ for (let c = 1; c < size; c++) {
285
+ if (m[r][c] === m[r][c - 1]) run++;
286
+ else { if (run >= 5) score += run - 2; run = 1; }
287
+ }
288
+ if (run >= 5) score += run - 2;
289
+ }
290
+ for (let c = 0; c < size; c++) {
291
+ let run = 1;
292
+ for (let r = 1; r < size; r++) {
293
+ if (m[r][c] === m[r - 1][c]) run++;
294
+ else { if (run >= 5) score += run - 2; run = 1; }
295
+ }
296
+ if (run >= 5) score += run - 2;
297
+ }
298
+ for (let r = 0; r < size - 1; r++) {
299
+ for (let c = 0; c < size - 1; c++) {
300
+ const v = m[r][c];
301
+ if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
302
+ }
303
+ }
304
+ return score;
305
+ }
306
+
307
+ // EC level M format info table (pre-computed with BCH)
308
+ const FORMAT_BITS: number[] = [
309
+ 0x5412, 0x5125, 0x5e7c, 0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0,
310
+ 0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976,
311
+ ];
312
+
313
+ function placeFormatBits(m: number[][], maskIdx: number) {
314
+ const formatInfo = FORMAT_BITS[maskIdx]; // EC level M = index 0..7
315
+ const size = m.length;
316
+ const bits: number[] = [];
317
+ for (let i = 14; i >= 0; i--) bits.push((formatInfo >> i) & 1);
318
+ const pos1: [number, number][] = [
319
+ [0, 8], [1, 8], [2, 8], [3, 8], [4, 8], [5, 8], [7, 8], [8, 8],
320
+ [8, 7], [8, 5], [8, 4], [8, 3], [8, 2], [8, 1], [8, 0],
321
+ ];
322
+ for (let i = 0; i < 15; i++) m[pos1[i][0]][pos1[i][1]] = bits[i];
323
+ for (let i = 0; i < 7; i++) m[size - 1 - i][8] = bits[i];
324
+ for (let i = 7; i < 15; i++) m[8][size - 15 + i] = bits[i];
325
+ }
326
+
327
+ const VERSION_INFO: number[] = [
328
+ 0, 0, 0, 0, 0, 0, 0,
329
+ 0x07c94, 0x085bc, 0x09a99, 0x0a4d3, 0x0bbf6, 0x0c762, 0x0d847,
330
+ 0x0e60d, 0x0f928, 0x10b78, 0x1145d, 0x12a17, 0x13532, 0x149a6,
331
+ ];
332
+
333
+ function placeVersionBits(m: number[][], version: number) {
334
+ if (version < 7) return;
335
+ const info = VERSION_INFO[version];
336
+ const size = m.length;
337
+ for (let i = 0; i < 18; i++) {
338
+ const bit = (info >> i) & 1;
339
+ const r = Math.floor(i / 3);
340
+ const c = size - 11 + (i % 3);
341
+ m[r][c] = bit;
342
+ m[c][r] = bit;
343
+ }
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Public API
348
+ // ---------------------------------------------------------------------------
349
+
350
+ export type QRResult = {
351
+ matrix: number[][];
352
+ size: number;
353
+ version: number;
354
+ };
355
+
356
+ export function generateQR(text: string): QRResult | null {
357
+ const dataBytes = encodeToBytes(text);
358
+ let overhead = Math.ceil((4 + 8) / 8);
359
+ let version = chooseVersion(dataBytes.length + overhead);
360
+ if (version === -1) {
361
+ overhead = Math.ceil((4 + 16) / 8);
362
+ version = chooseVersion(dataBytes.length + overhead);
363
+ }
364
+ if (version === -1) return null;
365
+
366
+ const ccBits = version <= 9 ? 8 : 16;
367
+ const needed = Math.ceil((4 + ccBits + dataBytes.length * 8) / 8);
368
+ if (needed > DATA_CAP[version]) {
369
+ version++;
370
+ if (version > 20) return null;
371
+ }
372
+
373
+ const bitstream = buildBitstream(dataBytes, version);
374
+ const codewords = interleaveBlocks(bitstream, version);
375
+ const dataBits: number[] = [];
376
+ for (const cw of codewords) {
377
+ for (let j = 7; j >= 0; j--) dataBits.push((cw >> j) & 1);
378
+ }
379
+
380
+ const m = createMatrix(version);
381
+ placeFinderPattern(m, 0, 0);
382
+ placeFinderPattern(m, 0, m.length - 7);
383
+ placeFinderPattern(m, m.length - 7, 0);
384
+ placeAlignmentPatterns(m, version);
385
+ placeTimingPatterns(m);
386
+ m[(4 * version) + 9][8] = 1; // dark module
387
+ reserveFormatBits(m);
388
+ reserveVersionBits(m, version);
389
+
390
+ const reserved = m.map((row) => row.slice());
391
+ placeDataBits(m, dataBits);
392
+
393
+ let bestMask = 0;
394
+ let bestScore = Infinity;
395
+ for (let mask = 0; mask < 8; mask++) {
396
+ const trial = m.map((row) => row.slice());
397
+ applyMask(trial, reserved, mask);
398
+ placeFormatBits(trial, mask);
399
+ placeVersionBits(trial, version);
400
+ const s = scorePenalty(trial);
401
+ if (s < bestScore) { bestScore = s; bestMask = mask; }
402
+ }
403
+
404
+ applyMask(m, reserved, bestMask);
405
+ placeFormatBits(m, bestMask);
406
+ placeVersionBits(m, version);
407
+
408
+ return { matrix: m, size: m.length, version };
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Terminal renderer — ANSI colors, half-block characters
413
+ // ---------------------------------------------------------------------------
414
+
415
+ export function renderQrTerminal(text: string): string | null {
416
+ const qr = generateQR(text);
417
+ if (!qr) return null;
418
+
419
+ const { matrix, size } = qr;
420
+ const quiet = 2;
421
+ const total = size + quiet * 2;
422
+
423
+ function mod(r: number, c: number): number {
424
+ if (r < quiet || r >= size + quiet || c < quiet || c >= size + quiet) return 0;
425
+ return matrix[r - quiet][c - quiet];
426
+ }
427
+
428
+ // ▄ (lower half block): foreground = lower half, background = upper half
429
+ function cell(top: number, bottom: number): string {
430
+ if (top === 1 && bottom === 1) return "\x1b[40m \x1b[0m"; // both black
431
+ if (top === 0 && bottom === 0) return "\x1b[47m \x1b[0m"; // both white
432
+ if (top === 1 && bottom === 0) return "\x1b[37;40m\u2584\x1b[0m"; // top black, bottom white
433
+ return "\x1b[30;47m\u2584\x1b[0m"; // top white, bottom black
434
+ }
435
+
436
+ const lines: string[] = [];
437
+ for (let r = 0; r < total; r += 2) {
438
+ let line = "";
439
+ for (let c = 0; c < total; c++) {
440
+ const top = mod(r, c);
441
+ const bottom = (r + 1 < total) ? mod(r + 1, c) : 0;
442
+ line += cell(top, bottom);
443
+ }
444
+ lines.push(line);
445
+ }
446
+
447
+ return lines.join("\n");
448
+ }
package/src/service.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { readFile, writeFile, rm } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
@@ -9,10 +10,13 @@ import {
9
10
  } from "./security.js";
10
11
  import { loadOrGenerateIdentity, loadPeers, loadSessions, saveSession } from "./identity.js";
11
12
  import { zeroBuffer } from "./ratchet.js";
12
- import { computeFingerprint, type X25519KeyPair } from "./crypto.js";
13
+ import { base64UrlEncode, computeFingerprint, ed25519Sign, type X25519KeyPair } from "./crypto.js";
13
14
  import type { IdentityBundle, PeerIdentity } from "./identity.js";
14
15
  import type { RatchetState } from "./ratchet.js";
15
16
  import { createOutboundQueue, type OutboundQueue } from "./outbound-queue.js";
17
+ import { renderQrTerminal } from "./qr.js";
18
+ import { resolveSoyehtAccount } from "./config.js";
19
+ import { buildPairingQrTranscript, buildPairingQrTranscriptV2 } from "./pairing.js";
16
20
 
17
21
  const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
18
22
 
@@ -65,6 +69,94 @@ export function createSecurityV2Deps(): SecurityV2Deps {
65
69
  };
66
70
  }
67
71
 
72
+ // ---------------------------------------------------------------------------
73
+ // Auto-pairing QR display (terminal)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const AUTO_PAIRING_TTL_MS = 90_000;
77
+
78
+ async function showPairingQr(api: OpenClawPluginApi, v2deps: SecurityV2Deps): Promise<void> {
79
+ const identity = v2deps.identity;
80
+ if (!identity) return;
81
+
82
+ const accountId = "default";
83
+
84
+ // Clear any stale pairing sessions for this account
85
+ for (const [token, session] of v2deps.pairingSessions) {
86
+ if (session.accountId === accountId) {
87
+ v2deps.pairingSessions.delete(token);
88
+ }
89
+ }
90
+
91
+ const pairingToken = base64UrlEncode(randomBytes(32));
92
+ const expiresAt = Date.now() + AUTO_PAIRING_TTL_MS;
93
+ const fingerprint = computeFingerprint(identity);
94
+
95
+ // Resolve gatewayUrl from config or runtime
96
+ const cfg = await api.runtime.config.loadConfig();
97
+ const account = resolveSoyehtAccount(cfg, accountId);
98
+ let gatewayUrl = account.gatewayUrl;
99
+ if (!gatewayUrl) {
100
+ const runtime = api.runtime as Record<string, unknown>;
101
+ if (typeof runtime["gatewayUrl"] === "string" && runtime["gatewayUrl"]) {
102
+ gatewayUrl = runtime["gatewayUrl"];
103
+ } else if (typeof runtime["baseUrl"] === "string" && runtime["baseUrl"]) {
104
+ gatewayUrl = runtime["baseUrl"];
105
+ }
106
+ }
107
+
108
+ const basePayload = {
109
+ accountId,
110
+ pairingToken,
111
+ expiresAt,
112
+ allowOverwrite: false,
113
+ pluginIdentityKey: identity.signKey.publicKeyB64,
114
+ pluginDhKey: identity.dhKey.publicKeyB64,
115
+ fingerprint,
116
+ };
117
+
118
+ let qrPayload: Record<string, unknown>;
119
+
120
+ if (gatewayUrl) {
121
+ const transcript = buildPairingQrTranscriptV2({ gatewayUrl, ...basePayload });
122
+ const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
123
+ qrPayload = {
124
+ version: 2,
125
+ type: "soyeht_pairing_qr",
126
+ gatewayUrl,
127
+ ...basePayload,
128
+ signature,
129
+ };
130
+ } else {
131
+ const transcript = buildPairingQrTranscript(basePayload);
132
+ const signature = base64UrlEncode(ed25519Sign(identity.signKey.privateKey, transcript));
133
+ qrPayload = {
134
+ version: 1,
135
+ type: "soyeht_pairing_qr",
136
+ ...basePayload,
137
+ signature,
138
+ };
139
+ }
140
+
141
+ v2deps.pairingSessions.set(pairingToken, {
142
+ token: pairingToken,
143
+ accountId,
144
+ expiresAt,
145
+ allowOverwrite: false,
146
+ });
147
+
148
+ const qrText = JSON.stringify(qrPayload);
149
+ const rendered = renderQrTerminal(qrText);
150
+
151
+ if (rendered) {
152
+ api.logger.info("[soyeht] Scan this QR code with the Soyeht app to pair:\n\n" + rendered);
153
+ api.logger.info(`[soyeht] Fingerprint: ${fingerprint}`);
154
+ api.logger.info(`[soyeht] QR expires in ${AUTO_PAIRING_TTL_MS / 1000}s — restart plugin to generate a new one`);
155
+ } else {
156
+ api.logger.warn("[soyeht] QR code too large for terminal rendering. Use RPC soyeht.security.pairing.start instead.");
157
+ }
158
+ }
159
+
68
160
  // ---------------------------------------------------------------------------
69
161
  // Service
70
162
  // ---------------------------------------------------------------------------
@@ -110,6 +202,15 @@ export function createSoyehtService(
110
202
  }
111
203
  if (v2deps) v2deps.ready = true;
112
204
 
205
+ // Auto-generate and display pairing QR if no peers are paired
206
+ if (v2deps?.identity && v2deps.peers.size === 0) {
207
+ try {
208
+ await showPairingQr(api, v2deps);
209
+ } catch (err) {
210
+ api.logger.error("[soyeht] Failed to auto-generate pairing QR", { err });
211
+ }
212
+ }
213
+
113
214
  api.logger.info("[soyeht] Service started");
114
215
 
115
216
  heartbeatTimer = setInterval(async () => {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.2.0";
1
+ export const PLUGIN_VERSION = "0.3.0";