openclaw-nostr 2026.9.2 → 2026.9.3
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 +3 -0
- package/package.json +4 -3
- package/src/channel-directory.ts +5 -3
- package/src/channel.ts +31 -20
- package/src/fleet-tasks.ts +13 -1
- package/src/metrics.ts +11 -2
- package/src/nip46-signer.ts +32 -12
- package/src/nostr-bus-nip29.ts +19 -6
- package/src/nostr-bus-relay.auth-patch.test.ts +29 -6
- package/src/nostr-bus-relay.ts +35 -22
- package/src/nostr-bus.ts +16 -4
- package/src/nostr-state-store.ts +3 -1
- package/src/nostr-taskflow-tool.ts +10 -6
- package/src/setup-surface.ts +49 -6
- package/src/types.test.ts +28 -0
- package/src/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased (Cascadia Fork)
|
|
4
4
|
|
|
5
|
+
### Fixes — relay AUTH regression in 2026.9.2 (ocn-a4wg)
|
|
6
|
+
- **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.
|
|
7
|
+
|
|
5
8
|
### Fixes — relay AUTH refusal crashed the process (ocn-n5g, ocn-uao2)
|
|
6
9
|
- **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
10
|
- **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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-nostr",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.3",
|
|
4
4
|
"description": "Nostr channel plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
"CHANGELOG.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"build": "tsc
|
|
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",
|
package/src/channel-directory.ts
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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
|
@@ -356,7 +356,7 @@ type NostrGroupInboundContextParams = {
|
|
|
356
356
|
roomConfig?: NostrRoomChannelMap[string];
|
|
357
357
|
preflight?: NostrGroupPreflightResult;
|
|
358
358
|
cfg?: unknown;
|
|
359
|
-
accountAllowFrom?: readonly string[];
|
|
359
|
+
accountAllowFrom?: readonly (string | number)[];
|
|
360
360
|
commandHelpers?: NostrGroupPreflightCommandHelpers;
|
|
361
361
|
/** P3: a pending-commitment reminder injected as untrusted context for this turn. */
|
|
362
362
|
commitmentReminder?: string;
|
|
@@ -373,7 +373,8 @@ type NostrChannelInboundEventContextParams = {
|
|
|
373
373
|
messageIdFull?: string;
|
|
374
374
|
timestamp?: number;
|
|
375
375
|
from: string;
|
|
376
|
-
|
|
376
|
+
/** Optional: system-authored room events have no actor pubkey. */
|
|
377
|
+
sender?: {
|
|
377
378
|
id?: string;
|
|
378
379
|
name?: string;
|
|
379
380
|
displayLabel?: string;
|
|
@@ -509,12 +510,12 @@ function buildChannelInboundEventContextCompat(params: NostrChannelInboundEventC
|
|
|
509
510
|
ConversationLabel: params.conversation.label,
|
|
510
511
|
GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined,
|
|
511
512
|
UntrustedStructuredContext: params.supplemental?.untrustedContext,
|
|
512
|
-
SenderName: params.sender
|
|
513
|
-
SenderId: params.sender
|
|
514
|
-
SenderIsBot: params.sender
|
|
515
|
-
SenderUsername: params.sender
|
|
516
|
-
SenderTag: params.sender
|
|
517
|
-
MemberRoleIds: params.sender
|
|
513
|
+
SenderName: params.sender?.name ?? params.sender?.displayLabel,
|
|
514
|
+
SenderId: params.sender?.id,
|
|
515
|
+
SenderIsBot: params.sender?.isBot,
|
|
516
|
+
SenderUsername: params.sender?.username,
|
|
517
|
+
SenderTag: params.sender?.tag,
|
|
518
|
+
MemberRoleIds: params.sender?.roles,
|
|
518
519
|
Timestamp: params.timestamp,
|
|
519
520
|
Provider: params.provider ?? params.channel,
|
|
520
521
|
Surface: params.surface ?? params.provider ?? params.channel,
|
|
@@ -922,8 +923,11 @@ async function assertBackedNostrRoomCommitment(params: {
|
|
|
922
923
|
}),
|
|
923
924
|
);
|
|
924
925
|
const assessment = await assessCommitment(params.text, roomKey, (reference) => {
|
|
926
|
+
// `effective` is a FleetTaskEventSnapshot; the status lives on its `task`
|
|
927
|
+
// payload. Reading it off the snapshot yielded undefined, so every
|
|
928
|
+
// referenced task — closed ones included — counted as still active.
|
|
925
929
|
const task = activeFleetTaskServices.get(params.accountId)?.getTask(reference)?.effective;
|
|
926
|
-
if (task && task.status !== "closed") return true;
|
|
930
|
+
if (task && task.task.status !== "closed") return true;
|
|
927
931
|
return resolveNostrTaskFlowBackingActive(
|
|
928
932
|
getNostrRuntime(),
|
|
929
933
|
params.accountId,
|
|
@@ -1297,8 +1301,11 @@ function localIsNostrAbortRequestText(text: string): boolean {
|
|
|
1297
1301
|
return /^(?:abort|cancel|stop)\b/i.test(text.trim());
|
|
1298
1302
|
}
|
|
1299
1303
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1304
|
+
// allowFrom entries may be numbers (the host's shared AllowFromListSchema is
|
|
1305
|
+
// ZodArray<ZodUnion<[ZodString, ZodNumber]>>), so stringify before trimming —
|
|
1306
|
+
// .trim() on a number throws, and this path gates control-command authorization.
|
|
1307
|
+
function normalizeNostrAllowEntry(value: string | number): string | undefined {
|
|
1308
|
+
const trimmed = String(value).trim();
|
|
1302
1309
|
if (!trimmed || trimmed === "*") {
|
|
1303
1310
|
return undefined;
|
|
1304
1311
|
}
|
|
@@ -1306,7 +1313,7 @@ function normalizeNostrAllowEntry(value: string): string | undefined {
|
|
|
1306
1313
|
}
|
|
1307
1314
|
|
|
1308
1315
|
function nostrAllowListExplicitlyIncludesSender(
|
|
1309
|
-
allowFrom: readonly string[] | undefined,
|
|
1316
|
+
allowFrom: readonly (string | number)[] | undefined,
|
|
1310
1317
|
senderPubkey: string,
|
|
1311
1318
|
): boolean {
|
|
1312
1319
|
const normalizedSender = senderPubkey.toLowerCase();
|
|
@@ -1317,7 +1324,7 @@ function resolveNostrCommandAuthorized(params: {
|
|
|
1317
1324
|
hasControlCommand: boolean;
|
|
1318
1325
|
senderPubkey: string;
|
|
1319
1326
|
roomConfig?: NostrRoomChannelMap[string];
|
|
1320
|
-
accountAllowFrom?: readonly string[];
|
|
1327
|
+
accountAllowFrom?: readonly (string | number)[];
|
|
1321
1328
|
}): boolean {
|
|
1322
1329
|
if (!params.hasControlCommand) {
|
|
1323
1330
|
return false;
|
|
@@ -1446,7 +1453,7 @@ export function resolveNostrGroupPreflight(params: {
|
|
|
1446
1453
|
route: Pick<NostrGroupRouteFacts, "agentId">;
|
|
1447
1454
|
meta: InboundGroupMessageMeta;
|
|
1448
1455
|
roomConfig?: NostrRoomChannelMap[string];
|
|
1449
|
-
accountAllowFrom?: readonly string[];
|
|
1456
|
+
accountAllowFrom?: readonly (string | number)[];
|
|
1450
1457
|
commandHelpers?: NostrGroupPreflightCommandHelpers;
|
|
1451
1458
|
/** True when the sender is a peer for implicit-mention suppression (P1); may include unknownAsPeer. */
|
|
1452
1459
|
senderIsPeer?: boolean;
|
|
@@ -1612,13 +1619,17 @@ function buildNostrGroupMembershipInboundEventContext(params: {
|
|
|
1612
1619
|
messageIdFull: params.meta.eventId,
|
|
1613
1620
|
timestamp: params.meta.createdAt ? params.meta.createdAt * 1000 : Date.now(),
|
|
1614
1621
|
from: params.meta.actorPubkey ? `nostr:${params.meta.actorPubkey}` : "nostr:system",
|
|
1615
|
-
sender
|
|
1622
|
+
// Omit `sender` entirely when there is no actor, rather than setting it to
|
|
1623
|
+
// undefined: the host's param type does not accept an explicit undefined.
|
|
1624
|
+
...(params.meta.actorPubkey
|
|
1616
1625
|
? {
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1626
|
+
sender: {
|
|
1627
|
+
id: params.meta.actorPubkey,
|
|
1628
|
+
name: nip19.npubEncode(params.meta.actorPubkey),
|
|
1629
|
+
displayLabel: nip19.npubEncode(params.meta.actorPubkey),
|
|
1630
|
+
},
|
|
1620
1631
|
}
|
|
1621
|
-
:
|
|
1632
|
+
: {}),
|
|
1622
1633
|
conversation: {
|
|
1623
1634
|
kind: "group",
|
|
1624
1635
|
id: roomKey,
|
|
@@ -2868,7 +2879,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|
|
2868
2879
|
);
|
|
2869
2880
|
}
|
|
2870
2881
|
}
|
|
2871
|
-
nip46SessionState = sessionState;
|
|
2882
|
+
nip46SessionState = sessionState ?? undefined;
|
|
2872
2883
|
|
|
2873
2884
|
ctx.setStatus({
|
|
2874
2885
|
accountId: account.accountId,
|
package/src/fleet-tasks.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type Event,
|
|
5
5
|
type EventTemplate,
|
|
6
6
|
type Filter,
|
|
7
|
+
type VerifiedEvent,
|
|
7
8
|
} from "nostr-tools";
|
|
8
9
|
import { CAS_CP_STATE, NIP51_TASK_COLLECTION } from "cascadia-ts";
|
|
9
10
|
import type { FleetAgentSigner } from "./fleet-agent.js";
|
|
@@ -771,8 +772,19 @@ export async function createFleetTaskService(
|
|
|
771
772
|
...(await buildPersistentPoolOptions()),
|
|
772
773
|
enableReconnect: true,
|
|
773
774
|
} as ConstructorParameters<typeof SimplePool>[0]);
|
|
775
|
+
// nostr-tools requires a VerifiedEvent here, not merely a signed one. The
|
|
776
|
+
// signer may be a remote NIP-46 bunker, so verify the returned event rather
|
|
777
|
+
// than asserting the brand: an unverifiable AUTH event would be rejected by
|
|
778
|
+
// the relay anyway, and failing here keeps a malformed bunker reply from
|
|
779
|
+
// being attributed to us.
|
|
774
780
|
const onauth = options.auth
|
|
775
|
-
? async (template: EventTemplate) =>
|
|
781
|
+
? async (template: EventTemplate): Promise<VerifiedEvent> => {
|
|
782
|
+
const signed = await options.signer.signEvent(template);
|
|
783
|
+
if (!verifyEvent(signed)) {
|
|
784
|
+
throw new Error("NIP-42 auth event failed signature verification");
|
|
785
|
+
}
|
|
786
|
+
return signed;
|
|
787
|
+
}
|
|
776
788
|
: undefined;
|
|
777
789
|
if (onauth) pool.automaticallyAuth = () => onauth;
|
|
778
790
|
const metrics = createNoopMetrics();
|
package/src/metrics.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type EventMetricName =
|
|
|
12
12
|
| "event.processed"
|
|
13
13
|
| "event.duplicate"
|
|
14
14
|
| "event.skipped.service_envelope"
|
|
15
|
+
| "event.rejected.invalid_content"
|
|
15
16
|
| "event.rejected.invalid_shape"
|
|
16
17
|
| "event.rejected.wrong_kind"
|
|
17
18
|
| "event.rejected.wrong_target"
|
|
@@ -143,6 +144,8 @@ export interface MetricsSnapshot {
|
|
|
143
144
|
eventsDuplicate: number;
|
|
144
145
|
/** Events rejected by reason */
|
|
145
146
|
eventsRejected: {
|
|
147
|
+
/** Content too short to be a NIP-44 ciphertext. */
|
|
148
|
+
invalidContent: number;
|
|
146
149
|
invalidShape: number;
|
|
147
150
|
wrongKind: number;
|
|
148
151
|
wrongTarget: number;
|
|
@@ -250,6 +253,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|
|
250
253
|
let eventsProcessed = 0;
|
|
251
254
|
let eventsDuplicate = 0;
|
|
252
255
|
const eventsRejected = {
|
|
256
|
+
invalidContent: 0,
|
|
253
257
|
invalidShape: 0,
|
|
254
258
|
wrongKind: 0,
|
|
255
259
|
wrongTarget: 0,
|
|
@@ -375,6 +379,9 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|
|
375
379
|
case "event.duplicate":
|
|
376
380
|
eventsDuplicate += value;
|
|
377
381
|
break;
|
|
382
|
+
case "event.rejected.invalid_content":
|
|
383
|
+
eventsRejected.invalidContent += value;
|
|
384
|
+
break;
|
|
378
385
|
case "event.rejected.invalid_shape":
|
|
379
386
|
eventsRejected.invalidShape += value;
|
|
380
387
|
break;
|
|
@@ -622,7 +629,8 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|
|
622
629
|
eventsProcessed = 0;
|
|
623
630
|
eventsDuplicate = 0;
|
|
624
631
|
Object.assign(eventsRejected, {
|
|
625
|
-
|
|
632
|
+
invalidContent: 0,
|
|
633
|
+
invalidShape: 0,
|
|
626
634
|
wrongKind: 0,
|
|
627
635
|
wrongTarget: 0,
|
|
628
636
|
stale: 0,
|
|
@@ -672,7 +680,8 @@ export function createNoopMetrics(): NostrMetrics {
|
|
|
672
680
|
eventsProcessed: 0,
|
|
673
681
|
eventsDuplicate: 0,
|
|
674
682
|
eventsRejected: {
|
|
675
|
-
|
|
683
|
+
invalidContent: 0,
|
|
684
|
+
invalidShape: 0,
|
|
676
685
|
wrongKind: 0,
|
|
677
686
|
wrongTarget: 0,
|
|
678
687
|
stale: 0,
|
package/src/nip46-signer.ts
CHANGED
|
@@ -360,7 +360,7 @@ export class SignerRequestQueue {
|
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
private async waitForDispatchSlot(): Promise<void> {
|
|
363
|
-
while (this.pending.length > 0 && this.
|
|
363
|
+
while (this.pending.length > 0 && !this.isClosed()) {
|
|
364
364
|
const dispatchIntervalMs = Math.max(this.intervalMs, this.adaptiveIntervalMs);
|
|
365
365
|
const now = Date.now();
|
|
366
366
|
if (dispatchIntervalMs <= 0) {
|
|
@@ -381,17 +381,31 @@ export class SignerRequestQueue {
|
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Read `state` through a method call, not a property access.
|
|
386
|
+
*
|
|
387
|
+
* `close()` flips the state to "closed" while `drain()` is parked on an
|
|
388
|
+
* `await`, but TypeScript does not invalidate property narrowing across an
|
|
389
|
+
* await — after one `this.state === "closed"` guard it believes the field can
|
|
390
|
+
* only be "open" | "closing" for the rest of the method and flags every later
|
|
391
|
+
* check as an impossible comparison. The checks are load-bearing, so re-read
|
|
392
|
+
* through a call, whose result is never narrowed.
|
|
393
|
+
*/
|
|
394
|
+
private isClosed(): boolean {
|
|
395
|
+
return this.state === "closed";
|
|
396
|
+
}
|
|
397
|
+
|
|
384
398
|
private async drain(): Promise<void> {
|
|
385
|
-
if (this.draining || this.
|
|
399
|
+
if (this.draining || this.isClosed()) return;
|
|
386
400
|
this.draining = true;
|
|
387
401
|
try {
|
|
388
402
|
while (
|
|
389
403
|
this.pending.length > 0
|
|
390
404
|
&& this.activeJobs.size < this.concurrency
|
|
391
|
-
&& this.
|
|
405
|
+
&& !this.isClosed()
|
|
392
406
|
) {
|
|
393
407
|
await this.waitForDispatchSlot();
|
|
394
|
-
if (this.
|
|
408
|
+
if (this.isClosed()) break;
|
|
395
409
|
const job = this.pending.shift();
|
|
396
410
|
if (!job) continue;
|
|
397
411
|
this.activeJobs.add(job);
|
|
@@ -902,20 +916,23 @@ export async function connectNip46SignerForEnrollment(
|
|
|
902
916
|
opts.connectionTimeoutMs ?? 60_000,
|
|
903
917
|
opts.abortSignal,
|
|
904
918
|
);
|
|
905
|
-
handle
|
|
919
|
+
// `handle` stays a mutable let for the catch-block cleanup check, but the
|
|
920
|
+
// liveness closure below needs a binding TypeScript can prove is defined.
|
|
921
|
+
const session = createSessionSigner(bunker, resolved.transportPubkey, {
|
|
906
922
|
...opts,
|
|
907
923
|
operatorRelays: opts.signerRelayMode === "operator-only"
|
|
908
924
|
? [...resolved.relays]
|
|
909
925
|
: undefined,
|
|
910
926
|
});
|
|
927
|
+
handle = session;
|
|
911
928
|
const userPubkey = await runNip46OperationWithTimeout(
|
|
912
|
-
() => proveNip46SessionLiveness(
|
|
929
|
+
() => proveNip46SessionLiveness(session.signer, opts.expectedUserPubkey),
|
|
913
930
|
"liveness",
|
|
914
931
|
opts.connectionTimeoutMs ?? 60_000,
|
|
915
932
|
opts.abortSignal,
|
|
916
933
|
);
|
|
917
|
-
|
|
918
|
-
return
|
|
934
|
+
session.setUserPubkey(userPubkey);
|
|
935
|
+
return session.signer;
|
|
919
936
|
} catch (error) {
|
|
920
937
|
if (handle) {
|
|
921
938
|
try {
|
|
@@ -956,20 +973,23 @@ export async function resumeNip46Signer(
|
|
|
956
973
|
});
|
|
957
974
|
let handle: ReturnType<typeof createSessionSigner> | undefined;
|
|
958
975
|
try {
|
|
959
|
-
handle
|
|
976
|
+
// See the enrollment path above: `handle` remains a let for cleanup, while
|
|
977
|
+
// the closure captures a const the compiler can prove is defined.
|
|
978
|
+
const session = createSessionSigner(bunker, transportPubkey, {
|
|
960
979
|
...opts,
|
|
961
980
|
operatorRelays: opts.signerRelayMode === "operator-only"
|
|
962
981
|
? [...pointer.relays]
|
|
963
982
|
: undefined,
|
|
964
983
|
});
|
|
984
|
+
handle = session;
|
|
965
985
|
const userPubkey = await runNip46OperationWithTimeout(
|
|
966
|
-
() => proveNip46SessionLiveness(
|
|
986
|
+
() => proveNip46SessionLiveness(session.signer, opts.pinnedUserPubkey),
|
|
967
987
|
"liveness",
|
|
968
988
|
opts.connectionTimeoutMs ?? 60_000,
|
|
969
989
|
opts.abortSignal,
|
|
970
990
|
);
|
|
971
|
-
|
|
972
|
-
return
|
|
991
|
+
session.setUserPubkey(userPubkey);
|
|
992
|
+
return session.signer;
|
|
973
993
|
} catch (error) {
|
|
974
994
|
try {
|
|
975
995
|
await handle?.signer.close();
|
package/src/nostr-bus-nip29.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
type SimplePool,
|
|
15
15
|
type Event,
|
|
16
16
|
type EventTemplate,
|
|
17
|
+
type Filter,
|
|
18
|
+
type VerifiedEvent,
|
|
17
19
|
} from "nostr-tools";
|
|
18
20
|
import { Reaction as ReactionKind } from "nostr-tools/kinds";
|
|
19
21
|
import { type NostrRoomState } from "./nostr-state-store.js";
|
|
@@ -807,6 +809,19 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
|
|
|
807
809
|
const doSignEvent = ctx.signEvent
|
|
808
810
|
?? ((template: EventTemplate) => Promise.resolve(finalizeEvent(template, sk)));
|
|
809
811
|
|
|
812
|
+
// nostr-tools' onauth contract requires a VerifiedEvent. doSignEvent may be a
|
|
813
|
+
// remote NIP-46 signer returning a plain Event, so verify rather than assert:
|
|
814
|
+
// an unverifiable AUTH event would be refused by the relay regardless.
|
|
815
|
+
const signAuthEventVerified = async (
|
|
816
|
+
authTemplate: EventTemplate,
|
|
817
|
+
): Promise<VerifiedEvent> => {
|
|
818
|
+
const signed = await doSignEvent(authTemplate);
|
|
819
|
+
if (!verifyEvent(signed)) {
|
|
820
|
+
throw new Error("NIP-42 auth event failed signature verification");
|
|
821
|
+
}
|
|
822
|
+
return signed;
|
|
823
|
+
};
|
|
824
|
+
|
|
810
825
|
async function withPublishTimeout<T>(promise: Promise<T>, relayUrl: string): Promise<T> {
|
|
811
826
|
if (!publishTimeoutMs || publishTimeoutMs <= 0) {
|
|
812
827
|
return promise;
|
|
@@ -872,7 +887,7 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
|
|
|
872
887
|
const event = await doSignEvent(args.template);
|
|
873
888
|
const results = (args.auth
|
|
874
889
|
? roomPool.publish([args.relayUrl], event, {
|
|
875
|
-
onauth:
|
|
890
|
+
onauth: signAuthEventVerified,
|
|
876
891
|
})
|
|
877
892
|
: roomPool.publish([args.relayUrl], event)
|
|
878
893
|
) as unknown as Promise<string>[];
|
|
@@ -1269,12 +1284,12 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
|
|
|
1269
1284
|
` since=${since} room=${roomKey}`,
|
|
1270
1285
|
);
|
|
1271
1286
|
let caughtUp = false;
|
|
1272
|
-
const eventFilter:
|
|
1287
|
+
const eventFilter: Filter = {
|
|
1273
1288
|
kinds: [9, ReactionKind, 9000, 9001, 9005],
|
|
1274
1289
|
"#h": [groupId],
|
|
1275
1290
|
since,
|
|
1276
1291
|
};
|
|
1277
|
-
const metadataFilter:
|
|
1292
|
+
const metadataFilter: Filter = {
|
|
1278
1293
|
kinds: [39000, 39001, 39002, 39003],
|
|
1279
1294
|
"#d": [groupId],
|
|
1280
1295
|
since,
|
|
@@ -1299,9 +1314,7 @@ export function createNip29Manager(ctx: Nip29Context): Nip29Manager {
|
|
|
1299
1314
|
// NIP-42 subscription-level auth: only included when the channel has
|
|
1300
1315
|
// auth:true. When the relay sends CLOSED auth-required, nostr-tools
|
|
1301
1316
|
// calls this signer, sends AUTH, and automatically re-subscribes.
|
|
1302
|
-
...(auth
|
|
1303
|
-
? { onauth: async (authTemplate: EventTemplate) => doSignEvent(authTemplate) }
|
|
1304
|
-
: {}),
|
|
1317
|
+
...(auth ? { onauth: signAuthEventVerified } : {}),
|
|
1305
1318
|
onclose: (reasons: string[]) => {
|
|
1306
1319
|
subscriptions.delete(sub);
|
|
1307
1320
|
const normalized = normalizeCloseReasons(reasons, 1);
|
|
@@ -45,7 +45,9 @@ describe("patchNostrToolsAuthSendRace", () => {
|
|
|
45
45
|
);
|
|
46
46
|
const relay = relayRejectingAuthWith("wss://relay.example", rejection);
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// The rejection must survive: nostr-tools' subscribeMap and publish both
|
|
49
|
+
// await auth() and treat a rejection as "auth failed, stop retrying".
|
|
50
|
+
await expect(relay.auth(async () => undefined)).rejects.toThrow(rejection);
|
|
49
51
|
|
|
50
52
|
expect(relay.authPromise).toBeUndefined(); // cleared so a retry can happen
|
|
51
53
|
expect(reports).toHaveLength(1);
|
|
@@ -71,8 +73,8 @@ describe("patchNostrToolsAuthSendRace", () => {
|
|
|
71
73
|
new Error("auth timed out"),
|
|
72
74
|
);
|
|
73
75
|
|
|
74
|
-
await closed.auth(async () => undefined);
|
|
75
|
-
await timedOut.auth(async () => undefined);
|
|
76
|
+
await expect(closed.auth(async () => undefined)).rejects.toThrow();
|
|
77
|
+
await expect(timedOut.auth(async () => undefined)).rejects.toThrow();
|
|
76
78
|
|
|
77
79
|
expect(reports.map((r) => [r.relayUrl, r.kind, r.benign])).toEqual([
|
|
78
80
|
["wss://flaky.example", "closed_connection", true],
|
|
@@ -84,7 +86,7 @@ describe("patchNostrToolsAuthSendRace", () => {
|
|
|
84
86
|
const reason = "error: relay needs serviceUrl to be configured before AUTH can work";
|
|
85
87
|
for (let i = 0; i < 4; i++) {
|
|
86
88
|
const relay = relayRejectingAuthWith("wss://noisy.example", new Error(reason));
|
|
87
|
-
await relay.auth(async () => undefined);
|
|
89
|
+
await expect(relay.auth(async () => undefined)).rejects.toThrow();
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
// First failure reports immediately; the rest are folded into the counters.
|
|
@@ -93,7 +95,7 @@ describe("patchNostrToolsAuthSendRace", () => {
|
|
|
93
95
|
|
|
94
96
|
// A different relay is tracked independently, not throttled by the first.
|
|
95
97
|
const other = relayRejectingAuthWith("wss://other.example", new Error(reason));
|
|
96
|
-
await other.auth(async () => undefined);
|
|
98
|
+
await expect(other.auth(async () => undefined)).rejects.toThrow();
|
|
97
99
|
expect(reports).toHaveLength(2);
|
|
98
100
|
expect(reports[1]!.relayUrl).toBe("wss://other.example");
|
|
99
101
|
});
|
|
@@ -102,11 +104,32 @@ describe("patchNostrToolsAuthSendRace", () => {
|
|
|
102
104
|
const relay = relayRejectingAuthWith("", new Error("nope"));
|
|
103
105
|
relay.url = undefined;
|
|
104
106
|
|
|
105
|
-
await relay.auth(async () => undefined);
|
|
107
|
+
await expect(relay.auth(async () => undefined)).rejects.toThrow();
|
|
106
108
|
|
|
107
109
|
expect(reports[0]!.relayUrl).toBe("<unknown relay>");
|
|
108
110
|
});
|
|
109
111
|
|
|
112
|
+
// Regression: the ocn-n5g crash fix originally swallowed the rejection and
|
|
113
|
+
// returned false. That stopped the crash but broke nostr-tools' auth-required
|
|
114
|
+
// retry — subscribeMap re-subscribed and publish republished as though a
|
|
115
|
+
// failed AUTH had succeeded, producing an endless auth-required loop. Both
|
|
116
|
+
// properties have to hold at once.
|
|
117
|
+
it("keeps an unawaited auth rejection from surfacing as unhandled", async () => {
|
|
118
|
+
const unhandled: unknown[] = [];
|
|
119
|
+
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
|
120
|
+
process.on("unhandledRejection", onUnhandled);
|
|
121
|
+
try {
|
|
122
|
+
const relay = relayRejectingAuthWith("wss://crash.example", new Error("refused"));
|
|
123
|
+
// Exactly how AbstractRelay._onmessage `case "AUTH"` calls it: no await,
|
|
124
|
+
// no catch.
|
|
125
|
+
void relay.auth(async () => undefined);
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
127
|
+
expect(unhandled).toEqual([]);
|
|
128
|
+
} finally {
|
|
129
|
+
process.off("unhandledRejection", onUnhandled);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
110
133
|
it("is idempotent across repeated installs", () => {
|
|
111
134
|
const before = relayBase().auth;
|
|
112
135
|
patchNostrToolsAuthSendRace();
|
package/src/nostr-bus-relay.ts
CHANGED
|
@@ -129,34 +129,47 @@ export function patchNostrToolsAuthSendRace(reporter?: RelayAuthPatchReporter):
|
|
|
129
129
|
}
|
|
130
130
|
};
|
|
131
131
|
if (typeof originalAuth === "function") {
|
|
132
|
-
relayBase.auth =
|
|
132
|
+
relayBase.auth = function patchedAuth(signAuthEvent: unknown): Promise<unknown> {
|
|
133
133
|
const self = this as { url?: string; authPromise?: unknown };
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
// instead of replaying this failure (and re-triggering the rejection).
|
|
151
|
-
self.authPromise = undefined;
|
|
152
|
-
reportRelayAuthFailure(relayUrlOf(self), err);
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
134
|
+
const promise = runPatchedAuth(originalAuth, self, signAuthEvent);
|
|
135
|
+
promise.catch(() => {});
|
|
136
|
+
// nostr-tools calls relay.auth() from three places, and they disagree
|
|
137
|
+
// about who owns the rejection:
|
|
138
|
+
// - AbstractRelay._onmessage `case "AUTH"` calls it unawaited and
|
|
139
|
+
// uncaught, so a rejection there crashes the process (ocn-n5g);
|
|
140
|
+
// - SimplePool.subscribeMap retries a CLOSED "auth-required: " by
|
|
141
|
+
// awaiting auth() and treating a rejection as "auth failed, stop";
|
|
142
|
+
// - SimplePool.publish awaits it before republishing.
|
|
143
|
+
// Attaching a handler here marks the promise handled for Node's
|
|
144
|
+
// unhandled-rejection detection WITHOUT consuming it: the two awaiting
|
|
145
|
+
// callers still observe the rejection and keep their retry semantics.
|
|
146
|
+
// Swallowing the rejection outright (returning false) instead made those
|
|
147
|
+
// callers believe a failed AUTH had succeeded, so they re-subscribed and
|
|
148
|
+
// republished into an endless auth-required loop.
|
|
149
|
+
return promise;
|
|
155
150
|
};
|
|
156
151
|
}
|
|
157
152
|
relayBase.__openclawAuthSendRacePatched = true;
|
|
158
153
|
}
|
|
159
154
|
|
|
155
|
+
async function runPatchedAuth(
|
|
156
|
+
originalAuth: (this: unknown, signAuthEvent: unknown) => Promise<unknown>,
|
|
157
|
+
self: { url?: string; authPromise?: unknown },
|
|
158
|
+
signAuthEvent: unknown,
|
|
159
|
+
): Promise<unknown> {
|
|
160
|
+
try {
|
|
161
|
+
return await originalAuth.call(self, signAuthEvent);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
164
|
+
// Drop the memoized rejected promise so a later challenge can retry
|
|
165
|
+
// instead of replaying this failure forever.
|
|
166
|
+
self.authPromise = undefined;
|
|
167
|
+
reportRelayAuthFailure(relayUrlOf(self), err);
|
|
168
|
+
// Rethrow: nostr-tools' own auth-required retry logic depends on it.
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
// ============================================================================
|
|
161
174
|
// Constants
|
|
162
175
|
// ============================================================================
|
package/src/nostr-bus.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
nip19,
|
|
9
9
|
type Event,
|
|
10
10
|
type EventTemplate,
|
|
11
|
+
type VerifiedEvent,
|
|
11
12
|
} from "nostr-tools";
|
|
12
13
|
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from "nostr-tools/nip04";
|
|
13
14
|
import * as nip59 from "nostr-tools/nip59";
|
|
@@ -157,8 +158,10 @@ const AUTH_SIGN_JITTER = 0.2;
|
|
|
157
158
|
class DeterministicNip17Error extends Error {}
|
|
158
159
|
|
|
159
160
|
type RelayAuthState = {
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
// nostr-tools' auth contract is VerifiedEvent, so the coalescing cache holds
|
|
162
|
+
// the verified event rather than re-verifying on every cache hit.
|
|
163
|
+
inFlight: Map<string, Promise<VerifiedEvent>>;
|
|
164
|
+
signedCache: Map<string, { event: VerifiedEvent; expiresAt: number }>;
|
|
162
165
|
serial: Promise<void>;
|
|
163
166
|
lastSignStartedAt: number;
|
|
164
167
|
windowStartedAt: number;
|
|
@@ -607,7 +610,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
|
|
|
607
610
|
const signRelayAuthEvent = async (
|
|
608
611
|
relayURL: string,
|
|
609
612
|
authTemplate: EventTemplate,
|
|
610
|
-
): Promise<
|
|
613
|
+
): Promise<VerifiedEvent> => {
|
|
611
614
|
const state = getRelayAuthState(relayURL);
|
|
612
615
|
const key = stableAuthChallengeKey(relayURL, authTemplate);
|
|
613
616
|
const now = Date.now();
|
|
@@ -684,6 +687,15 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
|
|
|
684
687
|
metrics.emit("relay.message.auth", 1, { relay: relayURL, outcome: "signed" });
|
|
685
688
|
try {
|
|
686
689
|
const signed = await signEvent(authTemplate);
|
|
690
|
+
// nostr-tools requires a VerifiedEvent here. signEvent may be a remote
|
|
691
|
+
// NIP-46 bunker returning a plain Event, so verify rather than assert:
|
|
692
|
+
// a relay would reject an unverifiable AUTH event anyway, and failing
|
|
693
|
+
// here keeps a malformed bunker reply from being attributed to us.
|
|
694
|
+
if (!verifyEvent(signed)) {
|
|
695
|
+
throw new Error(
|
|
696
|
+
`NIP-42 auth event for ${relayURL} failed signature verification`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
687
699
|
state.backoffAttempt = 0;
|
|
688
700
|
state.backoffUntil = 0;
|
|
689
701
|
state.signedCache.set(key, {
|
|
@@ -718,7 +730,7 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
|
|
|
718
730
|
// Pool-level handler for WebSocket AUTH challenges; publish-level handler
|
|
719
731
|
// (publishAuthSigner) for OK false "auth-required" responses on EVENT.
|
|
720
732
|
pool.automaticallyAuth = (relayURL: string) => {
|
|
721
|
-
return async (unsignedAuthEvent:
|
|
733
|
+
return async (unsignedAuthEvent: EventTemplate) => {
|
|
722
734
|
return signRelayAuthEvent(relayURL, unsignedAuthEvent);
|
|
723
735
|
};
|
|
724
736
|
};
|
package/src/nostr-state-store.ts
CHANGED
|
@@ -598,7 +598,9 @@ function parseNip46BunkerIdentity(
|
|
|
598
598
|
}
|
|
599
599
|
const identity = value as Record<string, unknown>;
|
|
600
600
|
if (identity.kind === "bunker") {
|
|
601
|
-
|
|
601
|
+
// Annotated so the template literal keeps its `bunker://${string}` type
|
|
602
|
+
// instead of widening to string, which the return type requires.
|
|
603
|
+
const expected: `bunker://${string}` = `bunker://${transportPubkey}`;
|
|
602
604
|
if (
|
|
603
605
|
typeof identity.uri !== "string"
|
|
604
606
|
|| identity.uri !== expected
|
|
@@ -212,7 +212,7 @@ type ResolvedTaskFlowRoom = {
|
|
|
212
212
|
type NostrChannelConfigLike = {
|
|
213
213
|
channels?: {
|
|
214
214
|
nostr?: {
|
|
215
|
-
allowFrom?: readonly string[];
|
|
215
|
+
allowFrom?: readonly (string | number)[];
|
|
216
216
|
nostr_channels?: Record<string, NostrRoomConfigLike | undefined>;
|
|
217
217
|
};
|
|
218
218
|
};
|
|
@@ -255,8 +255,12 @@ function roomTaskFlowsEnabled(roomConfig: NostrRoomConfigLike): boolean {
|
|
|
255
255
|
return roomConfig.config?.taskFlows === true;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// allowFrom entries are `string | number` per the host's shared
|
|
259
|
+
// AllowFromListSchema (ZodArray<ZodUnion<[ZodString, ZodNumber]>>), so a numeric
|
|
260
|
+
// entry is valid config. Stringify before trimming — calling .trim() on a number
|
|
261
|
+
// throws. src/nostr-access.ts already handles the union this way.
|
|
262
|
+
function normalizeAllowEntry(value: string | number): string | undefined {
|
|
263
|
+
const trimmed = String(value).trim();
|
|
260
264
|
if (!trimmed || trimmed === "*") {
|
|
261
265
|
return undefined;
|
|
262
266
|
}
|
|
@@ -290,8 +294,8 @@ export function resolveTaskFlowMutationPolicy(
|
|
|
290
294
|
export function isTaskFlowMutationAllowed(params: {
|
|
291
295
|
senderIsOwner?: boolean;
|
|
292
296
|
requesterSenderId?: string;
|
|
293
|
-
roomAllowFrom?: readonly string[];
|
|
294
|
-
accountAllowFrom?: readonly string[];
|
|
297
|
+
roomAllowFrom?: readonly (string | number)[];
|
|
298
|
+
accountAllowFrom?: readonly (string | number)[];
|
|
295
299
|
policy?: TaskFlowMutationPolicy;
|
|
296
300
|
}): boolean {
|
|
297
301
|
if (params.senderIsOwner === true) {
|
|
@@ -309,7 +313,7 @@ export function isTaskFlowMutationAllowed(params: {
|
|
|
309
313
|
}
|
|
310
314
|
if (
|
|
311
315
|
params.policy === "room" &&
|
|
312
|
-
(params.roomAllowFrom ?? []).some((entry) => entry.trim() === "*")
|
|
316
|
+
(params.roomAllowFrom ?? []).some((entry) => String(entry).trim() === "*")
|
|
313
317
|
) {
|
|
314
318
|
return true;
|
|
315
319
|
}
|
package/src/setup-surface.ts
CHANGED
|
@@ -19,6 +19,10 @@ import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js";
|
|
|
19
19
|
import { resolveNostrAccount } from "./types.js";
|
|
20
20
|
|
|
21
21
|
const channel = "nostr" as const;
|
|
22
|
+
|
|
23
|
+
// Widened deliberately — see the UPSTREAM GAP note at the textInput below.
|
|
24
|
+
type WizardTextInputKey = NonNullable<ChannelSetupWizard["textInputs"]>[number]["inputKey"];
|
|
25
|
+
const NIP46_BUNKER_URL_INPUT_KEY = "nip46BunkerUrl" as WizardTextInputKey;
|
|
22
26
|
const setNostrAllowFrom = createTopLevelChannelAllowFromSetter({
|
|
23
27
|
channel,
|
|
24
28
|
});
|
|
@@ -148,7 +152,26 @@ export const nostrSetupAdapter: ChannelSetupAdapter = {
|
|
|
148
152
|
},
|
|
149
153
|
};
|
|
150
154
|
|
|
151
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Shape of the `toggles` steps the host does not implement (ocn-06u9). Declared
|
|
157
|
+
* locally so the entries stay type-checked instead of degrading to `any` while
|
|
158
|
+
* they wait to be ported onto a supported mechanism.
|
|
159
|
+
*/
|
|
160
|
+
type UnsupportedWizardToggle = {
|
|
161
|
+
inputKey: string;
|
|
162
|
+
message: string;
|
|
163
|
+
helpTitle?: string;
|
|
164
|
+
helpLines?: string[];
|
|
165
|
+
currentValue?: (params: { cfg: OpenClawConfig }) => boolean | undefined;
|
|
166
|
+
applySet?: (params: {
|
|
167
|
+
cfg: OpenClawConfig;
|
|
168
|
+
value: boolean;
|
|
169
|
+
}) => OpenClawConfig | Promise<OpenClawConfig>;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const nostrSetupWizard: ChannelSetupWizard & {
|
|
173
|
+
toggles?: UnsupportedWizardToggle[];
|
|
174
|
+
} = {
|
|
152
175
|
channel,
|
|
153
176
|
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
|
|
154
177
|
resolveShouldPromptAccountIds: () => false,
|
|
@@ -230,6 +253,12 @@ export const nostrSetupWizard: ChannelSetupWizard = {
|
|
|
230
253
|
}),
|
|
231
254
|
},
|
|
232
255
|
],
|
|
256
|
+
// DEAD CONFIG (ocn-06u9): the host's ChannelSetupWizard has no `toggles`
|
|
257
|
+
// member and its wizard runtime never reads one — it iterates `credentials`
|
|
258
|
+
// and `textInputs` only. This step is therefore never shown and `applySet`
|
|
259
|
+
// never runs; operators are never asked whether to enable NIP-46. Kept (with
|
|
260
|
+
// a cast, so the typecheck can be a gate) until ocn-06u9 reimplements it on a
|
|
261
|
+
// mechanism the host supports, rather than deleting a documented intent.
|
|
233
262
|
toggles: [
|
|
234
263
|
{
|
|
235
264
|
inputKey: "nip46",
|
|
@@ -253,7 +282,18 @@ export const nostrSetupWizard: ChannelSetupWizard = {
|
|
|
253
282
|
],
|
|
254
283
|
textInputs: [
|
|
255
284
|
{
|
|
256
|
-
|
|
285
|
+
// UPSTREAM GAP (ocn-ozwe): the host's `ChannelSetupInput` is a closed type
|
|
286
|
+
// and has no `nip46BunkerUrl` member, though it does carry other
|
|
287
|
+
// nostr-specific keys (relayUrls, groupChannels, dmAllowlist). The cast
|
|
288
|
+
// records that gap rather than hiding a local defect.
|
|
289
|
+
//
|
|
290
|
+
// Runtime is unaffected: the host treats `inputKey` as an opaque computed
|
|
291
|
+
// key (onboard-channels reads `credentialValues[inputKey]`, then hands the
|
|
292
|
+
// entered value to our own `applySet`), so the interactive step works
|
|
293
|
+
// end to end. The real consequence is narrower — no CLI flag is derived
|
|
294
|
+
// for a key the host does not know, so this value cannot be supplied
|
|
295
|
+
// non-interactively via `openclaw channels add`.
|
|
296
|
+
inputKey: NIP46_BUNKER_URL_INPUT_KEY,
|
|
257
297
|
message: "NIP-46 bunker URL (bunker://... or user@domain)",
|
|
258
298
|
placeholder: "bunker://pubkey?relay=wss://relay.example.com",
|
|
259
299
|
required: false,
|
|
@@ -268,10 +308,13 @@ export const nostrSetupWizard: ChannelSetupWizard = {
|
|
|
268
308
|
return account.nip46BunkerUrl ?? "";
|
|
269
309
|
},
|
|
270
310
|
keepPrompt: (value) => `NIP-46 bunker URL set (${value}). Keep it?`,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
311
|
+
// The host contract is `string | undefined` (it returns `void 0` for
|
|
312
|
+
// "valid" in its own adapters); `null` happened to work only because
|
|
313
|
+
// every consumer truthiness-checks the result.
|
|
314
|
+
validate: ({ value }): string | undefined => {
|
|
315
|
+
if (!value.trim()) return undefined;
|
|
316
|
+
if (/^bunker:\/\/[0-9a-f]{64}/.test(value)) return undefined;
|
|
317
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return undefined;
|
|
275
318
|
return "Must be a bunker:// URL or user@domain NIP-05 identifier";
|
|
276
319
|
},
|
|
277
320
|
applySet: async ({ cfg, value }) =>
|
package/src/types.test.ts
CHANGED
|
@@ -798,3 +798,31 @@ describe("NIP-46 connect secret config resolution", () => {
|
|
|
798
798
|
expect(account.config.nip46ReconnectPolicy).toEqual({ refreshOnStart: true });
|
|
799
799
|
});
|
|
800
800
|
});
|
|
801
|
+
|
|
802
|
+
describe("resolveNostrAccount watchdog timeouts", () => {
|
|
803
|
+
// Regression (ocn-43nd): inboundDispatchAbortSeconds is documented and
|
|
804
|
+
// accepted by the Zod schema, but was absent from NostrAccountConfig and
|
|
805
|
+
// from this resolver, so the value an operator configured never reached the
|
|
806
|
+
// bus and the stage-2 hang watchdog always used its default.
|
|
807
|
+
it("propagates both inbound dispatch watchdog stages", () => {
|
|
808
|
+
const cfg = {
|
|
809
|
+
channels: {
|
|
810
|
+
nostr: {
|
|
811
|
+
privateKey: TEST_PRIVATE_KEY,
|
|
812
|
+
inboundDispatchTimeoutSeconds: 120,
|
|
813
|
+
inboundDispatchAbortSeconds: 600,
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
const account = resolveNostrAccount({ cfg });
|
|
818
|
+
expect(account.config.inboundDispatchTimeoutSeconds).toBe(120);
|
|
819
|
+
expect(account.config.inboundDispatchAbortSeconds).toBe(600);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("leaves both undefined when unset so defaults apply", () => {
|
|
823
|
+
const cfg = { channels: { nostr: { privateKey: TEST_PRIVATE_KEY } } };
|
|
824
|
+
const account = resolveNostrAccount({ cfg });
|
|
825
|
+
expect(account.config.inboundDispatchTimeoutSeconds).toBeUndefined();
|
|
826
|
+
expect(account.config.inboundDispatchAbortSeconds).toBeUndefined();
|
|
827
|
+
});
|
|
828
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -62,6 +62,7 @@ export interface NostrAccountConfig {
|
|
|
62
62
|
nip17Enabled?: boolean;
|
|
63
63
|
nip04Enabled?: boolean;
|
|
64
64
|
inboundDispatchTimeoutSeconds?: number;
|
|
65
|
+
inboundDispatchAbortSeconds?: number;
|
|
65
66
|
publishTimeoutSeconds?: number;
|
|
66
67
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
67
68
|
dmContinuity?: NostrDmContinuityConfig;
|
|
@@ -570,6 +571,7 @@ export function resolveNostrAccount(opts: {
|
|
|
570
571
|
nip17Enabled: nostrCfg?.nip17Enabled,
|
|
571
572
|
nip04Enabled: nostrCfg?.nip04Enabled,
|
|
572
573
|
inboundDispatchTimeoutSeconds: nostrCfg?.inboundDispatchTimeoutSeconds,
|
|
574
|
+
inboundDispatchAbortSeconds: nostrCfg?.inboundDispatchAbortSeconds,
|
|
573
575
|
publishTimeoutSeconds: nostrCfg?.publishTimeoutSeconds,
|
|
574
576
|
dmPolicy: nostrCfg?.dmPolicy,
|
|
575
577
|
allowFrom: nostrCfg?.allowFrom,
|