Skip to content

Commit d817beb

Browse files
Claudeclaude
authored andcommitted
fix: deactivate command now removes device server-side
Previously `gitmem-mcp deactivate` only cleared local credentials but left the device registered in gitmem_license_activations, consuming a device slot permanently. Now it calls the new gitmem_deactivate_device RPC to free the slot before clearing local config. - Add gitmem_deactivate_device() SECURITY DEFINER RPC to schema - Update deactivate.ts to call RPC with api_key + install_id - Graceful fallback: local cleanup still happens if server call fails - Tested full cycle: activate → deactivate → re-activate in Docker clean room Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cac9a8f commit d817beb

2 files changed

Lines changed: 106 additions & 8 deletions

File tree

schema/setup.sql

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,42 @@ BEGIN
414414
RETURN QUERY SELECT v_tier, true, 'Valid'::TEXT;
415415
END;
416416
$$;
417+
418+
-- ============================================================================
419+
-- Device Deactivation RPC
420+
-- Removes a device activation for a license key + install_id pair.
421+
-- Called by `gitmem-mcp deactivate` to free up a device slot.
422+
-- ============================================================================
423+
CREATE OR REPLACE FUNCTION gitmem_deactivate_device(p_api_key TEXT, p_install_id TEXT)
424+
RETURNS TABLE(success BOOLEAN, message TEXT)
425+
LANGUAGE plpgsql
426+
SECURITY DEFINER
427+
AS $$
428+
DECLARE
429+
v_license_id UUID;
430+
v_deleted INTEGER;
431+
BEGIN
432+
-- Look up the license
433+
SELECT l.id INTO v_license_id
434+
FROM gitmem_licenses l
435+
WHERE l.api_key = p_api_key;
436+
437+
IF v_license_id IS NULL THEN
438+
RETURN QUERY SELECT false, 'Invalid license key'::TEXT;
439+
RETURN;
440+
END IF;
441+
442+
-- Delete the activation for this install_id
443+
DELETE FROM gitmem_license_activations a
444+
WHERE a.license_id = v_license_id
445+
AND a.install_id = p_install_id;
446+
447+
GET DIAGNOSTICS v_deleted = ROW_COUNT;
448+
449+
IF v_deleted > 0 THEN
450+
RETURN QUERY SELECT true, 'Device deactivated'::TEXT;
451+
ELSE
452+
RETURN QUERY SELECT true, 'Device was not registered (already deactivated)'::TEXT;
453+
END IF;
454+
END;
455+
$$;

src/commands/deactivate.ts

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,60 @@
11
/**
22
* GitMem Pro Deactivation
33
*
4-
* Removes api_key, supabase_url, supabase_key, openrouter_key from config.json.
5-
* Deletes license-cache.json.
4+
* 1. Calls gitmem_deactivate_device RPC to remove this device server-side
5+
* 2. Removes api_key, supabase_url, supabase_key, openrouter_key from config.json
6+
* 3. Deletes license-cache.json
67
* Does NOT remove .gitmem/ directory or local data.
78
*/
89

910
import * as fs from "fs";
1011
import * as path from "path";
11-
import { getGitmemDir } from "../services/gitmem-dir.js";
12-
import { clearLicenseCache } from "../services/license.js";
12+
import { getGitmemDir, getInstallId } from "../services/gitmem-dir.js";
13+
import {
14+
clearLicenseCache,
15+
getLicenseKey,
16+
getValidationUrl,
17+
} from "../services/license.js";
18+
19+
// Same infra endpoint as validation — just different RPC
20+
const DEACTIVATION_URL =
21+
getValidationUrl().replace("gitmem_validate_license", "gitmem_deactivate_device");
22+
const VALIDATION_ANON_KEY =
23+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNqcHR4eWV6dXhkaWludWZncnJtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYxODY3MDMsImV4cCI6MjA4MTc2MjcwM30.L0oZy3LYCMikmZ15IUU5DnfJmucM37DJ14nUkM3AreY";
24+
25+
async function deactivateDeviceRemote(
26+
apiKey: string,
27+
installId: string,
28+
): Promise<{ success: boolean; message: string }> {
29+
try {
30+
const controller = new AbortController();
31+
const timeout = setTimeout(() => controller.abort(), 10000);
32+
33+
const response = await fetch(DEACTIVATION_URL, {
34+
method: "POST",
35+
headers: {
36+
"Content-Type": "application/json",
37+
apikey: VALIDATION_ANON_KEY,
38+
Authorization: `Bearer ${VALIDATION_ANON_KEY}`,
39+
},
40+
body: JSON.stringify({ p_api_key: apiKey, p_install_id: installId }),
41+
signal: controller.signal,
42+
});
43+
44+
clearTimeout(timeout);
45+
46+
if (!response.ok) {
47+
return { success: false, message: `HTTP ${response.status}` };
48+
}
49+
50+
const rows = (await response.json()) as { success: boolean; message: string }[];
51+
const data = Array.isArray(rows) ? rows[0] : rows;
52+
return data || { success: false, message: "Empty response" };
53+
} catch (err: unknown) {
54+
const message = err instanceof Error ? err.message : "Unknown error";
55+
return { success: false, message: `Network error: ${message}` };
56+
}
57+
}
1358

1459
export async function main(_args: string[]): Promise<void> {
1560
const gitmemDir = getGitmemDir();
@@ -28,9 +73,22 @@ export async function main(_args: string[]): Promise<void> {
2873
process.exit(1);
2974
}
3075

31-
const hadKey = !!config.api_key;
76+
const apiKey = getLicenseKey();
77+
const installId = getInstallId();
78+
const hadKey = !!apiKey;
79+
80+
// Step 1: Remove device server-side (if we have both key and install_id)
81+
if (apiKey && installId) {
82+
const result = await deactivateDeviceRemote(apiKey, installId);
83+
if (result.success) {
84+
console.log(` ✓ ${result.message}`);
85+
} else {
86+
console.log(` ⚠ Server deactivation failed: ${result.message}`);
87+
console.log(" Local credentials will still be removed.");
88+
}
89+
}
3290

33-
// Remove Pro credentials
91+
// Step 2: Remove Pro credentials from config
3492
delete config.api_key;
3593
delete config.supabase_url;
3694
delete config.supabase_key;
@@ -39,11 +97,12 @@ export async function main(_args: string[]): Promise<void> {
3997
// Write back config (preserving project, install_id, feedback_enabled)
4098
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
4199

42-
// Clear license cache
100+
// Step 3: Clear license cache
43101
clearLicenseCache();
44102

45103
if (hadKey) {
46-
console.log("Pro tier deactivated.");
104+
console.log("\nPro tier deactivated.");
105+
console.log(" - Device removed from license server");
47106
console.log(" - License key removed from config.json");
48107
console.log(" - Supabase and OpenRouter credentials removed");
49108
console.log(" - License cache cleared");

0 commit comments

Comments
 (0)