@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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.ts +121 -19
- package/src/config.ts +3 -0
- package/src/http.ts +223 -41
- package/src/index.ts +16 -1
- package/src/outbound-queue.ts +230 -0
- package/src/pairing.ts +86 -10
- package/src/rpc.ts +54 -44
- package/src/service.ts +5 -0
- package/src/types.ts +52 -0
- package/src/version.ts +1 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 (!
|
|
156
|
+
if (!ctx.mediaUrl) {
|
|
110
157
|
return {
|
|
111
158
|
channel: "soyeht",
|
|
112
159
|
messageId: randomUUID(),
|
|
113
|
-
meta: { error: true, reason:
|
|
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
|
|
102
|
-
if (
|
|
103
|
-
sendJson(res,
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
//
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
95
|
+
function signTranscript(
|
|
68
96
|
privateKey: Parameters<typeof ed25519Sign>[0],
|
|
69
|
-
|
|
97
|
+
transcript: Buffer,
|
|
70
98
|
): string {
|
|
71
|
-
return base64UrlEncode(ed25519Sign(privateKey,
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
128
|
+
// Direct mode: encrypt and enqueue if V2 session exists
|
|
129
|
+
if (v2deps) {
|
|
137
130
|
const ratchetSession = v2deps.sessions.get(account.accountId);
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 (
|
|
160
|
+
if (account.security.enabled) {
|
|
147
161
|
respond(false, undefined, {
|
|
148
|
-
code: "
|
|
149
|
-
message: "V2 session
|
|
162
|
+
code: "SESSION_REQUIRED",
|
|
163
|
+
message: "V2 session required for secure delivery",
|
|
150
164
|
});
|
|
151
165
|
return;
|
|
152
166
|
}
|
|
167
|
+
}
|
|
153
168
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
export const PLUGIN_VERSION = "0.2.0";
|