Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
"better-auth": "latest",
"cron-parser": "latest",
"hono": "latest",
"nodemailer": "^9.0.3",
"playwright": "^1.61.1",
"web-push": "^3.6.7",
"zod": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"@types/nodemailer": "^8.0.1",
"@types/web-push": "^3.6.4",
"typescript": "^6.0.3"
}
Expand Down
97 changes: 52 additions & 45 deletions apps/server/src/extensions.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,63 @@
export interface ExtensionCatalogEntry {
/** Stable key used to match an installed extension row back to its
* catalog entry — never renamed once shipped, since it's stored in the
* `extension.key` column. */
key: string;
name: string;
description: string;
category: string;
/** Sidebar icon name — must be a key in ICON_MAP on the web client (see
* apps/web/src/components/app-sidebar.tsx). */
icon: string;
/** Route segment under /workspace/{id}/extensions/{key} — normally equal
* to `key`, kept separate in case a future extension needs a nicer URL. */
route: string;
/** GitHub repo URL of a companion plugin (see plugins.ts) installed
* automatically alongside this extension — its skills/sub-agents are
* what the extension's own agents actually use at runtime, so a bare
* `extension` row without it would be a shell with nothing behind it.
* Best-effort: a failed plugin install never blocks extension activation
* (see ensureExtensionPlugin), the extension just falls back to its
* built-in heuristics until the plugin is installed (retry from the
* Plugins page, or by disabling/re-enabling the extension). */
pluginRepoUrl?: string;
/** Stable key used to match an installed extension row back to its
* catalog entry — never renamed once shipped, since it's stored in the
* `extension.key` column. */
key: string;
name: string;
description: string;
category: string;
/** Sidebar icon name — must be a key in ICON_MAP on the web client (see
* apps/web/src/components/app-sidebar.tsx). */
icon: string;
/** Route segment under /workspace/{id}/extensions/{key} — normally equal
* to `key`, kept separate in case a future extension needs a nicer URL. */
route: string;
/** GitHub repo URL of a companion plugin (see plugins.ts) installed
* automatically alongside this extension — its skills/sub-agents are
* what the extension's own agents actually use at runtime, so a bare
* `extension` row without it would be a shell with nothing behind it.
* Best-effort: a failed plugin install never blocks extension activation
* (see ensureExtensionPlugin), the extension just falls back to its
* built-in heuristics until the plugin is installed (retry from the
* Plugins page, or by disabling/re-enabling the extension). */
pluginRepoUrl?: string;
}

// The marketplace of extensions installable from workspace settings. Mirrors
// MCP_CONNECTOR_CATALOG's shape (see mcp-connectors.ts) — "installing" an
// extension creates an `extension` row scoped to the workspace, same as
// "connecting" a catalog entry creates an `mcpServer` row.
export const EXTENSION_CATALOG: ExtensionCatalogEntry[] = [
{
key: "seo-analyzer",
name: "SEO/GEO/AEO Analyzer",
description:
"Links a domain to a local repo, audits search/answer-engine readiness, dispatches an AI agent to fix findings, and drafts blog posts for keyword gaps.",
category: "Growth",
icon: "TrendingUp",
route: "seo-analyzer",
pluginRepoUrl: "https://github.com/AgricIDaniel/claude-seo",
},
{
key: "video-studio",
name: "Video Studio",
description:
"Describe a clip and generate it with Sora, then play back, edit, and organize everything it renders — every result lands in the Library too.",
category: "Creative",
icon: "Film",
route: "video-studio",
},
{
key: "seo-analyzer",
name: "SEO/GEO/AEO Analyzer",
description:
"Links a domain to a local repo, audits search/answer-engine readiness, dispatches an AI agent to fix findings, and drafts blog posts for keyword gaps.",
category: "Growth",
icon: "TrendingUp",
route: "seo-analyzer",
pluginRepoUrl: "https://github.com/AgricIDaniel/claude-seo",
},
{
key: "video-studio",
name: "Video Studio",
description:
"Describe a clip and generate it with Sora, then play back, edit, and organize everything it renders — every result lands in the Library too.",
category: "Creative",
icon: "Film",
route: "video-studio",
},
{
key: "local-lead-scout",
name: "Local Lead Scout",
description:
"Finds local businesses with no website via compliant sources (CSV import, OSM Overpass, official Places API), then drafts a prototype and outreach email for you to review and approve before anything is sent.",
category: "Growth",
icon: "MapPin",
route: "local-lead-scout",
},
];

export function getExtensionCatalogEntry(
key: string,
): ExtensionCatalogEntry | undefined {
return EXTENSION_CATALOG.find((entry) => entry.key === key);
export function getExtensionCatalogEntry(key: string): ExtensionCatalogEntry | undefined {
return EXTENSION_CATALOG.find((entry) => entry.key === key);
}
86 changes: 86 additions & 0 deletions apps/server/src/lead-scout-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { getDb } from "@nyxel/db";
import { installTestDb } from "@nyxel/db/test-utils";
import {
sendLeadScoutEmail,
toClientSafeLeadScoutEmailSettings,
upsertLeadScoutEmailSettings,
} from "./lead-scout-email";

let ctx: Awaited<ReturnType<typeof installTestDb>>;

beforeEach(async () => {
ctx = await installTestDb();
});
afterEach(async () => {
await ctx.cleanup();
});

async function withWorkspace(): Promise<string> {
const user = await getDb().getOrCreateDemoUser();
const workspace = await getDb().createWorkspace({ userId: user.id, name: "email-test" });
return workspace.id;
}

describe("toClientSafeLeadScoutEmailSettings", () => {
test("strips credentials, exposes hasCredentials boolean", async () => {
const workspaceId = await withWorkspace();
const settings = await upsertLeadScoutEmailSettings(workspaceId, {
fromName: "Acme",
fromEmail: "acme@example.com",
credentials: { host: "smtp.example.com", username: "u", password: "s3cret-password" },
});

const safe = toClientSafeLeadScoutEmailSettings(settings);
expect((safe as Record<string, unknown>).credentials).toBeUndefined();
expect(safe.hasCredentials).toBe(true);
expect(JSON.stringify(safe)).not.toContain("s3cret-password");
});

test("hasCredentials is false when none are configured", async () => {
const workspaceId = await withWorkspace();
const settings = await upsertLeadScoutEmailSettings(workspaceId, {
fromName: "Acme",
fromEmail: "acme@example.com",
});
expect(toClientSafeLeadScoutEmailSettings(settings).hasCredentials).toBe(false);
});
});

describe("sendLeadScoutEmail dry-run gating", () => {
test("dry run mode never dispatches, even with no credentials configured", async () => {
const workspaceId = await withWorkspace();
await upsertLeadScoutEmailSettings(workspaceId, {
fromName: "Acme",
fromEmail: "acme@example.com",
dryRunMode: true,
});

const result = await sendLeadScoutEmail(workspaceId, {
to: "lead@business.com",
subject: "Hi",
text: "body",
});
expect(result).toEqual({ sent: false, dryRun: true });
});

test("live mode without credentials throws instead of silently succeeding", async () => {
const workspaceId = await withWorkspace();
await upsertLeadScoutEmailSettings(workspaceId, {
fromName: "Acme",
fromEmail: "acme@example.com",
dryRunMode: false,
});

await expect(
sendLeadScoutEmail(workspaceId, { to: "lead@business.com", subject: "Hi", text: "body" }),
).rejects.toThrow(/configured/);
});

test("throws when email settings were never configured", async () => {
const workspaceId = await withWorkspace();
await expect(
sendLeadScoutEmail(workspaceId, { to: "lead@business.com", subject: "Hi", text: "body" }),
).rejects.toThrow(/configured/);
});
});
Loading
Loading