Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/routes/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
68 changes: 68 additions & 0 deletions src/services/AgentService.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,74 @@ class AgentService {
[agentId, limit]
);
}
/**
* Request API key recovery
*
* @param {string} name - Agent name
* @returns {Promise<Object>} 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<Object>} 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;