Merge 9fc020126953a7598ee331c1445ba88f81774339 into f4fd7403ccf1b479c9d717210a8e4768081d75a7

This commit is contained in:
Eric T 2025-12-05 02:47:08 +00:00 committed by GitHub
commit d91bfc392c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 11743 additions and 3829 deletions

2
.gitignore vendored
View File

@ -9,3 +9,5 @@ config
.env
.turbo
prisma
/.claude/
/.idea/

View File

@ -1,34 +1,62 @@
# Build stage - needs the ndk/core directory for local file dependency
FROM node:20.11-bullseye AS build
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
# Copy pre-built ndk/core for the local file dependency
# ndk/core must already be built locally (has dist/ directory)
# Also copy ndk/node_modules which contains ndk/core's hoisted dependencies (tseep, etc.)
COPY ndk/core /ndk/core
COPY ndk/node_modules /ndk/node_modules
# Remove the prepare script from ndk/core to prevent it from trying to rebuild
RUN sed -i 's/"prepare": "bun run build",//' /ndk/core/package.json
# Copy package files
COPY nsecbunkerd/package*.json ./
# Install dependencies
RUN npm install
# Copy application files
COPY . .
COPY nsecbunkerd/ .
# Generate prisma client and build the application
RUN npx prisma generate
RUN npm run build
# Prune dev dependencies to reduce image size
RUN npm prune --production
# Runtime stage
FROM node:20.11-alpine as runtime
FROM node:20.11-alpine AS runtime
WORKDIR /app
RUN apk update && \
apk add --no-cache openssl && \
apk add --no-cache openssl curl && \
rm -rf /var/cache/apk/*
# Copy built files from the build stage
COPY --from=build /app .
# Copy the ndk/core dependency and its hoisted node_modules
COPY --from=build /ndk/core /ndk/core
COPY --from=build /ndk/node_modules /ndk/node_modules
# Install only runtime dependencies
RUN npm install --only=production
# Copy built application with production node_modules from build stage
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/prisma ./prisma
# Copy scripts directory if it exists
COPY --from=build /app/scripts ./scripts
# Create config directory for runtime configuration
RUN mkdir -p /app/config
EXPOSE 3000
# No HTTP healthcheck - nsecbunkerd doesn't expose a health endpoint
# Docker Swarm will rely on process status
ENTRYPOINT [ "node", "./dist/index.js" ]
CMD ["start"]

44
Dockerfile.local Normal file
View File

@ -0,0 +1,44 @@
FROM node:20.11-bullseye AS build
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy package files
COPY package*.json pnpm-lock.yaml ./
# Replace workspace:* with actual npm version for @nostr-dev-kit/ndk
RUN sed -i 's/"@nostr-dev-kit\/ndk": "workspace:\*"/"@nostr-dev-kit\/ndk": "^2.10.0"/' package.json
# Install dependencies
RUN pnpm install --no-frozen-lockfile
# Copy application files
COPY . .
# Generate prisma client and build the application
RUN npx prisma generate
RUN pnpm run build
# Runtime stage
FROM node:20.11-alpine as runtime
WORKDIR /app
RUN apk update && \
apk add --no-cache openssl && \
rm -rf /var/cache/apk/*
# Copy built files from the build stage
COPY --from=build /app .
# Install only runtime dependencies
RUN npm install -g pnpm && \
sed -i 's/"@nostr-dev-kit\/ndk": "workspace:\*"/"@nostr-dev-kit\/ndk": "^2.10.0"/' package.json && \
pnpm install --prod --no-frozen-lockfile
EXPOSE 3000
ENTRYPOINT [ "node", "./dist/index.js" ]
CMD ["start"]

120
README.md
View File

@ -177,6 +177,126 @@ When a bunker provides a wallet and zapping service (`wallet` and `nostdressUrl`
}
```
# Admin API
nsecbunkerd exposes an admin API over Nostr (NIP-46) that allows authorized administrators to manage keys, policies, permissions, and tokens remotely.
## ⚠️ Breaking Change: NIP-46 Compliance (v0.11.0)
**Previous versions** of nsecbunkerd used event kind `24134` for admin API responses. This was a custom extension that was never part of the official NIP-46 specification.
**Starting with v0.11.0**, all admin communication now uses kind `24133` (`NDKKind.NostrConnect`) to be fully compliant with the [NIP-46 specification](https://github.com/nostr-protocol/nips/blob/master/46.md).
### Why this change?
1. **NIP-46 Compliance**: The NIP-46 spec only defines kind `24133` for all Nostr Connect communication. Kind `24134` was never standardized and doesn't exist in the spec.
2. **Interoperability**: Using the standard kind ensures compatibility with other NIP-46 implementations and clients.
3. **Simplification**: Having a single event kind for all NIP-46 communication (both standard signing requests and admin methods) simplifies the protocol.
### Migration Guide
If you have custom clients that communicate with the nsecbunkerd admin API:
- **Update your client** to listen for responses on kind `24133` instead of `24134`
- **No changes needed** for standard NIP-46 signing operations (these already used kind `24133`)
- **Admin clients** (like [app.nsecbunker.com](https://app.nsecbunker.com)) will need to be updated to match this change
## Authentication
Only npubs listed in the `ADMIN_NPUBS` environment variable can access admin methods. All requests and responses use kind `24133` (NIP-46 NostrConnect).
## Available Methods
### Key Management
| Method | Parameters | Description | Since |
|--------|------------|-------------|-------|
| `get_keys` | none | List all keys with their status (locked/unlocked) | |
| `get_key` | `keyName` | Get details about a specific key | v0.11.0 |
| `create_new_key` | `keyName`, `passphrase`, `[nsec]` | Create a new key or import existing nsec | |
| `unlock_key` | `keyName`, `passphrase` | Unlock an encrypted key | |
| `delete_key` | `keyName` | Soft-delete a key and its tokens | v0.11.0 |
| `rotate_key` | `oldKeyName`, `newKeyName`, `passphrase` | Create new key and migrate permissions | v0.11.0 |
### Policy Management
| Method | Parameters | Description | Since |
|--------|------------|-------------|-------|
| `get_policies` | none | List all policies with their rules | |
| `get_policy` | `policyId` | Get details about a specific policy | v0.11.0 |
| `create_new_policy` | `policyJson` | Create a new policy with rules | |
| `delete_policy` | `policyId` | Soft-delete a policy | v0.11.0 |
**Policy JSON Format:**
```json
{
"name": "signing-policy",
"expires_at": "2024-12-31T23:59:59Z",
"rules": [
{ "method": "sign_event", "kind": 1, "use_count": 100 },
{ "method": "sign_event", "kind": 7 }
]
}
```
### Permission Management
| Method | Parameters | Description | Since |
|--------|------------|-------------|-------|
| `get_key_users` | `keyName` | List all users with access to a key | |
| `grant_permission` | `keyName`, `userPubkey`, `policyId`, `[description]` | Grant user access to a key with a policy | v0.11.0 |
| `revoke_permission` | `keyName`, `userPubkey` | Revoke user's access to a key | v0.11.0 |
| `get_permissions` | `keyName`, `userPubkey` | Get user's permissions on a key | v0.11.0 |
| `rename_key_user` | `userPubkey`, `description` | Update a user's description | |
| `revoke_user` | `keyUserId` | Revoke user by ID | |
### Token Management
| Method | Parameters | Description | Since |
|--------|------------|-------------|-------|
| `get_key_tokens` | `keyName` | List all tokens for a key | |
| `get_token` | `tokenId` | Get details about a specific token | v0.11.0 |
| `create_new_token` | `keyName`, `clientName`, `policyId`, `[durationInHours]` | Create an access token | |
| `revoke_token` | `tokenId` | Revoke (soft-delete) a token | v0.11.0 |
| `validate_token` | `tokenString` | Check if a token is valid | v0.11.0 |
### Utility
| Method | Parameters | Description | Since |
|--------|------------|-------------|-------|
| `ping` | none | Health check - returns "pong" | |
| `create_account` | `[username]`, `[domain]`, `[email]` | Create a new user account (OAuth flow) | |
## Response Format
All responses follow NIP-46 format:
```json
{
"id": "<request_id>",
"result": "<json_stringified_result>",
"error": "<optional_error_string>"
}
```
## Development
### Running Tests
Unit tests for all admin commands were added in v0.11.0 using Vitest.
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
```
# Authors
* [pablof7z](nostr:npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)

51
nostr-tools-v2-patch.md Normal file
View File

@ -0,0 +1,51 @@
# Patch to upgrade nsecbunkerd to nostr-tools v2
## Step 1: Update package.json
Change: "nostr-tools": "^1.17.0" → "nostr-tools": "^2.17.0"
## Step 2: Add hex conversion utility
Create file: src/utils/hex.ts
```typescript
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
```
## Step 3: Patch files that decode nsec
### src/daemon/run.ts (line ~41)
```diff
+import { bytesToHex } from '../utils/hex.js';
...
-const hexpk = nip19.decode(nsec).data as string;
+const hexpk = bytesToHex(nip19.decode(nsec).data as Uint8Array);
```
### src/commands/add.ts (line ~37)
```diff
+import { bytesToHex } from '../utils/hex.js';
...
-decoded = nip19.decode(nsec);
+const decoded = nip19.decode(nsec);
+const hexpk = decoded.type === 'nsec' ? bytesToHex(decoded.data as Uint8Array) : decoded.data;
```
### src/daemon/admin/commands/create_new_key.ts (line ~16)
```diff
+import { bytesToHex } from '../../../utils/hex.js';
...
-key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
+key = new NDKPrivateKeySigner(bytesToHex(nip19.decode(_nsec).data as Uint8Array));
```
## Note
npubEncode/decode still uses hex strings in v2, so request-from-admin.ts works unchanged.

8935
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "nsecbunkerd",
"version": "0.10.5",
"version": "0.11.2",
"description": "nsecbunker daemon",
"main": "dist/index.js",
"bin": {
@ -27,7 +27,10 @@
"start": "node ./scripts/start.js",
"lfg": "node ./scripts/start.js start",
"nsecbunkerd": "node dist/index.js",
"client": "node dist/client/client.js"
"client": "node dist/client/client.js",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"keywords": [
"nostr"
@ -39,7 +42,7 @@
"@fastify/view": "^8.2.0",
"@inquirer/password": "^1.1.2",
"@inquirer/prompts": "^1.2.3",
"@nostr-dev-kit/ndk": "workspace:*",
"@nostr-dev-kit/ndk": "file:../ndk/core",
"@prisma/client": "^5.4.1",
"@scure/base": "^1.1.1",
"@types/yargs": "^17.0.24",
@ -57,7 +60,7 @@
"isomorphic-ws": "^5.0.0",
"lnbits": "^1.1.5",
"lnbits-ts": "^0.0.2",
"nostr-tools": "^1.17.0",
"nostr-tools": "^2.17.0",
"websocket-polyfill": "^0.0.3",
"ws": "^8.13.0",
"yargs": "^17.7.2"
@ -68,6 +71,7 @@
"prisma": "^5.4.1",
"ts-node": "^10.9.1",
"tsup": "^7.2.0",
"typescript": "^5.1.3"
"typescript": "^5.1.3",
"vitest": "^1.6.1"
}
}

3772
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -100,7 +100,7 @@ function loadPrivateKey(): string | undefined {
} else {
// check if we have a @ so we try to get the npub from nip05
if (remotePubkey.includes('@')) {
const u = await NDKUser.fromNip05(remotePubkey);
const u = await NDKUser.fromNip05(remotePubkey, ndk);
if (!u) {
console.log(`Invalid nip05 ${remotePubkey}`);
process.exit(1);

View File

@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the saveEncrypted function
vi.mock('../../../../commands/add.js', () => ({
saveEncrypted: vi.fn().mockResolvedValue(undefined),
}));
// Mock the setupSkeletonProfile function
vi.mock('../../../lib/profile.js', () => ({
setupSkeletonProfile: vi.fn(),
}));
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import createNewKey from '../create_new_key';
describe('create_new_key', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(createNewKey(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when passphrase is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
await expect(createNewKey(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when loadNsec is not implemented', async () => {
const admin = createMockAdmin({ loadNsec: undefined });
const req = createMockRequest(['my-key', 'passphrase']);
await expect(createNewKey(admin as any, req)).rejects.toThrow('No unlockKey method');
});
it('should generate a new key when no nsec provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'passphrase']);
await createNewKey(admin as any, req);
// Verify loadNsec was called with the key name and an nsec
expect(admin.loadNsec).toHaveBeenCalledWith(
'my-key',
expect.stringMatching(/^nsec1/)
);
// Verify key was saved to database
expect(mockPrisma.key.upsert).toHaveBeenCalledWith({
where: { keyName: 'my-key' },
update: {
pubkey: expect.any(String),
deletedAt: null,
},
create: {
keyName: 'my-key',
pubkey: expect.any(String),
},
});
// Verify response contains npub
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.npub).toMatch(/^npub1/);
});
it('should import existing key when nsec provided', async () => {
const admin = createMockAdmin();
// Valid nsec for testing (generates a known pubkey)
const testNsec = 'nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw';
const req = createMockRequest(['my-key', 'passphrase', testNsec]);
await createNewKey(admin as any, req);
// Verify loadNsec was called
expect(admin.loadNsec).toHaveBeenCalledWith(
'my-key',
expect.stringMatching(/^nsec1/)
);
// Verify response
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.npub).toMatch(/^npub1/);
});
});

View File

@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import createNewPolicy from '../create_new_policy';
describe('create_new_policy', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when policy param is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(createNewPolicy(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when policy is invalid JSON', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['not valid json']);
await expect(createNewPolicy(admin as any, req)).rejects.toThrow();
});
it('should create policy without rules', async () => {
const admin = createMockAdmin();
const policy = {
name: 'test-policy',
rules: [],
};
const req = createMockRequest([JSON.stringify(policy)]);
mockPrisma.policy.create.mockResolvedValue({
id: 1,
name: 'test-policy',
});
await createNewPolicy(admin as any, req);
expect(mockPrisma.policy.create).toHaveBeenCalledWith({
data: {
name: 'test-policy',
expiresAt: undefined,
},
});
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
it('should create policy with rules', async () => {
const admin = createMockAdmin();
const policy = {
name: 'signing-policy',
expires_at: '2024-12-31T23:59:59Z',
rules: [
{ method: 'sign_event', kind: 1, use_count: 100 },
{ method: 'sign_event', kind: 7 },
],
};
const req = createMockRequest([JSON.stringify(policy)]);
mockPrisma.policy.create.mockResolvedValue({
id: 1,
name: 'signing-policy',
});
mockPrisma.policyRule.create.mockResolvedValue({});
await createNewPolicy(admin as any, req);
expect(mockPrisma.policy.create).toHaveBeenCalledWith({
data: {
name: 'signing-policy',
expiresAt: '2024-12-31T23:59:59Z',
},
});
expect(mockPrisma.policyRule.create).toHaveBeenCalledTimes(2);
expect(mockPrisma.policyRule.create).toHaveBeenCalledWith({
data: {
Policy: { connect: { id: 1 } },
kind: '1',
method: 'sign_event',
maxUsageCount: 100,
currentUsageCount: 0,
},
});
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
it('should use default method sign_event when not specified', async () => {
const admin = createMockAdmin();
const policy = {
name: 'default-method-policy',
rules: [
{ kind: 1 }, // No method specified
],
};
const req = createMockRequest([JSON.stringify(policy)]);
mockPrisma.policy.create.mockResolvedValue({ id: 1 });
mockPrisma.policyRule.create.mockResolvedValue({});
await createNewPolicy(admin as any, req);
expect(mockPrisma.policyRule.create).toHaveBeenCalledWith({
data: {
Policy: { connect: { id: 1 } },
kind: '1',
method: 'sign_event',
maxUsageCount: undefined,
currentUsageCount: 0,
},
});
});
});

View File

@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import createNewToken from '../create_new_token';
describe('create_new_token', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when clientName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
await expect(createNewToken(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when policyId is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'Test App']);
await expect(createNewToken(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when policy is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'Test App', '999']);
mockPrisma.policy.findUnique.mockResolvedValue(null);
await expect(createNewToken(admin as any, req)).rejects.toThrow('Policy not found');
});
it('should create token without expiration', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'Test App', '1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
name: 'test-policy',
rules: [],
});
mockPrisma.token.create.mockResolvedValue({
id: 1,
token: 'generated-token',
});
await createNewToken(admin as any, req);
expect(mockPrisma.token.create).toHaveBeenCalledWith({
data: {
keyName: 'my-key',
clientName: 'Test App',
policyId: 1,
createdBy: 'test-pubkey-hex',
token: expect.any(String),
},
});
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
it('should create token with expiration', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'Test App', '1', '24']); // 24 hours
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
name: 'test-policy',
rules: [],
});
mockPrisma.token.create.mockResolvedValue({
id: 1,
token: 'generated-token',
});
await createNewToken(admin as any, req);
expect(mockPrisma.token.create).toHaveBeenCalledWith({
data: {
keyName: 'my-key',
clientName: 'Test App',
policyId: 1,
createdBy: 'test-pubkey-hex',
token: expect.any(String),
expiresAt: expect.any(Date),
},
});
// Verify expiration is approximately 24 hours from now
const createCall = mockPrisma.token.create.mock.calls[0][0];
const expiresAt = createCall.data.expiresAt as Date;
const expectedExpiry = Date.now() + (24 * 60 * 60 * 1000);
expect(expiresAt.getTime()).toBeCloseTo(expectedExpiry, -4); // Within 10 seconds
});
it('should generate 64-character hex token', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'Test App', '1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
name: 'test-policy',
rules: [],
});
mockPrisma.token.create.mockResolvedValue({
id: 1,
token: 'generated-token',
});
await createNewToken(admin as any, req);
const createCall = mockPrisma.token.create.mock.calls[0][0];
const token = createCall.data.token as string;
expect(token).toHaveLength(64);
expect(token).toMatch(/^[0-9a-f]+$/);
});
});

View File

@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import deleteKey from '../delete_key';
describe('delete_key', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(deleteKey(admin as any, req)).rejects.toThrow('Invalid params: keyName required');
});
it('should throw error when key is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
mockPrisma.key.findUnique.mockResolvedValue(null);
await expect(deleteKey(admin as any, req)).rejects.toThrow("Key 'my-key' not found");
});
it('should throw error when key is already deleted', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
mockPrisma.key.findUnique.mockResolvedValue({
keyName: 'my-key',
deletedAt: new Date('2024-01-01'),
});
await expect(deleteKey(admin as any, req)).rejects.toThrow("Key 'my-key' is already deleted");
});
it('should soft-delete key and its tokens', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
mockPrisma.key.findUnique.mockResolvedValue({
keyName: 'my-key',
deletedAt: null,
});
mockPrisma.key.update.mockResolvedValue({});
mockPrisma.token.updateMany.mockResolvedValue({ count: 2 });
await deleteKey(admin as any, req);
// Verify key was soft-deleted
expect(mockPrisma.key.update).toHaveBeenCalledWith({
where: { keyName: 'my-key' },
data: { deletedAt: expect.any(Date) },
});
// Verify tokens were soft-deleted
expect(mockPrisma.token.updateMany).toHaveBeenCalledWith({
where: { keyName: 'my-key', deletedAt: null },
data: { deletedAt: expect.any(Date) },
});
// Verify response
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import deletePolicy from '../delete_policy';
describe('delete_policy', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when policyId is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(deletePolicy(admin as any, req)).rejects.toThrow('Invalid params: policyId required');
});
it('should throw error when policyId is not a number', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['invalid']);
await expect(deletePolicy(admin as any, req)).rejects.toThrow('Invalid params: policyId must be a number');
});
it('should throw error when policy is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.policy.findUnique.mockResolvedValue(null);
await expect(deletePolicy(admin as any, req)).rejects.toThrow("Policy with id '1' not found");
});
it('should throw error when policy is already deleted', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
name: 'test-policy',
deletedAt: new Date('2024-01-01'),
});
await expect(deletePolicy(admin as any, req)).rejects.toThrow("Policy with id '1' is already deleted");
});
it('should soft-delete policy successfully', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
name: 'test-policy',
deletedAt: null,
});
mockPrisma.policy.update.mockResolvedValue({});
await deletePolicy(admin as any, req);
// Verify policy was soft-deleted
expect(mockPrisma.policy.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { deletedAt: expect.any(Date) },
});
// Verify response
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
});

View File

@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import getKey from '../get_key';
describe('get_key', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(getKey(admin as any, req)).rejects.toThrow('Invalid params: keyName required');
});
it('should throw error when getKeys is not implemented', async () => {
const admin = createMockAdmin({ getKeys: undefined });
const req = createMockRequest(['my-key']);
await expect(getKey(admin as any, req)).rejects.toThrow('getKeys() not implemented');
});
it('should throw error when key is not found', async () => {
const admin = createMockAdmin({
getKeys: vi.fn().mockResolvedValue([
{ name: 'other-key', npub: 'npub1abc' },
]),
});
const req = createMockRequest(['my-key']);
await expect(getKey(admin as any, req)).rejects.toThrow("Key 'my-key' not found");
});
it('should return key details for an unlocked key', async () => {
const admin = createMockAdmin({
getKeys: vi.fn().mockResolvedValue([
{ name: 'my-key', npub: 'npub1xyz123' },
]),
});
const req = createMockRequest(['my-key']);
mockPrisma.key.findUnique.mockResolvedValue({
keyName: 'my-key',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
});
await getKey(admin as any, req);
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.name).toBe('my-key');
expect(result.npub).toBe('npub1xyz123');
expect(result.locked).toBe(false);
});
it('should return locked status for a locked key', async () => {
const admin = createMockAdmin({
getKeys: vi.fn().mockResolvedValue([
{ name: 'locked-key', npub: undefined }, // No npub means locked
]),
});
const req = createMockRequest(['locked-key']);
mockPrisma.key.findUnique.mockResolvedValue({
keyName: 'locked-key',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
});
await getKey(admin as any, req);
const result = getResponseResult(admin);
expect(result.name).toBe('locked-key');
expect(result.npub).toBeNull();
expect(result.locked).toBe(true);
});
});

View File

@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import getPermissions from '../get_permissions';
describe('get_permissions', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(getPermissions(admin as any, req)).rejects.toThrow('Invalid params: keyName and userPubkey required');
});
it('should throw error when userPubkey is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
await expect(getPermissions(admin as any, req)).rejects.toThrow('Invalid params: keyName and userPubkey required');
});
it('should throw error when permission is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123']);
mockPrisma.keyUser.findUnique.mockResolvedValue(null);
await expect(getPermissions(admin as any, req)).rejects.toThrow("Permission not found for user on key 'my-key'");
});
it('should return permissions for active user', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123']);
mockPrisma.keyUser.findUnique.mockResolvedValue({
id: 1,
keyName: 'my-key',
userPubkey: 'pubkey123',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
revokedAt: null,
lastUsedAt: new Date('2024-01-03'),
description: 'Test User',
signingConditions: [
{ id: 1, method: 'sign_event', kind: '1', content: null, allowed: true },
{ id: 2, method: 'sign_event', kind: '7', content: null, allowed: true },
],
});
await getPermissions(admin as any, req);
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.id).toBe(1);
expect(result.key_name).toBe('my-key');
expect(result.user_pubkey).toBe('pubkey123');
expect(result.active).toBe(true);
expect(result.description).toBe('Test User');
expect(result.signing_conditions).toHaveLength(2);
expect(result.signing_conditions[0].method).toBe('sign_event');
});
it('should return inactive status for revoked user', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123']);
mockPrisma.keyUser.findUnique.mockResolvedValue({
id: 1,
keyName: 'my-key',
userPubkey: 'pubkey123',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
revokedAt: new Date('2024-01-05'),
lastUsedAt: new Date('2024-01-03'),
description: 'Revoked User',
signingConditions: [],
});
await getPermissions(admin as any, req);
const result = getResponseResult(admin);
expect(result.active).toBe(false);
expect(result.revoked_at).toBeDefined();
});
});

View File

@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import getPolicy from '../get_policy';
describe('get_policy', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when policyId is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(getPolicy(admin as any, req)).rejects.toThrow('Invalid params: policyId required');
});
it('should throw error when policyId is not a number', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['not-a-number']);
await expect(getPolicy(admin as any, req)).rejects.toThrow('Invalid params: policyId must be a number');
});
it('should throw error when policy is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.policy.findUnique.mockResolvedValue(null);
await expect(getPolicy(admin as any, req)).rejects.toThrow("Policy with id '1' not found");
});
it('should throw error when policy is deleted', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
name: 'test-policy',
deletedAt: new Date('2024-01-01'),
rules: [],
});
await expect(getPolicy(admin as any, req)).rejects.toThrow("Policy with id '1' has been deleted");
});
it('should return policy details with rules', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
name: 'signing-policy',
description: 'A test policy',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
expiresAt: null,
deletedAt: null,
rules: [
{ id: 1, method: 'sign_event', kind: '1', maxUsageCount: 100, currentUsageCount: 5 },
{ id: 2, method: 'sign_event', kind: '7', maxUsageCount: null, currentUsageCount: 0 },
],
});
await getPolicy(admin as any, req);
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.id).toBe(1);
expect(result.name).toBe('signing-policy');
expect(result.description).toBe('A test policy');
expect(result.rules).toHaveLength(2);
expect(result.rules[0].method).toBe('sign_event');
expect(result.rules[0].kind).toBe('1');
});
});

View File

@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import getToken from '../get_token';
describe('get_token', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when tokenId is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(getToken(admin as any, req)).rejects.toThrow('Invalid params: tokenId required');
});
it('should throw error when tokenId is not a number', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['invalid']);
await expect(getToken(admin as any, req)).rejects.toThrow('Invalid params: tokenId must be a number');
});
it('should throw error when token is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.token.findUnique.mockResolvedValue(null);
await expect(getToken(admin as any, req)).rejects.toThrow("Token with id '1' not found");
});
it('should return token details with npub prefix', async () => {
const admin = createMockAdmin({
getKeys: vi.fn().mockResolvedValue([
{ name: 'my-key', npub: 'npub1xyz123' },
]),
});
const req = createMockRequest(['1']);
mockPrisma.token.findUnique.mockResolvedValue({
id: 1,
keyName: 'my-key',
token: 'abc123token',
clientName: 'Test App',
createdBy: 'admin-pubkey',
policyId: 1,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
expiresAt: new Date('2024-12-31'),
deletedAt: null,
redeemedAt: null,
policy: { name: 'signing-policy' },
KeyUser: null,
});
await getToken(admin as any, req);
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.id).toBe(1);
expect(result.key_name).toBe('my-key');
expect(result.token).toBe('npub1xyz123#abc123token');
expect(result.client_name).toBe('Test App');
expect(result.policy_name).toBe('signing-policy');
});
it('should return token without npub prefix if key not found', async () => {
const admin = createMockAdmin({
getKeys: vi.fn().mockResolvedValue([]),
});
const req = createMockRequest(['1']);
mockPrisma.token.findUnique.mockResolvedValue({
id: 1,
keyName: 'unknown-key',
token: 'abc123token',
clientName: 'Test App',
createdBy: 'admin-pubkey',
policyId: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
expiresAt: null,
deletedAt: null,
redeemedAt: null,
policy: null,
KeyUser: null,
});
await getToken(admin as any, req);
const result = getResponseResult(admin);
expect(result.token).toBe('abc123token');
});
it('should include redeemed_by when token is redeemed', async () => {
const admin = createMockAdmin({
getKeys: vi.fn().mockResolvedValue([
{ name: 'my-key', npub: 'npub1xyz123' },
]),
});
const req = createMockRequest(['1']);
mockPrisma.token.findUnique.mockResolvedValue({
id: 1,
keyName: 'my-key',
token: 'abc123token',
clientName: 'Test App',
createdBy: 'admin-pubkey',
policyId: 1,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
expiresAt: null,
deletedAt: null,
redeemedAt: new Date('2024-01-05'),
policy: { name: 'signing-policy' },
KeyUser: { description: 'Redeemed by Test Client' },
});
await getToken(admin as any, req);
const result = getResponseResult(admin);
expect(result.redeemed_at).toBeDefined();
expect(result.redeemed_by).toBe('Redeemed by Test Client');
});
});

View File

@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import grantPermission from '../grant_permission';
describe('grant_permission', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(grantPermission(admin as any, req)).rejects.toThrow('Invalid params: keyName, userPubkey, and policyId required');
});
it('should throw error when userPubkey is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
await expect(grantPermission(admin as any, req)).rejects.toThrow('Invalid params: keyName, userPubkey, and policyId required');
});
it('should throw error when policyId is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123']);
await expect(grantPermission(admin as any, req)).rejects.toThrow('Invalid params: keyName, userPubkey, and policyId required');
});
it('should throw error when policyId is not a number', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123', 'invalid']);
await expect(grantPermission(admin as any, req)).rejects.toThrow('Invalid params: policyId must be a number');
});
it('should throw error when policy is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123', '1']);
mockPrisma.policy.findUnique.mockResolvedValue(null);
await expect(grantPermission(admin as any, req)).rejects.toThrow("Policy with id '1' not found");
});
it('should throw error when policy is deleted', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123', '1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
deletedAt: new Date('2024-01-01'),
rules: [],
});
await expect(grantPermission(admin as any, req)).rejects.toThrow("Policy with id '1' has been deleted");
});
it('should grant permission successfully with hex pubkey', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'abc123def456', '1', 'Test User']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
deletedAt: null,
rules: [
{ method: 'sign_event', kind: '1' },
],
});
mockPrisma.keyUser.upsert.mockResolvedValue({
id: 1,
keyName: 'my-key',
userPubkey: 'abc123def456',
createdAt: new Date('2024-01-01'),
description: 'Test User',
});
mockPrisma.signingCondition.deleteMany.mockResolvedValue({ count: 0 });
mockPrisma.signingCondition.create.mockResolvedValue({});
await grantPermission(admin as any, req);
// Verify KeyUser was upserted
expect(mockPrisma.keyUser.upsert).toHaveBeenCalledWith({
where: {
unique_key_user: {
keyName: 'my-key',
userPubkey: 'abc123def456',
},
},
update: {
revokedAt: null,
description: 'Test User',
},
create: {
keyName: 'my-key',
userPubkey: 'abc123def456',
description: 'Test User',
},
});
// Verify old signing conditions were deleted
expect(mockPrisma.signingCondition.deleteMany).toHaveBeenCalledWith({
where: { keyUserId: 1 },
});
// Verify new signing conditions were created
expect(mockPrisma.signingCondition.create).toHaveBeenCalledWith({
data: {
keyUserId: 1,
method: 'sign_event',
kind: '1',
allowed: true,
},
});
// Verify response
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.id).toBe(1);
expect(result.key_name).toBe('my-key');
expect(result.description).toBe('Test User');
});
it('should convert npub to hex pubkey', async () => {
const admin = createMockAdmin();
// Use a properly encoded npub (this is a valid bech32 encoded pubkey)
// npub for hex pubkey: 0000000000000000000000000000000000000000000000000000000000000001
const req = createMockRequest(['my-key', 'npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqshp52w2', '1']);
mockPrisma.policy.findUnique.mockResolvedValue({
id: 1,
deletedAt: null,
rules: [],
});
mockPrisma.keyUser.upsert.mockResolvedValue({
id: 1,
keyName: 'my-key',
userPubkey: '0000000000000000000000000000000000000000000000000000000000000001',
createdAt: new Date('2024-01-01'),
description: null,
});
mockPrisma.signingCondition.deleteMany.mockResolvedValue({ count: 0 });
await grantPermission(admin as any, req);
// Verify the pubkey was converted from npub (should be hex, not npub)
expect(mockPrisma.keyUser.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: {
unique_key_user: {
keyName: 'my-key',
userPubkey: expect.not.stringMatching(/^npub1/),
},
},
})
);
});
});

View File

@ -0,0 +1,25 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, resetMocks } from './test-utils';
import ping from '../ping';
describe('ping', () => {
beforeEach(() => {
resetMocks();
});
it('should respond with pong', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await ping(admin as any, req);
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
expect(admin.rpc.sendResponse).toHaveBeenCalledWith(
req.id,
req.pubkey,
'pong',
expect.anything()
);
});
});

View File

@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import renameKeyUser from '../rename_key_user';
describe('rename_key_user', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyUserPubkey is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(renameKeyUser(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when name is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['pubkey123']);
await expect(renameKeyUser(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when key user is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['pubkey123', 'New Name']);
mockPrisma.keyUser.findFirst.mockResolvedValue(null);
await expect(renameKeyUser(admin as any, req)).rejects.toThrow('Key user not found');
});
it('should update key user description', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['pubkey123', 'New Description']);
mockPrisma.keyUser.findFirst.mockResolvedValue({
id: 1,
userPubkey: 'pubkey123',
description: 'Old Description',
});
mockPrisma.keyUser.update.mockResolvedValue({
id: 1,
description: 'New Description',
});
await renameKeyUser(admin as any, req);
expect(mockPrisma.keyUser.findFirst).toHaveBeenCalledWith({
where: {
userPubkey: 'pubkey123',
},
});
expect(mockPrisma.keyUser.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { description: 'New Description' },
});
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
});

View File

@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import revokePermission from '../revoke_permission';
describe('revoke_permission', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(revokePermission(admin as any, req)).rejects.toThrow('Invalid params: keyName and userPubkey required');
});
it('should throw error when userPubkey is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
await expect(revokePermission(admin as any, req)).rejects.toThrow('Invalid params: keyName and userPubkey required');
});
it('should throw error when permission is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123']);
mockPrisma.keyUser.findUnique.mockResolvedValue(null);
await expect(revokePermission(admin as any, req)).rejects.toThrow("Permission not found for user on key 'my-key'");
});
it('should throw error when permission is already revoked', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123']);
mockPrisma.keyUser.findUnique.mockResolvedValue({
id: 1,
keyName: 'my-key',
userPubkey: 'pubkey123',
revokedAt: new Date('2024-01-01'),
});
await expect(revokePermission(admin as any, req)).rejects.toThrow('Permission already revoked');
});
it('should revoke permission successfully', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key', 'pubkey123']);
mockPrisma.keyUser.findUnique.mockResolvedValue({
id: 1,
keyName: 'my-key',
userPubkey: 'pubkey123',
revokedAt: null,
});
mockPrisma.keyUser.update.mockResolvedValue({});
await revokePermission(admin as any, req);
// Verify KeyUser was updated with revokedAt
expect(mockPrisma.keyUser.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { revokedAt: expect.any(Date) },
});
// Verify response
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
});

View File

@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import revokeToken from '../revoke_token';
describe('revoke_token', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when tokenId is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(revokeToken(admin as any, req)).rejects.toThrow('Invalid params: tokenId required');
});
it('should throw error when tokenId is not a number', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['invalid']);
await expect(revokeToken(admin as any, req)).rejects.toThrow('Invalid params: tokenId must be a number');
});
it('should throw error when token is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.token.findUnique.mockResolvedValue(null);
await expect(revokeToken(admin as any, req)).rejects.toThrow("Token with id '1' not found");
});
it('should throw error when token is already revoked', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.token.findUnique.mockResolvedValue({
id: 1,
deletedAt: new Date('2024-01-01'),
});
await expect(revokeToken(admin as any, req)).rejects.toThrow("Token with id '1' is already revoked");
});
it('should revoke token successfully', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.token.findUnique.mockResolvedValue({
id: 1,
deletedAt: null,
});
mockPrisma.token.update.mockResolvedValue({});
await revokeToken(admin as any, req);
// Verify token was soft-deleted
expect(mockPrisma.token.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { deletedAt: expect.any(Date) },
});
// Verify response
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
});

View File

@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import revokeUser from '../revoke_user';
describe('revoke_user', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyUserId is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(revokeUser(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when keyUserId is not a number', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['not-a-number']);
await expect(revokeUser(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should revoke user by setting revokedAt', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['1']);
mockPrisma.keyUser.update.mockResolvedValue({
id: 1,
revokedAt: new Date(),
});
await revokeUser(admin as any, req);
expect(mockPrisma.keyUser.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { revokedAt: expect.any(Date) },
});
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result).toEqual(['ok']);
});
it('should parse string id to integer', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['42']);
mockPrisma.keyUser.update.mockResolvedValue({
id: 42,
revokedAt: new Date(),
});
await revokeUser(admin as any, req);
expect(mockPrisma.keyUser.update).toHaveBeenCalledWith({
where: { id: 42 },
data: { revokedAt: expect.any(Date) },
});
});
});

View File

@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
// Mock the saveEncrypted function
vi.mock('../../../../commands/add.js', () => ({
saveEncrypted: vi.fn().mockResolvedValue(undefined),
}));
import rotateKey from '../rotate_key';
describe('rotate_key', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when oldKeyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(rotateKey(admin as any, req)).rejects.toThrow('Invalid params: oldKeyName, newKeyName, and passphrase required');
});
it('should throw error when newKeyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['old-key']);
await expect(rotateKey(admin as any, req)).rejects.toThrow('Invalid params: oldKeyName, newKeyName, and passphrase required');
});
it('should throw error when passphrase is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['old-key', 'new-key']);
await expect(rotateKey(admin as any, req)).rejects.toThrow('Invalid params: oldKeyName, newKeyName, and passphrase required');
});
it('should throw error when loadNsec is not implemented', async () => {
const admin = createMockAdmin({ loadNsec: undefined });
const req = createMockRequest(['old-key', 'new-key', 'passphrase']);
await expect(rotateKey(admin as any, req)).rejects.toThrow('No loadNsec method');
});
it('should throw error when old key is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['old-key', 'new-key', 'passphrase']);
mockPrisma.key.findUnique.mockResolvedValue(null);
await expect(rotateKey(admin as any, req)).rejects.toThrow("Key 'old-key' not found");
});
it('should throw error when old key is already deleted', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['old-key', 'new-key', 'passphrase']);
mockPrisma.key.findUnique.mockResolvedValueOnce({
keyName: 'old-key',
deletedAt: new Date('2024-01-01'),
});
await expect(rotateKey(admin as any, req)).rejects.toThrow("Key 'old-key' is already deleted");
});
it('should throw error when new key name already exists', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['old-key', 'new-key', 'passphrase']);
mockPrisma.key.findUnique
.mockResolvedValueOnce({ keyName: 'old-key', deletedAt: null }) // old key exists
.mockResolvedValueOnce({ keyName: 'new-key' }); // new key also exists
await expect(rotateKey(admin as any, req)).rejects.toThrow("Key 'new-key' already exists");
});
it('should rotate key successfully', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['old-key', 'new-key', 'passphrase']);
// Old key exists, new key doesn't
mockPrisma.key.findUnique
.mockResolvedValueOnce({ keyName: 'old-key', deletedAt: null })
.mockResolvedValueOnce(null);
// No existing key users to migrate
mockPrisma.keyUser.findMany.mockResolvedValue([]);
// Mock create operations
mockPrisma.key.create.mockResolvedValue({});
mockPrisma.key.update.mockResolvedValue({});
mockPrisma.token.updateMany.mockResolvedValue({ count: 0 });
await rotateKey(admin as any, req);
// Verify new key was created
expect(mockPrisma.key.create).toHaveBeenCalledWith({
data: {
keyName: 'new-key',
pubkey: expect.any(String),
},
});
// Verify old key was soft-deleted
expect(mockPrisma.key.update).toHaveBeenCalledWith({
where: { keyName: 'old-key' },
data: { deletedAt: expect.any(Date) },
});
// Verify loadNsec was called
expect(admin.loadNsec).toHaveBeenCalledWith('new-key', expect.stringMatching(/^nsec1/));
// Verify response
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.name).toBe('new-key');
expect(result.npub).toMatch(/^npub1/);
});
});

View File

@ -0,0 +1,109 @@
import { vi } from 'vitest';
import type { NDKRpcRequest } from '@nostr-dev-kit/ndk';
import type AdminInterface from '../../index.js';
// Mock Prisma client
export const mockPrisma = {
key: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
delete: vi.fn(),
},
keyUser: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
count: vi.fn(),
},
policy: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
policyRule: {
create: vi.fn(),
},
token: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
count: vi.fn(),
},
signingCondition: {
create: vi.fn(),
deleteMany: vi.fn(),
},
};
// Reset all mocks
export function resetMocks() {
vi.clearAllMocks();
}
// Create a mock NDKRpcRequest
export function createMockRequest(params: string[] = []): NDKRpcRequest {
return {
id: 'test-request-id',
pubkey: 'test-pubkey-hex',
method: 'test-method',
params,
event: {
kind: 24133, // NIP-46 NostrConnect kind
},
} as NDKRpcRequest;
}
// Create a mock AdminInterface
export function createMockAdmin(overrides: Partial<MockAdminInterface> = {}): MockAdminInterface {
const sendResponseMock = vi.fn();
return {
configFile: '/tmp/test-config.json',
rpc: {
sendResponse: sendResponseMock,
},
getKeys: vi.fn().mockResolvedValue([]),
getKeyUsers: vi.fn().mockResolvedValue([]),
unlockKey: vi.fn().mockResolvedValue(true),
loadNsec: vi.fn(),
config: vi.fn().mockResolvedValue({}),
...overrides,
} as MockAdminInterface;
}
export interface MockAdminInterface {
configFile: string;
rpc: {
sendResponse: ReturnType<typeof vi.fn>;
};
getKeys?: ReturnType<typeof vi.fn>;
getKeyUsers?: ReturnType<typeof vi.fn>;
unlockKey?: ReturnType<typeof vi.fn>;
loadNsec?: ReturnType<typeof vi.fn>;
config?: ReturnType<typeof vi.fn>;
}
// Helper to extract the result from sendResponse mock call
export function getResponseResult(admin: MockAdminInterface): any {
const calls = admin.rpc.sendResponse.mock.calls;
if (calls.length === 0) return null;
const lastCall = calls[calls.length - 1];
return JSON.parse(lastCall[2]); // result is the 3rd argument
}
// Helper to check if response was an error
export function getResponseError(admin: MockAdminInterface): string | null {
const calls = admin.rpc.sendResponse.mock.calls;
if (calls.length === 0) return null;
const lastCall = calls[calls.length - 1];
return lastCall[4] || null; // error is the 5th argument
}

View File

@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks } from './test-utils';
import unlockKey from '../unlock_key';
describe('unlock_key', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when keyName is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(unlockKey(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when passphrase is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['my-key']);
await expect(unlockKey(admin as any, req)).rejects.toThrow('Invalid params');
});
it('should throw error when unlockKey method is not implemented', async () => {
const admin = createMockAdmin({ unlockKey: undefined });
const req = createMockRequest(['my-key', 'passphrase']);
await expect(unlockKey(admin as any, req)).rejects.toThrow('No unlockKey method');
});
it('should return success true when unlock succeeds', async () => {
const admin = createMockAdmin({
unlockKey: vi.fn().mockResolvedValue(true),
});
const req = createMockRequest(['my-key', 'correct-passphrase']);
await unlockKey(admin as any, req);
expect(admin.unlockKey).toHaveBeenCalledWith('my-key', 'correct-passphrase');
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.success).toBe(true);
});
it('should return success false with error when unlock fails', async () => {
const admin = createMockAdmin({
unlockKey: vi.fn().mockRejectedValue(new Error('Wrong passphrase')),
});
const req = createMockRequest(['my-key', 'wrong-passphrase']);
await unlockKey(admin as any, req);
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
const result = getResponseResult(admin);
expect(result.success).toBe(false);
expect(result.error).toBe('Wrong passphrase');
});
});

View File

@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
// Mock the prisma module
vi.mock('../../../../db.js', () => ({
default: mockPrisma,
}));
import validateToken from '../validate_token';
describe('validate_token', () => {
beforeEach(() => {
resetMocks();
});
it('should throw error when token is not provided', async () => {
const admin = createMockAdmin();
const req = createMockRequest([]);
await expect(validateToken(admin as any, req)).rejects.toThrow('Invalid params: token required');
});
it('should return invalid when token is not found', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['nonexistent-token']);
mockPrisma.token.findUnique.mockResolvedValue(null);
await validateToken(admin as any, req);
const result = getResponseResult(admin);
expect(result.valid).toBe(false);
expect(result.reason).toBe('Token not found');
});
it('should return invalid when token is revoked', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['revoked-token']);
mockPrisma.token.findUnique.mockResolvedValue({
token: 'revoked-token',
deletedAt: new Date('2024-01-01'),
expiresAt: null,
});
await validateToken(admin as any, req);
const result = getResponseResult(admin);
expect(result.valid).toBe(false);
expect(result.reason).toBe('Token has been revoked');
});
it('should return invalid when token is expired', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['expired-token']);
mockPrisma.token.findUnique.mockResolvedValue({
token: 'expired-token',
deletedAt: null,
expiresAt: new Date('2020-01-01'), // Past date
});
await validateToken(admin as any, req);
const result = getResponseResult(admin);
expect(result.valid).toBe(false);
expect(result.reason).toBe('Token has expired');
});
it('should return valid for active token', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['valid-token']);
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
mockPrisma.token.findUnique.mockResolvedValue({
token: 'valid-token',
keyName: 'my-key',
clientName: 'Test App',
deletedAt: null,
expiresAt: futureDate,
redeemedAt: null,
});
await validateToken(admin as any, req);
const result = getResponseResult(admin);
expect(result.valid).toBe(true);
expect(result.key_name).toBe('my-key');
expect(result.client_name).toBe('Test App');
expect(result.redeemed).toBe(false);
});
it('should return valid for token without expiration', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['valid-token']);
mockPrisma.token.findUnique.mockResolvedValue({
token: 'valid-token',
keyName: 'my-key',
clientName: 'Test App',
deletedAt: null,
expiresAt: null, // No expiration
redeemedAt: new Date('2024-01-05'),
});
await validateToken(admin as any, req);
const result = getResponseResult(admin);
expect(result.valid).toBe(true);
expect(result.redeemed).toBe(true);
});
it('should parse token with npub# prefix', async () => {
const admin = createMockAdmin();
const req = createMockRequest(['npub1xyz123#actual-token-value']);
mockPrisma.token.findUnique.mockResolvedValue({
token: 'actual-token-value',
keyName: 'my-key',
clientName: 'Test App',
deletedAt: null,
expiresAt: null,
redeemedAt: null,
});
await validateToken(admin as any, req);
// Verify the token lookup used the parsed value
expect(mockPrisma.token.findUnique).toHaveBeenCalledWith({
where: { token: 'actual-token-value' },
});
const result = getResponseResult(admin);
expect(result.valid).toBe(true);
});
});

View File

@ -1,6 +1,7 @@
import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
import AdminInterface from "..";
import { nip19 } from 'nostr-tools';
import { hexToBytes } from "../../../utils/hex.js";
import { setupSkeletonProfile } from "../../lib/profile";
import { IConfig, getCurrentConfig, saveCurrentConfig } from "../../../config";
import { readFileSync, writeFileSync } from "fs";
@ -131,7 +132,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
username = payload[0];
domain = payload[1];
email = payload[2];
return createAccountReal(admin, req, username, domain, email);
return createAccountReal(admin, req, username!, domain!, email);
}
}
@ -195,7 +196,7 @@ export async function createAccountReal(
}
const keyName = nip05;
const nsec = nip19.nsecEncode(key.privateKey!);
const nsec = nip19.nsecEncode(hexToBytes(key.privateKey!));
currentConfig.keys[keyName] = { key: key.privateKey };
saveCurrentConfig(admin.configFile, currentConfig);
@ -209,10 +210,10 @@ export async function createAccountReal(
// access it without having to go through an approval flow
await grantPermissions(req, keyName);
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin);
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnect);
} catch (e: any) {
console.trace('error', e);
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin,
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnect,
e.message);
}
}

View File

@ -1,8 +1,10 @@
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { saveEncrypted } from "../../../commands/add.js";
import { nip19 } from 'nostr-tools';
import { setupSkeletonProfile } from "../../lib/profile.js";
import { bytesToHex, hexToBytes } from "../../../utils/hex.js";
import prisma from "../../../db.js";
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ];
@ -13,7 +15,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
let key;
if (_nsec) {
key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
key = new NDKPrivateKeySigner(bytesToHex(nip19.decode(_nsec).data as Uint8Array));
} else {
key = NDKPrivateKeySigner.generate();
@ -23,7 +25,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
}
const user = await key.user();
const nsec = nip19.nsecEncode(key.privateKey!);
const nsec = nip19.nsecEncode(hexToBytes(key.privateKey!));
await saveEncrypted(
admin.configFile,
@ -34,9 +36,22 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
await admin.loadNsec(keyName, nsec);
// Also save to database so delete_key can find it
await prisma.key.upsert({
where: { keyName },
update: {
pubkey: user.pubkey,
deletedAt: null, // Ensure it's not marked as deleted
},
create: {
keyName,
pubkey: user.pubkey,
},
});
const result = JSON.stringify({
npub: user.npub,
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
@ -19,9 +19,9 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
for (const rule of policy.rules) {
await prisma.policyRule.create({
data: {
policyId: policyRecord.id,
kind: rule.kind.toString(),
method: rule.method,
Policy: { connect: { id: policyRecord.id } },
kind: rule.kind != null ? rule.kind.toString() : null,
method: rule.method ?? "sign_event",
maxUsageCount: rule.use_count,
currentUsageCount: 0,
}
@ -29,5 +29,5 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
}
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
@ -15,7 +15,7 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
const data: any = {
keyName, clientName, policyId,
keyName, clientName, policyId: parseInt(policyId),
createdBy: req.pubkey,
token
};
@ -26,5 +26,5 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
if (!tokenRecord) throw new Error("Token not created");
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,41 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function deleteKey(admin: AdminInterface, req: NDKRpcRequest) {
const [keyName] = req.params as [string];
if (!keyName) throw new Error("Invalid params: keyName required");
// Check if key exists in database
const existingKey = await prisma.key.findUnique({
where: { keyName },
});
if (!existingKey) {
throw new Error(`Key '${keyName}' not found`);
}
if (existingKey.deletedAt) {
throw new Error(`Key '${keyName}' is already deleted`);
}
// Soft delete the key
await prisma.key.update({
where: { keyName },
data: { deletedAt: new Date() },
});
// Also soft-delete all tokens for this key
await prisma.token.updateMany({
where: {
keyName,
deletedAt: null,
},
data: { deletedAt: new Date() },
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,34 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function deletePolicy(admin: AdminInterface, req: NDKRpcRequest) {
const [policyIdStr] = req.params as [string];
if (!policyIdStr) throw new Error("Invalid params: policyId required");
const policyId = parseInt(policyIdStr);
if (isNaN(policyId)) throw new Error("Invalid params: policyId must be a number");
const policy = await prisma.policy.findUnique({
where: { id: policyId },
});
if (!policy) {
throw new Error(`Policy with id '${policyId}' not found`);
}
if (policy.deletedAt) {
throw new Error(`Policy with id '${policyId}' is already deleted`);
}
// Soft delete the policy
await prisma.policy.update({
where: { id: policyId },
data: { deletedAt: new Date() },
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,33 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function getKey(admin: AdminInterface, req: NDKRpcRequest) {
const [keyName] = req.params as [string];
if (!keyName) throw new Error("Invalid params: keyName required");
if (!admin.getKeys) throw new Error("getKeys() not implemented");
// Get all keys to check locked status
const keys = await admin.getKeys();
const keyInfo = keys.find((k) => k.name === keyName);
if (!keyInfo) {
throw new Error(`Key '${keyName}' not found`);
}
// Get additional metadata from database
const dbKey = await prisma.key.findUnique({
where: { keyName },
});
const result = JSON.stringify({
name: keyInfo.name,
npub: keyInfo.npub || null,
locked: !keyInfo.npub, // If no npub, the key is locked
created_at: dbKey?.createdAt || null,
updated_at: dbKey?.updatedAt || null,
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,63 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function getPermissions(admin: AdminInterface, req: NDKRpcRequest) {
const [keyName, userPubkey] = req.params as [string, string];
if (!keyName || !userPubkey) {
throw new Error("Invalid params: keyName and userPubkey required");
}
// Normalize userPubkey (convert npub to hex if needed)
let normalizedPubkey = userPubkey;
if (userPubkey.startsWith('npub1')) {
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
normalizedPubkey = decoded.data as string;
}
} catch (e) {
throw new Error("Invalid npub format");
}
}
// Find the KeyUser with signing conditions
const keyUser = await prisma.keyUser.findUnique({
where: {
unique_key_user: {
keyName,
userPubkey: normalizedPubkey,
},
},
include: {
signingConditions: true,
},
});
if (!keyUser) {
throw new Error(`Permission not found for user on key '${keyName}'`);
}
const result = JSON.stringify({
id: keyUser.id,
key_name: keyUser.keyName,
user_pubkey: keyUser.userPubkey,
active: keyUser.revokedAt === null,
created_at: keyUser.createdAt,
updated_at: keyUser.updatedAt,
revoked_at: keyUser.revokedAt,
last_used_at: keyUser.lastUsedAt,
description: keyUser.description,
signing_conditions: keyUser.signingConditions.map((sc) => ({
id: sc.id,
method: sc.method,
kind: sc.kind,
content: sc.content,
allowed: sc.allowed,
})),
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,43 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function getPolicy(admin: AdminInterface, req: NDKRpcRequest) {
const [policyIdStr] = req.params as [string];
if (!policyIdStr) throw new Error("Invalid params: policyId required");
const policyId = parseInt(policyIdStr);
if (isNaN(policyId)) throw new Error("Invalid params: policyId must be a number");
const policy = await prisma.policy.findUnique({
where: { id: policyId },
include: { rules: true },
});
if (!policy) {
throw new Error(`Policy with id '${policyId}' not found`);
}
if (policy.deletedAt) {
throw new Error(`Policy with id '${policyId}' has been deleted`);
}
const result = JSON.stringify({
id: policy.id,
name: policy.name,
description: policy.description,
created_at: policy.createdAt,
updated_at: policy.updatedAt,
expires_at: policy.expiresAt,
rules: policy.rules.map((r) => ({
id: r.id,
method: r.method,
kind: r.kind,
max_usage_count: r.maxUsageCount,
current_usage_count: r.currentUsageCount,
})),
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,51 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function getToken(admin: AdminInterface, req: NDKRpcRequest) {
const [tokenIdStr] = req.params as [string];
if (!tokenIdStr) throw new Error("Invalid params: tokenId required");
const tokenId = parseInt(tokenIdStr);
if (isNaN(tokenId)) throw new Error("Invalid params: tokenId must be a number");
const token = await prisma.token.findUnique({
where: { id: tokenId },
include: {
policy: true,
KeyUser: true,
},
});
if (!token) {
throw new Error(`Token with id '${tokenId}' not found`);
}
// Get the key's npub for the token string
let npub: string | null = null;
if (admin.getKeys) {
const keys = await admin.getKeys();
const key = keys.find((k) => k.name === token.keyName);
npub = key?.npub || null;
}
const result = JSON.stringify({
id: token.id,
key_name: token.keyName,
token: npub ? `${npub}#${token.token}` : token.token,
client_name: token.clientName,
created_by: token.createdBy,
policy_id: token.policyId,
policy_name: token.policy?.name,
created_at: token.createdAt,
updated_at: token.updatedAt,
expires_at: token.expiresAt,
deleted_at: token.deletedAt,
redeemed_at: token.redeemedAt,
redeemed_by: token.KeyUser?.description,
revoked: !!token.deletedAt, // Revoked if deletedAt is set
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,88 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function grantPermission(admin: AdminInterface, req: NDKRpcRequest) {
const [keyName, userPubkey, policyIdStr, description] = req.params as [string, string, string, string?];
if (!keyName || !userPubkey || !policyIdStr) {
throw new Error("Invalid params: keyName, userPubkey, and policyId required");
}
// Normalize userPubkey (convert npub to hex if needed)
let normalizedPubkey = userPubkey;
if (userPubkey.startsWith('npub1')) {
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
normalizedPubkey = decoded.data as string;
}
} catch (e) {
throw new Error("Invalid npub format");
}
}
const policyId = parseInt(policyIdStr);
if (isNaN(policyId)) throw new Error("Invalid params: policyId must be a number");
// Validate policy exists
const policy = await prisma.policy.findUnique({
where: { id: policyId },
include: { rules: true },
});
if (!policy) {
throw new Error(`Policy with id '${policyId}' not found`);
}
if (policy.deletedAt) {
throw new Error(`Policy with id '${policyId}' has been deleted`);
}
// Upsert KeyUser (create or update if exists)
const keyUser = await prisma.keyUser.upsert({
where: {
unique_key_user: {
keyName,
userPubkey: normalizedPubkey,
},
},
update: {
revokedAt: null, // Re-enable if previously revoked
description: description || undefined,
},
create: {
keyName,
userPubkey: normalizedPubkey,
description: description || undefined,
},
});
// Remove existing signing conditions for this user
await prisma.signingCondition.deleteMany({
where: { keyUserId: keyUser.id },
});
// Copy policy rules to signing conditions
for (const rule of policy.rules) {
await prisma.signingCondition.create({
data: {
keyUserId: keyUser.id,
method: rule.method,
kind: rule.kind,
allowed: true,
},
});
}
const result = JSON.stringify({
id: keyUser.id,
key_name: keyUser.keyName,
user_pubkey: keyUser.userPubkey,
created_at: keyUser.createdAt,
description: keyUser.description,
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -1,6 +1,6 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
export default async function ping(admin: AdminInterface, req: NDKRpcRequest) {
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, "pong", NDKKind.NostrConnect);
}

View File

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
@ -25,5 +25,5 @@ export default async function renameKeyUser(admin: AdminInterface, req: NDKRpcRe
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,53 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function revokePermission(admin: AdminInterface, req: NDKRpcRequest) {
const [keyName, userPubkey] = req.params as [string, string];
if (!keyName || !userPubkey) {
throw new Error("Invalid params: keyName and userPubkey required");
}
// Normalize userPubkey (convert npub to hex if needed)
let normalizedPubkey = userPubkey;
if (userPubkey.startsWith('npub1')) {
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
normalizedPubkey = decoded.data as string;
}
} catch (e) {
throw new Error("Invalid npub format");
}
}
// Find the KeyUser
const keyUser = await prisma.keyUser.findUnique({
where: {
unique_key_user: {
keyName,
userPubkey: normalizedPubkey,
},
},
});
if (!keyUser) {
throw new Error(`Permission not found for user on key '${keyName}'`);
}
if (keyUser.revokedAt) {
throw new Error(`Permission already revoked`);
}
// Revoke by setting revokedAt timestamp
await prisma.keyUser.update({
where: { id: keyUser.id },
data: { revokedAt: new Date() },
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,34 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) {
const [tokenIdStr] = req.params as [string];
if (!tokenIdStr) throw new Error("Invalid params: tokenId required");
const tokenId = parseInt(tokenIdStr);
if (isNaN(tokenId)) throw new Error("Invalid params: tokenId must be a number");
const token = await prisma.token.findUnique({
where: { id: tokenId },
});
if (!token) {
throw new Error(`Token with id '${tokenId}' not found`);
}
if (token.deletedAt) {
throw new Error(`Token with id '${tokenId}' is already revoked`);
}
// Soft delete the token
await prisma.token.update({
where: { id: tokenId },
data: { deletedAt: new Date() },
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
@ -20,5 +20,5 @@ export default async function revokeUser(admin: AdminInterface, req: NDKRpcReque
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,114 @@
import { NDKKind, NDKPrivateKeySigner, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
import { saveEncrypted } from "../../../commands/add.js";
import { hexToBytes } from "../../../utils/hex.js";
export default async function rotateKey(admin: AdminInterface, req: NDKRpcRequest) {
const [oldKeyName, newKeyName, passphrase] = req.params as [string, string, string];
if (!oldKeyName || !newKeyName || !passphrase) {
throw new Error("Invalid params: oldKeyName, newKeyName, and passphrase required");
}
if (!admin.loadNsec) throw new Error("No loadNsec method");
// Validate old key exists
const oldKey = await prisma.key.findUnique({
where: { keyName: oldKeyName },
});
if (!oldKey) {
throw new Error(`Key '${oldKeyName}' not found`);
}
if (oldKey.deletedAt) {
throw new Error(`Key '${oldKeyName}' is already deleted`);
}
// Check new key name doesn't exist
const existingNewKey = await prisma.key.findUnique({
where: { keyName: newKeyName },
});
if (existingNewKey) {
throw new Error(`Key '${newKeyName}' already exists`);
}
// Generate new key
const newSigner = NDKPrivateKeySigner.generate();
const newUser = await newSigner.user();
const newNsec = nip19.nsecEncode(hexToBytes(newSigner.privateKey!));
// Save new key encrypted
await saveEncrypted(
admin.configFile,
newNsec,
passphrase,
newKeyName
);
// Create new Key record in database
await prisma.key.create({
data: {
keyName: newKeyName,
pubkey: newUser.pubkey,
},
});
// Copy KeyUser records from old key to new key
const oldKeyUsers = await prisma.keyUser.findMany({
where: { keyName: oldKeyName },
include: { signingConditions: true },
});
for (const oldKeyUser of oldKeyUsers) {
// Create new KeyUser for the new key
const newKeyUser = await prisma.keyUser.create({
data: {
keyName: newKeyName,
userPubkey: oldKeyUser.userPubkey,
description: oldKeyUser.description,
},
});
// Copy signing conditions
for (const condition of oldKeyUser.signingConditions) {
await prisma.signingCondition.create({
data: {
keyUserId: newKeyUser.id,
method: condition.method,
kind: condition.kind,
content: condition.content,
allowed: condition.allowed,
},
});
}
}
// Soft-delete old key
await prisma.key.update({
where: { keyName: oldKeyName },
data: { deletedAt: new Date() },
});
// Also soft-delete tokens for old key (tokens are key-specific, not transferred)
await prisma.token.updateMany({
where: {
keyName: oldKeyName,
deletedAt: null,
},
data: { deletedAt: new Date() },
});
// Load the new key into the daemon
await admin.loadNsec(newKeyName, newNsec);
const result = JSON.stringify({
npub: newUser.npub,
name: newKeyName,
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -1,4 +1,4 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
export default async function unlockKey(admin: AdminInterface, req: NDKRpcRequest) {
@ -16,5 +16,5 @@ export default async function unlockKey(admin: AdminInterface, req: NDKRpcReques
result = JSON.stringify({ success: false, error: e.message });
}
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -0,0 +1,56 @@
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
export default async function validateToken(admin: AdminInterface, req: NDKRpcRequest) {
const [tokenString] = req.params as [string];
if (!tokenString) throw new Error("Invalid params: token required");
// Parse token string - may include npub# prefix
let tokenValue = tokenString;
if (tokenString.includes('#')) {
tokenValue = tokenString.split('#')[1];
}
const token = await prisma.token.findUnique({
where: { token: tokenValue },
});
if (!token) {
const result = JSON.stringify({
valid: false,
reason: "Token not found",
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}
// Check if token is revoked (soft deleted)
if (token.deletedAt) {
const result = JSON.stringify({
valid: false,
reason: "Token has been revoked",
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}
// Check if token is expired
if (token.expiresAt && token.expiresAt < new Date()) {
const result = JSON.stringify({
valid: false,
reason: "Token has expired",
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}
// Token is valid
const result = JSON.stringify({
valid: true,
key_name: token.keyName,
client_name: token.clientName,
expires_at: token.expiresAt,
redeemed: token.redeemedAt !== null,
});
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}

View File

@ -13,6 +13,17 @@ import createNewToken from './commands/create_new_token';
import unlockKey from './commands/unlock_key';
import renameKeyUser from './commands/rename_key_user.js';
import revokeUser from './commands/revoke_user';
import getKey from './commands/get_key';
import deleteKey from './commands/delete_key';
import rotateKey from './commands/rotate_key';
import getPolicy from './commands/get_policy';
import deletePolicy from './commands/delete_policy';
import grantPermission from './commands/grant_permission';
import revokePermission from './commands/revoke_permission';
import getPermissions from './commands/get_permissions';
import getToken from './commands/get_token';
import revokeToken from './commands/revoke_token';
import validateToken from './commands/validate_token';
import fs from 'fs';
import { validateRequestFromAdmin } from './validations/request-from-admin';
import { dmUser } from '../../utils/dm-user';
@ -116,7 +127,7 @@ class AdminInterface {
this.ndk.connect(2500).then(() => {
// connect for whitelisted admins
this.rpc.subscribe({
"kinds": [NDKKind.NostrConnect, 24134 as number],
"kinds": [NDKKind.NostrConnect],
"#p": [this.signerUser!.pubkey]
});
@ -135,17 +146,28 @@ class AdminInterface {
switch (req.method) {
case 'get_keys': await this.reqGetKeys(req); break;
case 'get_key': await getKey(this, req); break;
case 'get_key_users': await this.reqGetKeyUsers(req); break;
case 'rename_key_user': await renameKeyUser(this, req); break;
case 'get_key_tokens': await this.reqGetKeyTokens(req); break;
case 'revoke_user': await revokeUser(this, req); break;
case 'create_new_key': await createNewKey(this, req); break;
case 'delete_key': await deleteKey(this, req); break;
case 'rotate_key': await rotateKey(this, req); break;
case 'create_account': await createAccount(this, req); break;
case 'ping': await ping(this, req); break;
case 'unlock_key': await unlockKey(this, req); break;
case 'create_new_policy': await createNewPolicy(this, req); break;
case 'get_policies': await this.reqListPolicies(req); break;
case 'get_policy': await getPolicy(this, req); break;
case 'delete_policy': await deletePolicy(this, req); break;
case 'grant_permission': await grantPermission(this, req); break;
case 'revoke_permission': await revokePermission(this, req); break;
case 'get_permissions': await getPermissions(this, req); break;
case 'create_new_token': await createNewToken(this, req); break;
case 'get_token': await getToken(this, req); break;
case 'revoke_token': await revokeToken(this, req); break;
case 'validate_token': await validateToken(this, req); break;
default:
const originalKind = req.event.kind!;
console.log(`Unknown method ${req.method}`);
@ -158,7 +180,7 @@ class AdminInterface {
}
} catch (err: any) {
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message);
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnect, err?.message);
}
}
@ -196,7 +218,7 @@ class AdminInterface {
const key = keys.find((k) => k.name === keyName);
if (!key || !key.npub) {
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), 24134);
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), NDKKind.NostrConnect);
}
const npub = key.npub;
@ -205,6 +227,7 @@ class AdminInterface {
return {
id: t.id,
key_name: t.keyName,
key_npub: npub,
client_name: t.clientName,
token: [ npub, t.token ].join('#'),
policy_id: t.policyId,
@ -215,10 +238,11 @@ class AdminInterface {
redeemed_at: t.redeemedAt,
redeemed_by: t.KeyUser?.description,
time_until_expiration: t.expiresAt ? (t.expiresAt.getTime() - Date.now()) / 1000 : null,
revoked: !!t.deletedAt, // Revoked if deletedAt is set
};
}));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return this.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}
/**
@ -250,7 +274,7 @@ class AdminInterface {
};
}));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return this.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}
/**
@ -262,7 +286,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeys());
const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
return this.rpc.sendResponse(req.id, pubkey, result, NDKKind.NostrConnect);
}
/**
@ -274,7 +298,7 @@ class AdminInterface {
const result = JSON.stringify(await this.getKeyUsers(req));
const pubkey = req.pubkey;
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
return this.rpc.sendResponse(req.id, pubkey, result, NDKKind.NostrConnect);
}
/**
@ -340,7 +364,7 @@ class AdminInterface {
remoteUser.pubkey,
'acl',
[params],
24134,
NDKKind.NostrConnect,
(res: NDKRpcResponse) => {
this.requestPermissionResponse(
remotePubkey,

View File

@ -59,7 +59,7 @@ async function createRecord(
) {
let params: string | undefined;
if (param?.rawEvent) {
if (param && typeof param !== 'string' && 'rawEvent' in param) {
const e = param as NDKEvent;
params = JSON.stringify(e.rawEvent());
} else if (param) {
@ -113,7 +113,7 @@ export function urlAuthFlow(
clearInterval(checkingInterval);
if (record.allowed === false) {
reject(record.payload);
reject(record.params);
}
console.log('resolve urlAuthFlow', !!record.params);
resolve(record.params);

View File

@ -3,7 +3,7 @@ import { IEventHandlingStrategy } from '@nostr-dev-kit/ndk';
export default class PublishEventHandlingStrategy implements IEventHandlingStrategy {
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]): Promise<string|undefined> {
const event = await backend.signEvent(remotePubkey, params);
const event = await (backend as any).signEvent(remotePubkey, params);
if (!event) return undefined;
console.log('Publishing event', event);

View File

@ -79,7 +79,8 @@ export function requestToSigningConditionQuery(method: IMethod, payload?: string
switch (method) {
case 'sign_event':
signingConditionQuery.kind = { in: [ payload?.kind?.toString(), 'all' ] };
const kind = typeof payload === 'object' && payload !== null ? (payload as NostrEvent).kind : undefined;
signingConditionQuery.kind = { in: [ kind?.toString(), 'all' ] };
break;
}

View File

@ -1,5 +1,6 @@
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { bytesToHex, hexToBytes } from '../utils/hex.js';
import { Backend } from './backend/index.js';
import {
IMethod,
@ -34,11 +35,22 @@ export type KeyUser = {
function getKeys(config: DaemonConfig) {
return async (): Promise<Key[]> => {
let lockedKeyNames = Object.keys(config.allKeys);
// Get soft-deleted key names from database to exclude them
const deletedKeys = await prisma.key.findMany({
where: { deletedAt: { not: null } },
select: { keyName: true },
});
const deletedKeyNames = new Set(deletedKeys.map(k => k.keyName));
let lockedKeyNames = Object.keys(config.allKeys).filter(name => !deletedKeyNames.has(name));
const keys: Key[] = [];
for (const [name, nsec] of Object.entries(config.keys)) {
const hexpk = nip19.decode(nsec).data as string;
// Skip soft-deleted keys
if (deletedKeyNames.has(name)) continue;
const decoded = nip19.decode(nsec) as unknown as { type: 'nsec', data: Uint8Array };
const hexpk = bytesToHex(decoded.data);
const user = await new NDKPrivateKeySigner(hexpk).user();
const key = {
name,
@ -82,6 +94,7 @@ function getKeyUsers(config: IConfig) {
createdAt: user.createdAt,
lastUsedAt: user.lastUsedAt || undefined,
revokedAt: user.revokedAt || undefined,
active: !user.revokedAt, // Active if not revoked
signingConditions: user.signingConditions, // Include signing conditions
};
@ -164,7 +177,7 @@ class Daemon {
explicitRelayUrls: config.nostr.relays,
});
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
this.ndk.pool.on('relay:notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); });
(this.ndk.pool as any).on('relay:notice', (n: string, r: any) => { console.log(`👀 Notice from ${r.url}`, n); });
this.ndk.pool.on('relay:disconnect', (r) => {
console.log(`🚫 Disconnected from ${r.url}`);
@ -206,7 +219,7 @@ class Daemon {
continue;
}
const nsec = nip19.nsecEncode(settings.key);
const nsec = nip19.nsecEncode(hexToBytes(settings.key));
this.loadNsec(keyName, nsec);
}
}

View File

@ -17,7 +17,7 @@ async function validateAuthCookie(request) {
return false;
}
const user = await prisma.user.findUnique({
const user = await prisma.user.findFirst({
where: { pubkey: jwt }
});
@ -230,7 +230,7 @@ export async function processRegistrationWebHandler(request, reply) {
await allowAllRequestsFromKey(
record.remotePubkey,
record.keyName,
record.keyName!,
record.method,
undefined,
undefined,

17
src/utils/hex.ts Normal file
View File

@ -0,0 +1,17 @@
/**
* Convert Uint8Array to hex string
*/
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Convert hex string to Uint8Array
*/
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}

15
vitest.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/daemon/admin/commands/**/*.ts'],
exclude: ['**/*.test.ts'],
},
},
});