diff --git a/scripts/schema.sql b/scripts/schema.sql index 876d570..98715ce 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -15,6 +15,7 @@ CREATE TABLE agents ( -- Authentication api_key_hash VARCHAR(64) NOT NULL, claim_token VARCHAR(80), + recovery_token VARCHAR(80), verification_code VARCHAR(16), -- Status diff --git a/src/routes/agents.js b/src/routes/agents.js index 58398ef..0f85466 100644 --- a/src/routes/agents.js +++ b/src/routes/agents.js @@ -122,4 +122,25 @@ router.delete('/:name/follow', requireAuth, asyncHandler(async (req, res) => { success(res, result); })); +/** + * POST /agents/recover + * Request API key recovery + */ +router.post('/recover', asyncHandler(async (req, res) => { + const { name } = req.body; + const result = await AgentService.requestRecovery(name); + success(res, result); +})); + +/** + * POST /agents/verify-recovery + * Verify recovery token and issue new API key + * Internal route typically called by web server after X login + */ +router.post('/verify-recovery', asyncHandler(async (req, res) => { + const { token, twitterData } = req.body; + const result = await AgentService.verifyRecovery(token, twitterData); + success(res, result); +})); + module.exports = router; diff --git a/src/services/AgentService.js b/src/services/AgentService.js index 29bc501..ab681b7 100644 --- a/src/services/AgentService.js +++ b/src/services/AgentService.js @@ -325,6 +325,74 @@ class AgentService { [agentId, limit] ); } + /** + * Request API key recovery + * + * @param {string} name - Agent name + * @returns {Promise} Recovery URL + */ + static async requestRecovery(name) { + const agent = await this.findByName(name); + + if (!agent) { + throw new NotFoundError('Agent'); + } + + if (!agent.is_claimed) { + throw new BadRequestError('Agent has not been claimed yet. Use the claim URL instead.'); + } + + const recoveryToken = generateClaimToken().replace('claim_', 'recover_'); + + await queryOne( + `UPDATE agents SET recovery_token = $1, updated_at = NOW() WHERE id = $2`, + [recoveryToken, agent.id] + ); + + return { + recovery_url: `${config.moltbook.baseUrl}/recover/${recoveryToken}`, + message: 'Human owner must visit this URL and sign in with X to issue a new API key.' + }; + } + + /** + * Verify recovery and issue new API key + * + * @param {string} recoveryToken - Recovery token + * @param {Object} twitterData - Verified Twitter data + * @returns {Promise} New API key + */ + static async verifyRecovery(recoveryToken, twitterData) { + const agent = await queryOne( + 'SELECT id, owner_twitter_id FROM agents WHERE recovery_token = $1', + [recoveryToken] + ); + + if (!agent) { + throw new NotFoundError('Recovery token'); + } + + if (agent.owner_twitter_id !== twitterData.id) { + throw new BadRequestError('Twitter account does not match the agent owner'); + } + + const newApiKey = generateApiKey(); + const apiKeyHash = hashToken(newApiKey); + + await queryOne( + `UPDATE agents + SET api_key_hash = $1, + recovery_token = NULL, + updated_at = NOW() + WHERE id = $2`, + [apiKeyHash, agent.id] + ); + + return { + api_key: newApiKey, + message: 'API key successfully rotated. Save this key immediately!' + }; + } } module.exports = AgentService;