mirror of
https://github.com/kind-0/nsecbunkerd.git
synced 2026-05-03 07:00:11 +00:00
Merge 9fc020126953a7598ee331c1445ba88f81774339 into f4fd7403ccf1b479c9d717210a8e4768081d75a7
This commit is contained in:
commit
d91bfc392c
2
.gitignore
vendored
2
.gitignore
vendored
@ -9,3 +9,5 @@ config
|
||||
.env
|
||||
.turbo
|
||||
prisma
|
||||
/.claude/
|
||||
/.idea/
|
||||
|
||||
46
Dockerfile
46
Dockerfile
@ -1,34 +1,62 @@
|
||||
# Build stage - needs the ndk/core directory for local file dependency
|
||||
FROM node:20.11-bullseye AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
# Copy pre-built ndk/core for the local file dependency
|
||||
# ndk/core must already be built locally (has dist/ directory)
|
||||
# Also copy ndk/node_modules which contains ndk/core's hoisted dependencies (tseep, etc.)
|
||||
COPY ndk/core /ndk/core
|
||||
COPY ndk/node_modules /ndk/node_modules
|
||||
|
||||
# Remove the prepare script from ndk/core to prevent it from trying to rebuild
|
||||
RUN sed -i 's/"prepare": "bun run build",//' /ndk/core/package.json
|
||||
|
||||
# Copy package files
|
||||
COPY nsecbunkerd/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
COPY nsecbunkerd/ .
|
||||
|
||||
# Generate prisma client and build the application
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# Prune dev dependencies to reduce image size
|
||||
RUN npm prune --production
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20.11-alpine as runtime
|
||||
FROM node:20.11-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache openssl && \
|
||||
apk add --no-cache openssl curl && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
# Copy built files from the build stage
|
||||
COPY --from=build /app .
|
||||
# Copy the ndk/core dependency and its hoisted node_modules
|
||||
COPY --from=build /ndk/core /ndk/core
|
||||
COPY --from=build /ndk/node_modules /ndk/node_modules
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN npm install --only=production
|
||||
# Copy built application with production node_modules from build stage
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
|
||||
# Copy scripts directory if it exists
|
||||
COPY --from=build /app/scripts ./scripts
|
||||
|
||||
# Create config directory for runtime configuration
|
||||
RUN mkdir -p /app/config
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# No HTTP healthcheck - nsecbunkerd doesn't expose a health endpoint
|
||||
# Docker Swarm will rely on process status
|
||||
|
||||
ENTRYPOINT [ "node", "./dist/index.js" ]
|
||||
CMD ["start"]
|
||||
|
||||
44
Dockerfile.local
Normal file
44
Dockerfile.local
Normal file
@ -0,0 +1,44 @@
|
||||
FROM node:20.11-bullseye AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json pnpm-lock.yaml ./
|
||||
|
||||
# Replace workspace:* with actual npm version for @nostr-dev-kit/ndk
|
||||
RUN sed -i 's/"@nostr-dev-kit\/ndk": "workspace:\*"/"@nostr-dev-kit\/ndk": "^2.10.0"/' package.json
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Generate prisma client and build the application
|
||||
RUN npx prisma generate
|
||||
RUN pnpm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20.11-alpine as runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache openssl && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
# Copy built files from the build stage
|
||||
COPY --from=build /app .
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN npm install -g pnpm && \
|
||||
sed -i 's/"@nostr-dev-kit\/ndk": "workspace:\*"/"@nostr-dev-kit\/ndk": "^2.10.0"/' package.json && \
|
||||
pnpm install --prod --no-frozen-lockfile
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "node", "./dist/index.js" ]
|
||||
CMD ["start"]
|
||||
120
README.md
120
README.md
@ -177,6 +177,126 @@ When a bunker provides a wallet and zapping service (`wallet` and `nostdressUrl`
|
||||
}
|
||||
```
|
||||
|
||||
# Admin API
|
||||
|
||||
nsecbunkerd exposes an admin API over Nostr (NIP-46) that allows authorized administrators to manage keys, policies, permissions, and tokens remotely.
|
||||
|
||||
## ⚠️ Breaking Change: NIP-46 Compliance (v0.11.0)
|
||||
|
||||
**Previous versions** of nsecbunkerd used event kind `24134` for admin API responses. This was a custom extension that was never part of the official NIP-46 specification.
|
||||
|
||||
**Starting with v0.11.0**, all admin communication now uses kind `24133` (`NDKKind.NostrConnect`) to be fully compliant with the [NIP-46 specification](https://github.com/nostr-protocol/nips/blob/master/46.md).
|
||||
|
||||
### Why this change?
|
||||
|
||||
1. **NIP-46 Compliance**: The NIP-46 spec only defines kind `24133` for all Nostr Connect communication. Kind `24134` was never standardized and doesn't exist in the spec.
|
||||
|
||||
2. **Interoperability**: Using the standard kind ensures compatibility with other NIP-46 implementations and clients.
|
||||
|
||||
3. **Simplification**: Having a single event kind for all NIP-46 communication (both standard signing requests and admin methods) simplifies the protocol.
|
||||
|
||||
### Migration Guide
|
||||
|
||||
If you have custom clients that communicate with the nsecbunkerd admin API:
|
||||
|
||||
- **Update your client** to listen for responses on kind `24133` instead of `24134`
|
||||
- **No changes needed** for standard NIP-46 signing operations (these already used kind `24133`)
|
||||
- **Admin clients** (like [app.nsecbunker.com](https://app.nsecbunker.com)) will need to be updated to match this change
|
||||
|
||||
## Authentication
|
||||
|
||||
Only npubs listed in the `ADMIN_NPUBS` environment variable can access admin methods. All requests and responses use kind `24133` (NIP-46 NostrConnect).
|
||||
|
||||
## Available Methods
|
||||
|
||||
### Key Management
|
||||
|
||||
| Method | Parameters | Description | Since |
|
||||
|--------|------------|-------------|-------|
|
||||
| `get_keys` | none | List all keys with their status (locked/unlocked) | |
|
||||
| `get_key` | `keyName` | Get details about a specific key | v0.11.0 |
|
||||
| `create_new_key` | `keyName`, `passphrase`, `[nsec]` | Create a new key or import existing nsec | |
|
||||
| `unlock_key` | `keyName`, `passphrase` | Unlock an encrypted key | |
|
||||
| `delete_key` | `keyName` | Soft-delete a key and its tokens | v0.11.0 |
|
||||
| `rotate_key` | `oldKeyName`, `newKeyName`, `passphrase` | Create new key and migrate permissions | v0.11.0 |
|
||||
|
||||
### Policy Management
|
||||
|
||||
| Method | Parameters | Description | Since |
|
||||
|--------|------------|-------------|-------|
|
||||
| `get_policies` | none | List all policies with their rules | |
|
||||
| `get_policy` | `policyId` | Get details about a specific policy | v0.11.0 |
|
||||
| `create_new_policy` | `policyJson` | Create a new policy with rules | |
|
||||
| `delete_policy` | `policyId` | Soft-delete a policy | v0.11.0 |
|
||||
|
||||
**Policy JSON Format:**
|
||||
```json
|
||||
{
|
||||
"name": "signing-policy",
|
||||
"expires_at": "2024-12-31T23:59:59Z",
|
||||
"rules": [
|
||||
{ "method": "sign_event", "kind": 1, "use_count": 100 },
|
||||
{ "method": "sign_event", "kind": 7 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Management
|
||||
|
||||
| Method | Parameters | Description | Since |
|
||||
|--------|------------|-------------|-------|
|
||||
| `get_key_users` | `keyName` | List all users with access to a key | |
|
||||
| `grant_permission` | `keyName`, `userPubkey`, `policyId`, `[description]` | Grant user access to a key with a policy | v0.11.0 |
|
||||
| `revoke_permission` | `keyName`, `userPubkey` | Revoke user's access to a key | v0.11.0 |
|
||||
| `get_permissions` | `keyName`, `userPubkey` | Get user's permissions on a key | v0.11.0 |
|
||||
| `rename_key_user` | `userPubkey`, `description` | Update a user's description | |
|
||||
| `revoke_user` | `keyUserId` | Revoke user by ID | |
|
||||
|
||||
### Token Management
|
||||
|
||||
| Method | Parameters | Description | Since |
|
||||
|--------|------------|-------------|-------|
|
||||
| `get_key_tokens` | `keyName` | List all tokens for a key | |
|
||||
| `get_token` | `tokenId` | Get details about a specific token | v0.11.0 |
|
||||
| `create_new_token` | `keyName`, `clientName`, `policyId`, `[durationInHours]` | Create an access token | |
|
||||
| `revoke_token` | `tokenId` | Revoke (soft-delete) a token | v0.11.0 |
|
||||
| `validate_token` | `tokenString` | Check if a token is valid | v0.11.0 |
|
||||
|
||||
### Utility
|
||||
|
||||
| Method | Parameters | Description | Since |
|
||||
|--------|------------|-------------|-------|
|
||||
| `ping` | none | Health check - returns "pong" | |
|
||||
| `create_account` | `[username]`, `[domain]`, `[email]` | Create a new user account (OAuth flow) | |
|
||||
|
||||
## Response Format
|
||||
|
||||
All responses follow NIP-46 format:
|
||||
```json
|
||||
{
|
||||
"id": "<request_id>",
|
||||
"result": "<json_stringified_result>",
|
||||
"error": "<optional_error_string>"
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
Unit tests for all admin commands were added in v0.11.0 using Vitest.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run tests with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
# Authors
|
||||
|
||||
* [pablof7z](nostr:npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
|
||||
|
||||
51
nostr-tools-v2-patch.md
Normal file
51
nostr-tools-v2-patch.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Patch to upgrade nsecbunkerd to nostr-tools v2
|
||||
|
||||
## Step 1: Update package.json
|
||||
Change: "nostr-tools": "^1.17.0" → "nostr-tools": "^2.17.0"
|
||||
|
||||
## Step 2: Add hex conversion utility
|
||||
Create file: src/utils/hex.ts
|
||||
|
||||
```typescript
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Patch files that decode nsec
|
||||
|
||||
### src/daemon/run.ts (line ~41)
|
||||
```diff
|
||||
+import { bytesToHex } from '../utils/hex.js';
|
||||
...
|
||||
-const hexpk = nip19.decode(nsec).data as string;
|
||||
+const hexpk = bytesToHex(nip19.decode(nsec).data as Uint8Array);
|
||||
```
|
||||
|
||||
### src/commands/add.ts (line ~37)
|
||||
```diff
|
||||
+import { bytesToHex } from '../utils/hex.js';
|
||||
...
|
||||
-decoded = nip19.decode(nsec);
|
||||
+const decoded = nip19.decode(nsec);
|
||||
+const hexpk = decoded.type === 'nsec' ? bytesToHex(decoded.data as Uint8Array) : decoded.data;
|
||||
```
|
||||
|
||||
### src/daemon/admin/commands/create_new_key.ts (line ~16)
|
||||
```diff
|
||||
+import { bytesToHex } from '../../../utils/hex.js';
|
||||
...
|
||||
-key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
|
||||
+key = new NDKPrivateKeySigner(bytesToHex(nip19.decode(_nsec).data as Uint8Array));
|
||||
```
|
||||
|
||||
## Note
|
||||
npubEncode/decode still uses hex strings in v2, so request-from-admin.ts works unchanged.
|
||||
8935
package-lock.json
generated
Normal file
8935
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nsecbunkerd",
|
||||
"version": "0.10.5",
|
||||
"version": "0.11.2",
|
||||
"description": "nsecbunker daemon",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@ -27,7 +27,10 @@
|
||||
"start": "node ./scripts/start.js",
|
||||
"lfg": "node ./scripts/start.js start",
|
||||
"nsecbunkerd": "node dist/index.js",
|
||||
"client": "node dist/client/client.js"
|
||||
"client": "node dist/client/client.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"keywords": [
|
||||
"nostr"
|
||||
@ -39,7 +42,7 @@
|
||||
"@fastify/view": "^8.2.0",
|
||||
"@inquirer/password": "^1.1.2",
|
||||
"@inquirer/prompts": "^1.2.3",
|
||||
"@nostr-dev-kit/ndk": "workspace:*",
|
||||
"@nostr-dev-kit/ndk": "file:../ndk/core",
|
||||
"@prisma/client": "^5.4.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@types/yargs": "^17.0.24",
|
||||
@ -57,7 +60,7 @@
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lnbits": "^1.1.5",
|
||||
"lnbits-ts": "^0.0.2",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"nostr-tools": "^2.17.0",
|
||||
"websocket-polyfill": "^0.0.3",
|
||||
"ws": "^8.13.0",
|
||||
"yargs": "^17.7.2"
|
||||
@ -68,6 +71,7 @@
|
||||
"prisma": "^5.4.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
"typescript": "^5.1.3",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
3772
pnpm-lock.yaml
generated
3772
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
97
src/daemon/admin/commands/__tests__/create_new_key.test.ts
Normal file
97
src/daemon/admin/commands/__tests__/create_new_key.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createMockAdmin, createMockRequest, getResponseResult, resetMocks, mockPrisma } from './test-utils';
|
||||
|
||||
// Mock the saveEncrypted function
|
||||
vi.mock('../../../../commands/add.js', () => ({
|
||||
saveEncrypted: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock the setupSkeletonProfile function
|
||||
vi.mock('../../../lib/profile.js', () => ({
|
||||
setupSkeletonProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the prisma module
|
||||
vi.mock('../../../../db.js', () => ({
|
||||
default: mockPrisma,
|
||||
}));
|
||||
|
||||
import createNewKey from '../create_new_key';
|
||||
|
||||
describe('create_new_key', () => {
|
||||
beforeEach(() => {
|
||||
resetMocks();
|
||||
});
|
||||
|
||||
it('should throw error when keyName is not provided', async () => {
|
||||
const admin = createMockAdmin();
|
||||
const req = createMockRequest([]);
|
||||
|
||||
await expect(createNewKey(admin as any, req)).rejects.toThrow('Invalid params');
|
||||
});
|
||||
|
||||
it('should throw error when passphrase is not provided', async () => {
|
||||
const admin = createMockAdmin();
|
||||
const req = createMockRequest(['my-key']);
|
||||
|
||||
await expect(createNewKey(admin as any, req)).rejects.toThrow('Invalid params');
|
||||
});
|
||||
|
||||
it('should throw error when loadNsec is not implemented', async () => {
|
||||
const admin = createMockAdmin({ loadNsec: undefined });
|
||||
const req = createMockRequest(['my-key', 'passphrase']);
|
||||
|
||||
await expect(createNewKey(admin as any, req)).rejects.toThrow('No unlockKey method');
|
||||
});
|
||||
|
||||
it('should generate a new key when no nsec provided', async () => {
|
||||
const admin = createMockAdmin();
|
||||
const req = createMockRequest(['my-key', 'passphrase']);
|
||||
|
||||
await createNewKey(admin as any, req);
|
||||
|
||||
// Verify loadNsec was called with the key name and an nsec
|
||||
expect(admin.loadNsec).toHaveBeenCalledWith(
|
||||
'my-key',
|
||||
expect.stringMatching(/^nsec1/)
|
||||
);
|
||||
|
||||
// Verify key was saved to database
|
||||
expect(mockPrisma.key.upsert).toHaveBeenCalledWith({
|
||||
where: { keyName: 'my-key' },
|
||||
update: {
|
||||
pubkey: expect.any(String),
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
keyName: 'my-key',
|
||||
pubkey: expect.any(String),
|
||||
},
|
||||
});
|
||||
|
||||
// Verify response contains npub
|
||||
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
|
||||
const result = getResponseResult(admin);
|
||||
expect(result.npub).toMatch(/^npub1/);
|
||||
});
|
||||
|
||||
it('should import existing key when nsec provided', async () => {
|
||||
const admin = createMockAdmin();
|
||||
// Valid nsec for testing (generates a known pubkey)
|
||||
const testNsec = 'nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw';
|
||||
const req = createMockRequest(['my-key', 'passphrase', testNsec]);
|
||||
|
||||
await createNewKey(admin as any, req);
|
||||
|
||||
// Verify loadNsec was called
|
||||
expect(admin.loadNsec).toHaveBeenCalledWith(
|
||||
'my-key',
|
||||
expect.stringMatching(/^nsec1/)
|
||||
);
|
||||
|
||||
// Verify response
|
||||
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
|
||||
const result = getResponseResult(admin);
|
||||
expect(result.npub).toMatch(/^npub1/);
|
||||
});
|
||||
});
|
||||
124
src/daemon/admin/commands/__tests__/create_new_policy.test.ts
Normal file
124
src/daemon/admin/commands/__tests__/create_new_policy.test.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/daemon/admin/commands/__tests__/create_new_token.test.ts
Normal file
128
src/daemon/admin/commands/__tests__/create_new_token.test.ts
Normal 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]+$/);
|
||||
});
|
||||
});
|
||||
74
src/daemon/admin/commands/__tests__/delete_key.test.ts
Normal file
74
src/daemon/admin/commands/__tests__/delete_key.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
76
src/daemon/admin/commands/__tests__/delete_policy.test.ts
Normal file
76
src/daemon/admin/commands/__tests__/delete_policy.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
85
src/daemon/admin/commands/__tests__/get_key.test.ts
Normal file
85
src/daemon/admin/commands/__tests__/get_key.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
src/daemon/admin/commands/__tests__/get_permissions.test.ts
Normal file
93
src/daemon/admin/commands/__tests__/get_permissions.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
82
src/daemon/admin/commands/__tests__/get_policy.test.ts
Normal file
82
src/daemon/admin/commands/__tests__/get_policy.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
132
src/daemon/admin/commands/__tests__/get_token.test.ts
Normal file
132
src/daemon/admin/commands/__tests__/get_token.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
169
src/daemon/admin/commands/__tests__/grant_permission.test.ts
Normal file
169
src/daemon/admin/commands/__tests__/grant_permission.test.ts
Normal 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/),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
25
src/daemon/admin/commands/__tests__/ping.test.ts
Normal file
25
src/daemon/admin/commands/__tests__/ping.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createMockAdmin, createMockRequest, resetMocks } from './test-utils';
|
||||
|
||||
import ping from '../ping';
|
||||
|
||||
describe('ping', () => {
|
||||
beforeEach(() => {
|
||||
resetMocks();
|
||||
});
|
||||
|
||||
it('should respond with pong', async () => {
|
||||
const admin = createMockAdmin();
|
||||
const req = createMockRequest([]);
|
||||
|
||||
await ping(admin as any, req);
|
||||
|
||||
expect(admin.rpc.sendResponse).toHaveBeenCalledTimes(1);
|
||||
expect(admin.rpc.sendResponse).toHaveBeenCalledWith(
|
||||
req.id,
|
||||
req.pubkey,
|
||||
'pong',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
71
src/daemon/admin/commands/__tests__/rename_key_user.test.ts
Normal file
71
src/daemon/admin/commands/__tests__/rename_key_user.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
74
src/daemon/admin/commands/__tests__/revoke_token.test.ts
Normal file
74
src/daemon/admin/commands/__tests__/revoke_token.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
67
src/daemon/admin/commands/__tests__/revoke_user.test.ts
Normal file
67
src/daemon/admin/commands/__tests__/revoke_user.test.ts
Normal 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) },
|
||||
});
|
||||
});
|
||||
});
|
||||
123
src/daemon/admin/commands/__tests__/rotate_key.test.ts
Normal file
123
src/daemon/admin/commands/__tests__/rotate_key.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
109
src/daemon/admin/commands/__tests__/test-utils.ts
Normal file
109
src/daemon/admin/commands/__tests__/test-utils.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { NDKRpcRequest } from '@nostr-dev-kit/ndk';
|
||||
import type AdminInterface from '../../index.js';
|
||||
|
||||
// Mock Prisma client
|
||||
export const mockPrisma = {
|
||||
key: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
keyUser: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
policy: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
policyRule: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
token: {
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
signingCondition: {
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Reset all mocks
|
||||
export function resetMocks() {
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
|
||||
// Create a mock NDKRpcRequest
|
||||
export function createMockRequest(params: string[] = []): NDKRpcRequest {
|
||||
return {
|
||||
id: 'test-request-id',
|
||||
pubkey: 'test-pubkey-hex',
|
||||
method: 'test-method',
|
||||
params,
|
||||
event: {
|
||||
kind: 24133, // NIP-46 NostrConnect kind
|
||||
},
|
||||
} as NDKRpcRequest;
|
||||
}
|
||||
|
||||
// Create a mock AdminInterface
|
||||
export function createMockAdmin(overrides: Partial<MockAdminInterface> = {}): MockAdminInterface {
|
||||
const sendResponseMock = vi.fn();
|
||||
|
||||
return {
|
||||
configFile: '/tmp/test-config.json',
|
||||
rpc: {
|
||||
sendResponse: sendResponseMock,
|
||||
},
|
||||
getKeys: vi.fn().mockResolvedValue([]),
|
||||
getKeyUsers: vi.fn().mockResolvedValue([]),
|
||||
unlockKey: vi.fn().mockResolvedValue(true),
|
||||
loadNsec: vi.fn(),
|
||||
config: vi.fn().mockResolvedValue({}),
|
||||
...overrides,
|
||||
} as MockAdminInterface;
|
||||
}
|
||||
|
||||
export interface MockAdminInterface {
|
||||
configFile: string;
|
||||
rpc: {
|
||||
sendResponse: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
getKeys?: ReturnType<typeof vi.fn>;
|
||||
getKeyUsers?: ReturnType<typeof vi.fn>;
|
||||
unlockKey?: ReturnType<typeof vi.fn>;
|
||||
loadNsec?: ReturnType<typeof vi.fn>;
|
||||
config?: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
// Helper to extract the result from sendResponse mock call
|
||||
export function getResponseResult(admin: MockAdminInterface): any {
|
||||
const calls = admin.rpc.sendResponse.mock.calls;
|
||||
if (calls.length === 0) return null;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
return JSON.parse(lastCall[2]); // result is the 3rd argument
|
||||
}
|
||||
|
||||
// Helper to check if response was an error
|
||||
export function getResponseError(admin: MockAdminInterface): string | null {
|
||||
const calls = admin.rpc.sendResponse.mock.calls;
|
||||
if (calls.length === 0) return null;
|
||||
const lastCall = calls[calls.length - 1];
|
||||
return lastCall[4] || null; // error is the 5th argument
|
||||
}
|
||||
61
src/daemon/admin/commands/__tests__/unlock_key.test.ts
Normal file
61
src/daemon/admin/commands/__tests__/unlock_key.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
138
src/daemon/admin/commands/__tests__/validate_token.test.ts
Normal file
138
src/daemon/admin/commands/__tests__/validate_token.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
import { Hexpubkey, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "..";
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { hexToBytes } from "../../../utils/hex.js";
|
||||
import { setupSkeletonProfile } from "../../lib/profile";
|
||||
import { IConfig, getCurrentConfig, saveCurrentConfig } from "../../../config";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
@ -131,7 +132,7 @@ export default async function createAccount(admin: AdminInterface, req: NDKRpcRe
|
||||
username = payload[0];
|
||||
domain = payload[1];
|
||||
email = payload[2];
|
||||
return createAccountReal(admin, req, username, domain, email);
|
||||
return createAccountReal(admin, req, username!, domain!, email);
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,7 +196,7 @@ export async function createAccountReal(
|
||||
}
|
||||
|
||||
const keyName = nip05;
|
||||
const nsec = nip19.nsecEncode(key.privateKey!);
|
||||
const nsec = nip19.nsecEncode(hexToBytes(key.privateKey!));
|
||||
currentConfig.keys[keyName] = { key: key.privateKey };
|
||||
|
||||
saveCurrentConfig(admin.configFile, currentConfig);
|
||||
@ -209,10 +210,10 @@ export async function createAccountReal(
|
||||
// access it without having to go through an approval flow
|
||||
await grantPermissions(req, keyName);
|
||||
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnectAdmin);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, generatedUser.pubkey, NDKKind.NostrConnect);
|
||||
} catch (e: any) {
|
||||
console.trace('error', e);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin,
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnect,
|
||||
e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
|
||||
import NDK, { NDKEvent, NDKKind, NDKPrivateKeySigner, NDKRpcRequest, type NostrEvent } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import { saveEncrypted } from "../../../commands/add.js";
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { setupSkeletonProfile } from "../../lib/profile.js";
|
||||
import { bytesToHex, hexToBytes } from "../../../utils/hex.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
export default async function createNewKey(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [ keyName, passphrase, _nsec ] = req.params as [ string, string, string? ];
|
||||
@ -13,7 +15,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
|
||||
let key;
|
||||
|
||||
if (_nsec) {
|
||||
key = new NDKPrivateKeySigner(nip19.decode(_nsec).data as string);
|
||||
key = new NDKPrivateKeySigner(bytesToHex(nip19.decode(_nsec).data as Uint8Array));
|
||||
} else {
|
||||
key = NDKPrivateKeySigner.generate();
|
||||
|
||||
@ -23,7 +25,7 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
|
||||
}
|
||||
|
||||
const user = await key.user();
|
||||
const nsec = nip19.nsecEncode(key.privateKey!);
|
||||
const nsec = nip19.nsecEncode(hexToBytes(key.privateKey!));
|
||||
|
||||
await saveEncrypted(
|
||||
admin.configFile,
|
||||
@ -34,9 +36,22 @@ export default async function createNewKey(admin: AdminInterface, req: NDKRpcReq
|
||||
|
||||
await admin.loadNsec(keyName, nsec);
|
||||
|
||||
// Also save to database so delete_key can find it
|
||||
await prisma.key.upsert({
|
||||
where: { keyName },
|
||||
update: {
|
||||
pubkey: user.pubkey,
|
||||
deletedAt: null, // Ensure it's not marked as deleted
|
||||
},
|
||||
create: {
|
||||
keyName,
|
||||
pubkey: user.pubkey,
|
||||
},
|
||||
});
|
||||
|
||||
const result = JSON.stringify({
|
||||
npub: user.npub,
|
||||
});
|
||||
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
@ -19,9 +19,9 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
|
||||
for (const rule of policy.rules) {
|
||||
await prisma.policyRule.create({
|
||||
data: {
|
||||
policyId: policyRecord.id,
|
||||
kind: rule.kind.toString(),
|
||||
method: rule.method,
|
||||
Policy: { connect: { id: policyRecord.id } },
|
||||
kind: rule.kind != null ? rule.kind.toString() : null,
|
||||
method: rule.method ?? "sign_event",
|
||||
maxUsageCount: rule.use_count,
|
||||
currentUsageCount: 0,
|
||||
}
|
||||
@ -29,5 +29,5 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
|
||||
}
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
@ -15,7 +15,7 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
|
||||
|
||||
const token = [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
const data: any = {
|
||||
keyName, clientName, policyId,
|
||||
keyName, clientName, policyId: parseInt(policyId),
|
||||
createdBy: req.pubkey,
|
||||
token
|
||||
};
|
||||
@ -26,5 +26,5 @@ export default async function createNewToken(admin: AdminInterface, req: NDKRpcR
|
||||
if (!tokenRecord) throw new Error("Token not created");
|
||||
|
||||
const result = JSON.stringify(["ok"]);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
41
src/daemon/admin/commands/delete_key.ts
Normal file
41
src/daemon/admin/commands/delete_key.ts
Normal 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);
|
||||
}
|
||||
34
src/daemon/admin/commands/delete_policy.ts
Normal file
34
src/daemon/admin/commands/delete_policy.ts
Normal 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);
|
||||
}
|
||||
33
src/daemon/admin/commands/get_key.ts
Normal file
33
src/daemon/admin/commands/get_key.ts
Normal 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);
|
||||
}
|
||||
63
src/daemon/admin/commands/get_permissions.ts
Normal file
63
src/daemon/admin/commands/get_permissions.ts
Normal 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);
|
||||
}
|
||||
43
src/daemon/admin/commands/get_policy.ts
Normal file
43
src/daemon/admin/commands/get_policy.ts
Normal 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);
|
||||
}
|
||||
51
src/daemon/admin/commands/get_token.ts
Normal file
51
src/daemon/admin/commands/get_token.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
import prisma from "../../../db.js";
|
||||
|
||||
export default async function getToken(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
const [tokenIdStr] = req.params as [string];
|
||||
|
||||
if (!tokenIdStr) throw new Error("Invalid params: tokenId required");
|
||||
|
||||
const tokenId = parseInt(tokenIdStr);
|
||||
if (isNaN(tokenId)) throw new Error("Invalid params: tokenId must be a number");
|
||||
|
||||
const token = await prisma.token.findUnique({
|
||||
where: { id: tokenId },
|
||||
include: {
|
||||
policy: true,
|
||||
KeyUser: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new Error(`Token with id '${tokenId}' not found`);
|
||||
}
|
||||
|
||||
// Get the key's npub for the token string
|
||||
let npub: string | null = null;
|
||||
if (admin.getKeys) {
|
||||
const keys = await admin.getKeys();
|
||||
const key = keys.find((k) => k.name === token.keyName);
|
||||
npub = key?.npub || null;
|
||||
}
|
||||
|
||||
const result = JSON.stringify({
|
||||
id: token.id,
|
||||
key_name: token.keyName,
|
||||
token: npub ? `${npub}#${token.token}` : token.token,
|
||||
client_name: token.clientName,
|
||||
created_by: token.createdBy,
|
||||
policy_id: token.policyId,
|
||||
policy_name: token.policy?.name,
|
||||
created_at: token.createdAt,
|
||||
updated_at: token.updatedAt,
|
||||
expires_at: token.expiresAt,
|
||||
deleted_at: token.deletedAt,
|
||||
redeemed_at: token.redeemedAt,
|
||||
redeemed_by: token.KeyUser?.description,
|
||||
revoked: !!token.deletedAt, // Revoked if deletedAt is set
|
||||
});
|
||||
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
88
src/daemon/admin/commands/grant_permission.ts
Normal file
88
src/daemon/admin/commands/grant_permission.ts
Normal 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);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import { NDKKind, NDKRpcRequest } from "@nostr-dev-kit/ndk";
|
||||
import AdminInterface from "../index.js";
|
||||
|
||||
export default async function ping(admin: AdminInterface, req: NDKRpcRequest) {
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "ok", 24134);
|
||||
return admin.rpc.sendResponse(req.id, req.pubkey, "pong", NDKKind.NostrConnect);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
53
src/daemon/admin/commands/revoke_permission.ts
Normal file
53
src/daemon/admin/commands/revoke_permission.ts
Normal 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);
|
||||
}
|
||||
34
src/daemon/admin/commands/revoke_token.ts
Normal file
34
src/daemon/admin/commands/revoke_token.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
114
src/daemon/admin/commands/rotate_key.ts
Normal file
114
src/daemon/admin/commands/rotate_key.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
56
src/daemon/admin/commands/validate_token.ts
Normal file
56
src/daemon/admin/commands/validate_token.ts
Normal 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);
|
||||
}
|
||||
@ -13,6 +13,17 @@ import createNewToken from './commands/create_new_token';
|
||||
import unlockKey from './commands/unlock_key';
|
||||
import renameKeyUser from './commands/rename_key_user.js';
|
||||
import revokeUser from './commands/revoke_user';
|
||||
import getKey from './commands/get_key';
|
||||
import deleteKey from './commands/delete_key';
|
||||
import rotateKey from './commands/rotate_key';
|
||||
import getPolicy from './commands/get_policy';
|
||||
import deletePolicy from './commands/delete_policy';
|
||||
import grantPermission from './commands/grant_permission';
|
||||
import revokePermission from './commands/revoke_permission';
|
||||
import getPermissions from './commands/get_permissions';
|
||||
import getToken from './commands/get_token';
|
||||
import revokeToken from './commands/revoke_token';
|
||||
import validateToken from './commands/validate_token';
|
||||
import fs from 'fs';
|
||||
import { validateRequestFromAdmin } from './validations/request-from-admin';
|
||||
import { dmUser } from '../../utils/dm-user';
|
||||
@ -116,7 +127,7 @@ class AdminInterface {
|
||||
this.ndk.connect(2500).then(() => {
|
||||
// connect for whitelisted admins
|
||||
this.rpc.subscribe({
|
||||
"kinds": [NDKKind.NostrConnect, 24134 as number],
|
||||
"kinds": [NDKKind.NostrConnect],
|
||||
"#p": [this.signerUser!.pubkey]
|
||||
});
|
||||
|
||||
@ -135,17 +146,28 @@ class AdminInterface {
|
||||
|
||||
switch (req.method) {
|
||||
case 'get_keys': await this.reqGetKeys(req); break;
|
||||
case 'get_key': await getKey(this, req); break;
|
||||
case 'get_key_users': await this.reqGetKeyUsers(req); break;
|
||||
case 'rename_key_user': await renameKeyUser(this, req); break;
|
||||
case 'get_key_tokens': await this.reqGetKeyTokens(req); break;
|
||||
case 'revoke_user': await revokeUser(this, req); break;
|
||||
case 'create_new_key': await createNewKey(this, req); break;
|
||||
case 'delete_key': await deleteKey(this, req); break;
|
||||
case 'rotate_key': await rotateKey(this, req); break;
|
||||
case 'create_account': await createAccount(this, req); break;
|
||||
case 'ping': await ping(this, req); break;
|
||||
case 'unlock_key': await unlockKey(this, req); break;
|
||||
case 'create_new_policy': await createNewPolicy(this, req); break;
|
||||
case 'get_policies': await this.reqListPolicies(req); break;
|
||||
case 'get_policy': await getPolicy(this, req); break;
|
||||
case 'delete_policy': await deletePolicy(this, req); break;
|
||||
case 'grant_permission': await grantPermission(this, req); break;
|
||||
case 'revoke_permission': await revokePermission(this, req); break;
|
||||
case 'get_permissions': await getPermissions(this, req); break;
|
||||
case 'create_new_token': await createNewToken(this, req); break;
|
||||
case 'get_token': await getToken(this, req); break;
|
||||
case 'revoke_token': await revokeToken(this, req); break;
|
||||
case 'validate_token': await validateToken(this, req); break;
|
||||
default:
|
||||
const originalKind = req.event.kind!;
|
||||
console.log(`Unknown method ${req.method}`);
|
||||
@ -158,7 +180,7 @@ class AdminInterface {
|
||||
}
|
||||
} catch (err: any) {
|
||||
debug(`Error handling request ${req.method}: ${err?.message??err}`, req.params);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnectAdmin, err?.message);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, "error", NDKKind.NostrConnect, err?.message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,7 +218,7 @@ class AdminInterface {
|
||||
const key = keys.find((k) => k.name === keyName);
|
||||
|
||||
if (!key || !key.npub) {
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), 24134);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), NDKKind.NostrConnect);
|
||||
}
|
||||
|
||||
const npub = key.npub;
|
||||
@ -205,6 +227,7 @@ class AdminInterface {
|
||||
return {
|
||||
id: t.id,
|
||||
key_name: t.keyName,
|
||||
key_npub: npub,
|
||||
client_name: t.clientName,
|
||||
token: [ npub, t.token ].join('#'),
|
||||
policy_id: t.policyId,
|
||||
@ -215,10 +238,11 @@ class AdminInterface {
|
||||
redeemed_at: t.redeemedAt,
|
||||
redeemed_by: t.KeyUser?.description,
|
||||
time_until_expiration: t.expiresAt ? (t.expiresAt.getTime() - Date.now()) / 1000 : null,
|
||||
revoked: !!t.deletedAt, // Revoked if deletedAt is set
|
||||
};
|
||||
}));
|
||||
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -250,7 +274,7 @@ class AdminInterface {
|
||||
};
|
||||
}));
|
||||
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
||||
return this.rpc.sendResponse(req.id, req.pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -262,7 +286,7 @@ class AdminInterface {
|
||||
const result = JSON.stringify(await this.getKeys());
|
||||
const pubkey = req.pubkey;
|
||||
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -274,7 +298,7 @@ class AdminInterface {
|
||||
const result = JSON.stringify(await this.getKeyUsers(req));
|
||||
const pubkey = req.pubkey;
|
||||
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
|
||||
return this.rpc.sendResponse(req.id, pubkey, result, NDKKind.NostrConnect);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -340,7 +364,7 @@ class AdminInterface {
|
||||
remoteUser.pubkey,
|
||||
'acl',
|
||||
[params],
|
||||
24134,
|
||||
NDKKind.NostrConnect,
|
||||
(res: NDKRpcResponse) => {
|
||||
this.requestPermissionResponse(
|
||||
remotePubkey,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { bytesToHex, hexToBytes } from '../utils/hex.js';
|
||||
import { Backend } from './backend/index.js';
|
||||
import {
|
||||
IMethod,
|
||||
@ -34,11 +35,22 @@ export type KeyUser = {
|
||||
|
||||
function getKeys(config: DaemonConfig) {
|
||||
return async (): Promise<Key[]> => {
|
||||
let lockedKeyNames = Object.keys(config.allKeys);
|
||||
// Get soft-deleted key names from database to exclude them
|
||||
const deletedKeys = await prisma.key.findMany({
|
||||
where: { deletedAt: { not: null } },
|
||||
select: { keyName: true },
|
||||
});
|
||||
const deletedKeyNames = new Set(deletedKeys.map(k => k.keyName));
|
||||
|
||||
let lockedKeyNames = Object.keys(config.allKeys).filter(name => !deletedKeyNames.has(name));
|
||||
const keys: Key[] = [];
|
||||
|
||||
for (const [name, nsec] of Object.entries(config.keys)) {
|
||||
const hexpk = nip19.decode(nsec).data as string;
|
||||
// Skip soft-deleted keys
|
||||
if (deletedKeyNames.has(name)) continue;
|
||||
|
||||
const decoded = nip19.decode(nsec) as unknown as { type: 'nsec', data: Uint8Array };
|
||||
const hexpk = bytesToHex(decoded.data);
|
||||
const user = await new NDKPrivateKeySigner(hexpk).user();
|
||||
const key = {
|
||||
name,
|
||||
@ -82,6 +94,7 @@ function getKeyUsers(config: IConfig) {
|
||||
createdAt: user.createdAt,
|
||||
lastUsedAt: user.lastUsedAt || undefined,
|
||||
revokedAt: user.revokedAt || undefined,
|
||||
active: !user.revokedAt, // Active if not revoked
|
||||
signingConditions: user.signingConditions, // Include signing conditions
|
||||
};
|
||||
|
||||
@ -164,7 +177,7 @@ class Daemon {
|
||||
explicitRelayUrls: config.nostr.relays,
|
||||
});
|
||||
this.ndk.pool.on('relay:connect', (r) => console.log(`✅ Connected to ${r.url}`) );
|
||||
this.ndk.pool.on('relay:notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); });
|
||||
(this.ndk.pool as any).on('relay:notice', (n: string, r: any) => { console.log(`👀 Notice from ${r.url}`, n); });
|
||||
|
||||
this.ndk.pool.on('relay:disconnect', (r) => {
|
||||
console.log(`🚫 Disconnected from ${r.url}`);
|
||||
@ -206,7 +219,7 @@ class Daemon {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nsec = nip19.nsecEncode(settings.key);
|
||||
const nsec = nip19.nsecEncode(hexToBytes(settings.key));
|
||||
this.loadNsec(keyName, nsec);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ async function validateAuthCookie(request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { pubkey: jwt }
|
||||
});
|
||||
|
||||
@ -230,7 +230,7 @@ export async function processRegistrationWebHandler(request, reply) {
|
||||
|
||||
await allowAllRequestsFromKey(
|
||||
record.remotePubkey,
|
||||
record.keyName,
|
||||
record.keyName!,
|
||||
record.method,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
17
src/utils/hex.ts
Normal file
17
src/utils/hex.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Convert Uint8Array to hex string
|
||||
*/
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
*/
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user