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
4 changes: 2 additions & 2 deletions .codelit-platform.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
"name": "SendLit",
"slug": "sendlit"
},
"cliVersion": "0.6.0",
"cliVersion": "0.8.0",
"capabilities": [
"auth",
"mcp",
"observability",
"billing"
],
"managedFiles": {
".github/workflows/platform-conformance.yml": "2f7ededb9ad4a18228873bf23e5607aaa3822fc236762af2eda1a18a82d35d8f"
".github/workflows/platform-conformance.yml": "50a2ddaa53da139f1f34cad7f8caa06e29893ed99b13b7ca4e89566aff4e55df"
},
"productOwnedGlobs": [
"apps/**",
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/platform-conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
if: ${{ !hashFiles('bun.lock') }}
- run: bun install --frozen-lockfile
# Pinned to the CLI release that wrote this file. `sync` moves it.
- run: bunx @codelitdev/platform-cli@0.6.0 doctor
- run: bunx @codelitdev/platform-cli@0.8.0 doctor
- run: bun run --filter "*/api" check:drift
# Fails when the API package has no `test:conformance` script.
- run: bun run --filter "*/api" test:conformance
12 changes: 6 additions & 6 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@
"@anthropic-ai/sdk": "^0.135.0",
"@better-auth/cimd": "1.7.7",
"@better-auth/oauth-provider": "1.7.7",
"@codelitdev/billing": "0.6.0",
"@codelitdev/mcp-server-kit": "0.6.0",
"@codelitdev/oauth-server-kit": "0.6.0",
"@codelitdev/observability": "0.6.0",
"@codelitdev/platform": "0.6.0",
"@codelitdev/billing": "0.8.0",
"@codelitdev/mcp-server-kit": "0.8.0",
"@codelitdev/oauth-server-kit": "0.8.0",
"@codelitdev/observability": "0.8.0",
"@codelitdev/platform": "0.8.0",
"@modelcontextprotocol/node": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"@sendlit/api-contract": "workspace:*",
Expand Down Expand Up @@ -64,7 +64,7 @@
},
"devDependencies": {
"@better-auth/core": "1.7.7",
"@codelitdev/platform-conformance": "0.6.0",
"@codelitdev/platform-conformance": "0.8.0",
"@electric-sql/pglite": "0.5.8",
"@modelcontextprotocol/client": "^2.0.0",
"@sendlit/test-utils": "workspace:*",
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import espWebhookRoutes from "./delivery-feedback/webhook-route";
import eventsRoutes from "./events/routes";
import signupFormPublicRoutes from "./forms/public-routes";
import signupFormRoutes from "./forms/routes";
import mcpRoutes from "./mcp/routes";
import { createMcpRoutes } from "./mcp/routes";
import mediaRoutes from "./media/routes";
import { captureError, getTeamId } from "./observability";
import organizationRoutes from "./organization/routes";
Expand Down Expand Up @@ -100,7 +100,7 @@ export function createApp(options: CreateAppOptions) {
app.use(oauthPagesRoutes);
// The MCP handler owns JSON parsing and protocol content-type errors, so it
// must be mounted before the API's global Express body parser.
app.use(mcpRoutes);
app.use(createMcpRoutes());
// Mounted before global body parsing: this route needs the unmodified raw
// request bytes for provider signature verification and has no
// session/API-key concept at all — see delivery-feedback/webhook-route.ts.
Expand Down
143 changes: 88 additions & 55 deletions apps/api/src/auth/require-team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,91 @@ function getHeaderValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

export type UserTeamResolution =
| { ok: true; teamId: string }
| {
ok: false;
status: number;
error: "invalid_team_id" | "not_a_team_member" | "no_team" | "team_required";
body: Record<string, unknown>;
};

/**
* The team a user-authenticated request acts on: the `X-Sendlit-Team-Id`
* header, validated against team membership on every call (so the dashboard
* can switch teams without re-authenticating), or the user's only team when
* the header is absent. Shared by REST (`requireTeam`) and MCP.
*/
export async function resolveUserTeam(
userId: string,
headerTeamIdValue: unknown,
): Promise<UserTeamResolution> {
const headerTeamId = getHeaderValue(headerTeamIdValue);
if (headerTeamId) {
const invalid = {
ok: false as const,
status: 400,
error: "invalid_team_id" as const,
body: {
error: "invalid_team_id",
error_description: "The provided team ID is not valid.",
},
};
const team = await getTeamByTeamId(headerTeamId);
if (!team) return invalid;
let membership: Awaited<ReturnType<typeof getTeamMembership>>;
try {
membership = await getTeamMembership(team.id, userId);
} catch {
return invalid;
}
if (!membership) {
return {
ok: false,
status: 403,
error: "not_a_team_member",
body: {
error: "not_a_team_member",
error_description: "You are not a member of this team.",
},
};
}
return { ok: true, teamId: team.id };
}

const teams = await listTeamsForUser(userId);
if (teams.length === 1) return { ok: true, teamId: teams[0].id };
if (teams.length === 0) {
return {
ok: false,
status: 409,
error: "no_team",
body: {
error: "no_team",
error_description: "This user doesn't belong to any team yet.",
},
};
}
return {
ok: false,
status: 409,
error: "team_required",
body: {
error: "team_required",
error_description:
"This user belongs to multiple teams — specify one via the X-Sendlit-Team-Id header.",
teams: teams.map((t) => ({ teamId: t.teamId, name: t.name })),
},
};
}

/**
* Resolves `req.teamId` — the one thing every resource route actually needs.
* Must run after `requireAuth`/`mcpAuth`.
* Must run after `requireAuth`.
*
* - API-key requests already have `req.teamId` set by `auth/middleware.ts`
* (a key authenticates as exactly one, fixed team) — this is a no-op for them.
* - User-authenticated requests pick a team via the `X-Sendlit-Team-Id`
* header, validated against team membership on every call — this is what
* lets the web dashboard switch teams instantly, without re-authenticating.
* If the header is absent and the account belongs to exactly one team, that
* team is used automatically (the common case). Otherwise the caller must
* specify one explicitly.
* - User-authenticated requests resolve it with `resolveUserTeam`.
*/
export async function requireTeam(
req: Request,
Expand All @@ -33,54 +106,14 @@ export async function requireTeam(
return;
}

const headerTeamId = getHeaderValue(req.headers["x-sendlit-team-id"]);
if (headerTeamId) {
const team = await getTeamByTeamId(headerTeamId);
if (!team) {
res.status(400).json({
error: "invalid_team_id",
error_description: "The provided team ID is not valid.",
});
return;
}
let membership: Awaited<ReturnType<typeof getTeamMembership>>;
try {
membership = await getTeamMembership(team.id, anyReq.userId);
} catch {
res.status(400).json({
error: "invalid_team_id",
error_description: "The provided team ID is not valid.",
});
return;
}
if (!membership) {
res.status(403).json({
error: "not_a_team_member",
error_description: "You are not a member of this team.",
});
return;
}
anyReq.teamId = team.id;
return next();
}

const teams = await listTeamsForUser(anyReq.userId);
if (teams.length === 1) {
anyReq.teamId = teams[0].id;
return next();
}
if (teams.length === 0) {
res.status(409).json({
error: "no_team",
error_description: "This user doesn't belong to any team yet.",
});
const resolved = await resolveUserTeam(
anyReq.userId,
req.headers["x-sendlit-team-id"],
);
if (!resolved.ok) {
res.status(resolved.status).json(resolved.body);
return;
}

res.status(409).json({
error: "team_required",
error_description:
"This user belongs to multiple teams — specify one via the X-Sendlit-Team-Id header.",
teams: teams.map((t) => ({ teamId: t.teamId, name: t.name })),
});
anyReq.teamId = resolved.teamId;
next();
}
48 changes: 0 additions & 48 deletions apps/api/src/contacts/tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const schema = await import("../db/schema");
const { seedTeamAndContact, truncateAll } = await import("../test/db");
const { requestApp } = await import("../test/http");
const { default: tagRoutes } = await import("./tags-routes");
const { registerTagTools } = await import("../mcp/tools/tags");
type TestDb = import("../test/db").TestDb;

const tdb = db as unknown as TestDb;
Expand Down Expand Up @@ -339,50 +338,3 @@ describe("tag routes", () => {
expect(response.json().contactsUpdated).toBe(1);
});
});

describe("MCP tag tools", () => {
function tools() {
const registered = new Map<string, { config: any; handler: any }>();
registerTagTools({
registerTool: (name: string, config: any, handler: any) => {
registered.set(name, { config, handler });
},
} as any);
return registered;
}
const authFor = (teamId: string) => ({
http: { authInfo: { extra: { authKind: "team_key", teamId, user: null } } },
});

it("requires auth", async () => {
const result = await tools().get("list_tags")!.handler({});
expect(result.isError).toBe(true);
});

it("lists, inspects, renames and deletes tags with valid output", async () => {
const { team } = await seedReferences();
const registered = tools();
const auth = authFor(team.id);
const call = async (name: string, args?: unknown) => {
const tool = registered.get(name)!;
const result =
args === undefined ? await tool.handler(auth) : await tool.handler(args, auth);
expect(result.isError).toBeUndefined();
expect(tool.config.outputSchema.safeParse(result.structuredContent).success).toBe(
true,
);
return result.structuredContent;
};

expect((await call("list_tags")).items).toHaveLength(2);
expect((await call("get_tag_references", { tag: "vip" })).segments).toHaveLength(1);
expect((await call("rename_tag", { tag: "vip", name: "gold" })).tag).toBe("gold");
expect((await call("delete_tag", { tag: "gold" })).contactsUpdated).toBe(2);

const missing = await registered
.get("delete_tag")!
.handler({ tag: "nope" }, authFor(team.id));
expect(missing.isError).toBe(true);
expect(registered.get("delete_tag")!.config.annotations.destructiveHint).toBe(true);
});
});
57 changes: 0 additions & 57 deletions apps/api/src/mcp/auth-context.test.ts

This file was deleted.

Loading
Loading