@soyeht/soyeht 0.2.9 → 0.2.11

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 CHANGED
@@ -2,16 +2,24 @@
2
2
 
3
3
  Channel plugin for connecting the Soyeht Flutter mobile app to an OpenClaw gateway.
4
4
 
5
- ## Install in OpenClaw
5
+ ## V1 Architecture
6
6
 
7
- After this package is published to npm, install it on the OpenClaw host with:
7
+ The app and the OpenClaw instance communicate over **HTTP + SSE** on a shared network (typically Tailscale).
8
+
9
+ - **Inbound** (app → plugin): `POST {gatewayUrl}/soyeht/messages/inbound`
10
+ - **Outbound** (plugin → app): `GET {gatewayUrl}/soyeht/events/{accountId}?token=...` (SSE)
11
+ - **Pairing**: QR code scanned by the app, then HTTP handshake
12
+
13
+ No WebRTC. No public domain required. Tailscale resolves connectivity.
14
+
15
+ ## Install
8
16
 
9
17
  ```bash
10
- openclaw plugins install @soyeht/soyeht@0.1.2 --pin
18
+ openclaw plugins install @soyeht/soyeht --pin
11
19
  openclaw plugins enable soyeht
12
20
  ```
13
21
 
14
- Then verify:
22
+ Verify:
15
23
 
16
24
  ```bash
17
25
  openclaw plugins list
@@ -19,9 +27,42 @@ openclaw plugins info soyeht
19
27
  openclaw plugins doctor
20
28
  ```
21
29
 
22
- ## Minimal configuration
30
+ ## Configuration
31
+
32
+ Set `gatewayUrl` to the URL accessible from the app. With Tailscale, this is your instance's Tailscale IP or MagicDNS hostname:
33
+
34
+ ```bash
35
+ # Using Tailscale IP
36
+ openclaw config set channels.soyeht.gatewayUrl "http://100.x.y.z:18789"
37
+
38
+ # Or using MagicDNS
39
+ openclaw config set channels.soyeht.gatewayUrl "http://my-machine.tailnet.ts.net:18789"
40
+
41
+ openclaw config set channels.soyeht.enabled true
42
+ openclaw gateway restart
43
+ ```
44
+
45
+ The plugin will auto-generate a pairing QR on startup if no peers are paired.
46
+
47
+ ### Manual pairing
48
+
49
+ ```bash
50
+ openclaw gateway call soyeht.security.pairing.start '{"accountId": "default", "allowOverwrite": true}'
51
+ ```
52
+
53
+ ### Full config reference
23
54
 
24
- Add a Soyeht account under `channels.soyeht.accounts.default`:
55
+ ```yaml
56
+ channels:
57
+ soyeht:
58
+ enabled: true
59
+ gatewayUrl: "http://100.x.y.z:18789" # required — your Tailscale/LAN URL
60
+ # Optional (only for legacy backend mode, not needed in V1):
61
+ # backendBaseUrl: "https://your-backend.example"
62
+ # pluginAuthToken: "your-token"
63
+ ```
64
+
65
+ Or with named accounts:
25
66
 
26
67
  ```yaml
27
68
  channels:
@@ -29,45 +70,47 @@ channels:
29
70
  accounts:
30
71
  default:
31
72
  enabled: true
32
- backendBaseUrl: https://your-backend.example
33
- pluginAuthToken: your-plugin-auth-token
34
- security:
35
- enabled: true
73
+ gatewayUrl: "http://100.x.y.z:18789"
36
74
  ```
37
75
 
38
- ## Local validation
76
+ ## App endpoints
39
77
 
40
- ```bash
41
- npm ci
42
- npm run validate
43
- ```
78
+ All called by the Flutter app against `{gatewayUrl}`:
44
79
 
45
- ## Publish to npm
80
+ | Method | Path | Purpose |
81
+ |--------|------|---------|
82
+ | `GET` | `/soyeht/pairing/info?t={token}` | Fetch plugin keys for pairing |
83
+ | `POST` | `/soyeht/pairing/pair` | Register peer + start handshake |
84
+ | `POST` | `/soyeht/pairing/finish` | Complete handshake, get streamToken |
85
+ | `POST` | `/soyeht/messages/inbound` | Send encrypted message to agent |
86
+ | `GET` | `/soyeht/events/{accountId}?token={streamToken}` | SSE stream for agent replies |
87
+ | `GET` | `/soyeht/health` | Health check (503 if not ready) |
46
88
 
47
- 1. Make sure the package name and scope are available on npm.
48
- 2. Log in:
89
+ ## QR format
90
+
91
+ The compact QR URL emitted by the plugin:
49
92
 
50
- ```bash
51
- npm login
52
93
  ```
94
+ soyeht://pair?g={gatewayUrl}&t={pairingToken}&fp={fingerprint}
95
+ ```
96
+
97
+ The app parses the URL, then calls the HTTP pairing endpoints above.
53
98
 
54
- 3. Bump the version:
99
+ ## Local development
55
100
 
56
101
  ```bash
57
- npm version patch
102
+ npm ci
103
+ npm run validate # typecheck + tests
104
+ npm run test:watch # vitest in watch mode
58
105
  ```
59
106
 
60
- This also syncs `openclaw.plugin.json`.
61
-
62
- 4. Publish publicly:
107
+ ## Publish
63
108
 
64
109
  ```bash
110
+ npm version patch # bumps package.json + openclaw.plugin.json
65
111
  npm publish
66
112
  ```
67
113
 
68
- The package is configured with `publishConfig.access=public`, so the first publish of the scoped package does not need an extra `--access public`.
69
-
70
114
  ## Protocol docs
71
115
 
72
- - [Protocol](docs/PROTOCOL.md)
73
- - [Flutter agent prompt](docs/FLUTTER_AGENT_PROMPT.md)
116
+ - [Security Protocol](docs/PROTOCOL.md)
package/docs/PROTOCOL.md CHANGED
@@ -67,13 +67,14 @@ Request params:
67
67
  }
68
68
  ```
69
69
 
70
- Success payload:
70
+ Success payload (QR V2 — when `gatewayUrl` is configured):
71
71
 
72
72
  ```json
73
73
  {
74
74
  "qrPayload": {
75
- "version": 1,
75
+ "version": 2,
76
76
  "type": "soyeht_pairing_qr",
77
+ "gatewayUrl": "http://100.x.y.z:18789",
77
78
  "accountId": "default",
78
79
  "pairingToken": "<base64url-32-bytes>",
79
80
  "expiresAt": 1730000000000,
@@ -88,7 +89,13 @@ Success payload:
88
89
  }
89
90
  ```
90
91
 
91
- QR signature transcript:
92
+ QR V2 signature transcript:
93
+
94
+ ```text
95
+ pairing_qr_v2|{gatewayUrl}|{accountId}|{pairingToken}|{expiresAt}|{allowOverwrite?1:0}|{pluginIdentityKey}|{pluginDhKey}|{fingerprint}
96
+ ```
97
+
98
+ QR V1 signature transcript (fallback — no `gatewayUrl`):
92
99
 
93
100
  ```text
94
101
  pairing_qr_v1|{accountId}|{pairingToken}|{expiresAt}|{allowOverwrite?1:0}|{pluginIdentityKey}|{pluginDhKey}|{fingerprint}
@@ -96,6 +103,20 @@ pairing_qr_v1|{accountId}|{pairingToken}|{expiresAt}|{allowOverwrite?1:0}|{plugi
96
103
 
97
104
  The signature is Ed25519 with the plugin identity private key.
98
105
 
106
+ ### Compact QR URL
107
+
108
+ On service startup (auto-pairing) and via `pairing.start`, the plugin also emits a compact URL suitable for QR rendering:
109
+
110
+ ```
111
+ soyeht://pair?g={gatewayUrl}&t={pairingToken}&fp={fingerprint}
112
+ ```
113
+
114
+ The app parses this URL, then calls the HTTP pairing endpoints to fetch full key material and complete pairing:
115
+
116
+ 1. `GET {gatewayUrl}/soyeht/pairing/info?t={pairingToken}` — get plugin keys
117
+ 2. `POST {gatewayUrl}/soyeht/pairing/pair` — register peer + start handshake
118
+ 3. `POST {gatewayUrl}/soyeht/pairing/finish` — complete handshake, get streamToken
119
+
99
120
  ### `soyeht.security.pair`
100
121
 
101
122
  Purpose:
@@ -373,16 +394,59 @@ Webhook/envelope:
373
394
  - `direction_mismatch`
374
395
  - `version_mismatch`
375
396
 
376
- ## Recommended Client Flow
377
-
378
- 1. Plugin/host calls `soyeht.security.pairing.start`
379
- 2. Plugin/host renders `qrText` as QR
380
- 3. App scans QR and verifies it
381
- 4. App generates long-term keys if needed
382
- 5. App calls `soyeht.security.pair`
383
- 6. App generates a fresh ephemeral X25519 key + nonce
384
- 7. App calls `soyeht.security.handshake.init`
385
- 8. App verifies `pluginSignature`
386
- 9. App signs the same transcript and calls `soyeht.security.handshake.finish`
387
- 10. App derives the same X3DH session locally and starts Envelope V2 messaging
397
+ ## V1 Transport: HTTP + SSE over Tailscale
398
+
399
+ In V1, the app and plugin communicate over a shared network (Tailscale). All endpoints use the `gatewayUrl` from config (e.g., `http://100.x.y.z:18789`).
400
+
401
+ ### Inbound (app plugin agent)
402
+
403
+ ```
404
+ POST {gatewayUrl}/soyeht/messages/inbound
405
+ Content-Type: application/json
406
+
407
+ {EnvelopeV2 JSON}
408
+ ```
409
+
410
+ The plugin decrypts, responds `200`, then dispatches to the OpenClaw agent pipeline. The agent's reply is encrypted and pushed to the outbound SSE stream.
411
+
412
+ ### Outbound (plugin → app via SSE)
413
+
414
+ ```
415
+ GET {gatewayUrl}/soyeht/events/{accountId}?token={streamToken}
416
+ ```
417
+
418
+ Or with header:
419
+
420
+ ```
421
+ GET {gatewayUrl}/soyeht/events/{accountId}
422
+ Authorization: Bearer {streamToken}
423
+ ```
424
+
425
+ The `streamToken` is returned by `POST /soyeht/pairing/finish` on successful handshake.
426
+
427
+ SSE events are encrypted EnvelopeV2 payloads. The app decrypts using its ratchet session state.
428
+
429
+ ### Health check
430
+
431
+ ```
432
+ GET {gatewayUrl}/soyeht/health
433
+ ```
434
+
435
+ Returns `200` with state info when ready, `503` when not.
436
+
437
+ ## Recommended Client Flow (V1 — HTTP Pairing)
438
+
439
+ 1. Plugin auto-generates QR on startup (or via `soyeht.security.pairing.start`)
440
+ 2. App scans QR: `soyeht://pair?g={gatewayUrl}&t={pairingToken}&fp={fingerprint}`
441
+ 3. App calls `GET {gatewayUrl}/soyeht/pairing/info?t={pairingToken}`
442
+ 4. App verifies plugin keys and fingerprint
443
+ 5. App generates long-term keys if needed
444
+ 6. App calls `POST {gatewayUrl}/soyeht/pairing/pair` (registers peer + starts handshake)
445
+ 7. App verifies `pluginSignature` from response
446
+ 8. App signs the handshake transcript
447
+ 9. App calls `POST {gatewayUrl}/soyeht/pairing/finish`
448
+ 10. App receives `streamToken` and `sessionExpiresAt`
449
+ 11. App derives X3DH session and connects to SSE: `GET {gatewayUrl}/soyeht/events/{accountId}?token={streamToken}`
450
+ 12. App sends messages via `POST {gatewayUrl}/soyeht/messages/inbound`
451
+ 13. App receives agent replies via SSE stream
388
452
 
@@ -5,7 +5,7 @@
5
5
  ],
6
6
  "name": "Soyeht",
7
7
  "description": "Channel plugin for the Soyeht Flutter mobile app",
8
- "version": "0.2.9",
8
+ "version": "0.2.11",
9
9
  "configSchema": {
10
10
  "type": "object",
11
11
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soyeht/soyeht",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/http.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import type { IncomingMessage, ServerResponse } from "node:http";
2
3
  import type { OpenClawPluginApi, PluginRuntimeChannel } from "openclaw/plugin-sdk";
3
4
  import { normalizeAccountId, resolveSoyehtAccount } from "./config.js";
@@ -8,14 +9,22 @@ import type { SecurityV2Deps } from "./service.js";
8
9
  import { PLUGIN_VERSION } from "./version.js";
9
10
  import {
10
11
  base64UrlDecode,
12
+ base64UrlEncode,
11
13
  computeFingerprint,
14
+ ed25519Sign,
12
15
  generateX25519KeyPair,
13
16
  importEd25519PublicKey,
14
17
  importX25519PublicKey,
15
18
  ed25519Verify,
16
19
  isTimestampValid,
17
20
  } from "./crypto.js";
18
- import { buildPairingProofTranscript } from "./pairing.js";
21
+ import {
22
+ buildPairingProofTranscript,
23
+ buildPairingQrTranscript,
24
+ buildPairingQrTranscriptV2,
25
+ resolveGatewayUrl,
26
+ } from "./pairing.js";
27
+ import { renderQrTerminal } from "./qr.js";
19
28
  import {
20
29
  buildHandshakeTranscript,
21
30
  signHandshakeTranscript,
@@ -537,6 +546,103 @@ function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): vo
537
546
  }
538
547
  }
539
548
 
549
+ // ---------------------------------------------------------------------------
550
+ // GET /soyeht/pairing/start — generate a pairing QR via HTTP (no RPC needed)
551
+ // ---------------------------------------------------------------------------
552
+
553
+ const DEFAULT_PAIRING_TTL_MS = 90_000;
554
+
555
+ export function pairingStartHandler(
556
+ api: OpenClawPluginApi,
557
+ v2deps: SecurityV2Deps,
558
+ ) {
559
+ return async (req: IncomingMessage, res: ServerResponse) => {
560
+ if (req.method !== "GET") {
561
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
562
+ return;
563
+ }
564
+
565
+ if (!v2deps.ready || !v2deps.identity) {
566
+ sendJson(res, 503, { ok: false, error: "service_unavailable" });
567
+ return;
568
+ }
569
+
570
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
571
+ const accountId = normalizeAccountId(url.searchParams.get("accountId") ?? "default");
572
+ const allowOverwrite = url.searchParams.get("allowOverwrite") !== "false";
573
+ const format = url.searchParams.get("format") ?? "json";
574
+
575
+ const { allowed } = v2deps.rateLimiter.check(`pairing:start:http:${accountId}`);
576
+ if (!allowed) {
577
+ sendJson(res, 429, { ok: false, error: "rate_limited" });
578
+ return;
579
+ }
580
+
581
+ if (v2deps.peers.has(accountId) && !allowOverwrite) {
582
+ sendJson(res, 409, { ok: false, error: "peer_already_paired" });
583
+ return;
584
+ }
585
+
586
+ // Clear stale pairing sessions for this account
587
+ for (const [token, session] of v2deps.pairingSessions) {
588
+ if (session.accountId === accountId) {
589
+ v2deps.pairingSessions.delete(token);
590
+ }
591
+ }
592
+
593
+ const pairingToken = base64UrlEncode(randomBytes(32));
594
+ const expiresAt = Date.now() + DEFAULT_PAIRING_TTL_MS;
595
+ const fingerprint = computeFingerprint(v2deps.identity);
596
+
597
+ const cfg = await api.runtime.config.loadConfig();
598
+ const account = resolveSoyehtAccount(cfg, accountId);
599
+ const gatewayUrl = resolveGatewayUrl(api, account.gatewayUrl);
600
+
601
+ const basePayload = {
602
+ accountId,
603
+ pairingToken,
604
+ expiresAt,
605
+ allowOverwrite,
606
+ pluginIdentityKey: v2deps.identity.signKey.publicKeyB64,
607
+ pluginDhKey: v2deps.identity.dhKey.publicKeyB64,
608
+ fingerprint,
609
+ };
610
+
611
+ let qrPayload: Record<string, unknown>;
612
+ if (gatewayUrl) {
613
+ const transcript = buildPairingQrTranscriptV2({ gatewayUrl, ...basePayload });
614
+ const signature = base64UrlEncode(ed25519Sign(v2deps.identity.signKey.privateKey, transcript));
615
+ qrPayload = { version: 2, type: "soyeht_pairing_qr", gatewayUrl, ...basePayload, signature };
616
+ } else {
617
+ const transcript = buildPairingQrTranscript(basePayload);
618
+ const signature = base64UrlEncode(ed25519Sign(v2deps.identity.signKey.privateKey, transcript));
619
+ qrPayload = { version: 1, type: "soyeht_pairing_qr", ...basePayload, signature };
620
+ }
621
+
622
+ v2deps.pairingSessions.set(pairingToken, {
623
+ token: pairingToken,
624
+ accountId,
625
+ expiresAt,
626
+ allowOverwrite,
627
+ });
628
+
629
+ const qrUrl = `soyeht://pair?g=${gatewayUrl}&t=${pairingToken}&fp=${fingerprint}`;
630
+ api.logger.info("[soyeht] Pairing started via HTTP", { accountId, expiresAt });
631
+
632
+ if (format === "terminal") {
633
+ const rendered = renderQrTerminal(qrUrl);
634
+ const text = rendered
635
+ ? `\n${rendered}\n\n${qrUrl}\n\nExpires in ${DEFAULT_PAIRING_TTL_MS / 1000}s\n`
636
+ : `QR too large for terminal.\n\n${qrUrl}\n\nExpires in ${DEFAULT_PAIRING_TTL_MS / 1000}s\n`;
637
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
638
+ res.end(text);
639
+ return;
640
+ }
641
+
642
+ sendJson(res, 200, { ok: true, qrUrl, qrPayload, expiresAt });
643
+ };
644
+ }
645
+
540
646
  // ---------------------------------------------------------------------------
541
647
  // GET /soyeht/pairing/info?t=<pairingToken>
542
648
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  livekitTokenHandler,
16
16
  inboundHandler,
17
17
  sseHandler,
18
+ pairingStartHandler,
18
19
  pairingInfoHandler,
19
20
  pairingPairHandler,
20
21
  pairingFinishHandler,
@@ -131,6 +132,11 @@ const soyehtPlugin: OpenClawPluginDefinition = {
131
132
  });
132
133
 
133
134
  // HTTP pairing routes (app pairs via HTTP, no WebSocket needed)
135
+ api.registerHttpRoute({
136
+ path: "/soyeht/pairing/start",
137
+ auth: "plugin",
138
+ handler: pairingStartHandler(api, v2deps),
139
+ });
134
140
  api.registerHttpRoute({
135
141
  path: "/soyeht/pairing/info",
136
142
  auth: "plugin",
@@ -95,6 +95,19 @@ export function createOutboundQueue(opts: OutboundQueueOptions = {}): OutboundQu
95
95
  let waiting: ((value: IteratorResult<QueueEntry>) => void) | null = null;
96
96
  let closed = false;
97
97
 
98
+ // Drain backlog: snapshot current queue entries still within TTL.
99
+ // Done synchronously before registering live subscriber — no race in
100
+ // single-threaded Node.js, so no duplicates and no missed messages.
101
+ const now = Date.now();
102
+ const backlog = queues.get(accountId);
103
+ if (backlog) {
104
+ for (const entry of backlog) {
105
+ if (now - entry.enqueuedAt < ttlMs) {
106
+ pending.push(entry);
107
+ }
108
+ }
109
+ }
110
+
98
111
  const sub: Subscriber = {
99
112
  accountId,
100
113
  push(entry: QueueEntry) {
package/src/pairing.ts CHANGED
@@ -119,7 +119,7 @@ function clearAccountSessionState(v2deps: SecurityV2Deps, accountId: string): vo
119
119
  // Resolve the gateway URL for QR V2
120
120
  // ---------------------------------------------------------------------------
121
121
 
122
- function resolveGatewayUrl(api: OpenClawPluginApi, configGatewayUrl: string): string {
122
+ export function resolveGatewayUrl(api: OpenClawPluginApi, configGatewayUrl: string): string {
123
123
  // 1) Explicit config
124
124
  if (configGatewayUrl) return configGatewayUrl;
125
125
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.2.9";
1
+ export const PLUGIN_VERSION = "0.2.11";