@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.
- 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/qr.ts +448 -0
- package/src/rpc.ts +54 -44
- package/src/service.ts +107 -1
- package/src/types.ts +52 -0
- package/src/version.ts +1 -1
|
@@ -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,
|