feat!: Add Admin API methods and NIP-46 compliance

BREAKING CHANGE: Admin API now uses kind 24133 instead of 24134

This release adds 11 new admin methods and ensures full NIP-46 compliance
by switching from the non-standard kind 24134 to the spec-compliant 24133.

## New Admin Methods

### Key Management
- `get_key` - Get details about a specific key
- `delete_key` - Soft-delete a key and its tokens
- `rotate_key` - Create new key and migrate permissions

### Policy Management
- `get_policy` - Get policy details with rules
- `delete_policy` - Soft-delete a policy

### Permission Management
- `grant_permission` - Grant user access with a policy
- `revoke_permission` - Revoke user's access to a key
- `get_permissions` - Get user's permissions on a key

### Token Management
- `get_token` - Get token details
- `revoke_token` - Soft-delete a token
- `validate_token` - Check if a token is valid

## NIP-46 Compliance

Changed all admin communication from kind 24134 to kind 24133
(NDKKind.NostrConnect) to comply with the NIP-46 specification.
Kind 24134 was never part of the official spec.

## Other Changes

- Added Vitest test framework with 93 tests
- Fixed TypeScript compilation errors
- Updated documentation with breaking change notice
- Version bump to 0.11.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tcheeric 2025-11-26 03:41:15 +00:00
parent deda485763
commit 16dc15b486
51 changed files with 12189 additions and 3809 deletions

118
README.md
View File

@ -177,6 +177,124 @@ 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 |
|--------|------------|-------------|
| `get_keys` | none | List all keys with their status (locked/unlocked) |
| `get_key` | `keyName` | Get details about a specific key |
| `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 |
| `rotate_key` | `oldKeyName`, `newKeyName`, `passphrase` | Create new key and migrate permissions |
### Policy Management
| Method | Parameters | Description |
|--------|------------|-------------|
| `get_policies` | none | List all policies with their rules |
| `get_policy` | `policyId` | Get details about a specific policy |
| `create_new_policy` | `policyJson` | Create a new policy with rules |
| `delete_policy` | `policyId` | Soft-delete a policy |
**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 |
|--------|------------|-------------|
| `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 |
| `revoke_permission` | `keyName`, `userPubkey` | Revoke user's access to a key |
| `get_permissions` | `keyName`, `userPubkey` | Get user's permissions on a key |
| `rename_key_user` | `userPubkey`, `description` | Update a user's description |
| `revoke_user` | `keyUserId` | Revoke user by ID |
### Token Management
| Method | Parameters | Description |
|--------|------------|-------------|
| `get_key_tokens` | `keyName` | List all tokens for a key |
| `get_token` | `tokenId` | Get details about a specific token |
| `create_new_token` | `keyName`, `clientName`, `policyId`, `[durationInHours]` | Create an access token |
| `revoke_token` | `tokenId` | Revoke (soft-delete) a token |
| `validate_token` | `tokenString` | Check if a token is valid |
### Utility
| Method | Parameters | Description |
|--------|------------|-------------|
| `ping` | none | Health check - returns "ok" |
| `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
```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)

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.6",
"version": "0.11.0",
"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"
@ -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

@ -0,0 +1,660 @@
# nsecbunkerd Missing Admin Methods Implementation Plan
## Executive Summary
This document outlines the implementation plan for adding missing admin methods to nsecbunkerd to achieve full compatibility with the nsecbunker-java client library. The implementation follows NIP-46 conventions and the existing nsecbunkerd command patterns.
---
## Current State Analysis
### Implemented Methods (12 total)
| Method | File | Description |
|--------|------|-------------|
| `ping` | `commands/ping.ts` | Health check |
| `get_keys` | `admin/index.ts` | List all keys |
| `get_key_users` | `admin/index.ts` | Get users of a key |
| `get_key_tokens` | `admin/index.ts` | Get tokens for a key |
| `get_policies` | `admin/index.ts` | List all policies |
| `create_new_key` | `commands/create_new_key.ts` | Create/import a key |
| `unlock_key` | `commands/unlock_key.ts` | Unlock an encrypted key |
| `create_new_policy` | `commands/create_new_policy.ts` | Create a policy |
| `create_new_token` | `commands/create_new_token.ts` | Create an access token |
| `rename_key_user` | `commands/rename_key_user.ts` | Update user description |
| `revoke_user` | `commands/revoke_user.ts` | Revoke user by keyUserId |
| `create_account` | `commands/create_account.ts` | Create user account |
### Missing Methods (11 total)
| Category | Method | Priority | Complexity |
|----------|--------|----------|------------|
| Key Management | `get_key` | High | Low |
| Key Management | `delete_key` | Medium | Low |
| Key Management | `rotate_key` | Low | Medium |
| Policy Management | `get_policy` | High | Low |
| Policy Management | `delete_policy` | Medium | Low |
| Permission Management | `grant_permission` | High | Medium |
| Permission Management | `revoke_permission` | Medium | Low |
| Permission Management | `get_permissions` | High | Low |
| Token Management | `get_token` | Medium | Low |
| Token Management | `revoke_token` | Medium | Low |
| Token Management | `validate_token` | Medium | Low |
---
## Database Schema Reference
```prisma
model Key {
id Int @id @default(autoincrement())
keyName String @unique
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
deletedAt DateTime?
pubkey String
}
model KeyUser {
id Int @id @default(autoincrement())
keyName String
userPubkey String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
revokedAt DateTime?
lastUsedAt DateTime?
description String?
signingConditions SigningCondition[]
Token Token[]
@@unique([keyName, userPubkey], name: "unique_key_user")
}
model Policy {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
expiresAt DateTime?
deletedAt DateTime?
description String?
rules PolicyRule[]
Token Token[]
}
model Token {
id Int @id @default(autoincrement())
keyName String
token String @unique
clientName String
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
deletedAt DateTime?
expiresAt DateTime?
redeemedAt DateTime?
keyUserId Int?
policyId Int?
policy Policy? @relation(fields: [policyId], references: [id])
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
}
```
---
## Implementation Phases
### Phase 1: Key Management Methods (Priority: High)
**Duration Estimate**: 1-2 days
#### Task 1.1: Implement `get_key`
**File**: `src/daemon/admin/commands/get_key.ts`
**Purpose**: Retrieve detailed information about a single key by name.
**Parameters**:
- `params[0]`: keyName (string)
**Response**:
```json
{
"name": "my-key",
"npub": "npub1...",
"locked": false,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/get_key.ts`
2. Extract keyName from params
3. Query Key table and in-memory keys state (for locked status)
4. Return JSON response with key details
5. Register in `admin/index.ts` switch statement
6. Write unit tests
**Dependencies**: Access to `getKeys()` callback for locked status
---
#### Task 1.2: Implement `delete_key`
**File**: `src/daemon/admin/commands/delete_key.ts`
**Purpose**: Soft-delete a key (set deletedAt timestamp).
**Parameters**:
- `params[0]`: keyName (string)
**Response**:
```json
["ok"]
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/delete_key.ts`
2. Extract keyName from params
3. Update Key record with `deletedAt = new Date()`
4. Optionally remove from in-memory keys
5. Return `["ok"]` on success
6. Register in `admin/index.ts`
**Considerations**:
- Should we also revoke all tokens and permissions for this key?
- Should we remove from config file or just database?
---
#### Task 1.3: Implement `rotate_key` (Lower Priority)
**File**: `src/daemon/admin/commands/rotate_key.ts`
**Purpose**: Create a new key and migrate permissions from an old key.
**Parameters**:
- `params[0]`: oldKeyName (string)
- `params[1]`: newKeyName (string)
- `params[2]`: passphrase (string)
**Response**:
```json
{
"npub": "npub1...",
"name": "new-key-name"
}
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/rotate_key.ts`
2. Validate old key exists
3. Generate new key with passphrase
4. Copy KeyUser records from old key to new key
5. Copy Token records from old key to new key
6. Soft-delete old key
7. Return new key details
**Considerations**:
- This is a complex operation that should be transactional
- May want to preserve old key for a grace period
---
### Phase 2: Policy Management Methods (Priority: High)
**Duration Estimate**: 1 day
#### Task 2.1: Implement `get_policy`
**File**: `src/daemon/admin/commands/get_policy.ts`
**Purpose**: Retrieve a single policy with its rules.
**Parameters**:
- `params[0]`: policyId (string, will be parsed to int)
**Response**:
```json
{
"id": 1,
"name": "signing-policy",
"description": "Policy for signing",
"created_at": "2024-01-01T00:00:00Z",
"rules": [
{ "method": "sign_event", "kind": "1" }
]
}
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/get_policy.ts`
2. Parse policyId to integer
3. Query Policy with included rules
4. Return JSON response or error if not found
5. Register in `admin/index.ts`
---
#### Task 2.2: Implement `delete_policy`
**File**: `src/daemon/admin/commands/delete_policy.ts`
**Purpose**: Soft-delete a policy.
**Parameters**:
- `params[0]`: policyId (string)
**Response**:
```json
["ok"]
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/delete_policy.ts`
2. Parse policyId to integer
3. Check no active tokens reference this policy (optional)
4. Update Policy with `deletedAt = new Date()`
5. Return `["ok"]` on success
**Considerations**:
- Should we prevent deletion if tokens are using this policy?
- Should we cascade to PolicyRule deletion?
---
### Phase 3: Permission Management Methods (Priority: High)
**Duration Estimate**: 2 days
#### Task 3.1: Implement `grant_permission`
**File**: `src/daemon/admin/commands/grant_permission.ts`
**Purpose**: Grant a user permission to use a key with a specific policy.
**Parameters**:
- `params[0]`: keyName (string)
- `params[1]`: userPubkey (string, hex or npub)
- `params[2]`: policyId (string)
- `params[3]`: description (string, optional)
**Response**:
```json
{
"id": 1,
"key_name": "my-key",
"user_pubkey": "abc123...",
"created_at": "2024-01-01T00:00:00Z",
"description": "Client app"
}
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/grant_permission.ts`
2. Validate key exists
3. Validate policy exists
4. Normalize userPubkey (convert npub to hex if needed)
5. Create or update KeyUser record
6. Create SigningCondition records from policy rules
7. Return KeyUser details
8. Register in `admin/index.ts`
**Key Logic**:
```typescript
// Convert npub to hex if needed
if (userPubkey.startsWith('npub1')) {
userPubkey = nip19.decode(userPubkey).data as string;
}
// Upsert KeyUser
const keyUser = await prisma.keyUser.upsert({
where: { unique_key_user: { keyName, userPubkey } },
update: { revokedAt: null, description },
create: { keyName, userPubkey, description }
});
// Copy policy rules to signing conditions
const policy = await prisma.policy.findUnique({ where: { id: policyId }, include: { rules: true } });
for (const rule of policy.rules) {
await prisma.signingCondition.create({
data: {
keyUserId: keyUser.id,
method: rule.method,
kind: rule.kind,
allowed: true
}
});
}
```
---
#### Task 3.2: Implement `revoke_permission`
**File**: `src/daemon/admin/commands/revoke_permission.ts`
**Purpose**: Revoke a user's permission for a specific key.
**Parameters**:
- `params[0]`: keyName (string)
- `params[1]`: userPubkey (string)
**Response**:
```json
["ok"]
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/revoke_permission.ts`
2. Normalize userPubkey
3. Find KeyUser by unique constraint
4. Update revokedAt timestamp
5. Return `["ok"]`
**Note**: This differs from existing `revoke_user` which takes keyUserId directly.
---
#### Task 3.3: Implement `get_permissions`
**File**: `src/daemon/admin/commands/get_permissions.ts`
**Purpose**: Get permissions for a specific user on a key.
**Parameters**:
- `params[0]`: keyName (string)
- `params[1]`: userPubkey (string)
**Response**:
```json
{
"id": 1,
"key_name": "my-key",
"user_pubkey": "abc123...",
"active": true,
"created_at": "2024-01-01T00:00:00Z",
"signing_conditions": [
{ "method": "sign_event", "kind": "1", "allowed": true }
]
}
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/get_permissions.ts`
2. Normalize userPubkey
3. Query KeyUser with signingConditions included
4. Return user details with conditions
5. Include `active: revokedAt === null`
---
### Phase 4: Token Management Methods (Priority: Medium)
**Duration Estimate**: 1-2 days
#### Task 4.1: Implement `get_token`
**File**: `src/daemon/admin/commands/get_token.ts`
**Purpose**: Retrieve a single token by ID.
**Parameters**:
- `params[0]`: tokenId (string)
**Response**:
```json
{
"id": 1,
"key_name": "my-key",
"token": "npub1...#abc123",
"client_name": "My App",
"policy_id": 1,
"expires_at": "2024-12-31T23:59:59Z",
"redeemed_at": null
}
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/get_token.ts`
2. Parse tokenId to integer
3. Query Token with policy included
4. Format token string with npub prefix
5. Return token details or error if not found
---
#### Task 4.2: Implement `revoke_token`
**File**: `src/daemon/admin/commands/revoke_token.ts`
**Purpose**: Revoke (soft-delete) a token.
**Parameters**:
- `params[0]`: tokenId (string)
**Response**:
```json
["ok"]
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/revoke_token.ts`
2. Parse tokenId to integer
3. Update Token with `deletedAt = new Date()`
4. Return `["ok"]`
---
#### Task 4.3: Implement `validate_token`
**File**: `src/daemon/admin/commands/validate_token.ts`
**Purpose**: Check if a token is valid (not expired, not revoked).
**Parameters**:
- `params[0]`: tokenString (string) - the token value (without npub prefix)
**Response**:
```json
{
"valid": true,
"key_name": "my-key",
"expires_at": "2024-12-31T23:59:59Z"
}
```
or
```json
{
"valid": false,
"reason": "Token expired"
}
```
**Implementation Steps**:
1. Create new file `src/daemon/admin/commands/validate_token.ts`
2. Parse token string (may include npub# prefix)
3. Query Token by token value
4. Check: `deletedAt === null && (expiresAt === null || expiresAt > now)`
5. Return validity status with details
---
### Phase 5: Integration and Testing
**Duration Estimate**: 2-3 days
#### Task 5.1: Register All New Commands
**File**: `src/daemon/admin/index.ts`
Update the handleRequest switch statement:
```typescript
switch (req.method) {
// Existing commands...
// New Key commands
case 'get_key': await getKey(this, req); break;
case 'delete_key': await deleteKey(this, req); break;
case 'rotate_key': await rotateKey(this, req); break;
// New Policy commands
case 'get_policy': await getPolicy(this, req); break;
case 'delete_policy': await deletePolicy(this, req); break;
// New Permission commands
case 'grant_permission': await grantPermission(this, req); break;
case 'revoke_permission': await revokePermission(this, req); break;
case 'get_permissions': await getPermissions(this, req); break;
// New Token commands
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: // ...
}
```
#### Task 5.2: Add Imports
Add imports for all new command modules at the top of `admin/index.ts`.
#### Task 5.3: Enable E2E Tests in nsecbunker-java
Remove `@Disabled` annotations from E2E tests:
- `SigningFlowE2ETest.shouldSetUpKeyForSigning`
- `SigningFlowE2ETest.shouldGrantSigningPermission`
- `SigningFlowE2ETest.shouldGenerateSigningToken`
- `SigningFlowE2ETest.shouldCompleteFullSigningSetupFlow`
- `SigningFlowE2ETest.shouldImportExistingKeyForSigning`
#### Task 5.4: Run Full E2E Test Suite
```bash
cd /home/eric/IdeaProjects/nsecbunker-java
mvn test -pl nsecbunker-e2e -am -Pe2e
```
#### Task 5.5: Build and Test Docker Image
```bash
cd /home/eric/IdeaProjects/nsecbunkerd
npm run build
docker build -t nsecbunkerd-local:latest .
```
---
## File Structure After Implementation
```
src/daemon/admin/
├── index.ts # Main router (updated)
├── validations/
│ └── request-from-admin.ts
└── commands/
├── ping.ts
├── create_new_key.ts
├── get_key.ts # NEW
├── delete_key.ts # NEW
├── rotate_key.ts # NEW
├── unlock_key.ts
├── create_new_policy.ts
├── get_policy.ts # NEW
├── delete_policy.ts # NEW
├── create_new_token.ts
├── get_token.ts # NEW
├── revoke_token.ts # NEW
├── validate_token.ts # NEW
├── grant_permission.ts # NEW
├── revoke_permission.ts # NEW
├── get_permissions.ts # NEW
├── rename_key_user.ts
├── revoke_user.ts
├── create_account.ts
└── account/
└── wallet.ts
```
---
## Risk Assessment
| Risk | Impact | Mitigation |
|------|--------|------------|
| Breaking existing behavior | High | Preserve existing response formats |
| Database migrations needed | Medium | Use soft deletes, no schema changes required |
| Config file modifications | Medium | Only delete_key may need to update config |
| Security concerns | High | Validate all admin requests are from authorized npubs |
---
## Testing Strategy
### Unit Tests
- Each command should have corresponding unit tests
- Mock Prisma client for database operations
- Test error cases (invalid params, not found, etc.)
### Integration Tests (E2E)
- Use nsecbunker-java E2E test suite
- Run against local Docker container
- Verify round-trip: Java client → nsecbunkerd → response parsing
### Manual Testing
- Use nsecBunker admin UI if available
- Test with nostr client tools
---
## Rollout Plan
1. **Development**: Implement in feature branch
2. **Local Testing**: Run E2E tests with local build
3. **Code Review**: Review for security and correctness
4. **Docker Build**: Create new Docker image
5. **Staging**: Test with staging nsecbunker instance
6. **Release**: Tag version (0.11.0 - MINOR bump for new features)
7. **Documentation**: Update README with new admin methods
---
## Version Recommendation
Since this adds new features without breaking existing behavior:
- **Current**: 0.10.6
- **Proposed**: 0.11.0 (MINOR version bump)
---
## Appendix: NIP-46 Reference
### Request Format (kind: 24133)
```json
{
"id": "<random_string>",
"method": "<method_name>",
"params": ["<array_of_strings>"]
}
```
### Response Format
```json
{
"id": "<request_id>",
"result": "<json_stringified_result>",
"error": "<optional_error_string>"
}
```
### Standard Methods (NIP-46 Core)
- `connect`, `sign_event`, `ping`, `get_public_key`
- `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`
### Admin Methods (nsecbunkerd Extension)
- Key: `get_keys`, `get_key`, `create_new_key`, `delete_key`, `unlock_key`, `rotate_key`
- Policy: `get_policies`, `get_policy`, `create_new_policy`, `delete_policy`
- Permission: `grant_permission`, `revoke_permission`, `get_permissions`, `get_key_users`, `rename_key_user`, `revoke_user`
- Token: `get_key_tokens`, `get_token`, `create_new_token`, `revoke_token`, `validate_token`

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,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks } 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(),
}));
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 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 ok', 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,
'ok',
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,108 @@
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(),
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

@ -132,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);
}
}
@ -210,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,4 +1,4 @@
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';
@ -39,5 +39,5 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
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";
@ -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";
@ -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,50 @@
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,
});
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, "ok", 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;
@ -218,7 +240,7 @@ class AdminInterface {
};
}));
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
return this.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
}
/**
@ -250,7 +272,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 +284,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 +296,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 +362,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

@ -39,7 +39,8 @@ function getKeys(config: DaemonConfig) {
const keys: Key[] = [];
for (const [name, nsec] of Object.entries(config.keys)) {
const hexpk = bytesToHex(nip19.decode(nsec).data as Uint8Array);
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,
@ -165,7 +166,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}`);

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,

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'],
},
},
});