feat(admin): companion RPCs for live policy + token revocation (#11)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled

Six new admin-RPC handlers that complement the live-policy auth
rewrite. Each follows the canonical create_new_policy.ts pattern
(single JSON-stringified param, kind-24134 response, one prisma
mutation, no per-call ACL — validateRequestFromAdmin gates them
via the admin-npub allowlist).

Policy-level mutations propagate to every KeyUser bound to the
policy at the next sign-time check (the point of #11):

  add_policy_rule    { policyId, rule: {method, kind?, maxUsageCount?} }
  remove_policy_rule { ruleId }
  update_policy      { policyId, patch: {name?, expiresAt?} }

Per-KeyUser override mutations (override layer):

  add_signing_condition    { keyUserId, condition: {method, kind?, allowed} }
  remove_signing_condition { conditionId }

Surgical token revocation without nuking the KeyUser:

  revoke_token { tokenId }

The 'all' kind literal is honored as a wildcard in add_policy_rule
for parity with the SigningCondition override-layer convention.

Param shapes ratified by webapp in
#11 (comment).

refs: #11
This commit is contained in:
Padreug 2026-05-30 13:27:28 +02:00
commit 49091f722f
7 changed files with 243 additions and 0 deletions

View file

@ -0,0 +1,47 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Add a single PolicyRule to an existing Policy. Under the live-policy
* auth model (#11), this propagates to every KeyUser bound to the
* policy at the next sign-time check.
*
* Param shape (JSON-stringified):
* { policyId: number,
* rule: { method: string,
* kind?: number | "all" | null,
* maxUsageCount?: number } }
*
* `kind` is stored as a string for parity with create_new_policy.ts's
* `rule.kind.toString()` storage and the override-layer convention. The
* `'all'` literal is honored at sign-time as a wildcard across kinds.
*/
export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { policyId, rule } = payload;
if (typeof policyId !== "number" || !rule || !rule.method) {
throw new Error("Invalid params");
}
const policy = await prisma.policy.findUnique({ where: { id: policyId } });
if (!policy) throw new Error("Policy not found");
await prisma.policyRule.create({
data: {
policyId,
method: rule.method,
kind: rule.kind !== undefined && rule.kind !== null ? rule.kind.toString() : null,
maxUsageCount: rule.maxUsageCount,
currentUsageCount: 0,
}
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -0,0 +1,46 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Add a SigningCondition override row for a specific KeyUser. Used to
* grant a user a (method, kind) combination beyond the policy, or to
* deny one explicitly even when the policy allows.
*
* Param shape (JSON-stringified):
* { keyUserId: number,
* condition: { method: string,
* kind?: number | "all",
* allowed: boolean } }
*
* The override layer is consulted before the live policy join in
* checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here
* denies regardless of the policy.
*/
export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { keyUserId, condition } = payload;
if (typeof keyUserId !== "number" || !condition || !condition.method || typeof condition.allowed !== "boolean") {
throw new Error("Invalid params");
}
const keyUser = await prisma.keyUser.findUnique({ where: { id: keyUserId } });
if (!keyUser) throw new Error("KeyUser not found");
await prisma.signingCondition.create({
data: {
keyUserId,
method: condition.method,
kind: condition.kind !== undefined && condition.kind !== null ? condition.kind.toString() : null,
allowed: condition.allowed,
}
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -0,0 +1,33 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Delete a single PolicyRule by id. Under the live-policy auth model
* (#11), removal takes effect at the next sign-time check for every
* KeyUser bound to that policy.
*
* Param shape (JSON-stringified):
* { ruleId: number }
*
* Multi-instance bunker-sharing caveat (per comment 1473 §2):
* downstream reconcilers (e.g. lnbits' lnbits-default policy
* convergence) should treat this as an admin-only op concurrent
* removes across instance versions can race. Adds are safe, removes
* are not.
*/
export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { ruleId } = payload;
if (typeof ruleId !== "number") throw new Error("Invalid params");
await prisma.policyRule.delete({ where: { id: ruleId } });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -0,0 +1,26 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Delete a SigningCondition override row by id. Used to walk back a
* per-user grant or deny without affecting the policy.
*
* Param shape (JSON-stringified):
* { conditionId: number }
*/
export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { conditionId } = payload;
if (typeof conditionId !== "number") throw new Error("Invalid params");
await prisma.signingCondition.delete({ where: { id: conditionId } });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -0,0 +1,36 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Revoke a single Token without affecting other tokens for the same
* KeyUser. Sets Token.revokedAt to now; the live-policy auth check
* (#11) filters tokens with `revokedAt IS NULL` so a revoked token's
* policy no longer contributes to any sign-time grant for the
* associated KeyUser.
*
* Param shape (JSON-stringified):
* { tokenId: number }
*
* The KeyUser remains intact and any other (non-revoked) tokens
* bound to it continue to grant via their own policies. Use
* revoke_user for the binary "this user is gone" case.
*/
export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { tokenId } = payload;
if (typeof tokenId !== "number") throw new Error("Invalid params");
await prisma.token.update({
where: { id: tokenId },
data: { revokedAt: new Date() },
});
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -0,0 +1,43 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import prisma from "../../../db.js";
/**
* Update mutable fields on a Policy. Currently `name` and `expiresAt`.
* Other Policy columns (`description`, `deletedAt`, etc.) are not
* exposed by this RPC they aren't in the ratified shape, and we want
* a single point of intent.
*
* Param shape (JSON-stringified):
* { policyId: number,
* patch: { name?: string,
* expiresAt?: string | null } }
*
* `expiresAt: null` explicitly clears the field; `expiresAt` absent
* from the patch leaves it alone.
*/
export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { policyId, patch } = payload;
if (typeof policyId !== "number" || !patch || typeof patch !== "object") {
throw new Error("Invalid params");
}
const data: { name?: string; expiresAt?: Date | null } = {};
if (patch.name !== undefined) data.name = patch.name;
if (patch.expiresAt !== undefined) {
data.expiresAt = patch.expiresAt === null ? null : new Date(patch.expiresAt);
}
if (Object.keys(data).length === 0) throw new Error("Empty patch");
await prisma.policy.update({ where: { id: policyId }, data });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134);
}

View file

@ -13,6 +13,12 @@ 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 addPolicyRule from './commands/add_policy_rule';
import removePolicyRule from './commands/remove_policy_rule';
import updatePolicy from './commands/update_policy';
import addSigningCondition from './commands/add_signing_condition';
import removeSigningCondition from './commands/remove_signing_condition';
import revokeToken from './commands/revoke_token';
import fs from 'fs';
import { validateRequestFromAdmin } from './validations/request-from-admin';
import { dmUser } from '../../utils/dm-user';
@ -202,6 +208,12 @@ class AdminInterface {
case 'create_new_policy': await createNewPolicy(this, req); break;
case 'get_policies': await this.reqListPolicies(req); break;
case 'create_new_token': await createNewToken(this, req); break;
case 'add_policy_rule': await addPolicyRule(this, req); break;
case 'remove_policy_rule': await removePolicyRule(this, req); break;
case 'update_policy': await updatePolicy(this, req); break;
case 'add_signing_condition': await addSigningCondition(this, req); break;
case 'remove_signing_condition': await removeSigningCondition(this, req); break;
case 'revoke_token': await revokeToken(this, req); break;
default:
const originalKind = req.event.kind!;
console.log(`Unknown method ${req.method}`);