@soyeht/soyeht 0.2.8 → 0.2.10

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.8",
8
+ "version": "0.2.10",
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.8",
3
+ "version": "0.2.10",
4
4
  "description": "OpenClaw channel plugin for the Soyeht Flutter mobile app",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -71,14 +71,32 @@ function readAccountsSection(
71
71
  return section?.["accounts"] as Record<string, unknown> | undefined;
72
72
  }
73
73
 
74
+ /**
75
+ * Read flat config from `channels.soyeht.*` (all keys except "accounts").
76
+ * Used as defaults when no explicit account entry exists.
77
+ */
78
+ function readFlatConfig(cfg: OpenClawConfig): Record<string, unknown> {
79
+ const section = readConfigSection(cfg);
80
+ if (!section) return {};
81
+ const flat: Record<string, unknown> = {};
82
+ for (const [key, value] of Object.entries(section)) {
83
+ if (key !== "accounts") {
84
+ flat[key] = value;
85
+ }
86
+ }
87
+ return flat;
88
+ }
89
+
74
90
  function readAccountConfig(
75
91
  cfg: OpenClawConfig,
76
92
  accountId: string,
77
93
  ): Record<string, unknown> {
94
+ const flat = readFlatConfig(cfg);
78
95
  const accounts = readAccountsSection(cfg);
79
- if (!accounts) return {};
80
- const entry = accounts[accountId];
81
- return entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
96
+ const entry = accounts?.[accountId];
97
+ const accountRaw = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
98
+ // Merge: flat config as defaults, account-specific overrides
99
+ return { ...flat, ...accountRaw };
82
100
  }
83
101
 
84
102
  // ---------------------------------------------------------------------------
@@ -199,8 +217,16 @@ export function resolveSoyehtAccount(
199
217
 
200
218
  export function listSoyehtAccountIds(cfg: OpenClawConfig): string[] {
201
219
  const accounts = readAccountsSection(cfg);
202
- return listConfiguredAccountIds({
220
+ const ids = listConfiguredAccountIds({
203
221
  accounts: accounts as Record<string, unknown> | undefined,
204
222
  normalizeAccountId,
205
223
  });
224
+ // If no explicit accounts but flat config exists, treat as "default" account
225
+ if (ids.length === 0) {
226
+ const flat = readFlatConfig(cfg);
227
+ if (Object.keys(flat).length > 0) {
228
+ return [DEFAULT_ACCOUNT_ID];
229
+ }
230
+ }
231
+ return ids;
206
232
  }
package/src/http.ts CHANGED
@@ -126,9 +126,23 @@ export function processInboundEnvelope(
126
126
  // GET /soyeht/health
127
127
  // ---------------------------------------------------------------------------
128
128
 
129
- export function healthHandler(_api: OpenClawPluginApi) {
129
+ export function healthHandler(_api: OpenClawPluginApi, v2deps?: SecurityV2Deps) {
130
130
  return async (_req: IncomingMessage, res: ServerResponse) => {
131
- sendJson(res, 200, { ok: true, plugin: "soyeht", version: PLUGIN_VERSION });
131
+ const ready = v2deps?.ready ?? false;
132
+ const identityLoaded = Boolean(v2deps?.identity);
133
+ const activeSessions = v2deps?.sessions.size ?? 0;
134
+ const pairedPeers = v2deps?.peers.size ?? 0;
135
+ const queueStats = v2deps?.outboundQueue.stats() ?? { accounts: 0, totalMessages: 0 };
136
+
137
+ sendJson(res, ready ? 200 : 503, {
138
+ ok: ready,
139
+ plugin: "soyeht",
140
+ version: PLUGIN_VERSION,
141
+ identity: identityLoaded,
142
+ peers: pairedPeers,
143
+ sessions: activeSessions,
144
+ queue: queueStats,
145
+ });
132
146
  };
133
147
  }
134
148
 
package/src/index.ts CHANGED
@@ -104,7 +104,7 @@ const soyehtPlugin: OpenClawPluginDefinition = {
104
104
  api.registerHttpRoute({
105
105
  path: "/soyeht/health",
106
106
  auth: "plugin",
107
- handler: healthHandler(api),
107
+ handler: healthHandler(api, v2deps),
108
108
  });
109
109
  api.registerHttpRoute({
110
110
  path: "/soyeht/webhook/deliver",
@@ -41,6 +41,8 @@ export type OutboundQueue = {
41
41
  prune(): number;
42
42
  clear(): void;
43
43
 
44
+ stats(): { accounts: number; totalMessages: number };
45
+
44
46
  // Stream token management (for SSE auth)
45
47
  createStreamToken(accountId: string, expiresAt: number): string;
46
48
  validateStreamToken(token: string): StreamTokenInfo | null;
@@ -93,6 +95,19 @@ export function createOutboundQueue(opts: OutboundQueueOptions = {}): OutboundQu
93
95
  let waiting: ((value: IteratorResult<QueueEntry>) => void) | null = null;
94
96
  let closed = false;
95
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
+
96
111
  const sub: Subscriber = {
97
112
  accountId,
98
113
  push(entry: QueueEntry) {
@@ -217,12 +232,21 @@ export function createOutboundQueue(opts: OutboundQueueOptions = {}): OutboundQu
217
232
  }
218
233
  }
219
234
 
235
+ function stats(): { accounts: number; totalMessages: number } {
236
+ let totalMessages = 0;
237
+ for (const q of queues.values()) {
238
+ totalMessages += q.length;
239
+ }
240
+ return { accounts: queues.size, totalMessages };
241
+ }
242
+
220
243
  return {
221
244
  enqueue,
222
245
  subscribe,
223
246
  hasSubscribers,
224
247
  prune,
225
248
  clear,
249
+ stats,
226
250
  createStreamToken,
227
251
  validateStreamToken,
228
252
  revokeStreamTokensForAccount,
package/src/types.ts CHANGED
@@ -31,54 +31,57 @@ export type SoyehtAccountConfig = {
31
31
  };
32
32
  };
33
33
 
34
- export const SoyehtAccountConfigSchema: JsonSchema = {
35
- type: "object",
36
- additionalProperties: false,
37
- properties: {
38
- enabled: { type: "boolean" },
39
- backendBaseUrl: { type: "string" },
40
- pluginAuthToken: { type: "string" },
41
- gatewayUrl: { type: "string" },
42
- allowProactive: { type: "boolean" },
43
- audio: {
44
- type: "object",
45
- additionalProperties: false,
46
- properties: {
47
- transcribeInbound: { type: "boolean" },
48
- ttsOutbound: { type: "boolean" },
49
- },
34
+ // Shared properties object reused at account level and channel top level
35
+ const accountConfigProperties: Record<string, unknown> = {
36
+ enabled: { type: "boolean" },
37
+ backendBaseUrl: { type: "string" },
38
+ pluginAuthToken: { type: "string" },
39
+ gatewayUrl: { type: "string" },
40
+ allowProactive: { type: "boolean" },
41
+ audio: {
42
+ type: "object",
43
+ additionalProperties: false,
44
+ properties: {
45
+ transcribeInbound: { type: "boolean" },
46
+ ttsOutbound: { type: "boolean" },
50
47
  },
51
- files: {
52
- type: "object",
53
- additionalProperties: false,
54
- properties: {
55
- acceptInbound: { type: "boolean" },
56
- maxBytes: { type: "number" },
57
- },
48
+ },
49
+ files: {
50
+ type: "object",
51
+ additionalProperties: false,
52
+ properties: {
53
+ acceptInbound: { type: "boolean" },
54
+ maxBytes: { type: "number" },
58
55
  },
59
- security: {
60
- type: "object",
61
- additionalProperties: false,
62
- properties: {
63
- enabled: { type: "boolean" },
64
- timestampToleranceMs: { type: "number" },
65
- dhRatchetIntervalMessages: { type: "number" },
66
- dhRatchetIntervalMs: { type: "number" },
67
- sessionMaxAgeMs: { type: "number" },
68
- rateLimit: {
69
- type: "object",
70
- additionalProperties: false,
71
- properties: {
72
- maxRequests: { type: "number" },
73
- windowMs: { type: "number" },
74
- },
56
+ },
57
+ security: {
58
+ type: "object",
59
+ additionalProperties: false,
60
+ properties: {
61
+ enabled: { type: "boolean" },
62
+ timestampToleranceMs: { type: "number" },
63
+ dhRatchetIntervalMessages: { type: "number" },
64
+ dhRatchetIntervalMs: { type: "number" },
65
+ sessionMaxAgeMs: { type: "number" },
66
+ rateLimit: {
67
+ type: "object",
68
+ additionalProperties: false,
69
+ properties: {
70
+ maxRequests: { type: "number" },
71
+ windowMs: { type: "number" },
75
72
  },
76
73
  },
77
74
  },
78
75
  },
79
76
  };
80
77
 
81
- export type SoyehtChannelConfig = {
78
+ export const SoyehtAccountConfigSchema: JsonSchema = {
79
+ type: "object",
80
+ additionalProperties: false,
81
+ properties: accountConfigProperties,
82
+ };
83
+
84
+ export type SoyehtChannelConfig = SoyehtAccountConfig & {
82
85
  accounts?: Record<string, SoyehtAccountConfig>;
83
86
  };
84
87
 
@@ -86,6 +89,9 @@ export const SoyehtChannelConfigSchema: JsonSchema = {
86
89
  type: "object",
87
90
  additionalProperties: false,
88
91
  properties: {
92
+ // Top-level account fields (flat config shorthand for single-account setups)
93
+ ...accountConfigProperties,
94
+ // Named accounts (multi-account support)
89
95
  accounts: {
90
96
  type: "object",
91
97
  additionalProperties: SoyehtAccountConfigSchema,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PLUGIN_VERSION = "0.2.8";
1
+ export const PLUGIN_VERSION = "0.2.10";