@soyeht/soyeht 0.1.1 → 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.
@@ -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