@better_openclaw/betterclaw 2.0.2 → 2.1.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.
@@ -42,6 +42,16 @@
42
42
  "minimum": 0,
43
43
  "maximum": 23,
44
44
  "description": "Hour (system timezone) for daily analysis run"
45
+ },
46
+ "deduplicationCooldowns": {
47
+ "type": "object",
48
+ "additionalProperties": { "type": "number" },
49
+ "description": "Per-subscription dedup cooldowns in seconds (e.g. {\"default.geofence\": 300})"
50
+ },
51
+ "defaultCooldown": {
52
+ "type": "number",
53
+ "default": 1800,
54
+ "description": "Default dedup cooldown in seconds for subscriptions not in deduplicationCooldowns"
45
55
  }
46
56
  },
47
57
  "additionalProperties": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better_openclaw/betterclaw",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Intelligent event filtering, context tracking, and proactive triggers for BetterClaw",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { ProactiveEngine } from "./triggers.js";
9
9
  import { processEvent } from "./pipeline.js";
10
10
  import type { PipelineDeps } from "./pipeline.js";
11
11
  import { BETTERCLAW_COMMANDS, mergeAllowCommands } from "./cli.js";
12
+ import { storeJwt } from "./jwt.js";
12
13
  import { loadTriageProfile, runLearner } from "./learner.js";
13
14
  import { ReactionTracker } from "./reactions.js";
14
15
  import * as os from "node:os";
@@ -114,12 +115,22 @@ export default {
114
115
  })();
115
116
 
116
117
  // Ping health check
117
- api.registerGatewayMethod("betterclaw.ping", ({ params, respond }) => {
118
+ api.registerGatewayMethod("betterclaw.ping", async ({ params, respond }) => {
118
119
  const validTiers: Array<"free" | "premium" | "premium+"> = ["free", "premium", "premium+"];
119
120
  const rawTier = (params as Record<string, unknown>)?.tier as string;
120
121
  const tier = validTiers.includes(rawTier as any) ? (rawTier as "free" | "premium" | "premium+") : "free";
121
122
  const smartMode = (params as Record<string, unknown>)?.smartMode === true;
122
123
 
124
+ const jwt = (params as Record<string, unknown>)?.jwt as string | undefined;
125
+ if (jwt) {
126
+ const payload = await storeJwt(jwt);
127
+ if (payload) {
128
+ api.logger.info(`betterclaw: JWT verified, entitlements=${payload.ent.join(",")}`);
129
+ } else {
130
+ api.logger.warn("betterclaw: JWT verification failed");
131
+ }
132
+ }
133
+
123
134
  ctxManager.setRuntimeState({ tier, smartMode });
124
135
 
125
136
  const meta = ctxManager.get().meta;
package/src/jwt.ts ADDED
@@ -0,0 +1,147 @@
1
+ export interface JwtPayload {
2
+ sub: string;
3
+ aud: string;
4
+ ent: string[];
5
+ iat: number;
6
+ exp: number;
7
+ iss: string;
8
+ }
9
+
10
+ const EXPECTED_AUD = "betterclaw";
11
+ const EXPECTED_ISS = "api.betterclaw.app";
12
+
13
+ function base64urlDecode(str: string): Uint8Array {
14
+ const padded = str + "=".repeat((4 - (str.length % 4)) % 4);
15
+ const binary = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
16
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
17
+ }
18
+
19
+ async function importPublicKey(pem: string): Promise<CryptoKey> {
20
+ const b64 = pem
21
+ .replace(/-----BEGIN PUBLIC KEY-----/, "")
22
+ .replace(/-----END PUBLIC KEY-----/, "")
23
+ .replace(/\s/g, "");
24
+ const binary = atob(b64);
25
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
26
+ return crypto.subtle.importKey(
27
+ "spki",
28
+ bytes,
29
+ { name: "ECDSA", namedCurve: "P-256" },
30
+ false,
31
+ ["verify"]
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Verify an ES256 JWT and return the payload, or null if invalid.
37
+ * Never throws — all failures return null and are logged.
38
+ */
39
+ export async function verifyJwt(
40
+ token: string,
41
+ publicKeyPem: string
42
+ ): Promise<JwtPayload | null> {
43
+ try {
44
+ const parts = token.split(".");
45
+ if (parts.length !== 3) return null;
46
+
47
+ const [headerB64, payloadB64, signatureB64] = parts;
48
+
49
+ // Verify signature
50
+ const publicKey = await importPublicKey(publicKeyPem);
51
+ const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
52
+ const signature = base64urlDecode(signatureB64);
53
+
54
+ const valid = await crypto.subtle.verify(
55
+ { name: "ECDSA", hash: "SHA-256" },
56
+ publicKey,
57
+ signature,
58
+ signingInput
59
+ );
60
+ if (!valid) return null;
61
+
62
+ // Decode and validate payload
63
+ const payload: JwtPayload = JSON.parse(
64
+ new TextDecoder().decode(base64urlDecode(payloadB64))
65
+ );
66
+
67
+ if (payload.aud !== EXPECTED_AUD) return null;
68
+ if (payload.iss !== EXPECTED_ISS) return null;
69
+ if (payload.exp < Math.floor(Date.now() / 1000)) return null;
70
+ if (!Array.isArray(payload.ent)) return null;
71
+
72
+ return payload;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Check if a verified JWT payload has a specific entitlement.
80
+ */
81
+ export function hasEntitlement(
82
+ payload: JwtPayload | null,
83
+ entitlement: string
84
+ ): boolean {
85
+ return payload !== null && payload.ent.includes(entitlement);
86
+ }
87
+
88
+ // ES256 public key for JWT verification (from betterclaw-api)
89
+ const JWT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
90
+ MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEi2MFBpmWsTPIvJI4dTkrJVmeUE9L
91
+ j9wMeV+kpLoMxVt09srOoI3r2CGUSwktRI0WyHQPkQjV1GC08SZ2Y8mwPw==
92
+ -----END PUBLIC KEY-----`;
93
+
94
+ // Module-level JWT state — safe because this is per-plugin-instance,
95
+ // not per-request. Updated only from the heartbeat handler.
96
+ let currentJwtToken: string | null = null;
97
+ let currentPayload: JwtPayload | null = null;
98
+
99
+ /**
100
+ * Store and verify a JWT received from heartbeat.
101
+ * Only re-verifies if the token has changed.
102
+ */
103
+ export async function storeJwt(jwt: string): Promise<JwtPayload | null> {
104
+ if (jwt === currentJwtToken) return currentPayload;
105
+ currentJwtToken = jwt;
106
+ currentPayload = await verifyJwt(jwt, JWT_PUBLIC_KEY);
107
+ return currentPayload;
108
+ }
109
+
110
+ /**
111
+ * Get the current verified payload (may be null).
112
+ */
113
+ export function getVerifiedPayload(): JwtPayload | null {
114
+ return currentPayload;
115
+ }
116
+
117
+ /**
118
+ * Check if the current JWT grants an entitlement.
119
+ * Returns null if entitled, or an error message string if not.
120
+ */
121
+ export function requireEntitlement(entitlement: string): string | null {
122
+ if (!currentPayload) {
123
+ return "This feature requires an active Premium subscription. Please open BetterClaw and check your subscription status.";
124
+ }
125
+ if (!hasEntitlement(currentPayload, entitlement)) {
126
+ if (entitlement === "shortcuts") {
127
+ return "This feature requires the Shortcuts Pack add-on. Please open BetterClaw and check your subscription status.";
128
+ }
129
+ return "This feature requires an active Premium subscription. Please open BetterClaw and check your subscription status.";
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Reset JWT state (for testing).
136
+ */
137
+ export function _resetJwtState(): void {
138
+ currentJwtToken = null;
139
+ currentPayload = null;
140
+ }
141
+
142
+ /**
143
+ * Inject a payload directly (for testing requireEntitlement without real keys).
144
+ */
145
+ export function _setPayloadForTesting(payload: JwtPayload | null): void {
146
+ currentPayload = payload;
147
+ }
package/src/pipeline.ts CHANGED
@@ -6,6 +6,7 @@ import type { ReactionTracker } from "./reactions.js";
6
6
  import type { DeviceEvent, DeviceContext, PluginConfig } from "./types.js";
7
7
  import { triageEvent } from "./triage.js";
8
8
  import { loadTriageProfile } from "./learner.js";
9
+ import { requireEntitlement } from "./jwt.js";
9
10
 
10
11
  export interface PipelineDeps {
11
12
  api: OpenClawPluginApi;
@@ -20,10 +21,17 @@ export interface PipelineDeps {
20
21
  export async function processEvent(deps: PipelineDeps, event: DeviceEvent): Promise<void> {
21
22
  const { api, config, context, events, rules } = deps;
22
23
 
23
- // Always update context
24
+ // Always update context (even for non-premium users)
24
25
  context.updateFromEvent(event);
25
26
  await context.save();
26
27
 
28
+ // Gate event forwarding behind premium entitlement
29
+ const entitlementError = requireEntitlement("premium");
30
+ if (entitlementError) {
31
+ api.logger.info(`betterclaw: event blocked (no premium entitlement)`);
32
+ return;
33
+ }
34
+
27
35
  // If smartMode is OFF, store only — no filtering or pushing
28
36
  if (!context.getRuntimeState().smartMode) {
29
37
  await events.append({ event, decision: "stored", reason: "smartMode off", timestamp: Date.now() / 1000 });