@soyeht/soyeht 0.1.2 → 0.2.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/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/rpc.ts CHANGED
@@ -9,11 +9,11 @@ import {
9
9
  normalizeAccountId,
10
10
  } from "./config.js";
11
11
  import {
12
- deliverTextMessage,
13
12
  postToBackend,
14
13
  buildOutboundEnvelope,
15
14
  type PostToBackendOptions,
16
15
  } from "./outbound.js";
16
+ import { encryptEnvelopeV2 } from "./envelope-v2.js";
17
17
  import type { TextMessagePayload } from "./types.js";
18
18
  import {
19
19
  base64UrlDecode,
@@ -115,14 +115,6 @@ export function handleNotify(
115
115
  return;
116
116
  }
117
117
 
118
- if (!account.backendBaseUrl || !account.pluginAuthToken) {
119
- respond(false, undefined, {
120
- code: "NOT_CONFIGURED",
121
- message: "Account is not fully configured",
122
- });
123
- return;
124
- }
125
-
126
118
  const to = params["to"] as string;
127
119
  const text = params["text"] as string;
128
120
  if (!to || !text) {
@@ -133,54 +125,67 @@ export function handleNotify(
133
125
  return;
134
126
  }
135
127
 
136
- if (account.security.enabled && v2deps) {
128
+ // Direct mode: encrypt and enqueue if V2 session exists
129
+ if (v2deps) {
137
130
  const ratchetSession = v2deps.sessions.get(account.accountId);
138
- if (!ratchetSession) {
139
- respond(false, undefined, {
140
- code: "SESSION_REQUIRED",
141
- message: "V2 session required for secure delivery",
131
+ if (ratchetSession) {
132
+ if (ratchetSession.expiresAt < Date.now()) {
133
+ respond(false, undefined, {
134
+ code: "SESSION_EXPIRED",
135
+ message: "V2 session has expired, re-handshake required",
136
+ });
137
+ return;
138
+ }
139
+
140
+ const message: TextMessagePayload = { contentType: "text", text };
141
+ const envelope = buildOutboundEnvelope(account.accountId, to, message);
142
+ const { envelope: v2env, updatedSession } = encryptEnvelopeV2({
143
+ session: ratchetSession,
144
+ accountId: account.accountId,
145
+ plaintext: JSON.stringify(envelope),
146
+ dhRatchetCfg: {
147
+ intervalMessages: account.security.dhRatchetIntervalMessages,
148
+ intervalMs: account.security.dhRatchetIntervalMs,
149
+ },
150
+ });
151
+ v2deps.sessions.set(account.accountId, updatedSession);
152
+ const entry = v2deps.outboundQueue.enqueue(account.accountId, v2env);
153
+ respond(true, {
154
+ deliveryId: envelope.deliveryId,
155
+ meta: { transportMode: "direct", queueEntryId: entry.id },
142
156
  });
143
157
  return;
144
158
  }
145
159
 
146
- if (ratchetSession.expiresAt < Date.now()) {
160
+ if (account.security.enabled) {
147
161
  respond(false, undefined, {
148
- code: "SESSION_EXPIRED",
149
- message: "V2 session has expired, re-handshake required",
162
+ code: "SESSION_REQUIRED",
163
+ message: "V2 session required for secure delivery",
150
164
  });
151
165
  return;
152
166
  }
167
+ }
153
168
 
154
- const message: TextMessagePayload = { contentType: "text", text };
155
- const envelope = buildOutboundEnvelope(account.accountId, to, message);
156
- const opts: PostToBackendOptions = {
157
- ratchetSession,
158
- dhRatchetCfg: {
159
- intervalMessages: account.security.dhRatchetIntervalMessages,
160
- intervalMs: account.security.dhRatchetIntervalMs,
161
- },
162
- onSessionUpdated: (updated) => v2deps.sessions.set(account.accountId, updated),
163
- securityEnabled: true,
164
- };
165
- const result = await postToBackend(
166
- account.backendBaseUrl,
167
- account.pluginAuthToken,
168
- envelope,
169
- opts,
170
- );
171
- respond(true, { deliveryId: result.messageId, meta: result.meta });
169
+ // Backend mode fallback
170
+ if (!account.backendBaseUrl || !account.pluginAuthToken) {
171
+ respond(false, undefined, {
172
+ code: "NOT_CONFIGURED",
173
+ message: "Account is not fully configured (no session and no backend)",
174
+ });
172
175
  return;
173
176
  }
174
177
 
175
- const result = await deliverTextMessage({
176
- backendBaseUrl: account.backendBaseUrl,
177
- pluginAuthToken: account.pluginAuthToken,
178
- accountId: account.accountId,
179
- sessionId: to,
180
- to,
181
- text,
182
- });
183
-
178
+ const message: TextMessagePayload = { contentType: "text", text };
179
+ const envelope = buildOutboundEnvelope(account.accountId, to, message);
180
+ const opts: PostToBackendOptions = {
181
+ securityEnabled: false,
182
+ };
183
+ const result = await postToBackend(
184
+ account.backendBaseUrl,
185
+ account.pluginAuthToken,
186
+ envelope,
187
+ opts,
188
+ );
184
189
  respond(true, { deliveryId: result.messageId, meta: result.meta });
185
190
  };
186
191
  }
@@ -456,6 +461,10 @@ export function handleSecurityHandshakeFinish(
456
461
  });
457
462
  }
458
463
 
464
+ // Revoke old stream tokens and issue a fresh one for SSE auth
465
+ v2deps.outboundQueue.revokeStreamTokensForAccount(accountId);
466
+ const streamToken = v2deps.outboundQueue.createStreamToken(accountId, pending.sessionExpiresAt);
467
+
459
468
  api.logger.info("[soyeht] V2 handshake completed", { accountId });
460
469
 
461
470
  respond(true, {
@@ -463,6 +472,7 @@ export function handleSecurityHandshakeFinish(
463
472
  phase: "finish",
464
473
  complete: true,
465
474
  expiresAt: pending.sessionExpiresAt,
475
+ streamToken,
466
476
  });
467
477
  };
468
478
  }