@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.
- package/README.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -4
- 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 +414 -162
- 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,
|
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
|
|