openclaw-nostr 2026.9.2 → 2026.9.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased (Cascadia Fork)
4
4
 
5
+ ### Fixes — plugin fails to load on openclaw >= 2026.8 (ocn-o66w)
6
+ - **`ERR_PACKAGE_PATH_NOT_EXPORTED: './plugin-sdk/zod'`**: openclaw retired the `openclaw/plugin-sdk/zod` exports subpath (upstream `dceb2c343c4`, an early retirement of the August 15 compat batch), so gateways running openclaw 2026.8.x or newer failed to load the whole plugin at `index.ts`. The two importers (`src/config-schema.ts`, `src/nostr-profile-http.ts`) now import `z` from the `zod` package directly, as the upstream migration guide instructs, and `zod` is declared as a runtime dependency pinned to `4.4.3` to match the host's copy. Because openclaw inlines its own zod into `dist`, the plugin-built config schema is structurally identical but nominally distinct to TypeScript at the `buildChannelConfigSchema` boundary; the two host call sites carry a documented cast. Runtime behavior is unchanged.
7
+
8
+ ### Fixes — Blossom auth helper
9
+ - **Blossom uploads now have a BUD-11 auth helper**: added `buildBlossomAuthEvent`, `getBlossomAuthToken`, bus-level `getBlossomAuthToken`, and channel-level `getNostrBlossomAuthToken` for kind:24242 Blossom auth events with `t`, `x`, `expiration`, and optional `server` tags. Updated docs/comments that previously pointed Blossom upload callers at generic NIP-98 kind:27235 tokens, which BUD-11-only Blossom servers reject before storage.
10
+
11
+ ### Fixes — relay AUTH regression in 2026.9.2 (ocn-a4wg)
12
+ - **Upgrade from 2026.9.2.** That release swallowed every `relay.auth()` rejection and returned `false`, on the mistaken premise that nostr-tools never handles it. It handles it in two of three call sites: `SimplePool.subscribeMap` retries a CLOSED `auth-required:` via `auth().then(re-subscribe).catch(stop)`, and `SimplePool.publish` awaits it before republishing. Returning `false` made both treat a **failed** AUTH as success, so a NIP-29 room with `auth:true` re-subscribed unauthenticated in a loop — `closed: auth-required` → reconnect → repeat — with the relay's real refusal reason suppressed, which reads exactly like `auth:true` being ignored. `auth()` now rejects again; the returned promise carries a no-op `.catch()`, which marks it handled for Node's unhandled-rejection detection without consuming it, so the unawaited call in `AbstractRelay._onmessage` still cannot crash the process (the ocn-n5g guarantee) while awaiting callers keep their retry semantics.
13
+
5
14
  ### Fixes — relay AUTH refusal crashed the process (ocn-n5g, ocn-uao2)
6
15
  - **A relay refusing NIP-42 AUTH no longer takes the gateway down (ocn-n5g)**: a relay answering our AUTH event with `["OK", <id>, false, "error: relay needs serviceUrl to be configured before AUTH can work"]` — a misconfigured relay that can never authenticate anyone — rejected the auth promise inside nostr-tools' `AbstractRelay._onmessage`, which invokes `this.auth(this.onauth)` without awaiting or catching it. The rejection escaped as an unhandled rejection and killed the process (stability bundle written), with a stack containing no frames of ours. Our prototype patch previously swallowed only `SendingOnClosedConnection` and `"auth timed out"`, so relay-side refusals sailed through. It now swallows every auth rejection and returns `false` — since the sole caller never handles the promise, rethrowing can only ever crash — and clears the memoized `authPromise` so a later challenge retries instead of replaying a cached rejection forever. The patch moved to `nostr-bus-relay.ts` and is now installed by the fleet-tasks pool too, which sets `automaticallyAuth` but never ran it and carried the same latent crash.
7
16
  - **Failing relays are now named in the logs (ocn-uao2)**: the swallowed failure was reported as a bare string, so the offending relay was unidentifiable. Failures now carry a structured report (relay URL, kind, relay-supplied reason, occurrence and suppressed counts) and are classified `closed_connection` / `timed_out` / `no_challenge` / `relay_refused`, separating self-healing races from a relay that will never authenticate. Refusals route to `onError` so they are always visible in production logs; races route to `onDebug` and are lifted to `warn`. Repeats are throttled per relay+kind (first immediately, then at most one line per minute carrying the suppressed count) because a refusing relay re-refuses on every reconnect. AUTH sends dropped by the closed-connection guard, previously swallowed in silence, are now named too. Emits `relay.auth.rejected` tagged by relay and kind.
package/README.md CHANGED
@@ -989,6 +989,7 @@ import {
989
989
  getNostrRelayCapabilities,
990
990
  // Auth
991
991
  getNostrHttpAuthToken,
992
+ getNostrBlossomAuthToken,
992
993
  // Media
993
994
  publishNostrFileMetadata,
994
995
  // Social
@@ -1041,8 +1042,12 @@ await bus.sendChannelMessage({
1041
1042
  relayUrl: "wss://relay.example",
1042
1043
  });
1043
1044
 
1044
- // Generate NIP-98 auth token for a Blossom upload
1045
- const token = await bus.getNip98Token("https://media.example.com/upload", "POST");
1045
+ // Generate BUD-11 auth token for a Blossom upload
1046
+ const token = await bus.getBlossomAuthToken({
1047
+ verb: "upload",
1048
+ hashes: ["<sha256>"],
1049
+ server: "https://media.example.com",
1050
+ });
1046
1051
  ```
1047
1052
 
1048
1053
  ### Tier 3 Extras (Namespace Imports)
@@ -1117,7 +1122,7 @@ pnpm vitest run --config vitest.extensions.config.ts extensions/nostr/src/nostr-
1117
1122
  - Event signatures are verified before processing
1118
1123
  - Use environment variables for keys, never commit to config files
1119
1124
  - Consider using `allowlist` mode in production
1120
- - NIP-98 tokens are signed with the bus's key — scope them to specific URLs
1125
+ - NIP-98 and BUD-11 Blossom tokens are signed with the bus's key — scope them to specific URLs/servers and hashes
1121
1126
  - NIP-44 conversation keys are derived from the bus's secret key
1122
1127
  - HTTP mutation endpoints (PUT, POST, DELETE) are restricted to loopback addresses
1123
1128
  - **NIP-46**: The client secret (`nip46Secret`) is distinct from the identity key — store it via `NOSTR_NIP46_SECRET` env var or a `SecretRef` (`env`, `file`, `exec`), never in raw config
@@ -116,32 +116,32 @@ export const KindMeta: Record<number, {
116
116
  [CTXVM_SERVER_ANNOUNCEMENT]: {
117
117
  symbol: "CTXVM_SERVER_ANNOUNCEMENT",
118
118
  family: "ContextVM",
119
- eventForm: "replaceable",
120
- requiredTags: [],
119
+ eventForm: "regular",
120
+ requiredTags: ["p"],
121
121
  },
122
122
  [CTXVM_TOOLS_ANNOUNCEMENT]: {
123
123
  symbol: "CTXVM_TOOLS_ANNOUNCEMENT",
124
124
  family: "ContextVM",
125
- eventForm: "replaceable",
126
- requiredTags: [],
125
+ eventForm: "regular",
126
+ requiredTags: ["p"],
127
127
  },
128
128
  [CTXVM_RESOURCES_ANNOUNCEMENT]: {
129
129
  symbol: "CTXVM_RESOURCES_ANNOUNCEMENT",
130
130
  family: "ContextVM",
131
- eventForm: "replaceable",
132
- requiredTags: [],
131
+ eventForm: "regular",
132
+ requiredTags: ["p"],
133
133
  },
134
134
  [CTXVM_RESOURCE_TEMPLATES_ANNOUNCEMENT]: {
135
135
  symbol: "CTXVM_RESOURCE_TEMPLATES_ANNOUNCEMENT",
136
136
  family: "ContextVM",
137
- eventForm: "replaceable",
138
- requiredTags: [],
137
+ eventForm: "regular",
138
+ requiredTags: ["p"],
139
139
  },
140
140
  [CTXVM_PROMPTS_ANNOUNCEMENT]: {
141
141
  symbol: "CTXVM_PROMPTS_ANNOUNCEMENT",
142
142
  family: "ContextVM",
143
- eventForm: "replaceable",
144
- requiredTags: [],
143
+ eventForm: "regular",
144
+ requiredTags: ["p"],
145
145
  },
146
146
  [NIP59_EPHEMERAL_GIFT_WRAP]: {
147
147
  symbol: "NIP59_EPHEMERAL_GIFT_WRAP",
@@ -1443,6 +1443,91 @@ export function validateCascadiaLoomWorkerV1Payload(payload: CascadiaLoomWorkerV
1443
1443
  return errors;
1444
1444
  }
1445
1445
 
1446
+ export interface CascadiaMaintenancePressureResultV1Mount {
1447
+ path: string;
1448
+ total_bytes: number;
1449
+ free_bytes: number;
1450
+ used_pct: number;
1451
+ total_inodes: number;
1452
+ free_inodes: number;
1453
+ }
1454
+
1455
+ export function validateCascadiaMaintenancePressureResultV1Mount(payload: CascadiaMaintenancePressureResultV1Mount): string[] {
1456
+ const errors: string[] = [];
1457
+ if (payload.path === undefined || payload.path === null || payload.path === '') errors.push("cascadia.maintenance.pressure-result.v1.mounts[].path is required");
1458
+ if (payload.total_bytes === undefined || payload.total_bytes === null) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_bytes is required");
1459
+ if (payload.total_bytes !== undefined && payload.total_bytes < 0) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_bytes must be >= 0");
1460
+ if (payload.free_bytes === undefined || payload.free_bytes === null) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_bytes is required");
1461
+ if (payload.free_bytes !== undefined && payload.free_bytes < 0) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_bytes must be >= 0");
1462
+ if (payload.used_pct === undefined || payload.used_pct === null) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].used_pct is required");
1463
+ if (payload.used_pct !== undefined && payload.used_pct < 0) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].used_pct must be >= 0");
1464
+ if (payload.used_pct !== undefined && payload.used_pct > 100) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].used_pct must be <= 100");
1465
+ if (payload.total_inodes === undefined || payload.total_inodes === null) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_inodes is required");
1466
+ if (payload.total_inodes !== undefined && payload.total_inodes < 0) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_inodes must be >= 0");
1467
+ if (payload.free_inodes === undefined || payload.free_inodes === null) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_inodes is required");
1468
+ if (payload.free_inodes !== undefined && payload.free_inodes < 0) errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_inodes must be >= 0");
1469
+ return errors;
1470
+ }
1471
+
1472
+ /** Bounded fleet hygiene disk and inode pressure result payload
1473
+ * Schema: cascadia.maintenance.pressure-result.v1
1474
+ * CUE: schemas/control-plane/maintenance-pressure-result.cue (MaintenancePressureResult, MountPressure)
1475
+ */
1476
+ export interface CascadiaMaintenancePressureResultV1Payload {
1477
+ mounts: CascadiaMaintenancePressureResultV1Mount[];
1478
+ sampled_at: string;
1479
+ }
1480
+
1481
+ export function validateCascadiaMaintenancePressureResultV1Payload(payload: CascadiaMaintenancePressureResultV1Payload): string[] {
1482
+ const errors: string[] = [];
1483
+ if (payload.mounts === undefined || payload.mounts === null) errors.push("cascadia.maintenance.pressure-result.v1.mounts is required");
1484
+ if (payload.mounts !== undefined) payload.mounts.forEach((item) => errors.push(...validateCascadiaMaintenancePressureResultV1Mount(item)));
1485
+ if (payload.sampled_at === undefined || payload.sampled_at === null || payload.sampled_at === '') errors.push("cascadia.maintenance.pressure-result.v1.sampled_at is required");
1486
+ return errors;
1487
+ }
1488
+
1489
+ export interface CascadiaMaintenanceScanResultV1Candidate {
1490
+ id: string;
1491
+ path: string;
1492
+ class: "dup-clone" | "orphan-clone" | "misplaced-backup" | "cruft";
1493
+ reason?: string;
1494
+ canonical?: string;
1495
+ blocked?: "dirty-tree" | "orphan" | "in-use" | "policy" | "protected-root";
1496
+ }
1497
+
1498
+ export function validateCascadiaMaintenanceScanResultV1Candidate(payload: CascadiaMaintenanceScanResultV1Candidate): string[] {
1499
+ const errors: string[] = [];
1500
+ if (payload.id === undefined || payload.id === null || payload.id === '') errors.push("cascadia.maintenance.scan-result.v1.candidates[].id is required");
1501
+ if (payload.path === undefined || payload.path === null || payload.path === '') errors.push("cascadia.maintenance.scan-result.v1.candidates[].path is required");
1502
+ if (payload.class === undefined || payload.class === null) errors.push("cascadia.maintenance.scan-result.v1.candidates[].class is required");
1503
+ if (payload.class !== undefined && !["dup-clone", "orphan-clone", "misplaced-backup", "cruft"].includes(payload.class as string)) errors.push("cascadia.maintenance.scan-result.v1.candidates[].class must be one of: dup-clone, orphan-clone, misplaced-backup, cruft");
1504
+ if (payload.blocked !== undefined && !["dirty-tree", "orphan", "in-use", "policy", "protected-root"].includes(payload.blocked as string)) errors.push("cascadia.maintenance.scan-result.v1.candidates[].blocked must be one of: dirty-tree, orphan, in-use, policy, protected-root");
1505
+ return errors;
1506
+ }
1507
+
1508
+ /** Bounded fleet hygiene scan result payload
1509
+ * Schema: cascadia.maintenance.scan-result.v1
1510
+ * CUE: schemas/control-plane/maintenance-scan-result.cue (MaintenanceScanResult, MaintenanceCandidate)
1511
+ */
1512
+ export interface CascadiaMaintenanceScanResultV1Payload {
1513
+ candidates: CascadiaMaintenanceScanResultV1Candidate[];
1514
+ scanned_at: string;
1515
+ roots?: string[];
1516
+ total_candidates: number;
1517
+ truncated?: boolean;
1518
+ }
1519
+
1520
+ export function validateCascadiaMaintenanceScanResultV1Payload(payload: CascadiaMaintenanceScanResultV1Payload): string[] {
1521
+ const errors: string[] = [];
1522
+ if (payload.candidates === undefined || payload.candidates === null) errors.push("cascadia.maintenance.scan-result.v1.candidates is required");
1523
+ if (payload.candidates !== undefined && payload.candidates.length > 500) errors.push("cascadia.maintenance.scan-result.v1.candidates must contain at most 500 items");
1524
+ if (payload.candidates !== undefined) payload.candidates.forEach((item) => errors.push(...validateCascadiaMaintenanceScanResultV1Candidate(item)));
1525
+ if (payload.scanned_at === undefined || payload.scanned_at === null || payload.scanned_at === '') errors.push("cascadia.maintenance.scan-result.v1.scanned_at is required");
1526
+ if (payload.total_candidates === undefined || payload.total_candidates === null) errors.push("cascadia.maintenance.scan-result.v1.total_candidates is required");
1527
+ if (payload.total_candidates !== undefined && payload.total_candidates < 0) errors.push("cascadia.maintenance.scan-result.v1.total_candidates must be >= 0");
1528
+ return errors;
1529
+ }
1530
+
1446
1531
  /** Fleet hygiene (Swabbie) maintenance command payload
1447
1532
  * Schema: cascadia.maintenance.v1
1448
1533
  * CUE: schemas/control-plane/maintenance.cue (Maintenance)
@@ -675,6 +675,45 @@ export interface CascadiaLoomWorkerV1Payload {
675
675
  pricing?: Record<string, unknown>;
676
676
  }
677
677
  export declare function validateCascadiaLoomWorkerV1Payload(payload: CascadiaLoomWorkerV1Payload): string[];
678
+ export interface CascadiaMaintenancePressureResultV1Mount {
679
+ path: string;
680
+ total_bytes: number;
681
+ free_bytes: number;
682
+ used_pct: number;
683
+ total_inodes: number;
684
+ free_inodes: number;
685
+ }
686
+ export declare function validateCascadiaMaintenancePressureResultV1Mount(payload: CascadiaMaintenancePressureResultV1Mount): string[];
687
+ /** Bounded fleet hygiene disk and inode pressure result payload
688
+ * Schema: cascadia.maintenance.pressure-result.v1
689
+ * CUE: schemas/control-plane/maintenance-pressure-result.cue (MaintenancePressureResult, MountPressure)
690
+ */
691
+ export interface CascadiaMaintenancePressureResultV1Payload {
692
+ mounts: CascadiaMaintenancePressureResultV1Mount[];
693
+ sampled_at: string;
694
+ }
695
+ export declare function validateCascadiaMaintenancePressureResultV1Payload(payload: CascadiaMaintenancePressureResultV1Payload): string[];
696
+ export interface CascadiaMaintenanceScanResultV1Candidate {
697
+ id: string;
698
+ path: string;
699
+ class: "dup-clone" | "orphan-clone" | "misplaced-backup" | "cruft";
700
+ reason?: string;
701
+ canonical?: string;
702
+ blocked?: "dirty-tree" | "orphan" | "in-use" | "policy" | "protected-root";
703
+ }
704
+ export declare function validateCascadiaMaintenanceScanResultV1Candidate(payload: CascadiaMaintenanceScanResultV1Candidate): string[];
705
+ /** Bounded fleet hygiene scan result payload
706
+ * Schema: cascadia.maintenance.scan-result.v1
707
+ * CUE: schemas/control-plane/maintenance-scan-result.cue (MaintenanceScanResult, MaintenanceCandidate)
708
+ */
709
+ export interface CascadiaMaintenanceScanResultV1Payload {
710
+ candidates: CascadiaMaintenanceScanResultV1Candidate[];
711
+ scanned_at: string;
712
+ roots?: string[];
713
+ total_candidates: number;
714
+ truncated?: boolean;
715
+ }
716
+ export declare function validateCascadiaMaintenanceScanResultV1Payload(payload: CascadiaMaintenanceScanResultV1Payload): string[];
678
717
  /** Fleet hygiene (Swabbie) maintenance command payload
679
718
  * Schema: cascadia.maintenance.v1
680
719
  * CUE: schemas/control-plane/maintenance.cue (Maintenance)
@@ -88,32 +88,32 @@ export const KindMeta = {
88
88
  [CTXVM_SERVER_ANNOUNCEMENT]: {
89
89
  symbol: "CTXVM_SERVER_ANNOUNCEMENT",
90
90
  family: "ContextVM",
91
- eventForm: "replaceable",
92
- requiredTags: [],
91
+ eventForm: "regular",
92
+ requiredTags: ["p"],
93
93
  },
94
94
  [CTXVM_TOOLS_ANNOUNCEMENT]: {
95
95
  symbol: "CTXVM_TOOLS_ANNOUNCEMENT",
96
96
  family: "ContextVM",
97
- eventForm: "replaceable",
98
- requiredTags: [],
97
+ eventForm: "regular",
98
+ requiredTags: ["p"],
99
99
  },
100
100
  [CTXVM_RESOURCES_ANNOUNCEMENT]: {
101
101
  symbol: "CTXVM_RESOURCES_ANNOUNCEMENT",
102
102
  family: "ContextVM",
103
- eventForm: "replaceable",
104
- requiredTags: [],
103
+ eventForm: "regular",
104
+ requiredTags: ["p"],
105
105
  },
106
106
  [CTXVM_RESOURCE_TEMPLATES_ANNOUNCEMENT]: {
107
107
  symbol: "CTXVM_RESOURCE_TEMPLATES_ANNOUNCEMENT",
108
108
  family: "ContextVM",
109
- eventForm: "replaceable",
110
- requiredTags: [],
109
+ eventForm: "regular",
110
+ requiredTags: ["p"],
111
111
  },
112
112
  [CTXVM_PROMPTS_ANNOUNCEMENT]: {
113
113
  symbol: "CTXVM_PROMPTS_ANNOUNCEMENT",
114
114
  family: "ContextVM",
115
- eventForm: "replaceable",
116
- requiredTags: [],
115
+ eventForm: "regular",
116
+ requiredTags: ["p"],
117
117
  },
118
118
  [NIP59_EPHEMERAL_GIFT_WRAP]: {
119
119
  symbol: "NIP59_EPHEMERAL_GIFT_WRAP",
@@ -945,6 +945,74 @@ export function validateCascadiaLoomWorkerV1Payload(payload) {
945
945
  errors.push("cascadia.loom.worker.v1.capacity is required");
946
946
  return errors;
947
947
  }
948
+ export function validateCascadiaMaintenancePressureResultV1Mount(payload) {
949
+ const errors = [];
950
+ if (payload.path === undefined || payload.path === null || payload.path === '')
951
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].path is required");
952
+ if (payload.total_bytes === undefined || payload.total_bytes === null)
953
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_bytes is required");
954
+ if (payload.total_bytes !== undefined && payload.total_bytes < 0)
955
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_bytes must be >= 0");
956
+ if (payload.free_bytes === undefined || payload.free_bytes === null)
957
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_bytes is required");
958
+ if (payload.free_bytes !== undefined && payload.free_bytes < 0)
959
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_bytes must be >= 0");
960
+ if (payload.used_pct === undefined || payload.used_pct === null)
961
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].used_pct is required");
962
+ if (payload.used_pct !== undefined && payload.used_pct < 0)
963
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].used_pct must be >= 0");
964
+ if (payload.used_pct !== undefined && payload.used_pct > 100)
965
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].used_pct must be <= 100");
966
+ if (payload.total_inodes === undefined || payload.total_inodes === null)
967
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_inodes is required");
968
+ if (payload.total_inodes !== undefined && payload.total_inodes < 0)
969
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].total_inodes must be >= 0");
970
+ if (payload.free_inodes === undefined || payload.free_inodes === null)
971
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_inodes is required");
972
+ if (payload.free_inodes !== undefined && payload.free_inodes < 0)
973
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts[].free_inodes must be >= 0");
974
+ return errors;
975
+ }
976
+ export function validateCascadiaMaintenancePressureResultV1Payload(payload) {
977
+ const errors = [];
978
+ if (payload.mounts === undefined || payload.mounts === null)
979
+ errors.push("cascadia.maintenance.pressure-result.v1.mounts is required");
980
+ if (payload.mounts !== undefined)
981
+ payload.mounts.forEach((item) => errors.push(...validateCascadiaMaintenancePressureResultV1Mount(item)));
982
+ if (payload.sampled_at === undefined || payload.sampled_at === null || payload.sampled_at === '')
983
+ errors.push("cascadia.maintenance.pressure-result.v1.sampled_at is required");
984
+ return errors;
985
+ }
986
+ export function validateCascadiaMaintenanceScanResultV1Candidate(payload) {
987
+ const errors = [];
988
+ if (payload.id === undefined || payload.id === null || payload.id === '')
989
+ errors.push("cascadia.maintenance.scan-result.v1.candidates[].id is required");
990
+ if (payload.path === undefined || payload.path === null || payload.path === '')
991
+ errors.push("cascadia.maintenance.scan-result.v1.candidates[].path is required");
992
+ if (payload.class === undefined || payload.class === null)
993
+ errors.push("cascadia.maintenance.scan-result.v1.candidates[].class is required");
994
+ if (payload.class !== undefined && !["dup-clone", "orphan-clone", "misplaced-backup", "cruft"].includes(payload.class))
995
+ errors.push("cascadia.maintenance.scan-result.v1.candidates[].class must be one of: dup-clone, orphan-clone, misplaced-backup, cruft");
996
+ if (payload.blocked !== undefined && !["dirty-tree", "orphan", "in-use", "policy", "protected-root"].includes(payload.blocked))
997
+ errors.push("cascadia.maintenance.scan-result.v1.candidates[].blocked must be one of: dirty-tree, orphan, in-use, policy, protected-root");
998
+ return errors;
999
+ }
1000
+ export function validateCascadiaMaintenanceScanResultV1Payload(payload) {
1001
+ const errors = [];
1002
+ if (payload.candidates === undefined || payload.candidates === null)
1003
+ errors.push("cascadia.maintenance.scan-result.v1.candidates is required");
1004
+ if (payload.candidates !== undefined && payload.candidates.length > 500)
1005
+ errors.push("cascadia.maintenance.scan-result.v1.candidates must contain at most 500 items");
1006
+ if (payload.candidates !== undefined)
1007
+ payload.candidates.forEach((item) => errors.push(...validateCascadiaMaintenanceScanResultV1Candidate(item)));
1008
+ if (payload.scanned_at === undefined || payload.scanned_at === null || payload.scanned_at === '')
1009
+ errors.push("cascadia.maintenance.scan-result.v1.scanned_at is required");
1010
+ if (payload.total_candidates === undefined || payload.total_candidates === null)
1011
+ errors.push("cascadia.maintenance.scan-result.v1.total_candidates is required");
1012
+ if (payload.total_candidates !== undefined && payload.total_candidates < 0)
1013
+ errors.push("cascadia.maintenance.scan-result.v1.total_candidates must be >= 0");
1014
+ return errors;
1015
+ }
948
1016
  export function validateCascadiaMaintenanceV1Payload(payload) {
949
1017
  const errors = [];
950
1018
  return errors;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-nostr",
3
- "version": "2026.9.2",
3
+ "version": "2026.9.4",
4
4
  "description": "Nostr channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,14 +16,16 @@
16
16
  "CHANGELOG.md"
17
17
  ],
18
18
  "scripts": {
19
- "build": "tsc --noEmit --noCheck --module NodeNext --moduleResolution NodeNext --target ES2023 index.ts api.ts runtime-api.ts setup-api.ts setup-entry.ts src/*.ts test/setup.ts vitest.config.ts",
19
+ "build": "tsc -p tsconfig.json --noCheck",
20
20
  "test:bootstrap": "node --test test/nostr-claw-bootstrap.test.mjs",
21
- "test:scanner": "vitest run test/runtime-security-scanner.test.ts"
21
+ "test:scanner": "vitest run test/runtime-security-scanner.test.ts",
22
+ "typecheck": "tsc -p tsconfig.json"
22
23
  },
23
24
  "dependencies": {
24
25
  "cascadia-ts": "file:../cascadia-nips/generated/typescript",
25
26
  "nostr-tools": "2.23.3",
26
- "ws": "^8.18.0"
27
+ "ws": "^8.18.0",
28
+ "zod": "4.4.3"
27
29
  },
28
30
  "bundledDependencies": [
29
31
  "cascadia-ts"
@@ -40,7 +40,7 @@ type RoomConfigLike = {
40
40
  type NostrChannelCfgLike = {
41
41
  channels?: {
42
42
  nostr?: {
43
- allowFrom?: readonly string[];
43
+ allowFrom?: readonly (string | number)[];
44
44
  profile?: { name?: string; displayName?: string };
45
45
  nostr_channels?: Record<string, RoomConfigLike | undefined>;
46
46
  };
@@ -82,8 +82,10 @@ function toNpub(pubkeyHex: string): string {
82
82
  }
83
83
  }
84
84
 
85
- function normalizeEntryPubkey(value: string): string | undefined {
86
- const trimmed = value.trim();
85
+ // See normalizeAllowEntry in nostr-taskflow-tool.ts: allowFrom entries may be
86
+ // numbers, so stringify before trimming.
87
+ function normalizeEntryPubkey(value: string | number): string | undefined {
88
+ const trimmed = String(value).trim();
87
89
  if (!trimmed || trimmed === "*") {
88
90
  return undefined;
89
91
  }
package/src/channel.ts CHANGED
@@ -104,6 +104,7 @@ import type {
104
104
  ReactionTarget,
105
105
  DeletionOptions,
106
106
  RepostOptions,
107
+ BlossomAuthTokenOptions,
107
108
  FileMetadataObject,
108
109
  } from "./nostr-capabilities.js";
109
110
  import {
@@ -356,7 +357,7 @@ type NostrGroupInboundContextParams = {
356
357
  roomConfig?: NostrRoomChannelMap[string];
357
358
  preflight?: NostrGroupPreflightResult;
358
359
  cfg?: unknown;
359
- accountAllowFrom?: readonly string[];
360
+ accountAllowFrom?: readonly (string | number)[];
360
361
  commandHelpers?: NostrGroupPreflightCommandHelpers;
361
362
  /** P3: a pending-commitment reminder injected as untrusted context for this turn. */
362
363
  commitmentReminder?: string;
@@ -373,7 +374,8 @@ type NostrChannelInboundEventContextParams = {
373
374
  messageIdFull?: string;
374
375
  timestamp?: number;
375
376
  from: string;
376
- sender: {
377
+ /** Optional: system-authored room events have no actor pubkey. */
378
+ sender?: {
377
379
  id?: string;
378
380
  name?: string;
379
381
  displayLabel?: string;
@@ -509,12 +511,12 @@ function buildChannelInboundEventContextCompat(params: NostrChannelInboundEventC
509
511
  ConversationLabel: params.conversation.label,
510
512
  GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined,
511
513
  UntrustedStructuredContext: params.supplemental?.untrustedContext,
512
- SenderName: params.sender.name ?? params.sender.displayLabel,
513
- SenderId: params.sender.id,
514
- SenderIsBot: params.sender.isBot,
515
- SenderUsername: params.sender.username,
516
- SenderTag: params.sender.tag,
517
- MemberRoleIds: params.sender.roles,
514
+ SenderName: params.sender?.name ?? params.sender?.displayLabel,
515
+ SenderId: params.sender?.id,
516
+ SenderIsBot: params.sender?.isBot,
517
+ SenderUsername: params.sender?.username,
518
+ SenderTag: params.sender?.tag,
519
+ MemberRoleIds: params.sender?.roles,
518
520
  Timestamp: params.timestamp,
519
521
  Provider: params.provider ?? params.channel,
520
522
  Surface: params.surface ?? params.provider ?? params.channel,
@@ -922,8 +924,11 @@ async function assertBackedNostrRoomCommitment(params: {
922
924
  }),
923
925
  );
924
926
  const assessment = await assessCommitment(params.text, roomKey, (reference) => {
927
+ // `effective` is a FleetTaskEventSnapshot; the status lives on its `task`
928
+ // payload. Reading it off the snapshot yielded undefined, so every
929
+ // referenced task — closed ones included — counted as still active.
925
930
  const task = activeFleetTaskServices.get(params.accountId)?.getTask(reference)?.effective;
926
- if (task && task.status !== "closed") return true;
931
+ if (task && task.task.status !== "closed") return true;
927
932
  return resolveNostrTaskFlowBackingActive(
928
933
  getNostrRuntime(),
929
934
  params.accountId,
@@ -1297,8 +1302,11 @@ function localIsNostrAbortRequestText(text: string): boolean {
1297
1302
  return /^(?:abort|cancel|stop)\b/i.test(text.trim());
1298
1303
  }
1299
1304
 
1300
- function normalizeNostrAllowEntry(value: string): string | undefined {
1301
- const trimmed = value.trim();
1305
+ // allowFrom entries may be numbers (the host's shared AllowFromListSchema is
1306
+ // ZodArray<ZodUnion<[ZodString, ZodNumber]>>), so stringify before trimming —
1307
+ // .trim() on a number throws, and this path gates control-command authorization.
1308
+ function normalizeNostrAllowEntry(value: string | number): string | undefined {
1309
+ const trimmed = String(value).trim();
1302
1310
  if (!trimmed || trimmed === "*") {
1303
1311
  return undefined;
1304
1312
  }
@@ -1306,7 +1314,7 @@ function normalizeNostrAllowEntry(value: string): string | undefined {
1306
1314
  }
1307
1315
 
1308
1316
  function nostrAllowListExplicitlyIncludesSender(
1309
- allowFrom: readonly string[] | undefined,
1317
+ allowFrom: readonly (string | number)[] | undefined,
1310
1318
  senderPubkey: string,
1311
1319
  ): boolean {
1312
1320
  const normalizedSender = senderPubkey.toLowerCase();
@@ -1317,7 +1325,7 @@ function resolveNostrCommandAuthorized(params: {
1317
1325
  hasControlCommand: boolean;
1318
1326
  senderPubkey: string;
1319
1327
  roomConfig?: NostrRoomChannelMap[string];
1320
- accountAllowFrom?: readonly string[];
1328
+ accountAllowFrom?: readonly (string | number)[];
1321
1329
  }): boolean {
1322
1330
  if (!params.hasControlCommand) {
1323
1331
  return false;
@@ -1446,7 +1454,7 @@ export function resolveNostrGroupPreflight(params: {
1446
1454
  route: Pick<NostrGroupRouteFacts, "agentId">;
1447
1455
  meta: InboundGroupMessageMeta;
1448
1456
  roomConfig?: NostrRoomChannelMap[string];
1449
- accountAllowFrom?: readonly string[];
1457
+ accountAllowFrom?: readonly (string | number)[];
1450
1458
  commandHelpers?: NostrGroupPreflightCommandHelpers;
1451
1459
  /** True when the sender is a peer for implicit-mention suppression (P1); may include unknownAsPeer. */
1452
1460
  senderIsPeer?: boolean;
@@ -1612,13 +1620,17 @@ function buildNostrGroupMembershipInboundEventContext(params: {
1612
1620
  messageIdFull: params.meta.eventId,
1613
1621
  timestamp: params.meta.createdAt ? params.meta.createdAt * 1000 : Date.now(),
1614
1622
  from: params.meta.actorPubkey ? `nostr:${params.meta.actorPubkey}` : "nostr:system",
1615
- sender: params.meta.actorPubkey
1623
+ // Omit `sender` entirely when there is no actor, rather than setting it to
1624
+ // undefined: the host's param type does not accept an explicit undefined.
1625
+ ...(params.meta.actorPubkey
1616
1626
  ? {
1617
- id: params.meta.actorPubkey,
1618
- name: nip19.npubEncode(params.meta.actorPubkey),
1619
- displayLabel: nip19.npubEncode(params.meta.actorPubkey),
1627
+ sender: {
1628
+ id: params.meta.actorPubkey,
1629
+ name: nip19.npubEncode(params.meta.actorPubkey),
1630
+ displayLabel: nip19.npubEncode(params.meta.actorPubkey),
1631
+ },
1620
1632
  }
1621
- : undefined,
1633
+ : {}),
1622
1634
  conversation: {
1623
1635
  kind: "group",
1624
1636
  id: roomKey,
@@ -2199,7 +2211,10 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
2199
2211
  unsend: true,
2200
2212
  },
2201
2213
  reload: { configPrefixes: ["channels.nostr"] },
2202
- configSchema: buildChannelConfigSchema(NostrConfigSchema),
2214
+ // See config-schema.ts: bridges the host-bundled vs. package zod type seam.
2215
+ configSchema: buildChannelConfigSchema(
2216
+ NostrConfigSchema as unknown as Parameters<typeof buildChannelConfigSchema>[0],
2217
+ ),
2203
2218
 
2204
2219
  config: {
2205
2220
  listAccountIds: (cfg) => listNostrAccountIds(cfg),
@@ -2868,7 +2883,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
2868
2883
  );
2869
2884
  }
2870
2885
  }
2871
- nip46SessionState = sessionState;
2886
+ nip46SessionState = sessionState ?? undefined;
2872
2887
 
2873
2888
  ctx.setStatus({
2874
2889
  accountId: account.accountId,
@@ -3226,6 +3241,10 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
3226
3241
  hasControlCommand: runtime.channel.text?.hasControlCommand,
3227
3242
  shouldHandleTextCommands: runtime.channel.commands?.shouldHandleTextCommands,
3228
3243
  };
3244
+ const accountAllowFrom = resolveNostrAccount({
3245
+ cfg,
3246
+ accountId: account.accountId,
3247
+ }).config.allowFrom;
3229
3248
  // Classify the sender via the kind:0 bot index. isPeerAgent is a
3230
3249
  // synchronous cache read that schedules a background fetch on a
3231
3250
  // miss — safe on the admission-gate path. A DEFINITIVE known bot
@@ -3248,7 +3267,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
3248
3267
  roomConfig,
3249
3268
  route,
3250
3269
  meta,
3251
- accountAllowFrom: account.config.allowFrom,
3270
+ accountAllowFrom,
3252
3271
  commandHelpers,
3253
3272
  senderIsPeer,
3254
3273
  senderIsKnownBot,
@@ -3614,7 +3633,6 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
3614
3633
  route: prepared.route,
3615
3634
  meta,
3616
3635
  cfg: prepared.cfg,
3617
- accountAllowFrom: account.config.allowFrom,
3618
3636
  commandHelpers: prepared.commandHelpers,
3619
3637
  preflight: prepared.preflight,
3620
3638
  commitmentReminder,
@@ -4304,7 +4322,6 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
4304
4322
  route,
4305
4323
  meta,
4306
4324
  cfg,
4307
- accountAllowFrom: account.config.allowFrom,
4308
4325
  commandHelpers,
4309
4326
  preflight,
4310
4327
  commitmentReminder,
@@ -5448,6 +5465,27 @@ export async function getNostrHttpAuthToken(
5448
5465
  return bus.getNip98Token(url, method);
5449
5466
  }
5450
5467
 
5468
+ // ============================================================================
5469
+ // BUD-11: Blossom Auth
5470
+ // ============================================================================
5471
+
5472
+ /**
5473
+ * Generate a BUD-11 Blossom auth token.
5474
+ * @param opts - Blossom operation, optional hash scopes, expiration, and server
5475
+ * @param accountId - Account ID (defaults to "default")
5476
+ * @returns Base64url encoded kind:24242 auth event without the "Nostr " prefix
5477
+ */
5478
+ export async function getNostrBlossomAuthToken(
5479
+ opts: BlossomAuthTokenOptions,
5480
+ accountId: string = DEFAULT_ACCOUNT_ID,
5481
+ ): Promise<string> {
5482
+ const bus = activeBuses.get(accountId);
5483
+ if (!bus) {
5484
+ throw nostrBusNotRunningError(accountId);
5485
+ }
5486
+ return bus.getBlossomAuthToken(opts);
5487
+ }
5488
+
5451
5489
  // ============================================================================
5452
5490
  // NIP-94: File Metadata
5453
5491
  // ============================================================================
@@ -4,7 +4,7 @@ import {
4
4
  DmPolicySchema,
5
5
  MarkdownConfigSchema,
6
6
  } from "openclaw/plugin-sdk/channel-config-primitives";
7
- import { z } from "openclaw/plugin-sdk/zod";
7
+ import { z } from "zod";
8
8
 
9
9
  /**
10
10
  * Validates https:// URLs only (no javascript:, data:, file:, etc.)
@@ -686,4 +686,9 @@ export type NostrConfig = z.infer<typeof NostrConfigSchema>;
686
686
  /**
687
687
  * JSON Schema for Control UI (converted from Zod)
688
688
  */
689
- export const nostrChannelConfigSchema = buildChannelConfigSchema(NostrConfigSchema);
689
+ // The host bundles its own zod copy into openclaw/dist, so a schema built with
690
+ // the `zod` package is structurally identical but nominally distinct to TS.
691
+ // Runtime is unaffected (both are zod 4.4.3); the cast bridges the type seam.
692
+ export const nostrChannelConfigSchema = buildChannelConfigSchema(
693
+ NostrConfigSchema as unknown as Parameters<typeof buildChannelConfigSchema>[0],
694
+ );