Merge pull request 'fix(transport): swap the relay layer NDK → nostr-tools so subscriptions survive reconnects (#42, fixes #41)' (#43) from fix/42-nostr-tools-transport into dev
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Reviewed-on: #43
This commit is contained in:
commit
1ffa75dcd4
31 changed files with 1614 additions and 477 deletions
|
|
@ -22,8 +22,11 @@
|
||||||
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client",
|
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client",
|
||||||
"build:client": "tsup src/client.ts -d dist/client",
|
"build:client": "tsup src/client.ts -d dist/client",
|
||||||
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts",
|
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts",
|
||||||
|
"test:relay": "TS_NODE_TRANSPILE_ONLY=1 node --test-force-exit -r ts-node/register --test tests/relay-pool.test.ts",
|
||||||
|
"test:nip46": "node --test-force-exit -r ./tests/register-ts.cjs --test tests/nip46-backend.test.ts",
|
||||||
|
"test:admin": "node --test-force-exit -r ./tests/register-ts.cjs --test tests/admin-transport.test.ts",
|
||||||
"test:integration": "DATABASE_URL=\"file:./tests/.tmp/acl-int.db\" node -r ./tests/register-ts.cjs --test tests/acl.integration.test.ts",
|
"test:integration": "DATABASE_URL=\"file:./tests/.tmp/acl-int.db\" node -r ./tests/register-ts.cjs --test tests/acl.integration.test.ts",
|
||||||
"test:all": "npm run test && npm run test:integration",
|
"test:all": "npm run test && npm run test:relay && npm run test:nip46 && npm run test:admin && npm run test:integration",
|
||||||
"prisma:generate": "npx prisma generate",
|
"prisma:generate": "npx prisma generate",
|
||||||
"prisma:migrate": "npx prisma migrate deploy",
|
"prisma:migrate": "npx prisma migrate deploy",
|
||||||
"prisma:create": "npx prisma db push --preview-feature",
|
"prisma:create": "npx prisma db push --preview-feature",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
@ -23,7 +23,7 @@ import prisma from "../../../db.js";
|
||||||
* `rule.kind.toString()` storage and the override-layer convention. The
|
* `rule.kind.toString()` storage and the override-layer convention. The
|
||||||
* `'all'` literal is honored at sign-time as a wildcard across kinds.
|
* `'all'` literal is honored at sign-time as a wildcard across kinds.
|
||||||
*/
|
*/
|
||||||
export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function addPolicyRule(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _payload ] = req.params as [ string ];
|
const [ _payload ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_payload) throw new Error("Invalid params");
|
if (!_payload) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
@ -18,7 +18,7 @@ import prisma from "../../../db.js";
|
||||||
* checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here
|
* checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here
|
||||||
* denies regardless of the policy.
|
* denies regardless of the policy.
|
||||||
*/
|
*/
|
||||||
export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function addSigningCondition(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _payload ] = req.params as [ string ];
|
const [ _payload ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_payload) throw new Error("Invalid params");
|
if (!_payload) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Hexpubkey, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
import { Hexpubkey, NDKPrivateKeySigner, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||||
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "..";
|
import AdminInterface from "..";
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { setupSkeletonProfile } from "../../lib/profile";
|
import { setupSkeletonProfile } from "../../lib/profile";
|
||||||
|
|
@ -69,7 +70,7 @@ const RESERVED_USERNAMES = [
|
||||||
"admin", "root", "_", "administrator", "__"
|
"admin", "root", "_", "administrator", "__"
|
||||||
];
|
];
|
||||||
|
|
||||||
async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: NDKRpcRequest) {
|
async function validateUsername(username: string | undefined, domain: string, admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
if (!username || username.length === 0) {
|
if (!username || username.length === 0) {
|
||||||
// create a random username of 10 characters
|
// create a random username of 10 characters
|
||||||
username = Math.random().toString(36).substring(2, 15);
|
username = Math.random().toString(36).substring(2, 15);
|
||||||
|
|
@ -83,7 +84,7 @@ async function validateUsername(username: string | undefined, domain: string, ad
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateDomain(domain: string | undefined, admin: AdminInterface, req: NDKRpcRequest) {
|
async function validateDomain(domain: string | undefined, admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const availableDomains = (await admin.config()).domains;
|
const availableDomains = (await admin.config()).domains;
|
||||||
|
|
||||||
if (!availableDomains || Object.keys(availableDomains).length === 0)
|
if (!availableDomains || Object.keys(availableDomains).length === 0)
|
||||||
|
|
@ -99,7 +100,7 @@ async function validateDomain(domain: string | undefined, admin: AdminInterface,
|
||||||
return domain;
|
return domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function createAccount(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function createAccount(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
let [ username, domain, email ] = req.params as [ string?, string?, string? ];
|
let [ username, domain, email ] = req.params as [ string?, string?, string? ];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -143,7 +144,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
|
||||||
*/
|
*/
|
||||||
export async function createAccountReal(
|
export async function createAccountReal(
|
||||||
admin: AdminInterface,
|
admin: AdminInterface,
|
||||||
req: NDKRpcRequest,
|
req: AdminRpcRequest,
|
||||||
username: string,
|
username: string,
|
||||||
domain: string,
|
domain: string,
|
||||||
email?: string
|
email?: string
|
||||||
|
|
@ -227,7 +228,7 @@ export async function createAccountReal(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function grantPermissions(req: NDKRpcRequest, keyName: string) {
|
async function grantPermissions(req: AdminRpcRequest, keyName: string) {
|
||||||
await allowAllRequestsFromKey(req.pubkey, keyName, "connect");
|
await allowAllRequestsFromKey(req.pubkey, keyName, "connect");
|
||||||
await allowAllRequestsFromKey(req.pubkey, keyName, "sign_event", undefined, undefined, { kind: 'all' });
|
await allowAllRequestsFromKey(req.pubkey, keyName, "sign_event", undefined, undefined, { kind: 'all' });
|
||||||
await allowAllRequestsFromKey(req.pubkey, keyName, "encrypt");
|
await allowAllRequestsFromKey(req.pubkey, keyName, "encrypt");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
|
import NDK, { NDKEvent, NDKPrivateKeySigner, type NostrEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import { saveEncrypted } from "../../../commands/add.js";
|
import { saveEncrypted } from "../../../commands/add.js";
|
||||||
|
|
@ -6,7 +7,7 @@ import { getCurrentConfig } from "../../../config/index.js";
|
||||||
import { decryptNsec } from "../../../config/keys.js";
|
import { decryptNsec } from "../../../config/keys.js";
|
||||||
import { setupSkeletonProfile } from "../../lib/profile.js";
|
import { setupSkeletonProfile } from "../../lib/profile.js";
|
||||||
|
|
||||||
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function createNewKey(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ];
|
const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ];
|
||||||
|
|
||||||
if (!keyName || !passphrase) throw new Error("Invalid params");
|
if (!keyName || !passphrase) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
||||||
export default async function createNewPolicy(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function createNewPolicy(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _policy ] = req.params as [ string ];
|
const [ _policy ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_policy) throw new Error("Invalid params");
|
if (!_policy) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
||||||
export default async function createNewToken(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function createNewToken(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ keyName, clientName, policyId, durationInHours ] = req.params as [ string, string, string, string? ];
|
const [ keyName, clientName, policyId, durationInHours ] = req.params as [ string, string, string, string? ];
|
||||||
|
|
||||||
if (!clientName || !policyId) throw new Error("Invalid params");
|
if (!clientName || !policyId) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
|
|
||||||
export default async function ping(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function ping(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND);
|
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", NIP46_ADMIN_RESPONSE_KIND);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
@ -17,7 +17,7 @@ import prisma from "../../../db.js";
|
||||||
* removes across instance versions can race. Adds are safe, removes
|
* removes across instance versions can race. Adds are safe, removes
|
||||||
* are not.
|
* are not.
|
||||||
*/
|
*/
|
||||||
export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function removePolicyRule(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _payload ] = req.params as [ string ];
|
const [ _payload ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_payload) throw new Error("Invalid params");
|
if (!_payload) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
@ -10,7 +10,7 @@ import prisma from "../../../db.js";
|
||||||
* Param shape (JSON-stringified):
|
* Param shape (JSON-stringified):
|
||||||
* { conditionId: number }
|
* { conditionId: number }
|
||||||
*/
|
*/
|
||||||
export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function removeSigningCondition(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _payload ] = req.params as [ string ];
|
const [ _payload ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_payload) throw new Error("Invalid params");
|
if (!_payload) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
||||||
export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function renameKeyUser(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ keyUserPubkey, name ] = req.params as [ string, string ];
|
const [ keyUserPubkey, name ] = req.params as [ string, string ];
|
||||||
|
|
||||||
if (!keyUserPubkey || !name) throw new Error("Invalid params");
|
if (!keyUserPubkey || !name) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
@ -17,7 +17,7 @@ import prisma from "../../../db.js";
|
||||||
* bound to it continue to grant via their own policies. Use
|
* bound to it continue to grant via their own policies. Use
|
||||||
* revoke_user for the binary "this user is gone" case.
|
* revoke_user for the binary "this user is gone" case.
|
||||||
*/
|
*/
|
||||||
export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function revokeToken(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _payload ] = req.params as [ string ];
|
const [ _payload ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_payload) throw new Error("Invalid params");
|
if (!_payload) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
||||||
export default async function revokeUser(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function revokeUser(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ keyUserId ] = req.params as [ string ];
|
const [ keyUserId ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!keyUserId) throw new Error("Invalid params");
|
if (!keyUserId) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
|
|
||||||
export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function unlockKey(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ keyName, passphrase ] = req.params as [ string, string ];
|
const [ keyName, passphrase ] = req.params as [ string, string ];
|
||||||
|
|
||||||
if (!keyName || !passphrase) throw new Error("Invalid params");
|
if (!keyName || !passphrase) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
@ -17,7 +17,7 @@ import prisma from "../../../db.js";
|
||||||
* `expiresAt: null` explicitly clears the field; `expiresAt` absent
|
* `expiresAt: null` explicitly clears the field; `expiresAt` absent
|
||||||
* from the patch leaves it alone.
|
* from the patch leaves it alone.
|
||||||
*/
|
*/
|
||||||
export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function updatePolicy(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _payload ] = req.params as [ string ];
|
const [ _payload ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_payload) throw new Error("Invalid params");
|
if (!_payload) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import AdminInterface from "../index.js";
|
import AdminInterface from "../index.js";
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
|
||||||
import prisma from "../../../db.js";
|
import prisma from "../../../db.js";
|
||||||
|
|
@ -23,7 +23,7 @@ import prisma from "../../../db.js";
|
||||||
* Tightening a cap takes effect immediately — a client already over the new
|
* Tightening a cap takes effect immediately — a client already over the new
|
||||||
* limit within the window is denied until its trailing count falls below it.
|
* limit within the window is denied until its trailing count falls below it.
|
||||||
*/
|
*/
|
||||||
export default async function updatePolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
|
export default async function updatePolicyRule(admin: AdminInterface, req: AdminRpcRequest) {
|
||||||
const [ _payload ] = req.params as [ string ];
|
const [ _payload ] = req.params as [ string ];
|
||||||
|
|
||||||
if (!_payload) throw new Error("Invalid params");
|
if (!_payload) throw new Error("Invalid params");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import "websocket-polyfill";
|
import NDK, { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||||
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser } from '@nostr-dev-kit/ndk';
|
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||||
import { NDKNostrRpc } from '@nostr-dev-kit/ndk';
|
|
||||||
import createDebug from 'debug';
|
import createDebug from 'debug';
|
||||||
import { Key, KeyUser } from '../run';
|
import { Key, KeyUser } from '../run';
|
||||||
import { allowAllRequestsFromKey } from '../lib/acl/index.js';
|
import { allowAllRequestsFromKey } from '../lib/acl/index.js';
|
||||||
|
|
@ -21,12 +20,16 @@ import addSigningCondition from './commands/add_signing_condition';
|
||||||
import removeSigningCondition from './commands/remove_signing_condition';
|
import removeSigningCondition from './commands/remove_signing_condition';
|
||||||
import revokeToken from './commands/revoke_token';
|
import revokeToken from './commands/revoke_token';
|
||||||
import { NIP46_ADMIN_RESPONSE_KIND } from './kinds.js';
|
import { NIP46_ADMIN_RESPONSE_KIND } from './kinds.js';
|
||||||
|
import { NIP46_NOSTR_CONNECT_KIND } from './kinds.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { validateRequestFromAdmin } from './validations/request-from-admin';
|
import { validateRequestFromAdmin } from './validations/request-from-admin';
|
||||||
import { dmUser } from '../../utils/dm-user';
|
import { dmUser } from '../../utils/dm-user';
|
||||||
import { IConfig, getCurrentConfig } from "../../config";
|
import { IConfig, getCurrentConfig } from "../../config";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { attachIndefiniteReconnect } from '../lib/relay-reconnect.js';
|
import { RelayPool } from '../lib/relay-pool.js';
|
||||||
|
import { Nip46Transport, secretKeyBytes } from '../nip46/transport.js';
|
||||||
|
import type { AdminRpc, AdminRpcRequest } from './types.js';
|
||||||
|
import type { Nip46Request } from '../nip46/types.js';
|
||||||
|
|
||||||
|
|
||||||
const debug = createDebug("nsecbunker:admin");
|
const debug = createDebug("nsecbunker:admin");
|
||||||
|
|
@ -45,49 +48,65 @@ const allowNewKeys = true;
|
||||||
* This class represents the admin interface for the nsecbunker daemon.
|
* This class represents the admin interface for the nsecbunker daemon.
|
||||||
*
|
*
|
||||||
* It provides an interface for a UI to manage the daemon over nostr.
|
* It provides an interface for a UI to manage the daemon over nostr.
|
||||||
|
*
|
||||||
|
* Ported off NDK onto the nostr-tools RelayPool transport (aiolabs/nsecbunkerd#42)
|
||||||
|
* so the admin channel, like the signer channel, re-subscribes on every relay
|
||||||
|
* reconnect and can't go silently deaf after a flap (#41).
|
||||||
*/
|
*/
|
||||||
class AdminInterface {
|
class AdminInterface {
|
||||||
private npubs: string[];
|
private npubs: string[];
|
||||||
private ndk: NDK;
|
private pool: RelayPool;
|
||||||
private signerUser?: NDKUser;
|
private transport: Nip46Transport;
|
||||||
readonly rpc: NDKNostrRpc;
|
private adminPubkey: string;
|
||||||
|
private adminNsec: string;
|
||||||
|
/** Envelope encryption (nip04/nip44) of each in-flight request, by id, so
|
||||||
|
* responses go back in the scheme the client used. */
|
||||||
|
private reqEncryption: Map<string, "nip04" | "nip44"> = new Map();
|
||||||
|
readonly rpc: AdminRpc;
|
||||||
readonly configFile: string;
|
readonly configFile: string;
|
||||||
public getKeys?: () => Promise<Key[]>;
|
public getKeys?: () => Promise<Key[]>;
|
||||||
public getKeyUsers?: (req: NDKRpcRequest) => Promise<KeyUser[]>;
|
public getKeyUsers?: (req: AdminRpcRequest) => Promise<KeyUser[]>;
|
||||||
public unlockKey?: (keyName: string, passphrase: string) => Promise<boolean>;
|
public unlockKey?: (keyName: string, passphrase: string) => Promise<boolean>;
|
||||||
public loadNsec?: (keyName: string, nsec: string) => void;
|
public loadNsec?: (keyName: string, nsec: string) => void;
|
||||||
|
|
||||||
constructor(opts: IAdminOpts, configFile: string) {
|
constructor(opts: IAdminOpts, configFile: string) {
|
||||||
this.configFile = configFile;
|
this.configFile = configFile;
|
||||||
this.npubs = opts.npubs||[];
|
this.npubs = opts.npubs || [];
|
||||||
this.ndk = new NDK({
|
this.adminNsec = opts.key;
|
||||||
explicitRelayUrls: opts.adminRelays,
|
this.adminPubkey = getPublicKey(secretKeyBytes(opts.key));
|
||||||
signer: new NDKPrivateKeySigner(opts.key),
|
|
||||||
|
this.pool = new RelayPool(opts.adminRelays, {
|
||||||
|
log: (...a: any[]) => console.log(...a),
|
||||||
|
heartbeatMs: 30_000,
|
||||||
});
|
});
|
||||||
|
this.transport = new Nip46Transport(secretKeyBytes(opts.key), this.pool);
|
||||||
|
|
||||||
// Override NDK's "give up after detecting flapping" behavior so the
|
// The admin RPC the command handlers call. sendResponse encrypts with
|
||||||
// bunker's admin NDK keeps trying to reconnect indefinitely. The
|
// the scheme the request used (resolved per id) and publishes on the
|
||||||
// watchdog (when enabled) still fires after 60s of zero connected
|
// admin response channel (24134) unless a handler mirrors the request
|
||||||
// relays; this helper handles shorter disconnects (e.g. an lnbits
|
// kind for errors.
|
||||||
// restart that pulls the nostrrelay extension's WS for a few
|
this.rpc = {
|
||||||
// seconds) without involving the supervisor. See aiolabs/nsecbunkerd#20.
|
sendResponse: async (
|
||||||
attachIndefiniteReconnect(this.ndk, 'admin');
|
id: string,
|
||||||
|
remotePubkey: string,
|
||||||
this.ndk.signer?.user().then((user: NDKUser) => {
|
result: string,
|
||||||
let connectionString = `bunker://${user.npub}`;
|
kind: number = NIP46_NOSTR_CONNECT_KIND,
|
||||||
|
error?: string,
|
||||||
|
) => {
|
||||||
|
const encryption = this.reqEncryption.get(id) ?? "nip44";
|
||||||
|
await this.transport.sendResponse(id, remotePubkey, result, encryption, error, kind);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const npub = nip19.npubEncode(this.adminPubkey);
|
||||||
|
let connectionString = `bunker://${npub}`;
|
||||||
if (opts.adminRelays.length > 0) {
|
if (opts.adminRelays.length > 0) {
|
||||||
connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`);
|
connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`);
|
console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`);
|
||||||
|
const configFolder = path.dirname(configFile);
|
||||||
// write connection string to connection.txt
|
|
||||||
const configFolder = path.dirname(configFile)
|
|
||||||
fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString);
|
fs.writeFileSync(path.join(configFolder, 'connection.txt'), connectionString);
|
||||||
|
|
||||||
this.signerUser = user;
|
|
||||||
|
|
||||||
this.connect();
|
this.connect();
|
||||||
|
|
||||||
this.config().then((config) => {
|
this.config().then((config) => {
|
||||||
|
|
@ -95,23 +114,25 @@ class AdminInterface {
|
||||||
this.notifyAdminsOfNewConnection(connectionString);
|
this.notifyAdminsOfNewConnection(connectionString);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
this.rpc = new NDKNostrRpc(this.ndk, this.ndk.signer!, debug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async config(): Promise<IConfig> {
|
public async config(): Promise<IConfig> {
|
||||||
return getCurrentConfig(this.configFile);
|
return getCurrentConfig(this.configFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time DM to the admin npubs. One-shot, best-effort notification over
|
||||||
|
* public relays — not part of the reconnect-sensitive RPC path, so it still
|
||||||
|
* uses a throwaway NDK + the existing dmUser helper. (#42 leaves this on NDK.)
|
||||||
|
*/
|
||||||
private async notifyAdminsOfNewConnection(connectionString: string) {
|
private async notifyAdminsOfNewConnection(connectionString: string) {
|
||||||
const blastrNdk = new NDK({
|
const blastrNdk = new NDK({
|
||||||
explicitRelayUrls: ['wss://blastr.f7z.xyz', 'wss://nostr.mutinywallet.com'],
|
explicitRelayUrls: ['wss://blastr.f7z.xyz', 'wss://nostr.mutinywallet.com'],
|
||||||
signer: this.ndk.signer
|
signer: new NDKPrivateKeySigner(this.adminNsec),
|
||||||
});
|
});
|
||||||
await blastrNdk.connect(2500);
|
await blastrNdk.connect(2500);
|
||||||
|
|
||||||
for (const npub of this.npubs||[]) {
|
for (const npub of this.npubs || []) {
|
||||||
dmUser(blastrNdk, npub, `nsecBunker has started; use ${connectionString} to connect to it and unlock your key(s)`);
|
dmUser(blastrNdk, npub, `nsecBunker has started; use ${connectionString} to connect to it and unlock your key(s)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +141,7 @@ class AdminInterface {
|
||||||
* Get the npub of the admin interface.
|
* Get the npub of the admin interface.
|
||||||
*/
|
*/
|
||||||
public async npub() {
|
public async npub() {
|
||||||
return (await this.ndk.signer?.user())!.npub;
|
return nip19.npubEncode(this.adminPubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private connect() {
|
private connect() {
|
||||||
|
|
@ -129,81 +150,59 @@ class AdminInterface {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const debugTransport = process.env.NSEC_BUNKER_DEBUG_TRANSPORT === '1';
|
this.pool.start();
|
||||||
|
// Listen for admin requests on the NostrConnect channel (24133) AND the
|
||||||
// Per-relay publish-status logging for diagnosing aiolabs/nsecbunkerd#7.
|
// admin response channel (24134, where admin clients address us).
|
||||||
// NDKNostrRpc.sendResponse calls event.publish() and discards the
|
this.transport
|
||||||
// returned Set<NDKRelay>, so a silent outbox-drop is invisible without
|
.start((req) => this.onRequest(req), [NIP46_NOSTR_CONNECT_KIND, NIP46_ADMIN_RESPONSE_KIND])
|
||||||
// hooking the underlying per-relay events. Gated by env flag so
|
.then(() => console.log('✅ nsecBunker Admin Interface ready'))
|
||||||
// production deployments stay quiet.
|
.catch((err) => {
|
||||||
const attachRelayLogging = (relay: any) => {
|
console.log('❌ admin transport failed');
|
||||||
relay.on('published', (event: NDKEvent) => {
|
console.log(err);
|
||||||
console.log(`📤 PUBLISHED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)}`);
|
|
||||||
});
|
|
||||||
relay.on('publish:failed', (event: NDKEvent, err: any) => {
|
|
||||||
console.log(`❌ PUBLISH_FAILED relay=${relay.url} kind=${event.kind} id=${event.id?.slice(0,8)} err=${err?.message ?? err}`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ndk.pool.on('relay:connect', (relay: any) => {
|
|
||||||
console.log('✅ nsecBunker Admin Interface ready');
|
|
||||||
if (debugTransport) attachRelayLogging(relay);
|
|
||||||
});
|
|
||||||
this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
|
|
||||||
|
|
||||||
this.ndk.connect(2500).then(() => {
|
|
||||||
// connect for whitelisted admins
|
|
||||||
this.rpc.subscribe({
|
|
||||||
"kinds": [NDKKind.NostrConnect, NIP46_ADMIN_RESPONSE_KIND],
|
|
||||||
"#p": [this.signerUser!.pubkey]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach per-relay logging to relays that connected before our
|
// Session-liveness watchdog: exit (so the process supervisor restarts)
|
||||||
// 'relay:connect' listener was registered above (NDK can connect
|
// if the admin pool can't stay healthy — connected AND subscribed — for
|
||||||
// synchronously inside .connect() under some paths).
|
// >60s. Unlike the old connectedRelays()-only watchdog (#20), this can't
|
||||||
if (debugTransport) {
|
// be fooled by a reconnected-but-deaf socket (#41). Disable via
|
||||||
this.ndk.pool.relays.forEach((relay: any) => attachRelayLogging(relay));
|
|
||||||
|
|
||||||
// Wrap sendResponse to log id + kind + elapsed time so we
|
|
||||||
// can correlate REQUEST_IN → RESPONSE_SENT → PUBLISHED.
|
|
||||||
const originalSendResponse = this.rpc.sendResponse.bind(this.rpc);
|
|
||||||
this.rpc.sendResponse = async (id: string, remotePubkey: string, result: string, kind?: number, error?: string) => {
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
await originalSendResponse(id, remotePubkey, result, kind, error);
|
|
||||||
console.log(`📨 RESPONSE_SENT id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} elapsed=${Date.now()-start}ms`);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(`❌ RESPONSE_SEND_FAILED id=${id} remote=${remotePubkey.slice(0,8)} kind=${kind ?? NDKKind.NostrConnect} err=${e?.message ?? e}`);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rpc.on('request', (req) => {
|
|
||||||
if (debugTransport) {
|
|
||||||
console.log(`📥 REQUEST_IN method=${req.method} id=${req.id} from=${req.pubkey?.slice(0,8)} kind=${req.event?.kind}`);
|
|
||||||
}
|
|
||||||
this.handleRequest(req);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connection watchdog: exit if pool reports no connected relays
|
|
||||||
// for >60s so the process supervisor (systemd / docker restart
|
|
||||||
// policy / k8s) can recover. Replaces the original self-echo
|
|
||||||
// pingOrDie — see relayConnectionWatchdog comment + #4 + #7.
|
|
||||||
// Operators with external liveness checking can disable via
|
|
||||||
// NSEC_BUNKER_DISABLE_WATCHDOG=1.
|
// NSEC_BUNKER_DISABLE_WATCHDOG=1.
|
||||||
if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') {
|
if (process.env.NSEC_BUNKER_DISABLE_WATCHDOG !== '1') {
|
||||||
relayConnectionWatchdog(this.ndk);
|
this.startWatchdog();
|
||||||
} else {
|
} else {
|
||||||
console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1');
|
console.log('⏸ watchdog disabled via NSEC_BUNKER_DISABLE_WATCHDOG=1');
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
|
||||||
console.log('❌ admin connection failed');
|
|
||||||
console.log(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRequest(req: NDKRpcRequest) {
|
private startWatchdog() {
|
||||||
|
const POLL_INTERVAL_MS = 10_000;
|
||||||
|
const UNHEALTHY_THRESHOLD_MS = 60_000;
|
||||||
|
let lastHealthyAt = Date.now();
|
||||||
|
setInterval(() => {
|
||||||
|
if (this.pool.healthy()) {
|
||||||
|
lastHealthyAt = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - lastHealthyAt;
|
||||||
|
if (elapsed > UNHEALTHY_THRESHOLD_MS) {
|
||||||
|
console.log(`❌ Admin pool unhealthy for ${Math.floor(elapsed / 1000)}s. Exiting.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onRequest(req: Nip46Request) {
|
||||||
|
const adminReq: AdminRpcRequest = {
|
||||||
|
id: req.id,
|
||||||
|
pubkey: req.remotePubkey,
|
||||||
|
method: req.method,
|
||||||
|
params: req.params,
|
||||||
|
event: { kind: req.kind },
|
||||||
|
};
|
||||||
|
this.reqEncryption.set(req.id, req.encryption);
|
||||||
|
void this.handleRequest(adminReq).finally(() => this.reqEncryption.delete(req.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRequest(req: AdminRpcRequest) {
|
||||||
try {
|
try {
|
||||||
await this.validateRequest(req);
|
await this.validateRequest(req);
|
||||||
|
|
||||||
|
|
@ -228,30 +227,25 @@ class AdminInterface {
|
||||||
case 'remove_signing_condition': await removeSigningCondition(this, req); break;
|
case 'remove_signing_condition': await removeSigningCondition(this, req); break;
|
||||||
case 'revoke_token': await revokeToken(this, req); break;
|
case 'revoke_token': await revokeToken(this, req); break;
|
||||||
default:
|
default:
|
||||||
const originalKind = req.event.kind!;
|
|
||||||
console.log(`Unknown method ${req.method}`);
|
console.log(`Unknown method ${req.method}`);
|
||||||
return this.rpc.sendResponse(
|
return this.rpc.sendResponse(
|
||||||
req.id,
|
req.id,
|
||||||
req.pubkey,
|
req.pubkey,
|
||||||
JSON.stringify(['error', `Unknown method ${req.method}`]),
|
JSON.stringify(['error', `Unknown method ${req.method}`]),
|
||||||
originalKind
|
req.event.kind,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
|
debug(`Error handling request ${req.method}: ${err?.message ?? err}`, req.params);
|
||||||
// NDKKind.NostrConnectAdmin doesn't exist in NDK 2.8.1 — using it
|
// Mirror req.event.kind so the error goes back on the channel the
|
||||||
// makes sendResponse fall through to its default of 24133, which
|
// request came in on (aiolabs/nsecbunkerd#7).
|
||||||
// sends the error on a different channel than the request came in
|
const originalKind = req.event.kind;
|
||||||
// on. Mirror req.event.kind so the response goes back where the
|
|
||||||
// client is listening. Filed as part of aiolabs/nsecbunkerd#7
|
|
||||||
// diagnosis 2026-05-27.
|
|
||||||
const originalKind = req.event.kind!;
|
|
||||||
console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`);
|
console.log(`⚠️ HANDLE_REQUEST_ERROR method=${req.method} id=${req.id} kind=${originalKind} err=${err?.message ?? err}`);
|
||||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message);
|
return this.rpc.sendResponse(req.id, req.pubkey, "error", originalKind, err?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateRequest(req: NDKRpcRequest): Promise<void> {
|
private async validateRequest(req: AdminRpcRequest): Promise<void> {
|
||||||
// if this request is of type create_account, allow it
|
// if this request is of type create_account, allow it
|
||||||
// TODO: require some POW to prevent spam
|
// TODO: require some POW to prevent spam
|
||||||
if (req.method === 'create_account' && allowNewKeys) {
|
if (req.method === 'create_account' && allowNewKeys) {
|
||||||
|
|
@ -267,7 +261,7 @@ class AdminInterface {
|
||||||
/**
|
/**
|
||||||
* Command to list tokens
|
* Command to list tokens
|
||||||
*/
|
*/
|
||||||
private async reqGetKeyTokens(req: NDKRpcRequest) {
|
private async reqGetKeyTokens(req: AdminRpcRequest) {
|
||||||
const keyName = req.params[0];
|
const keyName = req.params[0];
|
||||||
const tokens = await prisma.token.findMany({
|
const tokens = await prisma.token.findMany({
|
||||||
where: { keyName },
|
where: { keyName },
|
||||||
|
|
@ -295,7 +289,7 @@ class AdminInterface {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
key_name: t.keyName,
|
key_name: t.keyName,
|
||||||
client_name: t.clientName,
|
client_name: t.clientName,
|
||||||
token: [ npub, t.token ].join('#'),
|
token: [npub, t.token].join('#'),
|
||||||
policy_id: t.policyId,
|
policy_id: t.policyId,
|
||||||
policy_name: t.policy?.name,
|
policy_name: t.policy?.name,
|
||||||
created_at: t.createdAt,
|
created_at: t.createdAt,
|
||||||
|
|
@ -313,7 +307,7 @@ class AdminInterface {
|
||||||
/**
|
/**
|
||||||
* Command to list policies
|
* Command to list policies
|
||||||
*/
|
*/
|
||||||
private async reqListPolicies(req: NDKRpcRequest) {
|
private async reqListPolicies(req: AdminRpcRequest) {
|
||||||
const policies = await prisma.policy.findMany({
|
const policies = await prisma.policy.findMany({
|
||||||
include: {
|
include: {
|
||||||
rules: true,
|
rules: true,
|
||||||
|
|
@ -346,7 +340,7 @@ class AdminInterface {
|
||||||
/**
|
/**
|
||||||
* Command to fetch keys and their current state
|
* Command to fetch keys and their current state
|
||||||
*/
|
*/
|
||||||
private async reqGetKeys(req: NDKRpcRequest) {
|
private async reqGetKeys(req: AdminRpcRequest) {
|
||||||
if (!this.getKeys) throw new Error('getKeys() not implemented');
|
if (!this.getKeys) throw new Error('getKeys() not implemented');
|
||||||
|
|
||||||
const result = JSON.stringify(await this.getKeys());
|
const result = JSON.stringify(await this.getKeys());
|
||||||
|
|
@ -358,7 +352,7 @@ class AdminInterface {
|
||||||
/**
|
/**
|
||||||
* Command to fetch users of a key
|
* Command to fetch users of a key
|
||||||
*/
|
*/
|
||||||
private async reqGetKeyUsers(req: NDKRpcRequest): Promise<void> {
|
private async reqGetKeyUsers(req: AdminRpcRequest): Promise<void> {
|
||||||
if (!this.getKeyUsers) throw new Error('getKeyUsers() not implemented');
|
if (!this.getKeyUsers) throw new Error('getKeyUsers() not implemented');
|
||||||
|
|
||||||
const result = JSON.stringify(await this.getKeyUsers(req));
|
const result = JSON.stringify(await this.getKeyUsers(req));
|
||||||
|
|
@ -388,36 +382,29 @@ class AdminInterface {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.trace({method, param});
|
|
||||||
|
|
||||||
if (method === 'sign_event') {
|
if (method === 'sign_event') {
|
||||||
const e = param.rawEvent();
|
// `param` is the parsed event object the signer passed to the ACL
|
||||||
|
// (a plain event under #42, no longer an NDKEvent.rawEvent()).
|
||||||
|
const e = param;
|
||||||
param = JSON.stringify(e);
|
param = JSON.stringify(e);
|
||||||
|
|
||||||
console.log(`👀 Event to be signed\n`, {
|
console.log(`👀 Event to be signed\n`, {
|
||||||
kind: e.kind,
|
kind: e?.kind,
|
||||||
content: e.content,
|
content: e?.content,
|
||||||
tags: e.tags,
|
tags: e?.tags,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
console.log(`requesting permission for`, keyName);
|
console.log(`requesting permission for`, keyName, { remotePubkey, method });
|
||||||
console.log(`remotePubkey`, remotePubkey);
|
|
||||||
console.log(`method`, method);
|
|
||||||
console.log(`param`, param);
|
|
||||||
console.log(`keyUser`, keyUser);
|
|
||||||
|
|
||||||
/**
|
// If an admin doesn't respond within 10 seconds, report timeout.
|
||||||
* If an admin doesn't respond within 10 seconds, report back to the user that the request timed out
|
|
||||||
*/
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve(undefined);
|
resolve(undefined);
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
for (const npub of this.npubs) {
|
for (const npub of this.npubs) {
|
||||||
const remoteUser = new NDKUser({npub});
|
const adminPubkey = nip19.decode(npub).data as string;
|
||||||
console.log(`sending request to ${npub}`, remoteUser.pubkey);
|
|
||||||
const params = JSON.stringify({
|
const params = JSON.stringify({
|
||||||
keyName,
|
keyName,
|
||||||
remotePubkey,
|
remotePubkey,
|
||||||
|
|
@ -426,12 +413,14 @@ class AdminInterface {
|
||||||
description: keyUser?.description,
|
description: keyUser?.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rpc.sendRequest(
|
const id = this.transport.sendRequest(
|
||||||
remoteUser.pubkey,
|
adminPubkey,
|
||||||
'acl',
|
'acl',
|
||||||
[params],
|
[params],
|
||||||
|
'nip44',
|
||||||
NIP46_ADMIN_RESPONSE_KIND,
|
NIP46_ADMIN_RESPONSE_KIND,
|
||||||
(res: NDKRpcResponse) => {
|
(res) => {
|
||||||
|
this.transport.clearPending(id);
|
||||||
this.requestPermissionResponse(
|
this.requestPermissionResponse(
|
||||||
remotePubkey,
|
remotePubkey,
|
||||||
keyName,
|
keyName,
|
||||||
|
|
@ -452,7 +441,7 @@ class AdminInterface {
|
||||||
method: string,
|
method: string,
|
||||||
param: string,
|
param: string,
|
||||||
resolve: (value: boolean) => void,
|
resolve: (value: boolean) => void,
|
||||||
res: NDKRpcResponse
|
res: { id: string; result: string; error?: string }
|
||||||
) {
|
) {
|
||||||
let resObj;
|
let resObj;
|
||||||
try {
|
try {
|
||||||
|
|
@ -485,47 +474,4 @@ class AdminInterface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pool-status connection watchdog. Exits the daemon if every relay in
|
|
||||||
* the pool stays disconnected for longer than PARTITION_THRESHOLD_MS.
|
|
||||||
*
|
|
||||||
* Replaces the original `pingOrDie` self-echo watchdog, which published
|
|
||||||
* a kind-24133 event to its own pubkey every 20s and exited if it
|
|
||||||
* didn't see the echo within 50s. That works on public relays but
|
|
||||||
* silently breaks on single-private-relay setups: NDK 2.8.1's outbox
|
|
||||||
* model doesn't reliably route self-publishes back through the
|
|
||||||
* matching subscription, so the watchdog fires false positives and
|
|
||||||
* exits the daemon every 50s while RPCs over the same channel still
|
|
||||||
* work fine. See aiolabs/nsecbunkerd#4 + #7.
|
|
||||||
*
|
|
||||||
* The pool-status approach uses NDK's own connection-lifecycle
|
|
||||||
* tracking — `pool.connectedRelays()` reports relays in
|
|
||||||
* NDKRelayStatus.CONNECTED — which is reliable across all relay
|
|
||||||
* configurations because it doesn't depend on round-trip
|
|
||||||
* publish/subscribe. No event is published; no relay traffic.
|
|
||||||
*
|
|
||||||
* Detects partition within POLL_INTERVAL + PARTITION_THRESHOLD ms.
|
|
||||||
* Transient disconnects shorter than PARTITION_THRESHOLD don't trip
|
|
||||||
* the watchdog — useful for relays that flap or briefly drop on
|
|
||||||
* network blips.
|
|
||||||
*/
|
|
||||||
async function relayConnectionWatchdog(ndk: NDK) {
|
|
||||||
const POLL_INTERVAL_MS = 10_000;
|
|
||||||
const PARTITION_THRESHOLD_MS = 60_000;
|
|
||||||
let lastConnectedAt = Date.now();
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
const connectedCount = ndk.pool.connectedRelays().length;
|
|
||||||
if (connectedCount > 0) {
|
|
||||||
lastConnectedAt = Date.now();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const elapsed = Date.now() - lastConnectedAt;
|
|
||||||
if (elapsed > PARTITION_THRESHOLD_MS) {
|
|
||||||
console.log(`❌ No connected relays for ${Math.floor(elapsed / 1000)}s. Exiting.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdminInterface;
|
export default AdminInterface;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import type { NDKKind } from '@nostr-dev-kit/ndk';
|
/**
|
||||||
|
* NIP-46 client channel — kind-24133. Carries `connect` / `sign_event` /
|
||||||
|
* `nip04_*` / `nip44_*` etc. (NDK called this `NDKKind.NostrConnect`.)
|
||||||
|
*/
|
||||||
|
export const NIP46_NOSTR_CONNECT_KIND = 24133;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-46 admin-RPC response channel — kind-24134. Distinct from the
|
* NIP-46 admin-RPC response channel — kind-24134. Distinct from the client
|
||||||
* standard NIP-46 client channel kind-24133 (`NDKKind.NostrConnect`)
|
* channel (24133) so signer clients and admin clients don't subscribe to each
|
||||||
* which carries `sign_event` / `nip04_*` / `nip44_*` / etc.
|
* other's events.
|
||||||
*
|
|
||||||
* nsecbunkerd's admin surface uses a dedicated kind so signer clients
|
|
||||||
* and admin clients don't subscribe to each other's events.
|
|
||||||
*
|
|
||||||
* NDK 3.x's `NDKKind` enum does not include 24134; the cast happens
|
|
||||||
* once here so callers can pass a typed value to `rpc.sendResponse`.
|
|
||||||
*/
|
*/
|
||||||
export const NIP46_ADMIN_RESPONSE_KIND = 24134 as NDKKind;
|
export const NIP46_ADMIN_RESPONSE_KIND = 24134;
|
||||||
|
|
|
||||||
35
src/daemon/admin/types.ts
Normal file
35
src/daemon/admin/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Admin-RPC types (aiolabs/nsecbunkerd#42).
|
||||||
|
*
|
||||||
|
* Replace NDK's `NDKRpcRequest` / `NDKNostrRpc` so the admin interface — like
|
||||||
|
* the signer backend — runs on the nostr-tools RelayPool transport instead of
|
||||||
|
* NDK. Shaped to match what the admin command handlers already use
|
||||||
|
* (`req.{id,pubkey,method,params,event.kind}`), so the handlers are unchanged
|
||||||
|
* apart from the import.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AdminRpcRequest {
|
||||||
|
id: string;
|
||||||
|
/** The verified sender (admin client) pubkey, hex. */
|
||||||
|
pubkey: string;
|
||||||
|
method: string;
|
||||||
|
params: string[];
|
||||||
|
/** The inbound event — handlers read `event.kind` to mirror the channel. */
|
||||||
|
event: { kind: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of NDKNostrRpc the admin command handlers call. Backed by the
|
||||||
|
* Nip46Transport; `sendResponse` encrypts the reply with the same scheme the
|
||||||
|
* request used (resolved per request id) and publishes it on `kind` (the admin
|
||||||
|
* response channel 24134, or the mirrored request kind for errors).
|
||||||
|
*/
|
||||||
|
export interface AdminRpc {
|
||||||
|
sendResponse(
|
||||||
|
id: string,
|
||||||
|
remotePubkey: string,
|
||||||
|
result: string,
|
||||||
|
kind?: number,
|
||||||
|
error?: string,
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
import { AdminRpcRequest } from "../types.js";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
|
|
||||||
export async function validateRequestFromAdmin(
|
export async function validateRequestFromAdmin(
|
||||||
req: NDKRpcRequest,
|
req: AdminRpcRequest,
|
||||||
npubs: string[],
|
npubs: string[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const hexpubkey = req.pubkey;
|
const hexpubkey = req.pubkey;
|
||||||
|
|
|
||||||
|
|
@ -1,130 +1,147 @@
|
||||||
import NDK, { NDKNip46Backend, NDKPrivateKeySigner, Nip46PermitCallback } from '@nostr-dev-kit/ndk';
|
import type { FastifyInstance } from "fastify";
|
||||||
import prisma from '../../db.js';
|
import type { RelayPool } from "../lib/relay-pool.js";
|
||||||
import type {FastifyInstance} from "fastify";
|
import { Nip46Transport, secretKeyBytes } from "../nip46/transport.js";
|
||||||
import { grantIsLive } from '../lib/acl/index.js';
|
import type { Nip46PermitCallback, Nip46Request } from "../nip46/types.js";
|
||||||
|
|
||||||
export class Backend extends NDKNip46Backend {
|
|
||||||
public baseUrl?: string;
|
|
||||||
public fastify: FastifyInstance;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
ndk: NDK,
|
|
||||||
fastify: FastifyInstance,
|
|
||||||
key: string,
|
|
||||||
cb: Nip46PermitCallback,
|
|
||||||
baseUrl?: string
|
|
||||||
) {
|
|
||||||
const signer = new NDKPrivateKeySigner(key);
|
|
||||||
super(ndk, signer, cb);
|
|
||||||
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
this.fastify = fastify;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Override NDKNip46Backend.start() to await the kind-24133
|
|
||||||
* subscription's EOSE before resolving. The base implementation
|
|
||||||
* calls `this.ndk.subscribe(...)` and returns immediately — the
|
|
||||||
* NDKSubscription queues a REQ on the relay connection but the
|
|
||||||
* relay's acknowledgement (EOSE) hasn't arrived yet. Any caller
|
|
||||||
* that publishes a NIP-46 event in the immediate window after
|
|
||||||
* `start()` returns races against the relay registering this
|
|
||||||
* subscription.
|
|
||||||
*
|
|
||||||
* aiolabs/lnbits#33's eager-bind chain publishes a NIP-46
|
|
||||||
* `connect` event in the same HTTP round-trip as `create_new_key`,
|
|
||||||
* which loses this race deterministically — the bunker never
|
|
||||||
* sees the connect event because its subscription wasn't yet
|
|
||||||
* registered with the relay when the event was broadcast.
|
|
||||||
*
|
|
||||||
* Awaiting EOSE closes the race: by the time `start()` resolves,
|
|
||||||
* the relay has confirmed it has the bunker's subscription on
|
|
||||||
* file and will route matching kind-24133 events to it.
|
|
||||||
*
|
|
||||||
* See aiolabs/nsecbunkerd#9 for the full diagnosis.
|
|
||||||
*/
|
|
||||||
async start(): Promise<void> {
|
|
||||||
this.localUser = await this.signer.user();
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
// Pin this subscription to the daemon's explicit relays via
|
|
||||||
// `relayUrls`. Without that, NDK 3.x's outbox routing tries to
|
|
||||||
// resolve the relay set from `this.localUser.pubkey`'s NIP-65
|
|
||||||
// relay list (kind:10002). Newly-provisioned bunker keys have
|
|
||||||
// no published kind:10002 yet, so NDK's subscription manager
|
|
||||||
// queues the REQ waiting for a relay list that will never
|
|
||||||
// arrive — the kind:24133 subscription never lands on the
|
|
||||||
// wire, and inbound NIP-46 events (sign_event, get_public_key,
|
|
||||||
// nip44_*) targeted at this key get dropped by the relay
|
|
||||||
// with "Filter didn't match" because the bunker isn't actually
|
|
||||||
// subscribed for them.
|
|
||||||
//
|
|
||||||
// `relayUrls` was added in NDK 2.13.0 as the supported way to
|
|
||||||
// bypass outbox routing per subscription (see
|
|
||||||
// NDKSubscriptionOptions.relayUrls in @nostr-dev-kit/ndk).
|
|
||||||
// The relay set built from these URLs matches what the rest
|
|
||||||
// of the bunker uses (admin RPC channel + per-key Backend
|
|
||||||
// channels alike), so events flow through the same connection
|
|
||||||
// the admin interface already established.
|
|
||||||
//
|
|
||||||
// See aiolabs/nsecbunkerd#21.
|
|
||||||
const sub = this.ndk.subscribe(
|
|
||||||
{
|
|
||||||
kinds: [24133],
|
|
||||||
"#p": [this.localUser!.pubkey],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
closeOnEose: false,
|
|
||||||
relayUrls: this.ndk.explicitRelayUrls,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
sub.on("event", (e: any) => this.handleIncomingEvent(e));
|
|
||||||
sub.on("eose", () => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateToken(token: string) {
|
|
||||||
if (!token) throw new Error("Invalid token");
|
|
||||||
|
|
||||||
const tokenRecord = await prisma.token.findUnique({ where: {
|
|
||||||
token
|
|
||||||
}, include: { policy: { include: { rules: true } } } });
|
|
||||||
|
|
||||||
if (!tokenRecord) throw new Error("Token not found");
|
|
||||||
if (tokenRecord.redeemedAt) throw new Error("Token already redeemed");
|
|
||||||
if (!tokenRecord.policy) throw new Error("Policy not found");
|
|
||||||
// Revoke + expiry via the single grantIsLive predicate — the exact
|
|
||||||
// check the sign-time ACL uses, so redeem-time and sign-time cannot
|
|
||||||
// drift (the root of #24). See aiolabs/nsecbunkerd#25.
|
|
||||||
if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked");
|
|
||||||
|
|
||||||
return tokenRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
async applyToken(userPubkey: string, token: string): Promise<void> {
|
|
||||||
const tokenRecord = await this.validateToken(token);
|
|
||||||
const keyName = tokenRecord.keyName;
|
|
||||||
|
|
||||||
// Record ONLY the binding (KeyUser <- Token). Under #25 the token's
|
|
||||||
// policy is evaluated live at sign time (checkIfPubkeyAllowed step 4)
|
|
||||||
// off Token -> Policy -> PolicyRule, NOT photocopied into
|
|
||||||
// SigningCondition rows here. That photocopy was the root of #24: the
|
|
||||||
// copy carried no expiry/revoke and short-circuited the live check, so
|
|
||||||
// an expired or revoked token kept signing forever. With no copy, the
|
|
||||||
// token's lifecycle is re-checked on every request and there is nothing
|
|
||||||
// to keep in sync.
|
|
||||||
const upsertedUser = await prisma.keyUser.upsert({
|
|
||||||
where: { unique_key_user: { keyName, userPubkey } },
|
|
||||||
update: { },
|
|
||||||
create: { keyName, userPubkey, description: tokenRecord.clientName },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.token.update({
|
|
||||||
where: { id: tokenRecord.id },
|
|
||||||
data: {
|
|
||||||
redeemedAt: new Date(),
|
|
||||||
keyUserId: upsertedUser.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export interface BackendConfig {
|
||||||
|
pool: RelayPool;
|
||||||
|
/** The held key (nsec1… or hex). Never leaves this object. */
|
||||||
|
nsec: string;
|
||||||
|
permitCallback: Nip46PermitCallback;
|
||||||
|
/** Connection-token redemption hook. The daemon injects the prisma-backed
|
||||||
|
* `applyToken` from `./token-store`; tests inject a stub. Required only if
|
||||||
|
* clients connect with a token. */
|
||||||
|
applyToken?: (remotePubkey: string, token: string) => Promise<void>;
|
||||||
|
baseUrl?: string;
|
||||||
|
fastify?: FastifyInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-46 signing backend for one held key (aiolabs/nsecbunkerd#42).
|
||||||
|
*
|
||||||
|
* Was `extends NDKNip46Backend`; now built on {@link Nip46Transport} over the
|
||||||
|
* RelayPool so the signing path survives relay flaps (#41) — NDK never replayed
|
||||||
|
* the kind:24133 subscription on reconnect. The protocol, response strings, and
|
||||||
|
* ACL hook (`pubkeyAllowed` → permitCallback) are preserved byte-for-byte so
|
||||||
|
* existing clients (lnbits, the spire) are unaffected. The token-redemption
|
||||||
|
* logic (`validateToken`/`applyToken`) is unchanged from the NDK version.
|
||||||
|
*/
|
||||||
|
export class Backend {
|
||||||
|
public baseUrl?: string;
|
||||||
|
public fastify?: FastifyInstance;
|
||||||
|
public readonly transport: Nip46Transport;
|
||||||
|
|
||||||
|
private readonly permitCallback: Nip46PermitCallback;
|
||||||
|
private readonly applyTokenFn: (remotePubkey: string, token: string) => Promise<void>;
|
||||||
|
|
||||||
|
constructor(config: BackendConfig) {
|
||||||
|
this.transport = new Nip46Transport(secretKeyBytes(config.nsec), config.pool);
|
||||||
|
this.permitCallback = config.permitCallback;
|
||||||
|
this.applyTokenFn =
|
||||||
|
config.applyToken ??
|
||||||
|
(async () => {
|
||||||
|
throw new Error("connection token redemption not configured");
|
||||||
|
});
|
||||||
|
this.baseUrl = config.baseUrl;
|
||||||
|
this.fastify = config.fastify;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The held key's public key (hex) — the npub the bunker signs as. */
|
||||||
|
get pubkey(): string {
|
||||||
|
return this.transport.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to this key's kind:24133 channel and serve requests. Resolves
|
||||||
|
* after the subscription's first EOSE (the #9 start-race guard). */
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.transport.start((req) => void this.handleRequest(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pubkeyAllowed(params: {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
method: any;
|
||||||
|
params?: any;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return this.permitCallback(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRequest(req: Nip46Request): Promise<void> {
|
||||||
|
const { id, method, params, remotePubkey, encryption } = req;
|
||||||
|
try {
|
||||||
|
const result = await this.dispatch(id, method, params, remotePubkey);
|
||||||
|
if (result !== undefined) {
|
||||||
|
await this.transport.sendResponse(id, remotePubkey, result, encryption);
|
||||||
|
} else {
|
||||||
|
await this.transport.sendResponse(id, remotePubkey, "error", encryption, "Not authorized");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
try {
|
||||||
|
await this.transport.sendResponse(id, remotePubkey, "error", encryption, e?.message ?? String(e));
|
||||||
|
} catch {
|
||||||
|
/* publish failed; nothing more we can do */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route a request to its handler. Returns the result string, or undefined
|
||||||
|
* for "Not authorized" — matching NDK's strategy contract exactly. */
|
||||||
|
private async dispatch(
|
||||||
|
id: string,
|
||||||
|
method: string,
|
||||||
|
params: string[],
|
||||||
|
remotePubkey: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
switch (method) {
|
||||||
|
case "connect": {
|
||||||
|
const [, token] = params;
|
||||||
|
if (token) await this.applyTokenFn(remotePubkey, token);
|
||||||
|
const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method: "connect", params: token });
|
||||||
|
return ok ? "ack" : undefined;
|
||||||
|
}
|
||||||
|
case "ping": {
|
||||||
|
const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method: "ping" });
|
||||||
|
return ok ? "pong" : undefined;
|
||||||
|
}
|
||||||
|
case "get_public_key":
|
||||||
|
return this.pubkey;
|
||||||
|
case "sign_event": {
|
||||||
|
const [eventString] = params;
|
||||||
|
const tmpl = JSON.parse(eventString);
|
||||||
|
const ok = await this.pubkeyAllowed({
|
||||||
|
id,
|
||||||
|
pubkey: remotePubkey,
|
||||||
|
method: "sign_event",
|
||||||
|
params: tmpl, // ACL reads only `.kind`
|
||||||
|
});
|
||||||
|
if (!ok) return undefined;
|
||||||
|
const signed = this.transport.sign({
|
||||||
|
kind: tmpl.kind,
|
||||||
|
created_at: tmpl.created_at ?? Math.floor(Date.now() / 1000),
|
||||||
|
tags: tmpl.tags ?? [],
|
||||||
|
content: tmpl.content ?? "",
|
||||||
|
});
|
||||||
|
return JSON.stringify(signed);
|
||||||
|
}
|
||||||
|
case "nip44_encrypt":
|
||||||
|
case "nip04_encrypt": {
|
||||||
|
const [recipientPubkey, payload] = params;
|
||||||
|
const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method, params: payload });
|
||||||
|
if (!ok) return undefined;
|
||||||
|
const scheme = method === "nip04_encrypt" ? "nip04" : "nip44";
|
||||||
|
return this.transport.encryptTo(recipientPubkey, payload, scheme);
|
||||||
|
}
|
||||||
|
case "nip44_decrypt":
|
||||||
|
case "nip04_decrypt": {
|
||||||
|
const [senderPubkey, ciphertext] = params;
|
||||||
|
const ok = await this.pubkeyAllowed({ id, pubkey: remotePubkey, method, params: ciphertext });
|
||||||
|
if (!ok) return undefined;
|
||||||
|
const scheme = method === "nip04_decrypt" ? "nip04" : "nip44";
|
||||||
|
return this.transport.decryptFrom(senderPubkey, ciphertext, scheme);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unknown method — undefined surfaces as "Not authorized".
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
src/daemon/backend/token-store.ts
Normal file
57
src/daemon/backend/token-store.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import prisma from "../../db.js";
|
||||||
|
import { grantIsLive } from "../lib/acl/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma-backed connection-token redemption (aiolabs/nsecbunkerd#42).
|
||||||
|
*
|
||||||
|
* Split out of the Backend so the NIP-46 protocol layer (`backend/index.ts`)
|
||||||
|
* has no database dependency and can be unit-tested without a generated prisma
|
||||||
|
* client. The daemon wires {@link applyToken} into the Backend as its
|
||||||
|
* `applyToken` hook; tests inject a stub. Logic is unchanged from the prior
|
||||||
|
* NDK-based Backend's `validateToken`/`applyToken`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function validateToken(token: string) {
|
||||||
|
if (!token) throw new Error("Invalid token");
|
||||||
|
|
||||||
|
const tokenRecord = await prisma.token.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { policy: { include: { rules: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenRecord) throw new Error("Token not found");
|
||||||
|
if (tokenRecord.redeemedAt) throw new Error("Token already redeemed");
|
||||||
|
if (!tokenRecord.policy) throw new Error("Policy not found");
|
||||||
|
// Revoke + expiry via the single grantIsLive predicate — the exact check
|
||||||
|
// the sign-time ACL uses, so redeem-time and sign-time cannot drift (the
|
||||||
|
// root of #24). See aiolabs/nsecbunkerd#25.
|
||||||
|
if (!grantIsLive(tokenRecord)) throw new Error("Token expired or revoked");
|
||||||
|
|
||||||
|
return tokenRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyToken(userPubkey: string, token: string): Promise<void> {
|
||||||
|
const tokenRecord = await validateToken(token);
|
||||||
|
const keyName = tokenRecord.keyName;
|
||||||
|
|
||||||
|
// Record ONLY the binding (KeyUser <- Token). Under #25 the token's policy
|
||||||
|
// is evaluated live at sign time (checkIfPubkeyAllowed step 4) off
|
||||||
|
// Token -> Policy -> PolicyRule, NOT photocopied into SigningCondition rows
|
||||||
|
// here. That photocopy was the root of #24: the copy carried no
|
||||||
|
// expiry/revoke and short-circuited the live check, so an expired or revoked
|
||||||
|
// token kept signing forever. With no copy, the token's lifecycle is
|
||||||
|
// re-checked on every request and there is nothing to keep in sync.
|
||||||
|
const upsertedUser = await prisma.keyUser.upsert({
|
||||||
|
where: { unique_key_user: { keyName, userPubkey } },
|
||||||
|
update: {},
|
||||||
|
create: { keyName, userPubkey, description: tokenRecord.clientName },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.token.update({
|
||||||
|
where: { id: tokenRecord.id },
|
||||||
|
data: {
|
||||||
|
redeemedAt: new Date(),
|
||||||
|
keyUserId: upsertedUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
407
src/daemon/lib/relay-pool.ts
Normal file
407
src/daemon/lib/relay-pool.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
import { Relay } from "nostr-tools";
|
||||||
|
import type { Event, Filter } from "nostr-tools";
|
||||||
|
|
||||||
|
// nostr-tools needs a WebSocket implementation injected under Node (no global
|
||||||
|
// WebSocket on the deploy target, Node 20). `useWebSocketImplementation` lives
|
||||||
|
// in the `nostr-tools/relay` subpath export, which tsc's classic
|
||||||
|
// `moduleResolution: node` can't resolve — but the build target is CommonJS and
|
||||||
|
// Node honours the package exports map at runtime, so require it. The same goes
|
||||||
|
// for `ws` (no @types/ws in the tree). `useWebSocketImplementation` sets
|
||||||
|
// nostr-tools' internal WS binding at call time, so import-order / global-capture
|
||||||
|
// don't apply.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const WebSocket = require("ws");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const { useWebSocketImplementation } = require("nostr-tools/relay") as {
|
||||||
|
useWebSocketImplementation: (ws: unknown) => void;
|
||||||
|
};
|
||||||
|
useWebSocketImplementation(WebSocket);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RelayPool — the daemon's relay/transport layer (aiolabs/nsecbunkerd#42).
|
||||||
|
*
|
||||||
|
* Replaces the NDK-based transport whose subscriptions did not survive a relay
|
||||||
|
* reconnect (#41): NDK registers `relay.once("ready", execute)` — fires once on
|
||||||
|
* the initial connect, never re-arms — so after a flap the socket reconnects but
|
||||||
|
* the kind:24133 REQ is never re-sent, and the bunker goes silently deaf. We
|
||||||
|
* chased that through #4 → #7 → #20 → #21 without closing it, because it is
|
||||||
|
* structural in NDK.
|
||||||
|
*
|
||||||
|
* The fix, modelled on lightning.pub's `RelayConnection` and signet's
|
||||||
|
* `relay-pool` (both nostr-tools, both bind resubscribe to reconnect): **we own
|
||||||
|
* the connect loop**, and every (re)connect re-subscribes the entire registry of
|
||||||
|
* active subscriptions. Subscription liveness can no longer drift from socket
|
||||||
|
* liveness because the two are established together, atomically, on every cycle.
|
||||||
|
*
|
||||||
|
* We deliberately disable nostr-tools' own `enableReconnect`: its resubscribe
|
||||||
|
* behaviour is version-fragile right now (the auto-resubscribe regressed in
|
||||||
|
* 2.23.0 `fb7de7f` and the fix `455124e` is unreleased as of 2026-06-26), so we
|
||||||
|
* make the resubscribe OUR code instead of depending on which nostr-tools
|
||||||
|
* version is installed. See #42 for the version analysis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PoolSubscription {
|
||||||
|
id: string;
|
||||||
|
filters: Filter[];
|
||||||
|
onevent: (event: Event) => void;
|
||||||
|
/** Fires on EOSE — note it fires again on every reconnect's resubscribe, so
|
||||||
|
* callers that want only the FIRST EOSE (e.g. the #9 start-race guard) must
|
||||||
|
* latch it themselves. */
|
||||||
|
oneose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSub {
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECONNECT_BASE_MS = 1_000;
|
||||||
|
const RECONNECT_CAP_MS = 10_000; // match #20's cap — cheap to retry a LAN relay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single relay connection that owns its (re)connect loop. On every successful
|
||||||
|
* connect it re-subscribes the shared registry; when the socket closes the loop
|
||||||
|
* reconnects and re-subscribes again. This is the unit that makes
|
||||||
|
* connected-but-deaf impossible.
|
||||||
|
*/
|
||||||
|
class ManagedRelay {
|
||||||
|
private relay: Relay | null = null;
|
||||||
|
private active: Map<string, ActiveSub> = new Map();
|
||||||
|
private stopped = false;
|
||||||
|
/** Interrupts a pending reconnect backoff so stop() takes effect at once. */
|
||||||
|
private wake: (() => void) | null = null;
|
||||||
|
|
||||||
|
public connected = false;
|
||||||
|
public lastConnectedAt = 0;
|
||||||
|
public lastDisconnectedAt = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly url: string,
|
||||||
|
private readonly registry: Map<string, PoolSubscription>,
|
||||||
|
private readonly log: (...args: any[]) => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
void this.connectLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
this.wake?.(); // break any pending reconnect backoff so we exit promptly
|
||||||
|
this.closeAllSubs();
|
||||||
|
try {
|
||||||
|
this.relay?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.relay = null;
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconnect backoff wait that (a) unrefs so a stopped daemon isn't held
|
||||||
|
* open by a pending timer, and (b) can be woken early by stop(). */
|
||||||
|
private backoff(ms: number): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
this.wake = null;
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
t.unref?.();
|
||||||
|
this.wake = () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
this.wake = null;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe one entry on the live connection (no-op if not connected — it
|
||||||
|
* will be picked up by the next resubscribeAll on connect). */
|
||||||
|
subscribeOne(id: string, s: PoolSubscription): void {
|
||||||
|
if (!this.relay || !this.connected) return;
|
||||||
|
// Replace any existing handle for this id (idempotent).
|
||||||
|
this.active.get(id)?.close();
|
||||||
|
try {
|
||||||
|
const sub = this.relay.subscribe(s.filters, {
|
||||||
|
onevent: (e: Event) => s.onevent(e),
|
||||||
|
oneose: () => s.oneose?.(),
|
||||||
|
});
|
||||||
|
this.active.set(id, sub);
|
||||||
|
} catch (e) {
|
||||||
|
this.log("subscribe failed", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSub(id: string): void {
|
||||||
|
const sub = this.active.get(id);
|
||||||
|
if (sub) {
|
||||||
|
try {
|
||||||
|
sub.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.active.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(event: Event): Promise<void> {
|
||||||
|
if (!this.relay || !this.connected) {
|
||||||
|
throw new Error(`relay not connected: ${this.url}`);
|
||||||
|
}
|
||||||
|
await this.relay.publish(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of registry entries currently active on the wire. The watchdog
|
||||||
|
* uses this to detect a connected-but-deaf relay (active < registry). */
|
||||||
|
activeCount(): number {
|
||||||
|
return this.active.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectLoop(): Promise<void> {
|
||||||
|
let failures = 0;
|
||||||
|
while (!this.stopped) {
|
||||||
|
const wasReal = await this.connectOnce();
|
||||||
|
if (this.stopped) break;
|
||||||
|
// A real connection that later dropped resets the backoff; a failed
|
||||||
|
// connect attempt grows it (capped). Either way we keep trying — a
|
||||||
|
// bunker disconnected is strictly worse than retry pressure on a LAN
|
||||||
|
// relay (#20's rationale).
|
||||||
|
failures = wasReal ? 0 : failures + 1;
|
||||||
|
const delay = Math.min(RECONNECT_BASE_MS * 2 ** failures, RECONNECT_CAP_MS);
|
||||||
|
await this.backoff(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the socket, re-subscribe the whole registry, and resolve when the
|
||||||
|
* socket closes (or immediately if the open failed). Returns true if we had
|
||||||
|
* a real connection (so the caller resets backoff), false if the open failed.
|
||||||
|
*/
|
||||||
|
private connectOnce(): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
void (async () => {
|
||||||
|
let relay: Relay;
|
||||||
|
try {
|
||||||
|
// enableReconnect:false — WE own reconnect, not nostr-tools.
|
||||||
|
relay = await Relay.connect(this.url, { enableReconnect: false });
|
||||||
|
} catch (e: any) {
|
||||||
|
this.log("connect failed:", e?.message ?? e);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.relay = relay;
|
||||||
|
this.connected = true;
|
||||||
|
this.lastConnectedAt = Date.now();
|
||||||
|
this.log(`connected; (re)subscribing ${this.registry.size} sub(s)`);
|
||||||
|
this.resubscribeAll();
|
||||||
|
|
||||||
|
relay.onclose = () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.lastDisconnectedAt = Date.now();
|
||||||
|
this.closeAllSubs();
|
||||||
|
if (this.relay) {
|
||||||
|
this.relay.onclose = null;
|
||||||
|
this.relay = null;
|
||||||
|
}
|
||||||
|
this.log("disconnected");
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-establish every registered subscription on the current connection.
|
||||||
|
* This is the line that fixes #41. */
|
||||||
|
private resubscribeAll(): void {
|
||||||
|
this.closeAllSubs();
|
||||||
|
for (const [id, s] of this.registry) {
|
||||||
|
this.subscribeOne(id, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeAllSubs(): void {
|
||||||
|
for (const sub of this.active.values()) {
|
||||||
|
try {
|
||||||
|
sub.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.active.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a clean reconnect (close → loop reconnects → resubscribes). Used by
|
||||||
|
* the pool's heartbeat after a detected time-jump (sleep/wake), when the
|
||||||
|
* socket may look alive but be stale. */
|
||||||
|
forceReconnect(): void {
|
||||||
|
if (this.relay) {
|
||||||
|
try {
|
||||||
|
this.relay.close();
|
||||||
|
} catch {
|
||||||
|
/* the onclose handler drives the reconnect */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayPoolOptions {
|
||||||
|
log?: (...args: any[]) => void;
|
||||||
|
/** Heartbeat interval for sleep/wake detection (signet pattern). 0 disables. */
|
||||||
|
heartbeatMs?: number;
|
||||||
|
/** If a heartbeat tick is later than interval + this slack, treat it as a
|
||||||
|
* process suspend (sleep/VM-pause) and force-reconnect all relays. */
|
||||||
|
sleepSlackMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pool of ManagedRelay connections sharing one subscription registry. Mirrors
|
||||||
|
* the surface the daemon needs from the old NDK instance: subscribe / publish /
|
||||||
|
* connect-status — plus a `healthy()` signal the watchdog can trust.
|
||||||
|
*/
|
||||||
|
export class RelayPool {
|
||||||
|
private readonly registry: Map<string, PoolSubscription> = new Map();
|
||||||
|
private readonly relays: ManagedRelay[];
|
||||||
|
private readonly log: (...args: any[]) => void;
|
||||||
|
private counter = 0;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private lastHeartbeat = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly relayUrls: string[],
|
||||||
|
private readonly opts: RelayPoolOptions = {},
|
||||||
|
) {
|
||||||
|
this.log = opts.log ?? (() => {});
|
||||||
|
this.relays = relayUrls.map(
|
||||||
|
(url) =>
|
||||||
|
new ManagedRelay(url, this.registry, (...a: any[]) =>
|
||||||
|
this.log(`[relay:${url}]`, ...a),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start every relay's connect loop + (optionally) the sleep/wake heartbeat. */
|
||||||
|
start(): void {
|
||||||
|
for (const r of this.relays) r.start();
|
||||||
|
const interval = this.opts.heartbeatMs ?? 0;
|
||||||
|
if (interval > 0) {
|
||||||
|
this.lastHeartbeat = Date.now();
|
||||||
|
this.heartbeatTimer = setInterval(() => this.runHeartbeat(), interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = undefined;
|
||||||
|
for (const r of this.relays) r.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a subscription and establish it on all connected relays. The
|
||||||
|
* registry entry persists across reconnects (each ManagedRelay re-subscribes
|
||||||
|
* it on every connect), which is the whole point. Returns a handle whose
|
||||||
|
* `close()` removes it from the registry so it stops being replayed.
|
||||||
|
*/
|
||||||
|
subscribe(
|
||||||
|
filters: Filter[],
|
||||||
|
onevent: (event: Event) => void,
|
||||||
|
opts: { id?: string; oneose?: () => void } = {},
|
||||||
|
): { id: string; close: () => void } {
|
||||||
|
const id = opts.id ?? `sub-${++this.counter}`;
|
||||||
|
const s: PoolSubscription = { id, filters, onevent, oneose: opts.oneose };
|
||||||
|
this.registry.set(id, s);
|
||||||
|
for (const r of this.relays) r.subscribeOne(id, s);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
close: () => {
|
||||||
|
this.registry.delete(id);
|
||||||
|
for (const r of this.relays) r.closeSub(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe and resolve once the FIRST EOSE arrives from any relay (the #9
|
||||||
|
* race guard: callers must not publish before the relay has the REQ on
|
||||||
|
* file). Re-EOSEs on later reconnects are ignored.
|
||||||
|
*/
|
||||||
|
subscribeAwaitingEose(
|
||||||
|
filters: Filter[],
|
||||||
|
onevent: (event: Event) => void,
|
||||||
|
opts: { id?: string } = {},
|
||||||
|
): Promise<{ id: string; close: () => void }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let eosed = false;
|
||||||
|
const handle = this.subscribe(filters, onevent, {
|
||||||
|
id: opts.id,
|
||||||
|
oneose: () => {
|
||||||
|
if (!eosed) {
|
||||||
|
eosed = true;
|
||||||
|
resolve(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish to every relay; resolves once at least one accepts it. Retries
|
||||||
|
* across a reconnect window: a publish that lands while a relay is mid-flap
|
||||||
|
* rejects ("relay connection errored"/"relay not connected"), so we wait for
|
||||||
|
* the pool to recover and try again. Safe to retry — relays dedupe by event
|
||||||
|
* id and a NIP-46 client matches the response by request id. Bounded so a
|
||||||
|
* genuinely-down relay set still surfaces an error rather than hanging.
|
||||||
|
*/
|
||||||
|
async publish(event: Event, opts: { retries?: number; retryDelayMs?: number } = {}): Promise<void> {
|
||||||
|
const retries = opts.retries ?? 4;
|
||||||
|
const retryDelayMs = opts.retryDelayMs ?? 300;
|
||||||
|
let lastReasons = "";
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
const results = await Promise.allSettled(this.relays.map((r) => r.publish(event)));
|
||||||
|
if (results.some((r) => r.status === "fulfilled")) return;
|
||||||
|
lastReasons = results
|
||||||
|
.map((r) => (r.status === "rejected" ? r.reason?.message ?? r.reason : ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; ");
|
||||||
|
if (attempt < retries) {
|
||||||
|
await new Promise((r) => setTimeout(r, retryDelayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`publish failed on all relays after ${retries + 1} attempts: ${lastReasons}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relays currently holding an open socket. */
|
||||||
|
connectedCount(): number {
|
||||||
|
return this.relays.filter((r) => r.connected).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-liveness, not just socket-liveness. Healthy iff at least one relay
|
||||||
|
* is connected AND has every registered subscription active on the wire.
|
||||||
|
* This is the check the old watchdog couldn't make: after #20 a reconnected
|
||||||
|
* socket looked healthy while the subscription set was empty (#41). Here a
|
||||||
|
* connected relay that hasn't (re)subscribed the registry reads as unhealthy.
|
||||||
|
*/
|
||||||
|
healthy(): boolean {
|
||||||
|
const want = this.registry.size;
|
||||||
|
return this.relays.some((r) => r.connected && r.activeCount() >= want);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runHeartbeat(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastHeartbeat;
|
||||||
|
this.lastHeartbeat = now;
|
||||||
|
const interval = this.opts.heartbeatMs ?? 0;
|
||||||
|
const slack = this.opts.sleepSlackMs ?? 30_000;
|
||||||
|
// A tick far later than scheduled means the process was suspended
|
||||||
|
// (laptop sleep, VM pause); sockets may be half-open but look alive.
|
||||||
|
// Force a clean reconnect so subscriptions are re-established. (signet)
|
||||||
|
if (interval > 0 && elapsed > interval + slack) {
|
||||||
|
this.log(
|
||||||
|
`heartbeat: ${Math.round(elapsed / 1000)}s gap (expected ~${Math.round(
|
||||||
|
interval / 1000,
|
||||||
|
)}s) — suspected sleep/wake, forcing reconnect`,
|
||||||
|
);
|
||||||
|
for (const r of this.relays) r.forceReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import NDK from "@nostr-dev-kit/ndk";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attaches an aggressive-reconnect supervisor to an NDK instance.
|
|
||||||
*
|
|
||||||
* NDK 3.x's per-relay connectivity state machine gives up retrying after
|
|
||||||
* a few consecutive fast-fail (e.g. ECONNREFUSED returns in <1 ms)
|
|
||||||
* connection attempts:
|
|
||||||
*
|
|
||||||
* 1. Each attempt's duration is recorded in `_connectionStats.durations`.
|
|
||||||
* 2. After every 3 attempts, `isFlapping()` checks the std-dev of those
|
|
||||||
* durations against `FLAPPING_THRESHOLD_MS` (1 second). Three fast
|
|
||||||
* failures look identical → tiny std-dev → flapping=true → status
|
|
||||||
* transitions to FLAPPING and the per-relay retry stops.
|
|
||||||
* 3. `NDKPool.handleFlapping` catches the event and reschedules a
|
|
||||||
* reconnect via doubling backoff (5s → 10s → 20s → 40s → 80s …),
|
|
||||||
* growing unbounded.
|
|
||||||
*
|
|
||||||
* For nsecbunkerd, where the admin relay is typically a single relay we
|
|
||||||
* **must** stay subscribed to, "disconnected for 80+s after every dev
|
|
||||||
* restart" is the failure mode users hit (aiolabs/nsecbunkerd#20). The
|
|
||||||
* pool's doubling backoff is too pessimistic for our use case.
|
|
||||||
*
|
|
||||||
* This helper sidesteps the give-up path: when the pool emits `flapping`
|
|
||||||
* (the symptom that NDK has internally given up), or when we see the
|
|
||||||
* relay disconnect outside our own request, we manually call
|
|
||||||
* `relay.connect()` with a SHORT capped delay. Successful connect resets
|
|
||||||
* the attempt counter so a future disconnect storm doesn't grow the
|
|
||||||
* delay.
|
|
||||||
*
|
|
||||||
* Trade-off: we may hammer a permanently-down relay every 10s. That's
|
|
||||||
* fine for a bunker — being disconnected silently is strictly worse than
|
|
||||||
* a retry storm against localhost. Acceptable because:
|
|
||||||
* - The bunker's primary relay is typically on the same host or LAN
|
|
||||||
* (`ws://lnbits:5001/...`); TCP RSTs are cheap.
|
|
||||||
* - Public-relay setups can layer external supervision on top if they
|
|
||||||
* care about retry pressure.
|
|
||||||
*/
|
|
||||||
export function attachIndefiniteReconnect(ndk: NDK, label: string): void {
|
|
||||||
const RECONNECT_BASE_MS = 1_000;
|
|
||||||
const RECONNECT_CAP_MS = 10_000;
|
|
||||||
|
|
||||||
const attempts = new Map<string, number>();
|
|
||||||
const pending = new Map<string, NodeJS.Timeout>();
|
|
||||||
|
|
||||||
const reconnectDelay = (n: number): number =>
|
|
||||||
Math.min(RECONNECT_BASE_MS * 2 ** n, RECONNECT_CAP_MS);
|
|
||||||
|
|
||||||
const scheduleReconnect = (relay: any): void => {
|
|
||||||
const url: string = relay.url;
|
|
||||||
if (pending.has(url)) return;
|
|
||||||
const n = attempts.get(url) ?? 0;
|
|
||||||
const delay = reconnectDelay(n);
|
|
||||||
console.log(
|
|
||||||
`🔁 ${label}: scheduling reconnect to ${url} in ${delay}ms ` +
|
|
||||||
`(attempt ${n + 1}, overriding NDK give-up)`
|
|
||||||
);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
pending.delete(url);
|
|
||||||
attempts.set(url, n + 1);
|
|
||||||
relay.connect().catch((e: any) => {
|
|
||||||
console.log(
|
|
||||||
`❌ ${label}: manual reconnect to ${url} failed: ` +
|
|
||||||
`${e?.message ?? e}`
|
|
||||||
);
|
|
||||||
// Don't recurse here — the next 'flapping' or 'disconnect'
|
|
||||||
// event will fire and schedule another attempt.
|
|
||||||
});
|
|
||||||
}, delay);
|
|
||||||
pending.set(url, timer);
|
|
||||||
};
|
|
||||||
|
|
||||||
ndk.pool.on("flapping", (relay: any) => {
|
|
||||||
console.log(
|
|
||||||
`⚠️ ${label}: NDK flagged ${relay.url} as flapping ` +
|
|
||||||
`(connectivity machine gave up internally)`
|
|
||||||
);
|
|
||||||
scheduleReconnect(relay);
|
|
||||||
});
|
|
||||||
|
|
||||||
ndk.pool.on("relay:disconnect", (relay: any) => {
|
|
||||||
scheduleReconnect(relay);
|
|
||||||
});
|
|
||||||
|
|
||||||
ndk.pool.on("relay:connect", (relay: any) => {
|
|
||||||
const url: string = relay.url;
|
|
||||||
const n = attempts.get(url) ?? 0;
|
|
||||||
if (n > 0) {
|
|
||||||
console.log(
|
|
||||||
`✅ ${label}: recovered ${url} after ${n} manual reconnect ` +
|
|
||||||
`attempt(s)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
attempts.delete(url);
|
|
||||||
const timer = pending.get(url);
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
pending.delete(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
205
src/daemon/nip46/transport.ts
Normal file
205
src/daemon/nip46/transport.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { finalizeEvent, verifyEvent, getPublicKey, nip04, nip44, nip19 } from "nostr-tools";
|
||||||
|
import type { Event } from "nostr-tools";
|
||||||
|
import type { RelayPool } from "../lib/relay-pool.js";
|
||||||
|
import type { Nip46Request } from "./types.js";
|
||||||
|
|
||||||
|
const NIP46_KIND = 24133;
|
||||||
|
|
||||||
|
/** nsec1… or 64-hex → 32-byte secret key. */
|
||||||
|
export function secretKeyBytes(key: string): Uint8Array {
|
||||||
|
if (key.startsWith("nsec1")) {
|
||||||
|
const { type, data } = nip19.decode(key);
|
||||||
|
if (type !== "nsec") throw new Error("not an nsec");
|
||||||
|
return data as Uint8Array;
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(key.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(key.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nip46Transport — the NIP-46 RPC wire layer over a {@link RelayPool},
|
||||||
|
* replacing NDK's `NDKNostrRpc` (aiolabs/nsecbunkerd#42). It owns exactly the
|
||||||
|
* crypto + framing NDK did, so existing clients (lnbits, the spire) keep
|
||||||
|
* working byte-for-byte:
|
||||||
|
*
|
||||||
|
* - inbound: verify the kind:24133 signature, decrypt the content (nip04 if it
|
||||||
|
* carries `?iv=`, else nip44 — falling back to the other on failure, the same
|
||||||
|
* adaptive scheme as `NDKNostrRpc.parseEvent`), JSON-parse `{id, method,
|
||||||
|
* params}`.
|
||||||
|
* - outbound: JSON `{id, result, error?}`, encrypted to the client with the
|
||||||
|
* SAME scheme the request used, signed as the held key, published kind:24133
|
||||||
|
* `#p`-tagged to the client.
|
||||||
|
*
|
||||||
|
* The held key never leaves this object; it signs/encrypts only.
|
||||||
|
*/
|
||||||
|
export class Nip46Transport {
|
||||||
|
public readonly pubkey: string;
|
||||||
|
private sub: { close: () => void } | null = null;
|
||||||
|
/** Callbacks awaiting a response to one of our outbound sendRequest()s, by
|
||||||
|
* request id (the admin approval flow). */
|
||||||
|
private pending = new Map<string, (res: { id: string; result: string; error?: string }) => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sk: Uint8Array,
|
||||||
|
private readonly pool: RelayPool,
|
||||||
|
private readonly log: (...args: any[]) => void = () => {},
|
||||||
|
) {
|
||||||
|
this.pubkey = getPublicKey(sk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start listening for this key's requests on the given kinds (the signer
|
||||||
|
* channel is [24133]; the admin channel is [24133, 24134]). Resolves once
|
||||||
|
* the subscription's first EOSE lands (the #9 start-race guard). */
|
||||||
|
async start(onRequest: (req: Nip46Request) => void, kinds: number[] = [NIP46_KIND]): Promise<void> {
|
||||||
|
this.sub = await this.pool.subscribeAwaitingEose(
|
||||||
|
[{ kinds, "#p": [this.pubkey] }],
|
||||||
|
(event: Event) => this.onEvent(event, onRequest),
|
||||||
|
{ id: `nip46:${this.pubkey}` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.sub?.close();
|
||||||
|
this.sub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEvent(event: Event, onRequest: (req: Nip46Request) => void): void {
|
||||||
|
const env = this.parseEnvelope(event);
|
||||||
|
if (!env) return;
|
||||||
|
const { body, encryption, remotePubkey, kind } = env;
|
||||||
|
if (body.method) {
|
||||||
|
onRequest({
|
||||||
|
id: body.id,
|
||||||
|
method: body.method,
|
||||||
|
params: body.params ?? [],
|
||||||
|
remotePubkey,
|
||||||
|
encryption,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
} else if (body.id !== undefined) {
|
||||||
|
// a response to one of our outbound requests (admin approval flow)
|
||||||
|
this.pending.get(body.id)?.({ id: body.id, result: body.result, error: body.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify + decrypt an inbound event into its JSON body + envelope metadata,
|
||||||
|
* or null if unreadable. Adaptive nip04/nip44 like NDKNostrRpc.parseEvent. */
|
||||||
|
private parseEnvelope(
|
||||||
|
event: Event,
|
||||||
|
): { body: any; encryption: "nip04" | "nip44"; remotePubkey: string; kind: number } | null {
|
||||||
|
if (!verifyEvent(event)) {
|
||||||
|
this.log("dropping event with invalid signature", event.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const remotePubkey = event.pubkey;
|
||||||
|
// nip04 ciphertext carries a `?iv=`; nip44 does not.
|
||||||
|
let encryption: "nip04" | "nip44" = event.content.includes("?iv=") ? "nip04" : "nip44";
|
||||||
|
let decrypted: string;
|
||||||
|
try {
|
||||||
|
decrypted = this.decrypt(remotePubkey, event.content, encryption);
|
||||||
|
} catch {
|
||||||
|
encryption = encryption === "nip04" ? "nip44" : "nip04";
|
||||||
|
try {
|
||||||
|
decrypted = this.decrypt(remotePubkey, event.content, encryption);
|
||||||
|
} catch (e) {
|
||||||
|
this.log("failed to decrypt event", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return { body: JSON.parse(decrypted), encryption, remotePubkey, kind: event.kind };
|
||||||
|
} catch {
|
||||||
|
this.log("event content was not JSON");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a NIP-46 request to a peer and register a one-shot response handler
|
||||||
|
* (the admin approval flow: bunker -> operator "acl" request). Returns the
|
||||||
|
* request id so the caller can `clearPending(id)` on timeout.
|
||||||
|
*/
|
||||||
|
sendRequest(
|
||||||
|
remotePubkey: string,
|
||||||
|
method: string,
|
||||||
|
params: string[],
|
||||||
|
encryption: "nip04" | "nip44",
|
||||||
|
kind: number,
|
||||||
|
cb: (res: { id: string; result: string; error?: string }) => void,
|
||||||
|
): string {
|
||||||
|
const id = Math.random().toString(36).substring(2, 12);
|
||||||
|
this.pending.set(id, cb);
|
||||||
|
const content = this.encrypt(remotePubkey, JSON.stringify({ id, method, params }), encryption);
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{ kind, created_at: Math.floor(Date.now() / 1000), tags: [["p", remotePubkey]], content },
|
||||||
|
this.sk,
|
||||||
|
);
|
||||||
|
void this.pool.publish(event);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPending(id: string): void {
|
||||||
|
this.pending.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt + sign + publish a NIP-46 response, matching the request's scheme.
|
||||||
|
* `kind` defaults to the signer channel (24133); the admin RPC passes its
|
||||||
|
* own response kind (24134) or mirrors the request kind for errors. */
|
||||||
|
async sendResponse(
|
||||||
|
id: string,
|
||||||
|
remotePubkey: string,
|
||||||
|
result: string,
|
||||||
|
encryption: "nip04" | "nip44",
|
||||||
|
error?: string,
|
||||||
|
kind: number = NIP46_KIND,
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: { id: string; result: string; error?: string } = { id, result };
|
||||||
|
if (error) payload.error = error;
|
||||||
|
const content = this.encrypt(remotePubkey, JSON.stringify(payload), encryption);
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [["p", remotePubkey]],
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
this.sk,
|
||||||
|
);
|
||||||
|
await this.pool.publish(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private encrypt(peerPubkey: string, plaintext: string, scheme: "nip04" | "nip44"): string {
|
||||||
|
if (scheme === "nip04") {
|
||||||
|
return nip04.encrypt(this.sk, peerPubkey, plaintext);
|
||||||
|
}
|
||||||
|
const convKey = nip44.getConversationKey(this.sk, peerPubkey);
|
||||||
|
return nip44.encrypt(plaintext, convKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrypt(peerPubkey: string, ciphertext: string, scheme: "nip04" | "nip44"): string {
|
||||||
|
if (scheme === "nip04") {
|
||||||
|
return nip04.decrypt(this.sk, peerPubkey, ciphertext);
|
||||||
|
}
|
||||||
|
const convKey = nip44.getConversationKey(this.sk, peerPubkey);
|
||||||
|
return nip44.decrypt(ciphertext, convKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt an arbitrary payload to a recipient (the nip04/44_encrypt method
|
||||||
|
* signs/encrypts on the client's behalf, as the held key). */
|
||||||
|
encryptTo(recipientPubkey: string, payload: string, scheme: "nip04" | "nip44"): string {
|
||||||
|
return this.encrypt(recipientPubkey, payload, scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a payload from a counterparty (the nip04/44_decrypt method). */
|
||||||
|
decryptFrom(senderPubkey: string, ciphertext: string, scheme: "nip04" | "nip44"): string {
|
||||||
|
return this.decrypt(senderPubkey, ciphertext, scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign an event template as the held key (the sign_event method). */
|
||||||
|
sign(template: { kind: number; created_at: number; tags: string[][]; content: string }): Event {
|
||||||
|
return finalizeEvent(template, this.sk);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/daemon/nip46/types.ts
Normal file
53
src/daemon/nip46/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Local NIP-46 types (aiolabs/nsecbunkerd#42).
|
||||||
|
*
|
||||||
|
* These replace the identically-named types we used to import from
|
||||||
|
* `@nostr-dev-kit/ndk`, so the daemon's signing path no longer depends on NDK.
|
||||||
|
* Kept structurally identical to NDK's so the ACL callback
|
||||||
|
* (`signingAuthorizationCallback`) and the admin layer don't have to change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NIP46Method =
|
||||||
|
| "connect"
|
||||||
|
| "sign_event"
|
||||||
|
| "nip04_encrypt"
|
||||||
|
| "nip04_decrypt"
|
||||||
|
| "nip44_encrypt"
|
||||||
|
| "nip44_decrypt"
|
||||||
|
| "get_public_key"
|
||||||
|
| "ping";
|
||||||
|
|
||||||
|
export interface Nip46PermitCallbackParams {
|
||||||
|
/** Request id. */
|
||||||
|
id: string;
|
||||||
|
/** The connected client's pubkey (hex). */
|
||||||
|
pubkey: string;
|
||||||
|
/** The NIP-46 method being requested. */
|
||||||
|
method: NIP46Method;
|
||||||
|
/**
|
||||||
|
* The method's payload. For `sign_event` it's the parsed event object (the
|
||||||
|
* ACL only reads `.kind`); for the encrypt/decrypt methods it's the payload
|
||||||
|
* string; for `connect` it's the token; otherwise undefined.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
params?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Nip46PermitCallback = (params: Nip46PermitCallbackParams) => Promise<boolean>;
|
||||||
|
|
||||||
|
/** Hook to redeem a connection token (the bunker's `applyToken`). */
|
||||||
|
export type Nip46ApplyTokenCallback = (pubkey: string, token: string) => Promise<void>;
|
||||||
|
|
||||||
|
/** A decrypted, verified inbound NIP-46 request. */
|
||||||
|
export interface Nip46Request {
|
||||||
|
id: string;
|
||||||
|
method: string;
|
||||||
|
params: string[];
|
||||||
|
/** The verified sender (client) pubkey, hex. */
|
||||||
|
remotePubkey: string;
|
||||||
|
/** Which envelope encryption the client used; the response must match it. */
|
||||||
|
encryption: "nip04" | "nip44";
|
||||||
|
/** The kind of the inbound event (24133 signer / 24134 admin) — lets a
|
||||||
|
* handler mirror the request's channel when responding. */
|
||||||
|
kind: number;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
|
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||||
import { nip19, utils as nostrUtils } from 'nostr-tools';
|
import { nip19, utils as nostrUtils } from 'nostr-tools';
|
||||||
import { Backend } from './backend/index.js';
|
import { Backend } from './backend/index.js';
|
||||||
|
import { applyToken } from './backend/token-store.js';
|
||||||
|
import { RelayPool } from './lib/relay-pool.js';
|
||||||
|
import type { Nip46PermitCallback, Nip46PermitCallbackParams } from './nip46/types.js';
|
||||||
import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js';
|
import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js';
|
||||||
import AdminInterface from './admin/index.js';
|
import AdminInterface from './admin/index.js';
|
||||||
import { IConfig } from '../config/index.js';
|
import { IConfig } from '../config/index.js';
|
||||||
import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
|
import type { AdminRpcRequest } from './admin/types.js';
|
||||||
import prisma from '../db.js';
|
import prisma from '../db.js';
|
||||||
import { DaemonConfig } from './index.js';
|
import { DaemonConfig } from './index.js';
|
||||||
import { decryptNsec } from '../config/keys.js';
|
import { decryptNsec } from '../config/keys.js';
|
||||||
|
|
@ -15,7 +18,6 @@ import FastifyView from '@fastify/view';
|
||||||
import Handlebars from "handlebars";
|
import Handlebars from "handlebars";
|
||||||
import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js";
|
import {authorizeRequestWebHandler, processRequestWebHandler} from "./web/authorize.js";
|
||||||
import {processRegistrationWebHandler} from "./web/authorize.js";
|
import {processRegistrationWebHandler} from "./web/authorize.js";
|
||||||
import { attachIndefiniteReconnect } from "./lib/relay-reconnect.js";
|
|
||||||
|
|
||||||
export type Key = {
|
export type Key = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -57,7 +59,7 @@ function getKeys(config: DaemonConfig) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeyUsers(config: IConfig) {
|
function getKeyUsers(config: IConfig) {
|
||||||
return async (req: NDKRpcRequest): Promise<KeyUser[]> => {
|
return async (req: AdminRpcRequest): Promise<KeyUser[]> => {
|
||||||
const keyUsers: KeyUser[] = [];
|
const keyUsers: KeyUser[] = [];
|
||||||
const keyName = req.params[0];
|
const keyName = req.params[0];
|
||||||
|
|
||||||
|
|
@ -151,7 +153,7 @@ class Daemon {
|
||||||
private config: DaemonConfig;
|
private config: DaemonConfig;
|
||||||
private activeKeys: Record<string, any>;
|
private activeKeys: Record<string, any>;
|
||||||
private adminInterface: AdminInterface;
|
private adminInterface: AdminInterface;
|
||||||
private ndk: NDK;
|
private pool: RelayPool;
|
||||||
public fastify: FastifyInstance;
|
public fastify: FastifyInstance;
|
||||||
|
|
||||||
constructor(config: DaemonConfig) {
|
constructor(config: DaemonConfig) {
|
||||||
|
|
@ -167,21 +169,15 @@ class Daemon {
|
||||||
this.fastify = Fastify({ logger: true });
|
this.fastify = Fastify({ logger: true });
|
||||||
this.fastify.register(FastifyFormBody);
|
this.fastify.register(FastifyFormBody);
|
||||||
|
|
||||||
this.ndk = new NDK({
|
// Backend transport. The RelayPool owns its reconnect loop and
|
||||||
explicitRelayUrls: config.nostr.relays,
|
// re-subscribes every held key's kind:24133 channel on every reconnect,
|
||||||
|
// so the bunker can't go deaf after a relay flap (#41/#42) — the failure
|
||||||
|
// the old NDK transport + attachIndefiniteReconnect (#20) couldn't close.
|
||||||
|
// The heartbeat adds sleep/wake (time-jump) recovery.
|
||||||
|
this.pool = new RelayPool(config.nostr.relays, {
|
||||||
|
log: (...a: any[]) => console.log(...a),
|
||||||
|
heartbeatMs: 30_000,
|
||||||
});
|
});
|
||||||
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
|
|
||||||
this.ndk.pool.on('notice', (r, n) => { console.log(`👀 Notice from ${r.url}`, n); });
|
|
||||||
|
|
||||||
this.ndk.pool.on('relay:disconnect', (r) => {
|
|
||||||
console.log(`🚫 Disconnected from ${r.url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Override NDK's "give up after detecting flapping" behavior so the
|
|
||||||
// bunker's backend NDK keeps trying to reconnect indefinitely.
|
|
||||||
// Without this, an ECONNREFUSED storm at boot (relay not yet up)
|
|
||||||
// permanently strands the bunker. See aiolabs/nsecbunkerd#20.
|
|
||||||
attachIndefiniteReconnect(this.ndk, 'backend');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async startWebAuth() {
|
async startWebAuth() {
|
||||||
|
|
@ -345,7 +341,7 @@ class Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
await this.ndk.connect(5000);
|
this.pool.start();
|
||||||
await this.startWebAuth();
|
await this.startWebAuth();
|
||||||
await this.startKeys();
|
await this.startKeys();
|
||||||
|
|
||||||
|
|
@ -365,7 +361,14 @@ class Daemon {
|
||||||
// passing it here"). The bech32-decode workaround for #8 was
|
// passing it here"). The bech32-decode workaround for #8 was
|
||||||
// tied to NDK 2.8.1's old constructor behavior and is no
|
// tied to NDK 2.8.1's old constructor behavior and is no
|
||||||
// longer needed post-#14 NDK bump.
|
// longer needed post-#14 NDK bump.
|
||||||
const backend = new Backend(this.ndk, this.fastify, nsec, cb, this.config.baseUrl);
|
const backend = new Backend({
|
||||||
|
pool: this.pool,
|
||||||
|
fastify: this.fastify,
|
||||||
|
nsec,
|
||||||
|
permitCallback: cb,
|
||||||
|
applyToken,
|
||||||
|
baseUrl: this.config.baseUrl,
|
||||||
|
});
|
||||||
await backend.start();
|
await backend.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
133
tests/admin-transport.test.ts
Normal file
133
tests/admin-transport.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { generateSecretKey, getPublicKey, finalizeEvent, nip44, type Event } from "nostr-tools";
|
||||||
|
import { MockRelay } from "./helpers/mock-relay";
|
||||||
|
import { RelayPool } from "../src/daemon/lib/relay-pool";
|
||||||
|
import { Nip46Transport } from "../src/daemon/nip46/transport";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin interface adds two things to the transport over the signer backend
|
||||||
|
* (#42): it listens on TWO kinds (24133 client + 24134 admin) and replies on a
|
||||||
|
* chosen kind, and it can *send* requests + match responses (the interactive
|
||||||
|
* approval flow, `rpc.sendRequest`). This exercises both directions over the
|
||||||
|
* pool and across a relay flap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOSTR_CONNECT = 24133;
|
||||||
|
const ADMIN_RESPONSE = 24134;
|
||||||
|
|
||||||
|
async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!pred()) {
|
||||||
|
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||||
|
await new Promise((r) => setTimeout(r, stepMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw NIP-46 client: send a request on `kind`, read the response on either
|
||||||
|
* channel addressed back to us. nip44 envelope. */
|
||||||
|
class RawClient {
|
||||||
|
readonly sk = generateSecretKey();
|
||||||
|
readonly pubkey = getPublicKey(this.sk);
|
||||||
|
private pending = new Map<string, (r: { result: string; error?: string }) => void>();
|
||||||
|
private n = 0;
|
||||||
|
|
||||||
|
constructor(private readonly pool: RelayPool, private readonly peer: string) {
|
||||||
|
this.pool.subscribe([{ kinds: [NOSTR_CONNECT, ADMIN_RESPONSE], "#p": [this.pubkey] }], (e: Event) => {
|
||||||
|
const m = JSON.parse(nip44.decrypt(e.content, nip44.getConversationKey(this.sk, e.pubkey)));
|
||||||
|
if (m.method) return; // we only consume responses here
|
||||||
|
this.pending.get(m.id)?.({ result: m.result, error: m.error });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request(method: string, params: string[] = [], kind = NOSTR_CONNECT, timeoutMs = 5000) {
|
||||||
|
const id = `c${++this.n}`;
|
||||||
|
const ck = nip44.getConversationKey(this.sk, this.peer);
|
||||||
|
const ev = finalizeEvent(
|
||||||
|
{ kind, created_at: Math.floor(Date.now() / 1000), tags: [["p", this.peer]], content: nip44.encrypt(JSON.stringify({ id, method, params }), ck) },
|
||||||
|
this.sk,
|
||||||
|
);
|
||||||
|
return new Promise<{ result: string; error?: string }>((res, rej) => {
|
||||||
|
const t = setTimeout(() => rej(new Error(`${method} timed out`)), timeoutMs);
|
||||||
|
this.pending.set(id, (r) => { clearTimeout(t); res(r); });
|
||||||
|
void this.pool.publish(ev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("admin transport: request on 24133 -> reply on 24134, survives a flap (#42)", async () => {
|
||||||
|
const relay = new MockRelay();
|
||||||
|
await relay.start();
|
||||||
|
|
||||||
|
const adminSk = generateSecretKey();
|
||||||
|
const adminPubkey = getPublicKey(adminSk);
|
||||||
|
|
||||||
|
const adminPool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
adminPool.start();
|
||||||
|
const admin = new Nip46Transport(adminSk, adminPool);
|
||||||
|
// Echo handler: reply "ok" on the admin channel (24134), like the ping cmd.
|
||||||
|
await admin.start((req) => {
|
||||||
|
void admin.sendResponse(req.id, req.remotePubkey, "ok", req.encryption, undefined, ADMIN_RESPONSE);
|
||||||
|
}, [NOSTR_CONNECT, ADMIN_RESPONSE]);
|
||||||
|
|
||||||
|
const clientPool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
clientPool.start();
|
||||||
|
await waitFor(() => clientPool.connectedCount() === 1 && adminPool.healthy());
|
||||||
|
const client = new RawClient(clientPool, adminPubkey);
|
||||||
|
|
||||||
|
assert.equal((await client.request("ping")).result, "ok");
|
||||||
|
|
||||||
|
await relay.flap();
|
||||||
|
await waitFor(() => adminPool.healthy() && clientPool.healthy());
|
||||||
|
assert.equal((await client.request("ping")).result, "ok", "admin still serving after flap");
|
||||||
|
|
||||||
|
admin.stop();
|
||||||
|
adminPool.stop();
|
||||||
|
clientPool.stop();
|
||||||
|
await relay.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin transport: sendRequest + response routing (the approval flow) (#42)", async () => {
|
||||||
|
const relay = new MockRelay();
|
||||||
|
await relay.start();
|
||||||
|
|
||||||
|
// "admin" (bunker) and "operator" (the human's client) — each a transport.
|
||||||
|
const adminSk = generateSecretKey();
|
||||||
|
const operatorSk = generateSecretKey();
|
||||||
|
const operatorPubkey = getPublicKey(operatorSk);
|
||||||
|
|
||||||
|
const adminPool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
adminPool.start();
|
||||||
|
const admin = new Nip46Transport(adminSk, adminPool);
|
||||||
|
await admin.start(() => {}, [NOSTR_CONNECT, ADMIN_RESPONSE]);
|
||||||
|
|
||||||
|
const opPool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
opPool.start();
|
||||||
|
const operator = new Nip46Transport(operatorSk, opPool);
|
||||||
|
// The operator auto-approves any "acl" request it receives.
|
||||||
|
await operator.start((req) => {
|
||||||
|
if (req.method === "acl") {
|
||||||
|
void operator.sendResponse(req.id, req.remotePubkey, JSON.stringify(["always"]), req.encryption, undefined, ADMIN_RESPONSE);
|
||||||
|
}
|
||||||
|
}, [NOSTR_CONNECT, ADMIN_RESPONSE]);
|
||||||
|
|
||||||
|
await waitFor(() => adminPool.healthy() && opPool.healthy());
|
||||||
|
|
||||||
|
// Admin asks the operator to approve; expects the routed response.
|
||||||
|
const approved = await new Promise<string>((resolve, reject) => {
|
||||||
|
const t = setTimeout(() => reject(new Error("approval timed out")), 5000);
|
||||||
|
const id = admin.sendRequest(operatorPubkey, "acl", ["{}"], "nip44", ADMIN_RESPONSE, (res) => {
|
||||||
|
admin.clearPending(id);
|
||||||
|
clearTimeout(t);
|
||||||
|
resolve(res.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(JSON.parse(approved)[0], "always", "operator's approval routed back to the admin");
|
||||||
|
|
||||||
|
admin.stop();
|
||||||
|
operator.stop();
|
||||||
|
adminPool.stop();
|
||||||
|
opPool.stop();
|
||||||
|
await relay.stop();
|
||||||
|
});
|
||||||
137
tests/helpers/mock-relay.ts
Normal file
137
tests/helpers/mock-relay.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import type { AddressInfo } from "net";
|
||||||
|
|
||||||
|
// `ws` has no @types in this tree; require it (any) — same as src/relay-pool.ts.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const { WebSocketServer, WebSocket } = require("ws");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-process nostr relay for transport tests (#42). Speaks just enough
|
||||||
|
* of the protocol — REQ / CLOSE / EVENT → EVENT / EOSE / OK — to exercise the
|
||||||
|
* RelayPool's subscribe + resubscribe-on-reconnect behaviour. It can be
|
||||||
|
* `flap()`ped: drop every client + close the server, then re-listen on the SAME
|
||||||
|
* port, simulating the relay restart that took the bunker deaf (#41).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Filter = Record<string, any>;
|
||||||
|
interface Sub {
|
||||||
|
subId: string;
|
||||||
|
filters: Filter[];
|
||||||
|
socket: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(filter: Filter, event: any): boolean {
|
||||||
|
if (filter.ids && !filter.ids.includes(event.id)) return false;
|
||||||
|
if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
|
||||||
|
if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
|
||||||
|
for (const key of Object.keys(filter)) {
|
||||||
|
if (key.startsWith("#")) {
|
||||||
|
const tag = key.slice(1);
|
||||||
|
const want: string[] = filter[key];
|
||||||
|
const have = (event.tags ?? [])
|
||||||
|
.filter((t: string[]) => t[0] === tag)
|
||||||
|
.map((t: string[]) => t[1]);
|
||||||
|
if (!want.some((v) => have.includes(v))) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockRelay {
|
||||||
|
private wss: any = null;
|
||||||
|
private subs: Sub[] = [];
|
||||||
|
private sockets: Set<any> = new Set();
|
||||||
|
public port = 0;
|
||||||
|
/** Total REQs seen across the relay's life — proves a (re)subscribe landed. */
|
||||||
|
public reqCount = 0;
|
||||||
|
|
||||||
|
get url(): string {
|
||||||
|
return `ws://127.0.0.1:${this.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(port = 0): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.wss = new WebSocketServer({ port }, () => {
|
||||||
|
this.port = (this.wss!.address() as AddressInfo).port;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
this.wss.on("connection", (socket: any) => {
|
||||||
|
this.sockets.add(socket);
|
||||||
|
socket.on("message", (data: any) => this.onMessage(socket, data.toString()));
|
||||||
|
socket.on("close", () => {
|
||||||
|
this.sockets.delete(socket);
|
||||||
|
this.subs = this.subs.filter((s) => s.socket !== socket);
|
||||||
|
});
|
||||||
|
socket.on("error", () => {
|
||||||
|
/* ignore — flapping closes sockets abruptly */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(socket: any, raw: string): void {
|
||||||
|
let msg: any[];
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [type, ...rest] = msg;
|
||||||
|
if (type === "REQ") {
|
||||||
|
const [subId, ...filters] = rest;
|
||||||
|
this.reqCount++;
|
||||||
|
this.subs.push({ subId, filters, socket });
|
||||||
|
// No stored events in this mock; just acknowledge the REQ is live.
|
||||||
|
socket.send(JSON.stringify(["EOSE", subId]));
|
||||||
|
} else if (type === "CLOSE") {
|
||||||
|
const [subId] = rest;
|
||||||
|
this.subs = this.subs.filter((s) => !(s.socket === socket && s.subId === subId));
|
||||||
|
} else if (type === "EVENT") {
|
||||||
|
const [event] = rest;
|
||||||
|
socket.send(JSON.stringify(["OK", event.id, true, ""]));
|
||||||
|
this.deliver(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a server-originated event to every matching live subscription. */
|
||||||
|
inject(event: any): void {
|
||||||
|
this.deliver(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private deliver(event: any): void {
|
||||||
|
for (const sub of this.subs) {
|
||||||
|
if (sub.socket.readyState !== WebSocket.OPEN) continue;
|
||||||
|
if (sub.filters.some((f) => matches(f, event))) {
|
||||||
|
sub.socket.send(JSON.stringify(["EVENT", sub.subId, event]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop all clients + close the server, keeping the port for a restart. */
|
||||||
|
private async down(): Promise<void> {
|
||||||
|
for (const s of this.sockets) {
|
||||||
|
try {
|
||||||
|
s.terminate();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sockets.clear();
|
||||||
|
this.subs = [];
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!this.wss) return resolve();
|
||||||
|
this.wss.close(() => resolve());
|
||||||
|
this.wss = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate a relay restart: go down, then come back on the SAME port. */
|
||||||
|
async flap(): Promise<void> {
|
||||||
|
const port = this.port;
|
||||||
|
await this.down();
|
||||||
|
await this.start(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
await this.down();
|
||||||
|
}
|
||||||
|
}
|
||||||
138
tests/nip46-backend.test.ts
Normal file
138
tests/nip46-backend.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
finalizeEvent,
|
||||||
|
verifyEvent,
|
||||||
|
generateSecretKey,
|
||||||
|
getPublicKey,
|
||||||
|
nip44,
|
||||||
|
type Event,
|
||||||
|
} from "nostr-tools";
|
||||||
|
import { MockRelay } from "./helpers/mock-relay";
|
||||||
|
import { RelayPool } from "../src/daemon/lib/relay-pool";
|
||||||
|
import { Backend } from "../src/daemon/backend/index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end NIP-46 round-trip against the ported Backend (#42): a real
|
||||||
|
* nostr-tools client sends connect / ping / get_public_key / sign_event /
|
||||||
|
* nip44_encrypt through a mock relay; we assert the responses, then FLAP the
|
||||||
|
* relay and assert the backend still answers — proving the whole signing
|
||||||
|
* protocol survives a reconnect, which it never did on the NDK transport (#41).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NIP46_KIND = 24133;
|
||||||
|
|
||||||
|
/** Minimal NIP-46 client: publishes requests to the bunker pubkey, decrypts the
|
||||||
|
* responses addressed back to it. nip44 envelope (the modern default). */
|
||||||
|
class TestClient {
|
||||||
|
readonly sk = generateSecretKey();
|
||||||
|
readonly pubkey = getPublicKey(this.sk);
|
||||||
|
private pending = new Map<string, (r: { result: string; error?: string }) => void>();
|
||||||
|
private n = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly pool: RelayPool,
|
||||||
|
private readonly bunkerPubkey: string,
|
||||||
|
) {
|
||||||
|
this.pool.subscribe([{ kinds: [NIP46_KIND], "#p": [this.pubkey] }], (e: Event) => {
|
||||||
|
const ck = nip44.getConversationKey(this.sk, e.pubkey);
|
||||||
|
const msg = JSON.parse(nip44.decrypt(e.content, ck));
|
||||||
|
this.pending.get(msg.id)?.({ result: msg.result, error: msg.error });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
request(method: string, params: string[] = [], timeoutMs = 5000): Promise<{ result: string; error?: string }> {
|
||||||
|
const id = `req-${++this.n}`;
|
||||||
|
const ck = nip44.getConversationKey(this.sk, this.bunkerPubkey);
|
||||||
|
const content = nip44.encrypt(JSON.stringify({ id, method, params }), ck);
|
||||||
|
const event = finalizeEvent(
|
||||||
|
{ kind: NIP46_KIND, created_at: Math.floor(Date.now() / 1000), tags: [["p", this.bunkerPubkey]], content },
|
||||||
|
this.sk,
|
||||||
|
);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`${method} timed out`)), timeoutMs);
|
||||||
|
this.pending.set(id, (r) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(r);
|
||||||
|
});
|
||||||
|
void this.pool.publish(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!pred()) {
|
||||||
|
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||||
|
await new Promise((r) => setTimeout(r, stepMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Backend serves NIP-46 over the pool and survives a relay flap (#42)", async () => {
|
||||||
|
const relay = new MockRelay();
|
||||||
|
await relay.start();
|
||||||
|
|
||||||
|
// The held key the bunker signs as.
|
||||||
|
const bunkerSk = generateSecretKey();
|
||||||
|
const bunkerPubkey = getPublicKey(bunkerSk);
|
||||||
|
const bunkerNsec = Buffer.from(bunkerSk).toString("hex");
|
||||||
|
|
||||||
|
let allow = true;
|
||||||
|
const seen: string[] = [];
|
||||||
|
const bunkerPool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
bunkerPool.start();
|
||||||
|
const backend = new Backend({
|
||||||
|
pool: bunkerPool,
|
||||||
|
nsec: bunkerNsec,
|
||||||
|
permitCallback: async (p) => {
|
||||||
|
seen.push(p.method);
|
||||||
|
return allow;
|
||||||
|
},
|
||||||
|
applyToken: async () => {
|
||||||
|
/* no DB in this test */
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await backend.start();
|
||||||
|
|
||||||
|
const clientPool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
clientPool.start();
|
||||||
|
await waitFor(() => clientPool.connectedCount() === 1 && bunkerPool.healthy());
|
||||||
|
const client = new TestClient(clientPool, bunkerPubkey);
|
||||||
|
|
||||||
|
// connect -> ack
|
||||||
|
assert.equal((await client.request("connect", ["", ""])).result, "ack");
|
||||||
|
// ping -> pong
|
||||||
|
assert.equal((await client.request("ping")).result, "pong");
|
||||||
|
// get_public_key -> the bunker pubkey
|
||||||
|
assert.equal((await client.request("get_public_key")).result, bunkerPubkey);
|
||||||
|
|
||||||
|
// sign_event -> a valid event signed AS the bunker key
|
||||||
|
const tmpl = { kind: 1, created_at: Math.floor(Date.now() / 1000), tags: [], content: "hi from atm" };
|
||||||
|
const signRes = await client.request("sign_event", [JSON.stringify(tmpl)]);
|
||||||
|
const signed = JSON.parse(signRes.result) as Event;
|
||||||
|
assert.equal(signed.pubkey, bunkerPubkey, "signed as the held key");
|
||||||
|
assert.equal(signed.kind, 1);
|
||||||
|
assert.ok(verifyEvent(signed), "signature valid");
|
||||||
|
|
||||||
|
// nip44_encrypt(clientPubkey, payload) -> ciphertext from bunker to client
|
||||||
|
const enc = await client.request("nip44_encrypt", [client.pubkey, "secret-payload"]);
|
||||||
|
const ck = nip44.getConversationKey(client.sk, bunkerPubkey);
|
||||||
|
assert.equal(nip44.decrypt(enc.result, ck), "secret-payload", "nip44_encrypt round-trips");
|
||||||
|
|
||||||
|
// FLAP the relay, then assert the backend still answers.
|
||||||
|
await relay.flap();
|
||||||
|
await waitFor(() => bunkerPool.healthy() && clientPool.healthy());
|
||||||
|
assert.equal((await client.request("ping")).result, "pong", "still serving after flap");
|
||||||
|
|
||||||
|
// Rejection path: permit returns false -> "Not authorized".
|
||||||
|
allow = false;
|
||||||
|
const denied = await client.request("ping");
|
||||||
|
assert.equal(denied.result, "error");
|
||||||
|
assert.equal(denied.error, "Not authorized");
|
||||||
|
|
||||||
|
assert.ok(seen.includes("sign_event") && seen.includes("connect"), "permit callback was consulted");
|
||||||
|
|
||||||
|
bunkerPool.stop();
|
||||||
|
clientPool.stop();
|
||||||
|
await relay.stop();
|
||||||
|
});
|
||||||
104
tests/relay-pool.test.ts
Normal file
104
tests/relay-pool.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
|
||||||
|
import { MockRelay } from "./helpers/mock-relay";
|
||||||
|
import { RelayPool } from "../src/daemon/lib/relay-pool";
|
||||||
|
|
||||||
|
// A throwaway client key. Events must be REAL (valid id + sig): nostr-tools
|
||||||
|
// verifies inbound events and silently drops invalid ones, so the test has to
|
||||||
|
// sign them exactly as a real bunker client would.
|
||||||
|
const CLIENT_SK = generateSecretKey();
|
||||||
|
// The spire pubkey the bunker subscribes for (`#p`), a fixture here.
|
||||||
|
const PUBKEY = "1508b42094e65dff982ac8ca5a264089f7de2d4bbda81bf32a91678f337ced3b";
|
||||||
|
|
||||||
|
function makeEvent(): { id: string; [k: string]: any } {
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
kind: 24133,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [["p", PUBKEY]],
|
||||||
|
content: "encrypted-blob",
|
||||||
|
},
|
||||||
|
CLIENT_SK,
|
||||||
|
) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
void getPublicKey; // (kept available for future signed-response assertions)
|
||||||
|
|
||||||
|
async function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 25): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!pred()) {
|
||||||
|
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||||
|
await new Promise((r) => setTimeout(r, stepMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regression that was missing every prior round (#4/#7/#20/#21): flap the
|
||||||
|
* relay mid-session and assert a subsequent inbound kind:24133 is still
|
||||||
|
* delivered. With the old NDK transport the socket reconnected but the REQ was
|
||||||
|
* never re-sent, so the bunker went silently deaf (#41). The RelayPool owns the
|
||||||
|
* connect loop and re-subscribes on every (re)connect, so this must pass.
|
||||||
|
*/
|
||||||
|
test("RelayPool re-subscribes after a relay flap — inbound events still delivered (#41/#42)", async () => {
|
||||||
|
const relay = new MockRelay();
|
||||||
|
await relay.start();
|
||||||
|
|
||||||
|
const received: string[] = [];
|
||||||
|
const pool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
pool.start();
|
||||||
|
|
||||||
|
// Subscribe for the bunker's kind:24133 channel; await the REQ landing.
|
||||||
|
await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], (e) =>
|
||||||
|
received.push(e.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Baseline: a matching event before any flap is delivered.
|
||||||
|
const before = makeEvent();
|
||||||
|
relay.inject(before);
|
||||||
|
await waitFor(() => received.includes(before.id));
|
||||||
|
|
||||||
|
// FLAP: relay down + back up on the same port (the relay-restart that took
|
||||||
|
// the demo bunker deaf). The pool must reconnect AND re-subscribe.
|
||||||
|
const reqsBefore = relay.reqCount;
|
||||||
|
await relay.flap();
|
||||||
|
|
||||||
|
// Wait until reconnected AND the subscription is re-established on the wire.
|
||||||
|
await waitFor(() => pool.healthy() && relay.reqCount > reqsBefore);
|
||||||
|
|
||||||
|
// THE assertion: a matching event AFTER the flap is still delivered.
|
||||||
|
const after = makeEvent();
|
||||||
|
relay.inject(after);
|
||||||
|
await waitFor(() => received.includes(after.id));
|
||||||
|
|
||||||
|
assert.ok(received.includes(before.id), "event before flap delivered");
|
||||||
|
assert.ok(received.includes(after.id), "event after flap delivered (resubscribed)");
|
||||||
|
assert.ok(pool.healthy(), "pool healthy (connected + subscribed) after flap");
|
||||||
|
|
||||||
|
pool.stop();
|
||||||
|
await relay.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* healthy() must distinguish a connected-but-deaf relay (the #41 state the old
|
||||||
|
* connectedRelays()-only watchdog could not see) from a genuinely serving one.
|
||||||
|
*/
|
||||||
|
test("RelayPool.healthy() is false until the registry is subscribed on the wire (#42)", async () => {
|
||||||
|
const relay = new MockRelay();
|
||||||
|
await relay.start();
|
||||||
|
|
||||||
|
const pool = new RelayPool([relay.url], { log: () => {} });
|
||||||
|
pool.start();
|
||||||
|
|
||||||
|
// No subscriptions registered yet — but once connected with an empty
|
||||||
|
// registry, healthy() is trivially true (nothing to be deaf about).
|
||||||
|
await waitFor(() => pool.connectedCount() === 1);
|
||||||
|
assert.equal(pool.healthy(), true, "connected, empty registry -> healthy");
|
||||||
|
|
||||||
|
// Register a sub; healthy stays true once it lands.
|
||||||
|
await pool.subscribeAwaitingEose([{ kinds: [24133], "#p": [PUBKEY] }], () => {});
|
||||||
|
assert.equal(pool.healthy(), true, "connected + subscribed -> healthy");
|
||||||
|
|
||||||
|
pool.stop();
|
||||||
|
await relay.stop();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue