Addie can't read this document. To fix it:
@@ -2179,6 +2184,13 @@
Recent Events
`;
}
+ function resetJoinButton() {
+ const joinBtn = document.getElementById('joinBtn');
+ joinBtn.disabled = false;
+ document.getElementById('joinBtnLabel').textContent =
+ currentGroup.committee_type === 'council' ? 'Join Council' : 'Join Working Group';
+ }
+
async function joinGroup() {
if (!currentUser) {
window.location.href = `/auth/login?return_to=${encodeURIComponent(window.location.pathname)}`;
@@ -2187,7 +2199,7 @@
Recent Events
const joinBtn = document.getElementById('joinBtn');
joinBtn.disabled = true;
- joinBtn.textContent = 'Joining...';
+ document.getElementById('joinBtnLabel').textContent = 'Joining...';
try {
const response = await fetch(`/api/working-groups/${currentSlug}/join`, {
@@ -2197,6 +2209,7 @@
Recent Events
if (response.ok) {
isMember = true;
+ resetJoinButton();
joinBtn.style.display = 'none';
document.getElementById('membershipStatus').style.display = 'block';
document.getElementById('leaveBtn').style.display = 'inline-flex';
@@ -2206,22 +2219,13 @@
Recent Events
await loadWorkingGroup();
} else {
const data = await response.json();
- alert(data.error || 'Failed to join group');
- joinBtn.disabled = false;
- joinBtn.innerHTML = `
-
- Join Working Group
- `;
+ alert(data.message || data.error || 'Failed to join group');
+ resetJoinButton();
}
} catch (error) {
console.error('Error joining group:', error);
alert('Failed to join group');
- joinBtn.disabled = false;
+ resetJoinButton();
}
}
@@ -2243,6 +2247,7 @@
Recent Events
document.getElementById('newPostBtn').style.display = 'none';
if (!currentGroup.is_private) {
+ resetJoinButton();
document.getElementById('joinBtn').style.display = 'inline-flex';
}
diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts
index 3cb6542876..32bb5616e4 100644
--- a/server/src/addie/mcp/member-tools.ts
+++ b/server/src/addie/mcp/member-tools.ts
@@ -111,6 +111,7 @@ import {
withdrawCommitteeInterest as withdrawCommitteeInterestService,
listMyWorkingGroups as listMyWorkingGroupsService,
listMyCommitteeInterests as listMyCommitteeInterestsService,
+ MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
WorkingGroupMembershipError,
} from '../../services/working-group-membership-service.js';
import { listMyContent as listMyContentService } from '../../services/my-content-service.js';
@@ -2593,7 +2594,7 @@ export function createMemberToolHandlers(
user: { id: wu.workos_user_id, email: wu.email, firstName: wu.first_name, lastName: wu.last_name },
slug,
});
- return `Successfully joined the "${result.groupName}" working group! You can now participate in discussions and see group posts.`;
+ return `Successfully joined "${result.groupName}"! You can now participate in discussions and see group posts.`;
} catch (error) {
if (error instanceof WorkingGroupMembershipError) {
if (error.is('group_not_found')) {
@@ -2602,11 +2603,14 @@ export function createMemberToolHandlers(
if (error.is('group_private')) {
return `"${error.meta.groupName}" is a private working group that requires an invitation. Use request_working_group_invitation to request access.`;
}
+ if (error.is('council_membership_required')) {
+ return MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE;
+ }
if (error.is('community_only_seat_blocked')) {
return `Joining "${error.meta.groupName}" requires a contributor seat. Ask your org admin to upgrade your access.`;
}
if (error.is('already_member')) {
- return `You're already a member of the "${error.meta.groupName}" working group!`;
+ return `You're already a member of "${error.meta.groupName}"!`;
}
}
throw new ToolError(`Failed to join working group: ${error instanceof Error ? error.message : String(error)}`);
diff --git a/server/src/routes/committees.ts b/server/src/routes/committees.ts
index 627e7cdc49..381dd3ce1d 100644
--- a/server/src/routes/committees.ts
+++ b/server/src/routes/committees.ts
@@ -40,6 +40,7 @@ import {
withdrawCommitteeInterest as withdrawCommitteeInterestService,
listMyWorkingGroups as listMyWorkingGroupsService,
listMyCommitteeInterests as listMyCommitteeInterestsService,
+ MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
WorkingGroupMembershipError,
} from "../services/working-group-membership-service.js";
import {
@@ -1201,6 +1202,12 @@ export function createCommitteeRouters(): {
message: 'This working group is private and requires an invitation to join',
});
}
+ if (error.is('council_membership_required')) {
+ return res.status(403).json({
+ error: 'Paid membership required',
+ message: MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
+ });
+ }
if (error.is('community_only_seat_blocked')) {
const { userOrgId, workingGroupId, groupName, resourceType } = error.meta;
return res.status(403).json({
diff --git a/server/src/services/active-membership-service.ts b/server/src/services/active-membership-service.ts
new file mode 100644
index 0000000000..d760112ae1
--- /dev/null
+++ b/server/src/services/active-membership-service.ts
@@ -0,0 +1,63 @@
+import { getPool } from '../db/client.js';
+import {
+ invalidateMembershipCache,
+ resolveEffectiveMembership,
+ type EffectiveMembership,
+} from '../db/org-filters.js';
+import { createLogger } from '../logger.js';
+
+const logger = createLogger('active-membership-service');
+
+export interface ActiveMembershipCheckDeps {
+ listOrganizationIdsForUser: (userId: string) => Promise
;
+ invalidateMembershipCache: (orgId: string) => void;
+ resolveEffectiveMembership: (orgId: string) => Promise;
+}
+
+async function listOrganizationIdsForUser(userId: string): Promise {
+ const result = await getPool().query<{ workos_organization_id: string }>(
+ `SELECT DISTINCT workos_organization_id
+ FROM organization_memberships
+ WHERE workos_user_id = $1`,
+ [userId],
+ );
+ return result.rows.map((row) => row.workos_organization_id);
+}
+
+const DEFAULT_DEPS: ActiveMembershipCheckDeps = {
+ listOrganizationIdsForUser,
+ invalidateMembershipCache,
+ resolveEffectiveMembership,
+};
+
+/**
+ * Check every organization the user belongs to for an active effective
+ * AgenticAdvertising.org membership. Each decision bypasses the membership
+ * cache so authorization never rests on a recently canceled subscription.
+ *
+ * `EffectiveMembership.is_member` intentionally includes Explorer
+ * (`individual_academic`) and inherited memberships. It does not use the
+ * narrower API-access tier list.
+ *
+ * Resolver and database failures fail closed.
+ */
+export async function hasActiveMembershipForUser(
+ userId: string,
+ deps: ActiveMembershipCheckDeps = DEFAULT_DEPS,
+): Promise {
+ try {
+ const organizationIds = await deps.listOrganizationIdsForUser(userId);
+ if (organizationIds.length === 0) return false;
+
+ const memberships = await Promise.all(
+ organizationIds.map((orgId) => {
+ deps.invalidateMembershipCache(orgId);
+ return deps.resolveEffectiveMembership(orgId);
+ }),
+ );
+ return memberships.some((membership) => membership.is_member);
+ } catch (error) {
+ logger.error({ err: error, userId }, 'Failed to resolve active membership for user');
+ return false;
+ }
+}
diff --git a/server/src/services/working-group-membership-service.ts b/server/src/services/working-group-membership-service.ts
index 4868a782bb..6ea245de8e 100644
--- a/server/src/services/working-group-membership-service.ts
+++ b/server/src/services/working-group-membership-service.ts
@@ -35,11 +35,15 @@ import { inviteToChannel } from '../slack/client.js';
import { sendWgWelcomeMessage } from '../addie/services/wg-welcome.js';
import { getUserSeatType } from '../db/organization-db.js';
import { getWorkos } from '../auth/workos-client.js';
+import { hasActiveMembershipForUser } from './active-membership-service.js';
const logger = createLogger('working-group-membership-service');
const workingGroupDb = new WorkingGroupDatabase();
+export const MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE =
+ 'Our Mastermind Councils are for paying member tiers only. AgenticAdvertising.org membership starts at $50 annually.';
+
/**
* Caller identity. Both route (`req.user`) and Addie tool
* (`memberContext.workos_user`) shape into this; the service doesn't
@@ -60,6 +64,7 @@ export interface WorkingGroupServiceUser {
export type WorkingGroupMembershipErrorCode =
| 'group_not_found'
| 'group_private'
+ | 'council_membership_required'
| 'community_only_seat_blocked'
| 'already_member'
| 'no_interest_recorded';
@@ -67,6 +72,7 @@ export type WorkingGroupMembershipErrorCode =
export interface WorkingGroupMembershipErrorMetaByCode {
group_not_found: { slug: string };
group_private: { slug: string; groupName: string };
+ council_membership_required: { slug: string; groupName: string };
community_only_seat_blocked: {
slug: string;
groupName: string;
@@ -163,28 +169,41 @@ export async function joinWorkingGroup({ user, slug }: JoinWorkingGroupInput): P
});
}
- // Community-only seats cannot join working groups or councils — they
- // need a contributor seat upgrade. Surface enough metadata for the
- // route to render a seat-request payload and the Addie tool to render
- // a "ask your org admin" CTA.
- const seatType = await getUserSeatType(user.id);
- if (seatType === 'community_only') {
- const orgRow = await query<{ workos_organization_id: string }>(
- 'SELECT workos_organization_id FROM organization_memberships WHERE workos_user_id = $1 LIMIT 1',
- [user.id],
- );
- const userOrgId = orgRow.rows[0]?.workos_organization_id ?? null;
- throw new WorkingGroupMembershipError(
- 'community_only_seat_blocked',
- 'Working group membership requires a contributor seat',
- {
- slug: group.slug,
- groupName: group.name,
- workingGroupId: group.id,
- resourceType: group.committee_type === 'council' ? 'council' : 'working_group',
- userOrgId,
- },
- );
+ if (group.committee_type === 'council') {
+ // Mastermind Councils are open to every active paid tier, including the
+ // $50 Explorer tier. This is deliberately separate from contributor-seat
+ // and API-access eligibility, both of which exclude Explorer.
+ const hasActiveMembership = await hasActiveMembershipForUser(user.id);
+ if (!hasActiveMembership) {
+ throw new WorkingGroupMembershipError(
+ 'council_membership_required',
+ MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
+ { slug: group.slug, groupName: group.name },
+ );
+ }
+ } else {
+ // Community-only seats cannot join working groups — they need a
+ // contributor seat upgrade. Surface enough metadata for the route to
+ // render a seat-request payload and Addie to render an upgrade CTA.
+ const seatType = await getUserSeatType(user.id);
+ if (seatType === 'community_only') {
+ const orgRow = await query<{ workos_organization_id: string }>(
+ 'SELECT workos_organization_id FROM organization_memberships WHERE workos_user_id = $1 LIMIT 1',
+ [user.id],
+ );
+ const userOrgId = orgRow.rows[0]?.workos_organization_id ?? null;
+ throw new WorkingGroupMembershipError(
+ 'community_only_seat_blocked',
+ 'Working group membership requires a contributor seat',
+ {
+ slug: group.slug,
+ groupName: group.name,
+ workingGroupId: group.id,
+ resourceType: 'working_group',
+ userOrgId,
+ },
+ );
+ }
}
const existingMembership = await workingGroupDb.getMembership(group.id, user.id);
diff --git a/server/tests/unit/active-membership-service.test.ts b/server/tests/unit/active-membership-service.test.ts
new file mode 100644
index 0000000000..df452d0b2b
--- /dev/null
+++ b/server/tests/unit/active-membership-service.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ hasActiveMembershipForUser,
+ type ActiveMembershipCheckDeps,
+} from '../../src/services/active-membership-service.js';
+import type { EffectiveMembership } from '../../src/db/org-filters.js';
+
+function membership(overrides: Partial = {}): EffectiveMembership {
+ return {
+ is_member: false,
+ is_inherited: false,
+ paying_org_id: null,
+ paying_org_name: null,
+ hierarchy_chain: [],
+ membership_tier: null,
+ ...overrides,
+ };
+}
+
+function deps(
+ organizationIds: string[],
+ resolve: ActiveMembershipCheckDeps['resolveEffectiveMembership'],
+): ActiveMembershipCheckDeps {
+ return {
+ listOrganizationIdsForUser: vi.fn().mockResolvedValue(organizationIds),
+ invalidateMembershipCache: vi.fn(),
+ resolveEffectiveMembership: resolve,
+ };
+}
+
+describe('active membership checks for Mastermind Councils', () => {
+ it.each([
+ ['no organizations', []],
+ ['a free/community organization', ['org_free']],
+ ['a canceled organization', ['org_canceled']],
+ ])('denies a user with %s', async (_label, organizationIds) => {
+ const resolve = vi.fn().mockResolvedValue(membership());
+
+ await expect(hasActiveMembershipForUser('user_1', deps(organizationIds, resolve))).resolves.toBe(false);
+
+ expect(resolve).toHaveBeenCalledTimes(organizationIds.length);
+ });
+
+ it.each([
+ ['individual_academic', false],
+ ['individual_professional', false],
+ ['company_standard', false],
+ ['company_icl', true],
+ ['company_leader', true],
+ ])('allows active %s membership, including inherited membership', async (tier, inherited) => {
+ const resolve = vi.fn().mockResolvedValue(membership({
+ is_member: true,
+ is_inherited: inherited,
+ paying_org_id: 'org_paying',
+ membership_tier: tier,
+ }));
+ const checkDeps = deps(['org_1'], resolve);
+
+ await expect(hasActiveMembershipForUser('user_1', checkDeps)).resolves.toBe(true);
+ expect(checkDeps.invalidateMembershipCache).toHaveBeenCalledWith('org_1');
+ expect(resolve).toHaveBeenCalledWith('org_1');
+ });
+
+ it('checks every organization instead of trusting the first or primary organization', async () => {
+ const resolve = vi.fn()
+ .mockResolvedValueOnce(membership())
+ .mockResolvedValueOnce(membership({
+ is_member: true,
+ paying_org_id: 'org_paid',
+ membership_tier: 'individual_academic',
+ }));
+
+ await expect(
+ hasActiveMembershipForUser('user_1', deps(['org_free', 'org_paid'], resolve)),
+ ).resolves.toBe(true);
+
+ expect(resolve).toHaveBeenCalledTimes(2);
+ expect(resolve).toHaveBeenNthCalledWith(1, 'org_free');
+ expect(resolve).toHaveBeenNthCalledWith(2, 'org_paid');
+ });
+
+ it('fails closed when organization lookup or effective-membership resolution fails', async () => {
+ const listFailure: ActiveMembershipCheckDeps = {
+ listOrganizationIdsForUser: vi.fn().mockRejectedValue(new Error('database unavailable')),
+ invalidateMembershipCache: vi.fn(),
+ resolveEffectiveMembership: vi.fn(),
+ };
+ const resolverFailure = deps(
+ ['org_1'],
+ vi.fn().mockRejectedValue(new Error('resolver unavailable')),
+ );
+
+ await expect(hasActiveMembershipForUser('user_1', listFailure)).resolves.toBe(false);
+ await expect(hasActiveMembershipForUser('user_1', resolverFailure)).resolves.toBe(false);
+ });
+});
diff --git a/server/tests/unit/addie-admin-global-auth.test.ts b/server/tests/unit/addie-admin-global-auth.test.ts
index e2dad03cfc..0a9c766783 100644
--- a/server/tests/unit/addie-admin-global-auth.test.ts
+++ b/server/tests/unit/addie-admin-global-auth.test.ts
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
loadSealedSession: vi.fn(),
checkPlatformBanForApiKey: vi.fn(),
checkPlatformBan: vi.fn(),
+ invalidateMembershipCache: vi.fn(),
resolveEffectiveMembership: vi.fn(),
getAdminWorkingGroupBySlug: vi.fn(),
isAdminGroupMember: vi.fn(),
@@ -41,6 +42,7 @@ vi.mock('../../src/db/bans-db.js', () => ({
}));
vi.mock('../../src/db/org-filters.js', () => ({
+ invalidateMembershipCache: mocks.invalidateMembershipCache,
resolveEffectiveMembership: mocks.resolveEffectiveMembership,
}));
diff --git a/server/tests/unit/markdown-to-plain-text.test.ts b/server/tests/unit/markdown-to-plain-text.test.ts
new file mode 100644
index 0000000000..579679df9a
--- /dev/null
+++ b/server/tests/unit/markdown-to-plain-text.test.ts
@@ -0,0 +1,72 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import createDOMPurify from 'dompurify';
+import { JSDOM } from 'jsdom';
+import { marked } from 'marked';
+import { describe, expect, it } from 'vitest';
+
+const helperSource = readFileSync(
+ join(process.cwd(), 'server/public/js/markdown-to-plain-text.js'),
+ 'utf8',
+);
+
+interface HelperWindow {
+ document: Document;
+ eval(source: string): unknown;
+ marked: typeof marked;
+ DOMPurify: { sanitize(dirty: string, config: unknown): string };
+ markdownToPlainText(markdown: string): string;
+}
+
+function loadHelper(): { window: HelperWindow; sanitized: () => string } {
+ const dom = new JSDOM('', { runScripts: 'outside-only' });
+ const browserWindow = dom.window as unknown as HelperWindow;
+ const purifier = createDOMPurify(dom.window);
+ let sanitizedHtml = '';
+
+ browserWindow.marked = marked;
+ browserWindow.DOMPurify = {
+ sanitize(dirty, config) {
+ sanitizedHtml = String(purifier.sanitize(dirty, config as never));
+ return sanitizedHtml;
+ },
+ };
+ browserWindow.eval(helperSource);
+
+ return { window: browserWindow, sanitized: () => sanitizedHtml };
+}
+
+describe('markdownToPlainText browser helper', () => {
+ it('preserves readable block, table, break, and image-alt text', () => {
+ const { window } = loadHelper();
+ const result = window.markdownToPlainText(`# Growth
+
+- First
+- Second
+
+| Outcome | Value |
+| --- | --- |
+| Brand | Strong |
+
+
+
+line
break`);
+
+ expect(result).toBe('Growth First Second Outcome Value Brand Strong Council diagram line break');
+ });
+
+ it('removes every resource-bearing tag and attribute before DOM insertion', () => {
+ const { window, sanitized } = loadHelper();
+ const result = window.markdownToPlainText(`
+
+
+Safe text
+
+`);
+
+ expect(result).toBe('Safe text Useful alt');
+ expect(sanitized()).not.toMatch(/<(?:video|svg|image)\b/i);
+ expect(sanitized()).not.toMatch(/\b(?:poster|href|xlink:href|style|src|srcset)\s*=/i);
+ expect(sanitized()).toContain('
');
+ });
+});
diff --git a/server/tests/unit/mastermind-council-membership-service.test.ts b/server/tests/unit/mastermind-council-membership-service.test.ts
new file mode 100644
index 0000000000..cae9adb467
--- /dev/null
+++ b/server/tests/unit/mastermind-council-membership-service.test.ts
@@ -0,0 +1,147 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ getGroup: vi.fn(),
+ getMembership: vi.fn(),
+ addMembership: vi.fn(),
+ getGroupsForUser: vi.fn(),
+ hasActiveMembership: vi.fn(),
+ getSeatType: vi.fn(),
+ query: vi.fn(),
+ poolQuery: vi.fn(),
+ awardPoints: vi.fn(),
+ checkAndAwardBadges: vi.fn(),
+ invalidateMemberContext: vi.fn(),
+ invalidateAdminStatus: vi.fn(),
+ notifyUser: vi.fn(),
+ inviteToChannel: vi.fn(),
+ sendWelcome: vi.fn(),
+}));
+
+vi.mock('../../src/db/client.js', () => ({
+ query: mocks.query,
+ getPool: () => ({ query: mocks.poolQuery }),
+}));
+
+vi.mock('../../src/db/working-group-db.js', () => ({
+ WorkingGroupDatabase: class {
+ getWorkingGroupBySlug = mocks.getGroup;
+ getMembership = mocks.getMembership;
+ addMembership = mocks.addMembership;
+ getWorkingGroupsForUser = mocks.getGroupsForUser;
+ },
+}));
+
+vi.mock('../../src/services/active-membership-service.js', () => ({
+ hasActiveMembershipForUser: mocks.hasActiveMembership,
+}));
+
+vi.mock('../../src/db/organization-db.js', () => ({ getUserSeatType: mocks.getSeatType }));
+vi.mock('../../src/db/community-db.js', () => ({
+ CommunityDatabase: class {
+ awardPoints = mocks.awardPoints;
+ checkAndAwardBadges = mocks.checkAndAwardBadges;
+ },
+}));
+vi.mock('../../src/db/slack-db.js', () => ({ SlackDatabase: class {} }));
+vi.mock('../../src/addie/member-context-cache.js', () => ({ invalidateMemberContextCache: mocks.invalidateMemberContext }));
+vi.mock('../../src/addie/admin-status-cache.js', () => ({ invalidateWebAdminStatusCache: mocks.invalidateAdminStatus }));
+vi.mock('../../src/notifications/notification-service.js', () => ({ notifyUser: mocks.notifyUser }));
+vi.mock('../../src/slack/client.js', () => ({ inviteToChannel: mocks.inviteToChannel }));
+vi.mock('../../src/addie/services/wg-welcome.js', () => ({ sendWgWelcomeMessage: mocks.sendWelcome }));
+vi.mock('../../src/auth/workos-client.js', () => ({
+ getWorkos: () => ({
+ userManagement: { listOrganizationMemberships: vi.fn().mockResolvedValue({ data: [] }) },
+ organizations: { getOrganization: vi.fn() },
+ }),
+}));
+
+import {
+ expressCommitteeInterest,
+ joinWorkingGroup,
+ MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
+} from '../../src/services/working-group-membership-service.js';
+
+const user = { id: 'user_1', email: 'person@example.test', firstName: 'Test', lastName: 'Person' };
+const council = {
+ id: 'council_growth',
+ slug: 'growth',
+ name: 'Growth Council',
+ status: 'active',
+ committee_type: 'council',
+ is_private: false,
+ leaders: null,
+ slack_channel_id: null,
+};
+const workingGroup = { ...council, id: 'wg_measurement', slug: 'measurement', name: 'Measurement', committee_type: 'working_group' };
+
+describe('Mastermind Council join membership boundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getGroup.mockResolvedValue(council);
+ mocks.getMembership.mockResolvedValue(null);
+ mocks.addMembership.mockResolvedValue({ working_group_id: council.id, workos_user_id: user.id, status: 'active' });
+ mocks.hasActiveMembership.mockResolvedValue(false);
+ mocks.getSeatType.mockResolvedValue('community_only');
+ mocks.query.mockResolvedValue({ rows: [] });
+ mocks.awardPoints.mockResolvedValue(undefined);
+ mocks.checkAndAwardBadges.mockResolvedValue(undefined);
+ mocks.sendWelcome.mockResolvedValue(undefined);
+ mocks.poolQuery.mockResolvedValue({ rows: [], rowCount: 1 });
+ });
+
+ it('denies a council join with the exact notice before any membership write or side effect', async () => {
+ await expect(joinWorkingGroup({ user, slug: council.slug })).rejects.toMatchObject({
+ code: 'council_membership_required',
+ message: MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE,
+ });
+
+ expect(mocks.getMembership).not.toHaveBeenCalled();
+ expect(mocks.addMembership).not.toHaveBeenCalled();
+ expect(mocks.invalidateMemberContext).not.toHaveBeenCalled();
+ expect(mocks.invalidateAdminStatus).not.toHaveBeenCalled();
+ expect(mocks.awardPoints).not.toHaveBeenCalled();
+ expect(mocks.checkAndAwardBadges).not.toHaveBeenCalled();
+ expect(mocks.notifyUser).not.toHaveBeenCalled();
+ expect(mocks.inviteToChannel).not.toHaveBeenCalled();
+ expect(mocks.sendWelcome).not.toHaveBeenCalled();
+ });
+
+ it('allows an eligible Explorer council member without applying contributor-seat rules', async () => {
+ mocks.hasActiveMembership.mockResolvedValue(true);
+
+ await expect(joinWorkingGroup({ user, slug: council.slug })).resolves.toMatchObject({
+ groupId: council.id,
+ groupSlug: council.slug,
+ });
+
+ expect(mocks.getSeatType).not.toHaveBeenCalled();
+ expect(mocks.addMembership).toHaveBeenCalledOnce();
+ expect(mocks.sendWelcome).toHaveBeenCalledOnce();
+ });
+
+ it('leaves non-council contributor-seat behavior unchanged', async () => {
+ mocks.getGroup.mockResolvedValue(workingGroup);
+
+ await expect(joinWorkingGroup({ user, slug: workingGroup.slug })).rejects.toMatchObject({
+ code: 'community_only_seat_blocked',
+ });
+
+ expect(mocks.hasActiveMembership).not.toHaveBeenCalled();
+ expect(mocks.getSeatType).toHaveBeenCalledWith(user.id);
+ expect(mocks.addMembership).not.toHaveBeenCalled();
+ });
+
+ it('leaves the pre-launch council interest funnel ungated', async () => {
+ await expect(expressCommitteeInterest({ user, slug: council.slug })).resolves.toMatchObject({
+ groupId: council.id,
+ interestLevel: 'participant',
+ });
+
+ expect(mocks.hasActiveMembership).not.toHaveBeenCalled();
+ expect(mocks.poolQuery).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO committee_interest'),
+ expect.any(Array),
+ );
+ });
+});
diff --git a/server/tests/unit/mastermind-council-surfaces.test.ts b/server/tests/unit/mastermind-council-surfaces.test.ts
new file mode 100644
index 0000000000..b5a2ec0999
--- /dev/null
+++ b/server/tests/unit/mastermind-council-surfaces.test.ts
@@ -0,0 +1,58 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+const root = process.cwd();
+const committees = readFileSync(join(root, 'server/public/committees.html'), 'utf8');
+const detail = readFileSync(join(root, 'server/public/working-groups/detail.html'), 'utf8');
+const markdownHelper = readFileSync(join(root, 'server/public/js/markdown-to-plain-text.js'), 'utf8');
+const service = readFileSync(join(root, 'server/src/services/working-group-membership-service.ts'), 'utf8');
+const route = readFileSync(join(root, 'server/src/routes/committees.ts'), 'utf8');
+const addie = readFileSync(join(root, 'server/src/addie/mcp/member-tools.ts'), 'utf8');
+
+const notice = 'Our Mastermind Councils are for paying member tiers only. AgenticAdvertising.org membership starts at $50 annually.';
+
+describe('Mastermind Council public and adapter surfaces', () => {
+ it('uses the requested Council landing-page copy while preserving other type copy', () => {
+ expect(committees).toContain('id="tab-council">Mastermind Councils');
+ expect(committees).toContain("title: 'Mastermind Councils'");
+ expect(committees).toContain("subtitle: 'Exploring how agentic AI is impacting two outcomes that marketers care about: growth and brand value.'");
+ expect(committees).toContain("sectionTitle: 'All Mastermind Councils'");
+ expect(committees).toContain('Mastermind Councils are interactive peer forums where members openly exchange ideas, share success stories, and collectively develop scalable playbooks and emerging best practices to shape the future of agentic marketing.');
+ expect(committees).toContain("emptyMessage: 'Mastermind Councils are being established. Check back soon!'");
+ expect(committees).toContain("joinCta: 'Become an AgenticAdvertising.org member to join Mastermind Councils");
+
+ expect(committees).toContain("title: 'Working Groups'");
+ expect(committees).toContain("title: 'Regional Chapters'");
+ expect(committees).toContain("title: 'Industry Gatherings'");
+ });
+
+ it('uses Council-only detail actions without changing the default working-group labels', () => {
+ expect(detail).toContain("currentGroup.committee_type === 'council'");
+ expect(detail).toContain("document.getElementById('backLinkText').textContent = 'Back to Councils'");
+ expect(detail).toContain("document.getElementById('joinBtnLabel').textContent = 'Join Council'");
+ expect(detail).toContain('Back to Working Groups');
+ expect(detail).toContain('Join Working Group');
+ });
+
+ it('renders document summaries as sanitized Markdown-derived plain text', () => {
+ expect(detail).toContain('escapeHtml(markdownToPlainText(doc.document_summary))');
+ expect(detail).toContain('');
+ expect(markdownHelper).toContain('ALLOWED_TAGS: SAFE_TAGS');
+ expect(markdownHelper).toContain("ALLOWED_ATTR: ['alt']");
+ expect(detail).toContain('descContent.innerHTML = DOMPurify.sanitize(renderedHtml);');
+ });
+
+ it('restores an enabled type-specific join action after success, failure, or leave', () => {
+ expect(detail).toContain('function resetJoinButton()');
+ expect(detail).toContain('joinBtn.disabled = false;');
+ expect(detail.match(/resetJoinButton\(\);/g)).toHaveLength(4);
+ });
+
+ it('shares the exact denial notice across HTTP, browser, and Addie adapters', () => {
+ expect(service).toContain(`'${notice}'`);
+ expect(route).toMatch(/error\.is\('council_membership_required'\)[\s\S]*?res\.status\(403\)[\s\S]*?message: MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE/);
+ expect(detail).toContain("alert(data.message || data.error || 'Failed to join group')");
+ expect(addie).toMatch(/error\.is\('council_membership_required'\)[\s\S]*?return MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE/);
+ });
+});
diff --git a/server/tests/unit/working-group-admin-global-auth.test.ts b/server/tests/unit/working-group-admin-global-auth.test.ts
index 868cce99f5..6ba0feb819 100644
--- a/server/tests/unit/working-group-admin-global-auth.test.ts
+++ b/server/tests/unit/working-group-admin-global-auth.test.ts
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
loadSealedSession: vi.fn(),
checkPlatformBanForApiKey: vi.fn(),
checkPlatformBan: vi.fn(),
+ invalidateMembershipCache: vi.fn(),
resolveEffectiveMembership: vi.fn(),
poolQuery: vi.fn(),
listWorkingGroups: vi.fn(),
@@ -44,6 +45,7 @@ vi.mock('../../src/db/bans-db.js', () => ({
}));
vi.mock('../../src/db/org-filters.js', () => ({
+ invalidateMembershipCache: mocks.invalidateMembershipCache,
resolveEffectiveMembership: mocks.resolveEffectiveMembership,
}));
diff --git a/server/tests/unit/working-group-perspective-url-validation.test.ts b/server/tests/unit/working-group-perspective-url-validation.test.ts
index b45227b8fc..5e12e5abd2 100644
--- a/server/tests/unit/working-group-perspective-url-validation.test.ts
+++ b/server/tests/unit/working-group-perspective-url-validation.test.ts
@@ -62,6 +62,7 @@ vi.mock('../../src/db/community-db.js', () => ({ CommunityDatabase: class {} }))
vi.mock('../../src/slack/db.js', () => ({ SlackDatabase: class {} }));
vi.mock('../../src/addie/services/wg-welcome.js', () => ({ sendWgWelcomeMessage: vi.fn() }));
vi.mock('../../src/services/working-group-membership-service.js', () => ({
+ MASTERMIND_COUNCIL_MEMBERSHIP_NOTICE: 'Our Mastermind Councils are for paying member tiers only. AgenticAdvertising.org membership starts at $50 annually.',
joinWorkingGroup: vi.fn(),
expressCommitteeInterest: vi.fn(),
withdrawCommitteeInterest: vi.fn(),