@soyeht/soyeht 0.1.2 → 0.2.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.1.2",
8
+ "version": "0.2.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.1.2",
3
+ "version": "0.2.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/channel.ts CHANGED
@@ -10,8 +10,38 @@ import {
10
10
  buildOutboundEnvelope,
11
11
  postToBackend,
12
12
  } from "./outbound.js";
13
+ import { encryptEnvelopeV2 } from "./envelope-v2.js";
13
14
  import type { SecurityV2Deps } from "./service.js";
14
15
 
16
+ // ---------------------------------------------------------------------------
17
+ // Transport mode detection
18
+ // ---------------------------------------------------------------------------
19
+
20
+ type TransportMode = "direct" | "backend";
21
+
22
+ function resolveTransportMode(
23
+ account: ResolvedSoyehtAccount,
24
+ v2deps?: SecurityV2Deps,
25
+ ): TransportMode {
26
+ const hasSession = v2deps?.sessions.has(account.accountId) ?? false;
27
+ const hasBackend = Boolean(account.backendBaseUrl && account.pluginAuthToken);
28
+ const hasSseSubscribers = v2deps?.outboundQueue.hasSubscribers(account.accountId) ?? false;
29
+
30
+ // Direct mode: active session + SSE client connected (or queue available)
31
+ if (hasSession && (hasSseSubscribers || !hasBackend)) {
32
+ return "direct";
33
+ }
34
+ // Backend mode: backend configured
35
+ if (hasBackend) {
36
+ return "backend";
37
+ }
38
+ // Default: direct (even without SSE subscriber — messages queue for later pickup)
39
+ if (hasSession) {
40
+ return "direct";
41
+ }
42
+ return "backend";
43
+ }
44
+
15
45
  export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<ResolvedSoyehtAccount> {
16
46
  return {
17
47
  id: "soyeht",
@@ -44,15 +74,26 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
44
74
  resolveAccount: (cfg, accountId) => resolveSoyehtAccount(cfg, accountId),
45
75
  defaultAccountId: () => "default",
46
76
  isEnabled: (account) => account.enabled,
47
- isConfigured: (account) => Boolean(account.backendBaseUrl && account.pluginAuthToken),
48
- describeAccount: (account) => ({
49
- accountId: account.accountId,
50
- enabled: account.enabled,
51
- configured: Boolean(account.backendBaseUrl && account.pluginAuthToken),
52
- backendConfigured: Boolean(account.backendBaseUrl),
53
- securityEnabled: account.security.enabled,
54
- allowProactive: account.allowProactive,
55
- }),
77
+ isConfigured: (account) => {
78
+ // Direct mode: active V2 session is sufficient
79
+ const hasSession = v2deps?.sessions.has(account.accountId) ?? false;
80
+ if (hasSession) return true;
81
+ // Backend mode: backendBaseUrl + pluginAuthToken
82
+ return Boolean(account.backendBaseUrl && account.pluginAuthToken);
83
+ },
84
+ describeAccount: (account) => {
85
+ const mode = resolveTransportMode(account, v2deps);
86
+ return {
87
+ accountId: account.accountId,
88
+ enabled: account.enabled,
89
+ configured: Boolean(account.backendBaseUrl && account.pluginAuthToken) ||
90
+ (v2deps?.sessions.has(account.accountId) ?? false),
91
+ backendConfigured: Boolean(account.backendBaseUrl),
92
+ securityEnabled: account.security.enabled,
93
+ allowProactive: account.allowProactive,
94
+ transportMode: mode,
95
+ };
96
+ },
56
97
  },
57
98
 
58
99
  outbound: {
@@ -60,14 +101,6 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
60
101
 
61
102
  async sendText(ctx) {
62
103
  const account = resolveSoyehtAccount(ctx.cfg, ctx.accountId);
63
- if (!account.backendBaseUrl) {
64
- return {
65
- channel: "soyeht",
66
- messageId: randomUUID(),
67
- meta: { error: true, reason: "no_backend_url" },
68
- };
69
- }
70
-
71
104
  const ratchetSession = v2deps?.sessions.get(account.accountId);
72
105
 
73
106
  if (account.security.enabled && !ratchetSession) {
@@ -86,11 +119,25 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
86
119
  };
87
120
  }
88
121
 
122
+ const mode = resolveTransportMode(account, v2deps);
89
123
  const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
90
124
  contentType: "text",
91
125
  text: ctx.text,
92
126
  });
93
127
 
128
+ if (mode === "direct" && ratchetSession && v2deps) {
129
+ return enqueueOutbound(account, envelope, ratchetSession, v2deps);
130
+ }
131
+
132
+ // Backend mode (or no session for direct)
133
+ if (!account.backendBaseUrl) {
134
+ return {
135
+ channel: "soyeht",
136
+ messageId: randomUUID(),
137
+ meta: { error: true, reason: "no_backend_url" },
138
+ };
139
+ }
140
+
94
141
  return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
95
142
  ratchetSession,
96
143
  dhRatchetCfg: {
@@ -106,11 +153,11 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
106
153
 
107
154
  async sendMedia(ctx) {
108
155
  const account = resolveSoyehtAccount(ctx.cfg, ctx.accountId);
109
- if (!account.backendBaseUrl || !ctx.mediaUrl) {
156
+ if (!ctx.mediaUrl) {
110
157
  return {
111
158
  channel: "soyeht",
112
159
  messageId: randomUUID(),
113
- meta: { error: true, reason: !ctx.mediaUrl ? "no_media_url" : "no_backend_url" },
160
+ meta: { error: true, reason: "no_media_url" },
114
161
  };
115
162
  }
116
163
 
@@ -132,6 +179,7 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
132
179
  };
133
180
  }
134
181
 
182
+ const mode = resolveTransportMode(account, v2deps);
135
183
  const envelope = buildOutboundEnvelope(account.accountId, ctx.to, {
136
184
  contentType: "audio",
137
185
  renderStyle: "voice_note",
@@ -140,6 +188,19 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
140
188
  url: ctx.mediaUrl,
141
189
  });
142
190
 
191
+ if (mode === "direct" && ratchetSession && v2deps) {
192
+ return enqueueOutbound(account, envelope, ratchetSession, v2deps);
193
+ }
194
+
195
+ // Backend mode
196
+ if (!account.backendBaseUrl) {
197
+ return {
198
+ channel: "soyeht",
199
+ messageId: randomUUID(),
200
+ meta: { error: true, reason: "no_backend_url" },
201
+ };
202
+ }
203
+
143
204
  return postToBackend(account.backendBaseUrl, account.pluginAuthToken, envelope, {
144
205
  ratchetSession,
145
206
  dhRatchetCfg: {
@@ -155,3 +216,44 @@ export function createSoyehtChannel(v2deps?: SecurityV2Deps): ChannelPlugin<Reso
155
216
  },
156
217
  };
157
218
  }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Encrypt + enqueue for direct mode (outbound via SSE)
222
+ // ---------------------------------------------------------------------------
223
+
224
+ import type { OutboundEnvelope } from "./types.js";
225
+ import type { RatchetState } from "./ratchet.js";
226
+
227
+ function enqueueOutbound(
228
+ account: ResolvedSoyehtAccount,
229
+ envelope: OutboundEnvelope,
230
+ ratchetSession: RatchetState,
231
+ v2deps: SecurityV2Deps,
232
+ ): { channel: string; messageId: string; meta?: Record<string, unknown> } {
233
+ if (ratchetSession.expiresAt < Date.now()) {
234
+ return {
235
+ channel: "soyeht",
236
+ messageId: envelope.deliveryId,
237
+ meta: { error: true, reason: "session_expired" },
238
+ };
239
+ }
240
+
241
+ const { envelope: v2env, updatedSession } = encryptEnvelopeV2({
242
+ session: ratchetSession,
243
+ accountId: envelope.accountId,
244
+ plaintext: JSON.stringify(envelope),
245
+ dhRatchetCfg: {
246
+ intervalMessages: account.security.dhRatchetIntervalMessages,
247
+ intervalMs: account.security.dhRatchetIntervalMs,
248
+ },
249
+ });
250
+ v2deps.sessions.set(account.accountId, updatedSession);
251
+
252
+ const entry = v2deps.outboundQueue.enqueue(account.accountId, v2env);
253
+
254
+ return {
255
+ channel: "soyeht",
256
+ messageId: envelope.deliveryId,
257
+ meta: { transportMode: "direct", queueEntryId: entry.id },
258
+ };
259
+ }
package/src/config.ts CHANGED
@@ -23,6 +23,7 @@ export type ResolvedSoyehtAccount = {
23
23
  enabled: boolean;
24
24
  backendBaseUrl: string;
25
25
  pluginAuthToken: string;
26
+ gatewayUrl: string;
26
27
  allowProactive: boolean;
27
28
  audio: {
28
29
  transcribeInbound: boolean;
@@ -133,6 +134,8 @@ export function resolveSoyehtAccount(
133
134
  : "",
134
135
  pluginAuthToken:
135
136
  typeof raw["pluginAuthToken"] === "string" ? raw["pluginAuthToken"] : "",
137
+ gatewayUrl:
138
+ typeof raw["gatewayUrl"] === "string" ? raw["gatewayUrl"] : "",
136
139
  allowProactive:
137
140
  typeof raw["allowProactive"] === "boolean" ? raw["allowProactive"] : false,
138
141
  audio: {
package/src/http.ts CHANGED
@@ -43,6 +43,67 @@ async function readRawBodyBuffer(req: IncomingMessage): Promise<Buffer> {
43
43
 
44
44
  export { readRawBodyBuffer };
45
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // Shared: process an inbound EnvelopeV2 (validate + decrypt + update session)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type ProcessInboundResult =
51
+ | { ok: true; plaintext: string; accountId: string; envelope: EnvelopeV2 }
52
+ | { ok: false; status: number; error: string };
53
+
54
+ export function processInboundEnvelope(
55
+ api: OpenClawPluginApi,
56
+ v2deps: SecurityV2Deps,
57
+ envelope: EnvelopeV2,
58
+ hintedAccountId?: string,
59
+ ): ProcessInboundResult {
60
+ const envelopeAccountId = normalizeAccountId(envelope.accountId);
61
+ if (hintedAccountId && hintedAccountId !== envelopeAccountId) {
62
+ return { ok: false, status: 401, error: "account_mismatch" };
63
+ }
64
+
65
+ const accountId = hintedAccountId ?? envelopeAccountId;
66
+ const session = v2deps.sessions.get(accountId);
67
+ if (!session) {
68
+ return { ok: false, status: 401, error: "session_required" };
69
+ }
70
+
71
+ if (session.expiresAt < Date.now()) {
72
+ return { ok: false, status: 401, error: "session_expired" };
73
+ }
74
+
75
+ if (session.accountId !== envelopeAccountId) {
76
+ return { ok: false, status: 401, error: "account_mismatch" };
77
+ }
78
+
79
+ const validation = validateEnvelopeV2(envelope, session);
80
+ if (!validation.valid) {
81
+ api.logger.warn("[soyeht] Envelope validation failed", { error: validation.error, accountId });
82
+ return { ok: false, status: 401, error: validation.error };
83
+ }
84
+
85
+ // Deep-clone session before decryption so in-place Buffer mutations
86
+ // cannot corrupt the stored session on failure.
87
+ const sessionClone = cloneRatchetSession(session);
88
+
89
+ let plaintext: string;
90
+ let updatedSession;
91
+ try {
92
+ const result = decryptEnvelopeV2({ session: sessionClone, envelope });
93
+ plaintext = result.plaintext;
94
+ updatedSession = result.updatedSession;
95
+ } catch (err) {
96
+ const msg = err instanceof Error ? err.message : "decryption_failed";
97
+ api.logger.warn("[soyeht] Envelope decryption failed", { error: msg, accountId });
98
+ return { ok: false, status: 401, error: msg };
99
+ }
100
+
101
+ // Update session
102
+ v2deps.sessions.set(accountId, updatedSession);
103
+
104
+ return { ok: true, plaintext, accountId, envelope };
105
+ }
106
+
46
107
  // ---------------------------------------------------------------------------
47
108
  // GET /soyeht/health
48
109
  // ---------------------------------------------------------------------------
@@ -54,7 +115,7 @@ export function healthHandler(_api: OpenClawPluginApi) {
54
115
  }
55
116
 
56
117
  // ---------------------------------------------------------------------------
57
- // POST /soyeht/webhook/deliver
118
+ // POST /soyeht/webhook/deliver (legacy backend mode)
58
119
  // ---------------------------------------------------------------------------
59
120
 
60
121
  export function webhookHandler(
@@ -89,7 +150,6 @@ export function webhookHandler(
89
150
  try {
90
151
  const rawBody = await readRawBodyBuffer(req);
91
152
 
92
- // Parse the envelope
93
153
  let envelope: EnvelopeV2;
94
154
  try {
95
155
  envelope = JSON.parse(rawBody.toString("utf8")) as EnvelopeV2;
@@ -98,60 +158,76 @@ export function webhookHandler(
98
158
  return;
99
159
  }
100
160
 
101
- const envelopeAccountId = normalizeAccountId(envelope.accountId);
102
- if (hintedAccountId && hintedAccountId !== envelopeAccountId) {
103
- sendJson(res, 401, { ok: false, error: "account_mismatch" });
161
+ const result = processInboundEnvelope(api, v2deps, envelope, hintedAccountId);
162
+ if (!result.ok) {
163
+ sendJson(res, result.status, { ok: false, error: result.error });
104
164
  return;
105
165
  }
106
166
 
107
- const accountId = hintedAccountId ?? envelopeAccountId;
108
- const session = v2deps.sessions.get(accountId);
109
- if (!session) {
110
- sendJson(res, 401, { ok: false, error: "session_required" });
111
- return;
112
- }
167
+ api.logger.info("[soyeht] Webhook delivery received", { accountId: result.accountId });
168
+ sendJson(res, 200, { ok: true, received: true });
169
+ } catch (err) {
170
+ const message = err instanceof Error ? err.message : "unknown_error";
171
+ sendJson(res, 400, { ok: false, error: message });
172
+ }
173
+ };
174
+ }
113
175
 
114
- if (session.expiresAt < Date.now()) {
115
- sendJson(res, 401, { ok: false, error: "session_expired" });
116
- return;
117
- }
176
+ // ---------------------------------------------------------------------------
177
+ // POST /soyeht/messages/inbound (direct mode app plugin agent)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export function inboundHandler(
181
+ api: OpenClawPluginApi,
182
+ v2deps: SecurityV2Deps,
183
+ ) {
184
+ return async (req: IncomingMessage, res: ServerResponse) => {
185
+ if (req.method !== "POST") {
186
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
187
+ return;
188
+ }
189
+
190
+ if (!v2deps.ready) {
191
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
192
+ return;
193
+ }
118
194
 
119
- if (session.accountId !== envelopeAccountId) {
120
- sendJson(res, 401, { ok: false, error: "account_mismatch" });
195
+ try {
196
+ const rawBody = await readRawBodyBuffer(req);
197
+
198
+ let envelope: EnvelopeV2;
199
+ try {
200
+ envelope = JSON.parse(rawBody.toString("utf8")) as EnvelopeV2;
201
+ } catch {
202
+ sendJson(res, 400, { ok: false, error: "invalid_json" });
121
203
  return;
122
204
  }
123
205
 
124
- // Validate envelope (trust only the parsed body — no header overrides)
125
- const validation = validateEnvelopeV2(envelope, session);
126
- if (!validation.valid) {
127
- api.logger.warn("[soyeht] Webhook validation failed", { error: validation.error, accountId });
128
- sendJson(res, 401, { ok: false, error: validation.error });
206
+ const envelopeAccountId = normalizeAccountId(envelope.accountId);
207
+
208
+ // Rate limit per-account
209
+ const { allowed, retryAfterMs } = v2deps.rateLimiter.check(`inbound:${envelopeAccountId}`);
210
+ if (!allowed) {
211
+ res.setHeader("Retry-After", String(Math.ceil((retryAfterMs ?? 60_000) / 1000)));
212
+ sendJson(res, 429, { ok: false, error: "rate_limited" });
129
213
  return;
130
214
  }
131
215
 
132
- // Deep-clone session before decryption so in-place Buffer mutations
133
- // (rootKey.fill(0), chainKey.fill(0)) cannot corrupt the stored session on failure.
134
- const sessionClone = cloneRatchetSession(session);
135
-
136
- // Decrypt
137
- let plaintext: string;
138
- let updatedSession;
139
- try {
140
- const result = decryptEnvelopeV2({ session: sessionClone, envelope });
141
- plaintext = result.plaintext;
142
- updatedSession = result.updatedSession;
143
- } catch (err) {
144
- const msg = err instanceof Error ? err.message : "decryption_failed";
145
- api.logger.warn("[soyeht] Webhook decryption failed", { error: msg, accountId });
146
- sendJson(res, 401, { ok: false, error: msg });
216
+ const result = processInboundEnvelope(api, v2deps, envelope);
217
+ if (!result.ok) {
218
+ sendJson(res, result.status, { ok: false, error: result.error });
147
219
  return;
148
220
  }
149
221
 
150
- // Update session
151
- v2deps.sessions.set(accountId, updatedSession);
222
+ // TODO: Push decrypted message to OpenClaw agent pipeline.
223
+ // The exact API depends on the OpenClaw runtime (e.g. api.channel.pushInbound()).
224
+ // For now the message is decrypted and the session is updated;
225
+ // wiring to the agent conversation will be done when the runtime API is known.
226
+ api.logger.info("[soyeht] Inbound message received (direct mode)", {
227
+ accountId: result.accountId,
228
+ });
152
229
 
153
- api.logger.info("[soyeht] Webhook delivery received", { accountId });
154
- sendJson(res, 200, { ok: true, received: true });
230
+ sendJson(res, 200, { ok: true, received: true, accountId: result.accountId });
155
231
  } catch (err) {
156
232
  const message = err instanceof Error ? err.message : "unknown_error";
157
233
  sendJson(res, 400, { ok: false, error: message });
@@ -159,6 +235,112 @@ export function webhookHandler(
159
235
  };
160
236
  }
161
237
 
238
+ // ---------------------------------------------------------------------------
239
+ // GET /soyeht/events/:accountId (SSE — plugin → app outbound stream)
240
+ // ---------------------------------------------------------------------------
241
+
242
+ const SSE_KEEPALIVE_MS = 30_000;
243
+
244
+ export function sseHandler(
245
+ api: OpenClawPluginApi,
246
+ v2deps: SecurityV2Deps,
247
+ ) {
248
+ return async (req: IncomingMessage, res: ServerResponse) => {
249
+ if (req.method !== "GET") {
250
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
251
+ return;
252
+ }
253
+
254
+ if (!v2deps.ready) {
255
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
256
+ return;
257
+ }
258
+
259
+ // Extract accountId from URL: /soyeht/events/<accountId>
260
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
261
+ const pathParts = url.pathname.split("/").filter(Boolean);
262
+ // Expected: ["soyeht", "events", "<accountId>"]
263
+ const accountId = pathParts.length >= 3 ? normalizeAccountId(pathParts[2]) : undefined;
264
+ if (!accountId) {
265
+ sendJson(res, 400, { ok: false, error: "missing_account_id" });
266
+ return;
267
+ }
268
+
269
+ // Auth: validate stream token from Authorization header or query param
270
+ const authHeader = req.headers.authorization ?? "";
271
+ const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
272
+ const token = bearerMatch?.[1] ?? url.searchParams.get("token") ?? "";
273
+
274
+ if (!token) {
275
+ sendJson(res, 401, { ok: false, error: "stream_token_required" });
276
+ return;
277
+ }
278
+
279
+ const tokenInfo = v2deps.outboundQueue.validateStreamToken(token);
280
+ if (!tokenInfo) {
281
+ sendJson(res, 401, { ok: false, error: "invalid_stream_token" });
282
+ return;
283
+ }
284
+
285
+ if (tokenInfo.accountId !== accountId) {
286
+ sendJson(res, 403, { ok: false, error: "token_account_mismatch" });
287
+ return;
288
+ }
289
+
290
+ // Verify active session exists
291
+ const session = v2deps.sessions.get(accountId);
292
+ if (!session || session.expiresAt < Date.now()) {
293
+ sendJson(res, 401, { ok: false, error: "session_required" });
294
+ return;
295
+ }
296
+
297
+ // SSE headers
298
+ res.writeHead(200, {
299
+ "Content-Type": "text/event-stream",
300
+ "Cache-Control": "no-cache",
301
+ Connection: "keep-alive",
302
+ "X-Accel-Buffering": "no",
303
+ });
304
+ res.write(`retry: 3000\n\n`);
305
+
306
+ api.logger.info("[soyeht] SSE client connected", { accountId });
307
+
308
+ // Subscribe to outbound queue
309
+ const subscription = v2deps.outboundQueue.subscribe(accountId);
310
+
311
+ // Keepalive ping
312
+ const keepalive = setInterval(() => {
313
+ if (!res.destroyed) {
314
+ res.write(`: ping\n\n`);
315
+ }
316
+ }, SSE_KEEPALIVE_MS);
317
+ if (typeof keepalive === "object" && "unref" in keepalive) {
318
+ keepalive.unref();
319
+ }
320
+
321
+ // Client disconnect
322
+ const onClose = () => {
323
+ clearInterval(keepalive);
324
+ subscription.unsubscribe();
325
+ api.logger.info("[soyeht] SSE client disconnected", { accountId });
326
+ };
327
+
328
+ req.on("close", onClose);
329
+ res.on("close", onClose);
330
+
331
+ // Stream messages
332
+ try {
333
+ for await (const entry of subscription) {
334
+ if (res.destroyed) break;
335
+ const data = JSON.stringify(entry.data);
336
+ res.write(`id: ${entry.id}\ndata: ${data}\n\n`);
337
+ }
338
+ } catch {
339
+ // subscription closed or error — clean up handled by onClose
340
+ }
341
+ };
342
+ }
343
+
162
344
  // ---------------------------------------------------------------------------
163
345
  // POST /soyeht/livekit/token — stub
164
346
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  healthHandler,
14
14
  webhookHandler,
15
15
  livekitTokenHandler,
16
+ inboundHandler,
17
+ sseHandler,
16
18
  } from "./http.js";
17
19
  import {
18
20
  createSoyehtService,
@@ -71,7 +73,7 @@ const soyehtPlugin: OpenClawPluginDefinition = {
71
73
 
72
74
  configSchema: emptyPluginConfigSchema,
73
75
 
74
- async register(api) {
76
+ register(api) {
75
77
  // V2 deps — identity/sessions loaded in service.start()
76
78
  const v2deps = createSecurityV2Deps();
77
79
 
@@ -110,6 +112,19 @@ const soyehtPlugin: OpenClawPluginDefinition = {
110
112
  handler: livekitTokenHandler(api),
111
113
  });
112
114
 
115
+ // Direct mode routes (app ↔ plugin)
116
+ api.registerHttpRoute({
117
+ path: "/soyeht/messages/inbound",
118
+ auth: "plugin",
119
+ handler: inboundHandler(api, v2deps),
120
+ });
121
+ api.registerHttpRoute({
122
+ path: "/soyeht/events",
123
+ auth: "plugin",
124
+ match: "prefix",
125
+ handler: sseHandler(api, v2deps),
126
+ });
127
+
113
128
  // Background service (manages state lifecycle)
114
129
  api.registerService(createSoyehtService(api, v2deps));
115
130
 
@@ -0,0 +1,230 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type QueueEntry = {
8
+ id: string;
9
+ accountId: string;
10
+ data: unknown; // EnvelopeV2 JSON
11
+ enqueuedAt: number;
12
+ };
13
+
14
+ type Subscriber = {
15
+ accountId: string;
16
+ push: (entry: QueueEntry) => void;
17
+ closed: boolean;
18
+ };
19
+
20
+ export type StreamTokenInfo = {
21
+ accountId: string;
22
+ expiresAt: number;
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // OutboundQueue — in-memory, bounded, per-accountId
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const DEFAULT_MAX_PER_ACCOUNT = 1000;
30
+ const DEFAULT_TTL_MS = 3_600_000; // 1 hour
31
+
32
+ export type OutboundQueueOptions = {
33
+ maxPerAccount?: number;
34
+ ttlMs?: number;
35
+ };
36
+
37
+ export type OutboundQueue = {
38
+ enqueue(accountId: string, data: unknown): QueueEntry;
39
+ subscribe(accountId: string): AsyncIterable<QueueEntry> & { unsubscribe: () => void };
40
+ hasSubscribers(accountId: string): boolean;
41
+ prune(): number;
42
+ clear(): void;
43
+
44
+ // Stream token management (for SSE auth)
45
+ createStreamToken(accountId: string, expiresAt: number): string;
46
+ validateStreamToken(token: string): StreamTokenInfo | null;
47
+ revokeStreamTokensForAccount(accountId: string): void;
48
+ };
49
+
50
+ export function createOutboundQueue(opts: OutboundQueueOptions = {}): OutboundQueue {
51
+ const maxPerAccount = opts.maxPerAccount ?? DEFAULT_MAX_PER_ACCOUNT;
52
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
53
+
54
+ const queues = new Map<string, QueueEntry[]>();
55
+ const subscribers = new Map<string, Set<Subscriber>>();
56
+ const streamTokens = new Map<string, StreamTokenInfo>();
57
+
58
+ function enqueue(accountId: string, data: unknown): QueueEntry {
59
+ const entry: QueueEntry = {
60
+ id: randomBytes(16).toString("hex"),
61
+ accountId,
62
+ data,
63
+ enqueuedAt: Date.now(),
64
+ };
65
+
66
+ let q = queues.get(accountId);
67
+ if (!q) {
68
+ q = [];
69
+ queues.set(accountId, q);
70
+ }
71
+ q.push(entry);
72
+
73
+ // FIFO overflow
74
+ while (q.length > maxPerAccount) {
75
+ q.shift();
76
+ }
77
+
78
+ // Push to live subscribers
79
+ const subs = subscribers.get(accountId);
80
+ if (subs) {
81
+ for (const sub of subs) {
82
+ if (!sub.closed) {
83
+ sub.push(entry);
84
+ }
85
+ }
86
+ }
87
+
88
+ return entry;
89
+ }
90
+
91
+ function subscribe(accountId: string): AsyncIterable<QueueEntry> & { unsubscribe: () => void } {
92
+ const pending: QueueEntry[] = [];
93
+ let waiting: ((value: IteratorResult<QueueEntry>) => void) | null = null;
94
+ let closed = false;
95
+
96
+ const sub: Subscriber = {
97
+ accountId,
98
+ push(entry: QueueEntry) {
99
+ if (closed) return;
100
+ if (waiting) {
101
+ const resolve = waiting;
102
+ waiting = null;
103
+ resolve({ value: entry, done: false });
104
+ } else {
105
+ pending.push(entry);
106
+ }
107
+ },
108
+ closed: false,
109
+ };
110
+
111
+ let subs = subscribers.get(accountId);
112
+ if (!subs) {
113
+ subs = new Set();
114
+ subscribers.set(accountId, subs);
115
+ }
116
+ subs.add(sub);
117
+
118
+ function unsubscribe() {
119
+ closed = true;
120
+ sub.closed = true;
121
+ const s = subscribers.get(accountId);
122
+ if (s) {
123
+ s.delete(sub);
124
+ if (s.size === 0) subscribers.delete(accountId);
125
+ }
126
+ if (waiting) {
127
+ waiting({ value: undefined as unknown as QueueEntry, done: true });
128
+ waiting = null;
129
+ }
130
+ }
131
+
132
+ return {
133
+ unsubscribe,
134
+ [Symbol.asyncIterator]() {
135
+ return {
136
+ next(): Promise<IteratorResult<QueueEntry>> {
137
+ if (closed) {
138
+ return Promise.resolve({ value: undefined as unknown as QueueEntry, done: true });
139
+ }
140
+ if (pending.length > 0) {
141
+ return Promise.resolve({ value: pending.shift()!, done: false });
142
+ }
143
+ return new Promise<IteratorResult<QueueEntry>>((resolve) => {
144
+ waiting = resolve;
145
+ });
146
+ },
147
+ return(): Promise<IteratorResult<QueueEntry>> {
148
+ unsubscribe();
149
+ return Promise.resolve({ value: undefined as unknown as QueueEntry, done: true });
150
+ },
151
+ };
152
+ },
153
+ };
154
+ }
155
+
156
+ function hasSubscribers(accountId: string): boolean {
157
+ const subs = subscribers.get(accountId);
158
+ return Boolean(subs && subs.size > 0);
159
+ }
160
+
161
+ function prune(): number {
162
+ const now = Date.now();
163
+ let pruned = 0;
164
+ for (const [accountId, q] of queues) {
165
+ const before = q.length;
166
+ const filtered = q.filter((e) => now - e.enqueuedAt < ttlMs);
167
+ pruned += before - filtered.length;
168
+ if (filtered.length === 0) {
169
+ queues.delete(accountId);
170
+ } else if (filtered.length !== before) {
171
+ queues.set(accountId, filtered);
172
+ }
173
+ }
174
+
175
+ // Prune expired stream tokens
176
+ for (const [token, info] of streamTokens) {
177
+ if (info.expiresAt < now) {
178
+ streamTokens.delete(token);
179
+ }
180
+ }
181
+
182
+ return pruned;
183
+ }
184
+
185
+ function clear(): void {
186
+ queues.clear();
187
+ for (const subs of subscribers.values()) {
188
+ for (const sub of subs) {
189
+ sub.closed = true;
190
+ }
191
+ }
192
+ subscribers.clear();
193
+ streamTokens.clear();
194
+ }
195
+
196
+ function createStreamToken(accountId: string, expiresAt: number): string {
197
+ const token = randomBytes(32).toString("hex");
198
+ streamTokens.set(token, { accountId, expiresAt });
199
+ return token;
200
+ }
201
+
202
+ function validateStreamToken(token: string): StreamTokenInfo | null {
203
+ const info = streamTokens.get(token);
204
+ if (!info) return null;
205
+ if (info.expiresAt < Date.now()) {
206
+ streamTokens.delete(token);
207
+ return null;
208
+ }
209
+ return info;
210
+ }
211
+
212
+ function revokeStreamTokensForAccount(accountId: string): void {
213
+ for (const [token, info] of streamTokens) {
214
+ if (info.accountId === accountId) {
215
+ streamTokens.delete(token);
216
+ }
217
+ }
218
+ }
219
+
220
+ return {
221
+ enqueue,
222
+ subscribe,
223
+ hasSubscribers,
224
+ prune,
225
+ clear,
226
+ createStreamToken,
227
+ validateStreamToken,
228
+ revokeStreamTokensForAccount,
229
+ };
230
+ }
package/src/pairing.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  importEd25519PublicKey,
11
11
  importX25519PublicKey,
12
12
  } from "./crypto.js";
13
- import { normalizeAccountId } from "./config.js";
13
+ import { normalizeAccountId, resolveSoyehtAccount } from "./config.js";
14
14
  import { deleteSession, savePeer, type PeerIdentity } from "./identity.js";
15
15
  import { zeroBuffer } from "./ratchet.js";
16
16
  import type { SecurityV2Deps } from "./service.js";
@@ -26,6 +26,7 @@ function clampPairingTtlMs(value: unknown): number {
26
26
  return Math.min(Math.max(Math.trunc(value), MIN_PAIRING_TTL_MS), MAX_PAIRING_TTL_MS);
27
27
  }
28
28
 
29
+ // V1 transcript (backward compat — no gatewayUrl)
29
30
  export function buildPairingQrTranscript(params: {
30
31
  accountId: string;
31
32
  pairingToken: string;
@@ -50,6 +51,33 @@ export function buildPairingQrTranscript(params: {
50
51
  );
51
52
  }
52
53
 
54
+ // V2 transcript — includes gatewayUrl
55
+ export function buildPairingQrTranscriptV2(params: {
56
+ gatewayUrl: string;
57
+ accountId: string;
58
+ pairingToken: string;
59
+ expiresAt: number;
60
+ allowOverwrite: boolean;
61
+ pluginIdentityKey: string;
62
+ pluginDhKey: string;
63
+ fingerprint: string;
64
+ }): Buffer {
65
+ const {
66
+ gatewayUrl,
67
+ accountId,
68
+ pairingToken,
69
+ expiresAt,
70
+ allowOverwrite,
71
+ pluginIdentityKey,
72
+ pluginDhKey,
73
+ fingerprint,
74
+ } = params;
75
+ return Buffer.from(
76
+ `pairing_qr_v2|${gatewayUrl}|${accountId}|${pairingToken}|${expiresAt}|${allowOverwrite ? 1 : 0}|${pluginIdentityKey}|${pluginDhKey}|${fingerprint}`,
77
+ "utf8",
78
+ );
79
+ }
80
+
53
81
  export function buildPairingProofTranscript(params: {
54
82
  accountId: string;
55
83
  pairingToken: string;
@@ -64,11 +92,11 @@ export function buildPairingProofTranscript(params: {
64
92
  );
65
93
  }
66
94
 
67
- function signPairingQrPayload(
95
+ function signTranscript(
68
96
  privateKey: Parameters<typeof ed25519Sign>[0],
69
- payload: Parameters<typeof buildPairingQrTranscript>[0],
97
+ transcript: Buffer,
70
98
  ): string {
71
- return base64UrlEncode(ed25519Sign(privateKey, buildPairingQrTranscript(payload)));
99
+ return base64UrlEncode(ed25519Sign(privateKey, transcript));
72
100
  }
73
101
 
74
102
  function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): void {
@@ -87,6 +115,27 @@ function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): vo
87
115
  }
88
116
  }
89
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // Resolve the gateway URL for QR V2
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function resolveGatewayUrl(api: OpenClawPluginApi, configGatewayUrl: string): string {
123
+ // 1) Explicit config
124
+ if (configGatewayUrl) return configGatewayUrl;
125
+
126
+ // 2) Attempt auto-detection from runtime (if available)
127
+ const runtime = api.runtime as Record<string, unknown>;
128
+ if (typeof runtime["gatewayUrl"] === "string" && runtime["gatewayUrl"]) {
129
+ return runtime["gatewayUrl"];
130
+ }
131
+ if (typeof runtime["baseUrl"] === "string" && runtime["baseUrl"]) {
132
+ return runtime["baseUrl"];
133
+ }
134
+
135
+ // 3) Empty — the app will need to be configured manually
136
+ return "";
137
+ }
138
+
90
139
  // ---------------------------------------------------------------------------
91
140
  // soyeht.security.identity — expose plugin public keys
92
141
  // ---------------------------------------------------------------------------
@@ -124,7 +173,7 @@ export function handleSecurityIdentity(
124
173
  // ---------------------------------------------------------------------------
125
174
 
126
175
  export function handleSecurityPairingStart(
127
- _api: OpenClawPluginApi,
176
+ api: OpenClawPluginApi,
128
177
  v2deps: SecurityV2Deps,
129
178
  ): GatewayRequestHandler {
130
179
  return async ({ params, respond }) => {
@@ -163,9 +212,13 @@ export function handleSecurityPairingStart(
163
212
  const pairingToken = base64UrlEncode(randomBytes(32));
164
213
  const expiresAt = Date.now() + ttlMs;
165
214
  const fingerprint = computeFingerprint(v2deps.identity);
166
- const payload = {
167
- version: 1 as const,
168
- type: "soyeht_pairing_qr" as const,
215
+
216
+ // Resolve gatewayUrl for QR V2
217
+ const cfg = await api.runtime.config.loadConfig();
218
+ const account = resolveSoyehtAccount(cfg, accountId);
219
+ const gatewayUrl = resolveGatewayUrl(api, account.gatewayUrl);
220
+
221
+ const basePayload = {
169
222
  accountId,
170
223
  pairingToken,
171
224
  expiresAt,
@@ -174,8 +227,31 @@ export function handleSecurityPairingStart(
174
227
  pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
175
228
  fingerprint,
176
229
  };
177
- const signature = signPairingQrPayload(v2deps.identity.signKey.privateKey, payload);
178
- const qrPayload = { ...payload, signature };
230
+
231
+ let qrPayload: Record<string, unknown>;
232
+
233
+ if (gatewayUrl) {
234
+ // QR V2 — includes gatewayUrl
235
+ const transcript = buildPairingQrTranscriptV2({ gatewayUrl, ...basePayload });
236
+ const signature = signTranscript(v2deps.identity.signKey.privateKey, transcript);
237
+ qrPayload = {
238
+ version: 2,
239
+ type: "soyeht_pairing_qr",
240
+ gatewayUrl,
241
+ ...basePayload,
242
+ signature,
243
+ };
244
+ } else {
245
+ // QR V1 fallback — no gatewayUrl available
246
+ const transcript = buildPairingQrTranscript(basePayload);
247
+ const signature = signTranscript(v2deps.identity.signKey.privateKey, transcript);
248
+ qrPayload = {
249
+ version: 1,
250
+ type: "soyeht_pairing_qr",
251
+ ...basePayload,
252
+ signature,
253
+ };
254
+ }
179
255
 
180
256
  v2deps.pairingSessions.set(pairingToken, {
181
257
  token: pairingToken,
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
  }
package/src/service.ts CHANGED
@@ -12,6 +12,7 @@ import { zeroBuffer } from "./ratchet.js";
12
12
  import { computeFingerprint, type X25519KeyPair } from "./crypto.js";
13
13
  import type { IdentityBundle, PeerIdentity } from "./identity.js";
14
14
  import type { RatchetState } from "./ratchet.js";
15
+ import { createOutboundQueue, type OutboundQueue } from "./outbound-queue.js";
15
16
 
16
17
  const HEARTBEAT_INTERVAL_MS = 60_000; // 60s
17
18
 
@@ -27,6 +28,7 @@ export type SecurityV2Deps = {
27
28
  pendingHandshakes: Map<string, PendingHandshake>;
28
29
  nonceCache: NonceCache;
29
30
  rateLimiter: RateLimiter;
31
+ outboundQueue: OutboundQueue;
30
32
  ready: boolean;
31
33
  stateDir?: string;
32
34
  };
@@ -58,6 +60,7 @@ export function createSecurityV2Deps(): SecurityV2Deps {
58
60
  pendingHandshakes: new Map(),
59
61
  nonceCache: createNonceCache(),
60
62
  rateLimiter: createRateLimiter(),
63
+ outboundQueue: createOutboundQueue(),
61
64
  ready: false,
62
65
  };
63
66
  }
@@ -115,6 +118,7 @@ export function createSoyehtService(
115
118
  const now = Date.now();
116
119
  v2deps.nonceCache.prune();
117
120
  v2deps.rateLimiter.prune();
121
+ v2deps.outboundQueue.prune();
118
122
  for (const [token, session] of v2deps.pairingSessions) {
119
123
  if (session.expiresAt <= now) {
120
124
  v2deps.pairingSessions.delete(token);
@@ -168,6 +172,7 @@ export function createSoyehtService(
168
172
  v2deps.peers.clear();
169
173
  v2deps.pairingSessions.clear();
170
174
  v2deps.pendingHandshakes.clear();
175
+ v2deps.outboundQueue.clear();
171
176
  v2deps.ready = false;
172
177
  }
173
178
 
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ export type SoyehtAccountConfig = {
8
8
  enabled?: boolean;
9
9
  backendBaseUrl?: string;
10
10
  pluginAuthToken?: string;
11
+ gatewayUrl?: string;
11
12
  allowProactive?: boolean;
12
13
  audio?: {
13
14
  transcribeInbound?: boolean;
@@ -37,6 +38,7 @@ export const SoyehtAccountConfigSchema: JsonSchema = {
37
38
  enabled: { type: "boolean" },
38
39
  backendBaseUrl: { type: "string" },
39
40
  pluginAuthToken: { type: "string" },
41
+ gatewayUrl: { type: "string" },
40
42
  allowProactive: { type: "boolean" },
41
43
  audio: {
42
44
  type: "object",
@@ -367,6 +369,51 @@ export const PairingQrPayloadSchema: JsonSchema = {
367
369
  },
368
370
  };
369
371
 
372
+ export type PairingQrPayloadV2 = {
373
+ version: 2;
374
+ type: "soyeht_pairing_qr";
375
+ gatewayUrl: string;
376
+ accountId: string;
377
+ pairingToken: string;
378
+ expiresAt: number;
379
+ allowOverwrite: boolean;
380
+ pluginIdentityKey: string;
381
+ pluginDhKey: string;
382
+ fingerprint: string;
383
+ signature: string;
384
+ };
385
+
386
+ export const PairingQrPayloadV2Schema: JsonSchema = {
387
+ type: "object",
388
+ additionalProperties: false,
389
+ required: [
390
+ "version",
391
+ "type",
392
+ "gatewayUrl",
393
+ "accountId",
394
+ "pairingToken",
395
+ "expiresAt",
396
+ "allowOverwrite",
397
+ "pluginIdentityKey",
398
+ "pluginDhKey",
399
+ "fingerprint",
400
+ "signature",
401
+ ],
402
+ properties: {
403
+ version: { const: 2 },
404
+ type: { const: "soyeht_pairing_qr" },
405
+ gatewayUrl: { type: "string", minLength: 1 },
406
+ accountId: { type: "string" },
407
+ pairingToken: { type: "string" },
408
+ expiresAt: { type: "number" },
409
+ allowOverwrite: { type: "boolean" },
410
+ pluginIdentityKey: { type: "string" },
411
+ pluginDhKey: { type: "string" },
412
+ fingerprint: { type: "string" },
413
+ signature: { type: "string" },
414
+ },
415
+ };
416
+
370
417
  export type HandshakeFinishV2 = {
371
418
  version: 2;
372
419
  accountId: string;
@@ -397,6 +444,11 @@ export const SOYEHT_CAPABILITIES = {
397
444
  voiceContractVersion: 1,
398
445
  pipeline: "stt->llm->tts" as const,
399
446
  supportedContentTypes: ["text", "audio", "file"] as const,
447
+ transport: {
448
+ direct: true,
449
+ backend: true,
450
+ defaultMode: "direct" as const,
451
+ },
400
452
  security: {
401
453
  version: 2,
402
454
  pairingMode: "qr_token" as const,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.1.2";
1
+ export const PLUGIN_VERSION = "0.2.0";