diff --git a/src/daemon/admin/commands/add_policy_rule.ts b/src/daemon/admin/commands/add_policy_rule.ts new file mode 100644 index 0000000..041fd1e --- /dev/null +++ b/src/daemon/admin/commands/add_policy_rule.ts @@ -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); +} diff --git a/src/daemon/admin/commands/add_signing_condition.ts b/src/daemon/admin/commands/add_signing_condition.ts new file mode 100644 index 0000000..6cfad88 --- /dev/null +++ b/src/daemon/admin/commands/add_signing_condition.ts @@ -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); +} diff --git a/src/daemon/admin/commands/remove_policy_rule.ts b/src/daemon/admin/commands/remove_policy_rule.ts new file mode 100644 index 0000000..b983acd --- /dev/null +++ b/src/daemon/admin/commands/remove_policy_rule.ts @@ -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); +} diff --git a/src/daemon/admin/commands/remove_signing_condition.ts b/src/daemon/admin/commands/remove_signing_condition.ts new file mode 100644 index 0000000..65eff96 --- /dev/null +++ b/src/daemon/admin/commands/remove_signing_condition.ts @@ -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); +} diff --git a/src/daemon/admin/commands/revoke_token.ts b/src/daemon/admin/commands/revoke_token.ts new file mode 100644 index 0000000..1224d0a --- /dev/null +++ b/src/daemon/admin/commands/revoke_token.ts @@ -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); +} diff --git a/src/daemon/admin/commands/update_policy.ts b/src/daemon/admin/commands/update_policy.ts new file mode 100644 index 0000000..43028e5 --- /dev/null +++ b/src/daemon/admin/commands/update_policy.ts @@ -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); +} diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index db8733b..21e5f23 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -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}`);