@soyeht/soyeht 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/http.ts ADDED
@@ -0,0 +1,175 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import { normalizeAccountId } from "./config.js";
4
+ import { decryptEnvelopeV2, validateEnvelopeV2, type EnvelopeV2 } from "./envelope-v2.js";
5
+ import { cloneRatchetSession } from "./ratchet.js";
6
+ import type { SecurityV2Deps } from "./service.js";
7
+ import { PLUGIN_VERSION } from "./version.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
14
+ const json = JSON.stringify(body);
15
+ res.writeHead(status, {
16
+ "Content-Type": "application/json",
17
+ "Content-Length": Buffer.byteLength(json),
18
+ });
19
+ res.end(json);
20
+ }
21
+
22
+ const MAX_BODY_BYTES = 1024 * 1024; // 1 MB
23
+
24
+ async function readRawBodyBuffer(req: IncomingMessage): Promise<Buffer> {
25
+ return new Promise((resolve, reject) => {
26
+ const chunks: Buffer[] = [];
27
+ let totalBytes = 0;
28
+
29
+ req.on("data", (chunk: Buffer) => {
30
+ totalBytes += chunk.length;
31
+ if (totalBytes > MAX_BODY_BYTES) {
32
+ req.destroy();
33
+ reject(new Error("body_too_large"));
34
+ return;
35
+ }
36
+ chunks.push(chunk);
37
+ });
38
+
39
+ req.on("end", () => resolve(Buffer.concat(chunks)));
40
+ req.on("error", reject);
41
+ });
42
+ }
43
+
44
+ export { readRawBodyBuffer };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // GET /soyeht/health
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export function healthHandler(_api: OpenClawPluginApi) {
51
+ return async (_req: IncomingMessage, res: ServerResponse) => {
52
+ sendJson(res, 200, { ok: true, plugin: "soyeht", version: PLUGIN_VERSION });
53
+ };
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // POST /soyeht/webhook/deliver
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export function webhookHandler(
61
+ api: OpenClawPluginApi,
62
+ v2deps?: SecurityV2Deps,
63
+ ) {
64
+ return async (req: IncomingMessage, res: ServerResponse) => {
65
+ if (req.method !== "POST") {
66
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
67
+ return;
68
+ }
69
+
70
+ const headers = req.headers ?? {};
71
+ const headerAccountIdRaw = (headers["x-soyeht-account-id"] as string | undefined)?.trim();
72
+ const hintedAccountId = headerAccountIdRaw ? normalizeAccountId(headerAccountIdRaw) : undefined;
73
+
74
+ if (!v2deps?.ready) {
75
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
76
+ return;
77
+ }
78
+
79
+ // Rate limit per-account
80
+ if (v2deps.rateLimiter) {
81
+ const { allowed, retryAfterMs } = v2deps.rateLimiter.check(`webhook:${hintedAccountId ?? "_unknown"}`);
82
+ if (!allowed) {
83
+ res.setHeader("Retry-After", String(Math.ceil((retryAfterMs ?? 60_000) / 1000)));
84
+ sendJson(res, 429, { ok: false, error: "rate_limited" });
85
+ return;
86
+ }
87
+ }
88
+
89
+ try {
90
+ const rawBody = await readRawBodyBuffer(req);
91
+
92
+ // Parse the envelope
93
+ let envelope: EnvelopeV2;
94
+ try {
95
+ envelope = JSON.parse(rawBody.toString("utf8")) as EnvelopeV2;
96
+ } catch {
97
+ sendJson(res, 400, { ok: false, error: "invalid_json" });
98
+ return;
99
+ }
100
+
101
+ const envelopeAccountId = normalizeAccountId(envelope.accountId);
102
+ if (hintedAccountId && hintedAccountId !== envelopeAccountId) {
103
+ sendJson(res, 401, { ok: false, error: "account_mismatch" });
104
+ return;
105
+ }
106
+
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
+ }
113
+
114
+ if (session.expiresAt < Date.now()) {
115
+ sendJson(res, 401, { ok: false, error: "session_expired" });
116
+ return;
117
+ }
118
+
119
+ if (session.accountId !== envelopeAccountId) {
120
+ sendJson(res, 401, { ok: false, error: "account_mismatch" });
121
+ return;
122
+ }
123
+
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 });
129
+ return;
130
+ }
131
+
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 });
147
+ return;
148
+ }
149
+
150
+ // Update session
151
+ v2deps.sessions.set(accountId, updatedSession);
152
+
153
+ api.logger.info("[soyeht] Webhook delivery received", { accountId });
154
+ sendJson(res, 200, { ok: true, received: true });
155
+ } catch (err) {
156
+ const message = err instanceof Error ? err.message : "unknown_error";
157
+ sendJson(res, 400, { ok: false, error: message });
158
+ }
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // POST /soyeht/livekit/token — stub
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export function livekitTokenHandler(_api: OpenClawPluginApi) {
167
+ return async (_req: IncomingMessage, res: ServerResponse) => {
168
+ sendJson(res, 501, {
169
+ ok: false,
170
+ error: "not_enabled",
171
+ pipeline: "stt->llm->tts",
172
+ availableIn: "v2",
173
+ });
174
+ };
175
+ }
@@ -0,0 +1,157 @@
1
+ import { mkdir, readFile, writeFile, readdir, unlink, chmod } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ ed25519GenerateKeyPair,
5
+ generateX25519KeyPair,
6
+ exportPrivateKeyPkcs8,
7
+ exportPublicKeyRaw,
8
+ importEd25519PublicKey,
9
+ importX25519PublicKey,
10
+ importPrivateKeyPkcs8,
11
+ base64UrlEncode,
12
+ base64UrlDecode,
13
+ type Ed25519KeyPair,
14
+ type X25519KeyPair,
15
+ } from "./crypto.js";
16
+ import type { RatchetState } from "./ratchet.js";
17
+ import { serializeSession, deserializeSession } from "./ratchet.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type IdentityBundle = {
24
+ signKey: Ed25519KeyPair;
25
+ dhKey: X25519KeyPair;
26
+ };
27
+
28
+ export type PeerIdentity = {
29
+ accountId: string;
30
+ identityKeyB64: string; // Ed25519 pub raw b64url (32 bytes)
31
+ dhKeyB64: string; // X25519 pub raw b64url (32 bytes)
32
+ };
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Account ID validation — reject path traversal attempts
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const SAFE_ACCOUNT_ID = /^[a-zA-Z0-9_-]+$/;
39
+
40
+ function validateAccountId(accountId: string): void {
41
+ if (!SAFE_ACCOUNT_ID.test(accountId)) {
42
+ throw new Error(`Invalid accountId for file operation: ${accountId}`);
43
+ }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Paths
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function secDir(stateDir: string): string {
51
+ return join(stateDir, "soyeht-security");
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Identity load / generate
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export async function loadOrGenerateIdentity(stateDir: string): Promise<IdentityBundle> {
59
+ const dir = secDir(stateDir);
60
+ await mkdir(dir, { recursive: true });
61
+ const identityPath = join(dir, "identity.json");
62
+
63
+ try {
64
+ const raw = JSON.parse(await readFile(identityPath, "utf8")) as Record<string, unknown>;
65
+ const signPriv = importPrivateKeyPkcs8(base64UrlDecode(raw["signPriv"] as string));
66
+ const signPubB64 = raw["signPub"] as string;
67
+ const signPub = importEd25519PublicKey(signPubB64);
68
+ const dhPriv = importPrivateKeyPkcs8(base64UrlDecode(raw["dhPriv"] as string));
69
+ const dhPubB64 = raw["dhPub"] as string;
70
+ const dhPub = importX25519PublicKey(dhPubB64);
71
+ const dhPubRaw = base64UrlDecode(dhPubB64);
72
+ return {
73
+ signKey: { publicKey: signPub, privateKey: signPriv, publicKeyB64: signPubB64 },
74
+ dhKey: { publicKey: dhPub, privateKey: dhPriv, publicKeyRaw: dhPubRaw, publicKeyB64: dhPubB64 },
75
+ };
76
+ } catch {
77
+ // Generate fresh identity
78
+ const signKp = ed25519GenerateKeyPair();
79
+ const dhKp = generateX25519KeyPair();
80
+ const data = {
81
+ signPub: signKp.publicKeyB64,
82
+ signPriv: base64UrlEncode(exportPrivateKeyPkcs8(signKp.privateKey)),
83
+ dhPub: dhKp.publicKeyB64,
84
+ dhPriv: base64UrlEncode(exportPrivateKeyPkcs8(dhKp.privateKey)),
85
+ };
86
+ await writeFile(identityPath, JSON.stringify(data, null, 2), "utf8");
87
+ try { await chmod(identityPath, 0o600); } catch { /* no-op on Windows */ }
88
+ return { signKey: signKp, dhKey: dhKp };
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Peer store
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export async function savePeer(stateDir: string, peer: PeerIdentity): Promise<void> {
97
+ validateAccountId(peer.accountId);
98
+ const dir = join(secDir(stateDir), "peers");
99
+ await mkdir(dir, { recursive: true });
100
+ const filePath = join(dir, `${peer.accountId}.json`);
101
+ await writeFile(filePath, JSON.stringify(peer, null, 2), "utf8");
102
+ try { await chmod(filePath, 0o600); } catch { /* no-op on Windows */ }
103
+ }
104
+
105
+ export async function loadPeers(stateDir: string): Promise<Map<string, PeerIdentity>> {
106
+ const dir = join(secDir(stateDir), "peers");
107
+ const map = new Map<string, PeerIdentity>();
108
+ try {
109
+ const files = await readdir(dir);
110
+ for (const file of files) {
111
+ if (!file.endsWith(".json")) continue;
112
+ try {
113
+ const peer = JSON.parse(await readFile(join(dir, file), "utf8")) as PeerIdentity;
114
+ map.set(peer.accountId, peer);
115
+ } catch { /* skip corrupt */ }
116
+ }
117
+ } catch { /* dir not exist */ }
118
+ return map;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Session store
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export async function saveSession(stateDir: string, session: RatchetState): Promise<void> {
126
+ validateAccountId(session.accountId);
127
+ const dir = join(secDir(stateDir), "sessions");
128
+ await mkdir(dir, { recursive: true });
129
+ const filePath = join(dir, `${session.accountId}.json`);
130
+ await writeFile(filePath, JSON.stringify(serializeSession(session), null, 2), "utf8");
131
+ try { await chmod(filePath, 0o600); } catch { /* no-op on Windows */ }
132
+ }
133
+
134
+ export async function loadSessions(stateDir: string): Promise<Map<string, RatchetState>> {
135
+ const dir = join(secDir(stateDir), "sessions");
136
+ const map = new Map<string, RatchetState>();
137
+ try {
138
+ const files = await readdir(dir);
139
+ for (const file of files) {
140
+ if (!file.endsWith(".json")) continue;
141
+ try {
142
+ const raw = JSON.parse(await readFile(join(dir, file), "utf8"));
143
+ const session = deserializeSession(raw);
144
+ if (session.expiresAt < Date.now()) continue; // skip expired
145
+ map.set(session.accountId, session);
146
+ } catch { /* skip corrupt */ }
147
+ }
148
+ } catch { /* dir not exist */ }
149
+ return map;
150
+ }
151
+
152
+ export async function deleteSession(stateDir: string, accountId: string): Promise<void> {
153
+ validateAccountId(accountId);
154
+ try {
155
+ await unlink(join(secDir(stateDir), "sessions", `${accountId}.json`));
156
+ } catch { /* ignore */ }
157
+ }
package/src/index.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { createSoyehtChannel } from "./channel.js";
3
+ import {
4
+ handleStatus,
5
+ handleCapabilities,
6
+ handleNotify,
7
+ handleLiveKitPrepare,
8
+ handleSecurityHandshake,
9
+ handleSecurityHandshakeFinish,
10
+ handleSecurityRotate,
11
+ } from "./rpc.js";
12
+ import {
13
+ healthHandler,
14
+ webhookHandler,
15
+ livekitTokenHandler,
16
+ } from "./http.js";
17
+ import {
18
+ createSoyehtService,
19
+ createSecurityV2Deps,
20
+ } from "./service.js";
21
+ import {
22
+ handleSecurityIdentity,
23
+ handleSecurityPair,
24
+ handleSecurityPairingStart,
25
+ } from "./pairing.js";
26
+ import { PLUGIN_VERSION } from "./version.js";
27
+
28
+ type OpenClawPluginDefinition = {
29
+ id?: string;
30
+ name?: string;
31
+ description?: string;
32
+ version?: string;
33
+ configSchema?: {
34
+ safeParse(value: unknown): { success: boolean; data?: unknown; error?: unknown };
35
+ jsonSchema: Record<string, unknown>;
36
+ };
37
+ register?: (api: OpenClawPluginApi) => void | Promise<void>;
38
+ };
39
+
40
+ const emptyPluginConfigSchema = {
41
+ safeParse(value: unknown) {
42
+ if (value === undefined) {
43
+ return { success: true, data: undefined };
44
+ }
45
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
46
+ return {
47
+ success: false,
48
+ error: { issues: [{ path: [], message: "expected config object" }] },
49
+ };
50
+ }
51
+ if (Object.keys(value as Record<string, unknown>).length > 0) {
52
+ return {
53
+ success: false,
54
+ error: { issues: [{ path: [], message: "config must be empty" }] },
55
+ };
56
+ }
57
+ return { success: true, data: value };
58
+ },
59
+ jsonSchema: {
60
+ type: "object",
61
+ additionalProperties: false,
62
+ properties: {},
63
+ },
64
+ };
65
+
66
+ const soyehtPlugin: OpenClawPluginDefinition = {
67
+ id: "soyeht",
68
+ name: "Soyeht",
69
+ description: "Channel plugin for the Soyeht Flutter mobile app",
70
+ version: PLUGIN_VERSION,
71
+
72
+ configSchema: emptyPluginConfigSchema,
73
+
74
+ async register(api) {
75
+ // V2 deps — identity/sessions loaded in service.start()
76
+ const v2deps = createSecurityV2Deps();
77
+
78
+ // Channel
79
+ api.registerChannel({ plugin: createSoyehtChannel(v2deps) });
80
+
81
+ // Gateway RPC methods
82
+ api.registerGatewayMethod("soyeht.status", handleStatus(api));
83
+ api.registerGatewayMethod("soyeht.capabilities", handleCapabilities(api));
84
+ api.registerGatewayMethod("soyeht.notify", handleNotify(api, v2deps));
85
+ api.registerGatewayMethod("soyeht.livekit.prepare", handleLiveKitPrepare(api));
86
+
87
+ // Security RPC
88
+ api.registerGatewayMethod("soyeht.security.identity", handleSecurityIdentity(api, v2deps));
89
+ api.registerGatewayMethod("soyeht.security.pairing.start", handleSecurityPairingStart(api, v2deps));
90
+ api.registerGatewayMethod("soyeht.security.pair", handleSecurityPair(api, v2deps));
91
+ api.registerGatewayMethod("soyeht.security.handshake.init", handleSecurityHandshake(api, v2deps));
92
+ api.registerGatewayMethod("soyeht.security.handshake.finish", handleSecurityHandshakeFinish(api, v2deps));
93
+ api.registerGatewayMethod("soyeht.security.handshake", handleSecurityHandshake(api, v2deps));
94
+ api.registerGatewayMethod("soyeht.security.rotate", handleSecurityRotate(api, v2deps));
95
+
96
+ // HTTP routes
97
+ api.registerHttpRoute({
98
+ path: "/soyeht/health",
99
+ auth: "plugin",
100
+ handler: healthHandler(api),
101
+ });
102
+ api.registerHttpRoute({
103
+ path: "/soyeht/webhook/deliver",
104
+ auth: "plugin",
105
+ handler: webhookHandler(api, v2deps),
106
+ });
107
+ api.registerHttpRoute({
108
+ path: "/soyeht/livekit/token",
109
+ auth: "plugin",
110
+ handler: livekitTokenHandler(api),
111
+ });
112
+
113
+ // Background service (manages state lifecycle)
114
+ api.registerService(createSoyehtService(api, v2deps));
115
+
116
+ api.logger.info("[soyeht] Plugin registered");
117
+ },
118
+ };
119
+
120
+ export default soyehtPlugin;
package/src/media.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { PluginRuntime, OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { resolveSoyehtAccount } from "./config.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // STT — transcribe inbound audio
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export type TranscribeResult = {
9
+ text: string | undefined;
10
+ skipped: boolean;
11
+ };
12
+
13
+ export async function transcribeInboundAudio(params: {
14
+ runtime: PluginRuntime;
15
+ cfg: OpenClawConfig;
16
+ filePath: string;
17
+ mime?: string;
18
+ accountId?: string;
19
+ }): Promise<TranscribeResult> {
20
+ const account = resolveSoyehtAccount(params.cfg, params.accountId);
21
+
22
+ if (!account.audio.transcribeInbound) {
23
+ return { text: undefined, skipped: true };
24
+ }
25
+
26
+ const result = await params.runtime.stt.transcribeAudioFile({
27
+ filePath: params.filePath,
28
+ cfg: params.cfg,
29
+ mime: params.mime,
30
+ });
31
+
32
+ return { text: result.text, skipped: false };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // TTS — generate outbound speech
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type TtsResult = {
40
+ success: boolean;
41
+ audioBuffer?: Buffer;
42
+ mimeType: string;
43
+ error?: string;
44
+ };
45
+
46
+ export async function generateOutboundTts(params: {
47
+ runtime: PluginRuntime;
48
+ cfg: OpenClawConfig;
49
+ text: string;
50
+ accountId?: string;
51
+ }): Promise<TtsResult> {
52
+ const account = resolveSoyehtAccount(params.cfg, params.accountId);
53
+
54
+ if (!account.audio.ttsOutbound) {
55
+ return { success: false, mimeType: "audio/wav", error: "tts_disabled" };
56
+ }
57
+
58
+ const result = await params.runtime.tts.textToSpeechTelephony({
59
+ text: params.text,
60
+ cfg: params.cfg,
61
+ });
62
+
63
+ return {
64
+ success: result.success,
65
+ audioBuffer: result.audioBuffer,
66
+ mimeType: result.outputFormat === "mp3" ? "audio/mpeg" : "audio/wav",
67
+ error: result.error,
68
+ };
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // File validation
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export type FileValidationResult = {
76
+ allowed: boolean;
77
+ reason?: string;
78
+ };
79
+
80
+ export function validateInboundFile(params: {
81
+ cfg: OpenClawConfig;
82
+ accountId?: string;
83
+ sizeBytes: number;
84
+ mimeType: string;
85
+ }): FileValidationResult {
86
+ const account = resolveSoyehtAccount(params.cfg, params.accountId);
87
+
88
+ if (!account.files.acceptInbound) {
89
+ return { allowed: false, reason: "files_disabled" };
90
+ }
91
+
92
+ if (params.sizeBytes > account.files.maxBytes) {
93
+ return {
94
+ allowed: false,
95
+ reason: `file_too_large: ${params.sizeBytes} > ${account.files.maxBytes}`,
96
+ };
97
+ }
98
+
99
+ return { allowed: true };
100
+ }