Skip to content

Commit 9f735c6

Browse files
committed
Add plugin store settings for DeepSec
1 parent 785bf5f commit 9f735c6

19 files changed

Lines changed: 1335 additions & 22 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ COINPAY_API_URL=https://coinpayportal.com
1717

1818
# App
1919
NEXT_PUBLIC_APP_URL=http://localhost:3000
20+
SETTINGS_ENCRYPTION_KEY=base64-encoded-32-byte-key

apps/cli/src/commands/modules.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,16 @@ export async function modulesInstallCommand(source: string): Promise<void> {
207207
return;
208208
}
209209

210-
if (install.git_url) {
210+
if (install.npm_package) {
211+
const npmSpinner = ora({ text: `Installing ${install.npm_package}...`, color: 'green' }).start();
212+
try {
213+
execSync(`npm install -g ${install.npm_package}`, { stdio: 'pipe' });
214+
npmSpinner.succeed(`Package ${install.npm_package} installed globally`);
215+
} catch (err) {
216+
npmSpinner.fail(`npm install failed: ${(err as Error).message}`);
217+
return;
218+
}
219+
} else if (install.git_url) {
211220
const cloneSpinner = ora({ text: 'Cloning module repository...', color: 'green' }).start();
212221
try {
213222
execSync(`git clone --depth 1 ${install.git_url} ${dest}`, { stdio: 'pipe' });
@@ -222,15 +231,6 @@ export async function modulesInstallCommand(source: string): Promise<void> {
222231
return;
223232
}
224233
cloneSpinner.succeed(`Installed ${mod.name} v${mod.version}${dest}`);
225-
} else if (install.npm_package) {
226-
const npmSpinner = ora({ text: `Installing ${install.npm_package}...`, color: 'green' }).start();
227-
try {
228-
execSync(`npm install -g ${install.npm_package}`, { stdio: 'pipe' });
229-
npmSpinner.succeed(`Package ${install.npm_package} installed globally`);
230-
} catch (err) {
231-
npmSpinner.fail(`npm install failed: ${(err as Error).message}`);
232-
return;
233-
}
234234
} else if (install.tarball_url) {
235235
const dlSpinner = ora({ text: 'Downloading module tarball...', color: 'green' }).start();
236236
try {

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@iarna/toml": "^2.2.5",
13+
"@profullstack/pluginstore": "workspace:*",
1314
"@profullstack/autoblog": "github:profullstack/autoblog#f76b7d3",
1415
"@supabase/supabase-js": "^2.101.1",
1516
"isomorphic-dompurify": "^3.12.0",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
normalizeConfigSchema,
4+
splitConfigSchema,
5+
type PluginInstallPayload,
6+
} from "@profullstack/pluginstore";
7+
8+
describe("pluginstore contracts", () => {
9+
it("normalizes public config schema without accepting malformed fields", () => {
10+
const schema = normalizeConfigSchema([
11+
{
12+
key: "AI_GATEWAY_API_KEY",
13+
label: "Vercel AI Gateway API key",
14+
type: "secret",
15+
scope: "global",
16+
required: true,
17+
placeholder: "vck_...",
18+
},
19+
{
20+
key: "DEEPSEC_SANDBOX_ENABLED",
21+
label: "Use Vercel Sandbox",
22+
type: "boolean",
23+
default: false,
24+
},
25+
{ key: "", label: "Bad", type: "secret" },
26+
{ key: "BAD_TYPE", label: "Bad", type: "password" },
27+
null,
28+
]);
29+
30+
expect(schema).toEqual([
31+
expect.objectContaining({
32+
key: "AI_GATEWAY_API_KEY",
33+
label: "Vercel AI Gateway API key",
34+
type: "secret",
35+
scope: "global",
36+
required: true,
37+
placeholder: "vck_...",
38+
}),
39+
expect.objectContaining({
40+
key: "DEEPSEC_SANDBOX_ENABLED",
41+
type: "boolean",
42+
scope: "module",
43+
default: false,
44+
}),
45+
]);
46+
});
47+
48+
it("splits secret/plain and global/module fields for settings UIs", () => {
49+
const fields = normalizeConfigSchema([
50+
{ key: "AI_GATEWAY_API_KEY", label: "Gateway key", type: "secret", scope: "global" },
51+
{ key: "DEEPSEC_AGENT", label: "Agent", type: "string", scope: "module" },
52+
]);
53+
54+
const split = splitConfigSchema(fields);
55+
56+
expect(split.secrets.map((field) => field.key)).toEqual(["AI_GATEWAY_API_KEY"]);
57+
expect(split.plain.map((field) => field.key)).toEqual(["DEEPSEC_AGENT"]);
58+
expect(split.global.map((field) => field.key)).toEqual(["AI_GATEWAY_API_KEY"]);
59+
expect(split.module.map((field) => field.key)).toEqual(["DEEPSEC_AGENT"]);
60+
});
61+
62+
it("documents the DeepSec install payload shape", () => {
63+
const payload: PluginInstallPayload = {
64+
name: "deepsec",
65+
slug: "deepsec",
66+
version: "2.0.10",
67+
downloads: 0,
68+
license: "Apache-2.0",
69+
min_version: ">=0.2.0",
70+
os_support: ["linux", "darwin"],
71+
config_notes: "AI_GATEWAY_API_KEY is a per-user/global secret.",
72+
config_schema: normalizeConfigSchema([
73+
{
74+
key: "AI_GATEWAY_API_KEY",
75+
label: "Vercel AI Gateway API key",
76+
type: "secret",
77+
scope: "global",
78+
required: true,
79+
},
80+
]),
81+
install: {
82+
npm_package: "deepsec",
83+
git_url: "https://github.com/vercel-labs/deepsec",
84+
tarball_url: null,
85+
},
86+
};
87+
88+
expect(payload.install.npm_package).toBe("deepsec");
89+
expect(payload.config_schema[0]).toEqual(
90+
expect.objectContaining({
91+
key: "AI_GATEWAY_API_KEY",
92+
type: "secret",
93+
scope: "global",
94+
}),
95+
);
96+
});
97+
});

apps/web/src/app/account/account-content.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export default function AccountContent() {
3535
const [referralWallets, setReferralWallets] = useState<ReferralWallet[]>([]);
3636
const [loadingWallets, setLoadingWallets] = useState(false);
3737
const [showAllWallets, setShowAllWallets] = useState(false);
38+
const [aiGatewayKeyDraft, setAiGatewayKeyDraft] = useState("");
39+
const [aiGatewayKeySet, setAiGatewayKeySet] = useState(false);
40+
const [settingsSaving, setSettingsSaving] = useState(false);
41+
const [settingsStatus, setSettingsStatus] = useState<string | null>(null);
42+
const [settingsError, setSettingsError] = useState<string | null>(null);
3843

3944
const fetchReferralWallets = useCallback(async () => {
4045
const token = localStorage.getItem("tc_access_token");
@@ -59,6 +64,21 @@ export default function AccountContent() {
5964
fetchReferralWallets();
6065
}, [fetchReferralWallets]);
6166

67+
useEffect(() => {
68+
if (!signedIn) return;
69+
async function loadSettings() {
70+
try {
71+
const res = await fetch("/api/settings", { headers: authHeaders(), cache: "no-store" });
72+
if (!res.ok) return;
73+
const data = await res.json();
74+
setAiGatewayKeySet(!!data.secrets?.AI_GATEWAY_API_KEY?.isSet);
75+
} catch {
76+
// ignore
77+
}
78+
}
79+
loadSettings();
80+
}, [signedIn]);
81+
6282
useEffect(() => {
6383
if (profile) {
6484
setDisplayName(profile.display_name || "");
@@ -174,6 +194,28 @@ export default function AccountContent() {
174194
}
175195
};
176196

197+
const saveAiGatewayKey = async (value: string | null) => {
198+
setSettingsSaving(true);
199+
setSettingsStatus(null);
200+
setSettingsError(null);
201+
try {
202+
const res = await fetch("/api/settings", {
203+
method: "PUT",
204+
headers: authHeaders({ "Content-Type": "application/json" }),
205+
body: JSON.stringify({ secrets: { AI_GATEWAY_API_KEY: value } }),
206+
});
207+
const data = await res.json();
208+
if (!res.ok) throw new Error(data.error || "Failed to save settings");
209+
setAiGatewayKeySet(!!data.secrets?.AI_GATEWAY_API_KEY?.isSet);
210+
setAiGatewayKeyDraft("");
211+
setSettingsStatus(value === null ? "Cleared" : "Saved");
212+
} catch (error) {
213+
setSettingsError((error as Error).message);
214+
} finally {
215+
setSettingsSaving(false);
216+
}
217+
};
218+
177219
return (
178220
<div className="min-h-screen bg-tc-darker">
179221
{/* Nav */}
@@ -276,6 +318,47 @@ export default function AccountContent() {
276318
)}
277319
</div>
278320

321+
{/* Global Module Settings */}
322+
<div className="bg-tc-card border border-tc-border rounded-xl p-6">
323+
<h2 className="text-lg font-semibold text-white mb-4">Global Module Settings</h2>
324+
<div>
325+
<label className="block text-sm text-tc-text-dim mb-1">
326+
Vercel AI Gateway API key
327+
<span className="ml-2 font-mono text-[10px] text-tc-text-dim">AI_GATEWAY_API_KEY</span>
328+
</label>
329+
<div className="flex flex-col sm:flex-row gap-2">
330+
<input
331+
type="password"
332+
value={aiGatewayKeyDraft}
333+
placeholder={aiGatewayKeySet ? "Saved secret set" : "vck_..."}
334+
onChange={(e) => setAiGatewayKeyDraft(e.target.value)}
335+
className="flex-1 bg-tc-darker border border-tc-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-tc-green/50"
336+
/>
337+
<button
338+
onClick={() => saveAiGatewayKey(aiGatewayKeyDraft)}
339+
disabled={settingsSaving || !aiGatewayKeyDraft.trim()}
340+
className="bg-tc-green text-black px-4 py-2 rounded-lg text-sm font-semibold hover:bg-tc-green-dim disabled:opacity-50"
341+
>
342+
{settingsSaving ? "Saving..." : "Save"}
343+
</button>
344+
{aiGatewayKeySet && (
345+
<button
346+
onClick={() => saveAiGatewayKey(null)}
347+
disabled={settingsSaving}
348+
className="border border-tc-border text-tc-text-dim px-4 py-2 rounded-lg text-sm hover:border-red-400/40 hover:text-red-400 disabled:opacity-50"
349+
>
350+
Clear
351+
</button>
352+
)}
353+
</div>
354+
<p className="text-xs text-tc-text-dim mt-2">
355+
Used by AI-powered modules such as DeepSec. The key is stored in your account settings, not in the public plugin store.
356+
</p>
357+
{settingsStatus && <p className="text-xs text-tc-green mt-2">{settingsStatus}</p>}
358+
{settingsError && <p className="text-xs text-red-400 mt-2">{settingsError}</p>}
359+
</div>
360+
</div>
361+
279362
{/* Referral Section — hidden for now */}
280363
<div className="hidden bg-tc-card border border-tc-border rounded-xl p-6">
281364
<h2 className="text-lg font-semibold text-white mb-4">Referrals</h2>

apps/web/src/app/api/modules/[slug]/install/route.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { getSupabaseAdmin } from "@/lib/supabase";
3+
import { getAuthenticatedRequestUser } from "@/lib/api-auth";
4+
import { normalizeConfigSchema } from "@profullstack/pluginstore";
35

46
type RouteContext = { params: Promise<{ slug: string }> };
57

68
const MODULE_INSTALL_COLUMNS =
7-
"id, name, slug, version, downloads, git_url, min_threatcrush_version, os_support, license";
9+
"id, name, slug, version, downloads, git_url, npm_package, tarball_url, min_threatcrush_version, os_support, license, config_schema, config_notes";
810

911
function installPayload(mod: {
12+
id?: string;
1013
name: string;
1114
slug: string;
1215
version: string;
1316
downloads?: number | null;
1417
git_url?: string | null;
18+
npm_package?: string | null;
19+
tarball_url?: string | null;
1520
min_threatcrush_version?: string | null;
1621
os_support?: string[] | null;
1722
license?: string | null;
23+
config_schema?: unknown;
24+
config_notes?: string | null;
1825
}) {
1926
return {
2027
name: mod.name,
@@ -23,11 +30,14 @@ function installPayload(mod: {
2330
downloads: mod.downloads || 0,
2431
license: mod.license,
2532
min_threatcrush_version: mod.min_threatcrush_version,
33+
min_version: mod.min_threatcrush_version,
2634
os_support: mod.os_support,
35+
config_schema: normalizeConfigSchema(mod.config_schema),
36+
config_notes: mod.config_notes || null,
2737
install: {
28-
npm_package: null,
38+
npm_package: mod.npm_package || null,
2939
git_url: mod.git_url || null,
30-
tarball_url: null,
40+
tarball_url: mod.tarball_url || null,
3141
},
3242
};
3343
}
@@ -88,6 +98,8 @@ export async function POST(
8898
return NextResponse.json({ error: "Module not found" }, { status: 404 });
8999
}
90100

101+
const user = await getAuthenticatedRequestUser(request);
102+
91103
// Increment download count
92104
const newCount = (mod.downloads || 0) + 1;
93105
await sb
@@ -103,9 +115,24 @@ export async function POST(
103115
platform: body.platform || "unknown",
104116
});
105117

118+
if (user) {
119+
await sb.from("user_installed_modules").upsert(
120+
{
121+
user_id: user.userId,
122+
module_id: mod.id,
123+
module_slug: mod.slug,
124+
version: body.version || mod.version,
125+
status: "active",
126+
updated_at: new Date().toISOString(),
127+
},
128+
{ onConflict: "user_id,module_id" },
129+
);
130+
}
131+
106132
return NextResponse.json({
107133
success: true,
108134
downloads: newCount,
135+
installed: !!user,
109136
module: installPayload({ ...mod, downloads: newCount }),
110137
});
111138
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getAuthenticatedRequestUser, getAdminClient, unauthorized } from "@/lib/api-auth";
3+
4+
type InstalledRow = {
5+
module_id: string;
6+
module_slug: string;
7+
version: string;
8+
status: string;
9+
installed_at: string;
10+
updated_at: string;
11+
};
12+
13+
export async function GET(request: NextRequest) {
14+
const user = await getAuthenticatedRequestUser(request);
15+
if (!user) return unauthorized();
16+
17+
const sb = getAdminClient();
18+
const { data, error } = await sb
19+
.from("user_installed_modules")
20+
.select("module_id, module_slug, version, status, installed_at, updated_at")
21+
.eq("user_id", user.userId)
22+
.order("installed_at", { ascending: false });
23+
24+
if (error) {
25+
return NextResponse.json({ error: error.message }, { status: 500 });
26+
}
27+
28+
return NextResponse.json({ installed: (data || []) as InstalledRow[] });
29+
}
30+
31+
export async function PATCH(request: NextRequest) {
32+
const user = await getAuthenticatedRequestUser(request);
33+
if (!user) return unauthorized();
34+
35+
const body = await request.json().catch(() => null) as { slug?: unknown; status?: unknown } | null;
36+
const slug = typeof body?.slug === "string" ? body.slug : "";
37+
const status = typeof body?.status === "string" ? body.status : "";
38+
39+
if (!slug) {
40+
return NextResponse.json({ error: "slug is required" }, { status: 400 });
41+
}
42+
if (!["active", "disabled", "removed"].includes(status)) {
43+
return NextResponse.json({ error: "status must be active, disabled, or removed" }, { status: 400 });
44+
}
45+
46+
const sb = getAdminClient();
47+
const { data, error } = await sb
48+
.from("user_installed_modules")
49+
.update({ status, updated_at: new Date().toISOString() })
50+
.eq("user_id", user.userId)
51+
.eq("module_slug", slug)
52+
.select("module_id, module_slug, version, status, installed_at, updated_at")
53+
.single();
54+
55+
if (error) {
56+
return NextResponse.json({ error: error.message }, { status: 500 });
57+
}
58+
59+
return NextResponse.json({ installed: data });
60+
}

0 commit comments

Comments
 (0)