@soyeht/soyeht 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,