diff --git a/server/public/admin-brands.html b/server/public/admin-brands.html
index ec8130d7b5..5c8dfbdb75 100644
--- a/server/public/admin-brands.html
+++ b/server/public/admin-brands.html
@@ -915,7 +915,8 @@
Brand Details
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
brand_domain: domain,
- brand_json: brandJson
+ brand_json: brandJson,
+ organization_id: localStorage.getItem('selectedOrgId') || ''
})
});
@@ -973,7 +974,8 @@ Brand Details
}
try {
- const response = await fetch(`/api/brands/hosted/${encodeURIComponent(domain)}`, {
+ const selectedOrgId = localStorage.getItem('selectedOrgId') || '';
+ const response = await fetch(`/api/brands/hosted/${encodeURIComponent(domain)}?org=${encodeURIComponent(selectedOrgId)}`, {
method: 'DELETE'
});
diff --git a/server/public/admin-properties.html b/server/public/admin-properties.html
index b1575b478c..1ea2ad3d5a 100644
--- a/server/public/admin-properties.html
+++ b/server/public/admin-properties.html
@@ -650,7 +650,8 @@ Property Details
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publisher_domain: domain,
- adagents_json: adagentsJson
+ adagents_json: adagentsJson,
+ organization_id: localStorage.getItem('selectedOrgId') || ''
})
});
@@ -708,7 +709,8 @@ Property Details
}
try {
- const response = await fetch(`/api/properties/hosted/${encodeURIComponent(domain)}`, {
+ const selectedOrgId = localStorage.getItem('selectedOrgId') || '';
+ const response = await fetch(`/api/properties/hosted/${encodeURIComponent(domain)}?org=${encodeURIComponent(selectedOrgId)}`, {
method: 'DELETE'
});
diff --git a/server/public/brand-builder.html b/server/public/brand-builder.html
index a91b1b9cff..dd8c76e28a 100644
--- a/server/public/brand-builder.html
+++ b/server/public/brand-builder.html
@@ -3800,6 +3800,11 @@ ${escapeHtml(brand.name || brand.brand_id || 'Untitled Brand')}
async function saveToRegistry() {
if (!currentDomain) return;
+ const selectedOrgId = new URLSearchParams(window.location.search).get('org') || localStorage.getItem('selectedOrgId');
+ if (!selectedOrgId) {
+ alert('Select an organization from the dashboard before saving brand setup.');
+ return;
+ }
const btn = document.getElementById('save-registry-btn');
const originalText = btn.textContent;
btn.disabled = true;
@@ -3812,6 +3817,7 @@ ${escapeHtml(brand.name || brand.brand_id || 'Untitled Brand')}
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
+ organization_id: selectedOrgId,
domain: currentDomain,
brand_name: getSetupBrandName(json),
brand_json: json,
diff --git a/server/public/brand-viewer.html b/server/public/brand-viewer.html
index 87550d0cfc..2104451f24 100644
--- a/server/public/brand-viewer.html
+++ b/server/public/brand-viewer.html
@@ -1733,6 +1733,7 @@ Upload logo
const form = new FormData();
form.append('file', uploadFile);
form.append('tags', tags.join(','));
+ form.append('organization_id', localStorage.getItem('selectedOrgId') || '');
if (note) form.append('note', note);
try {
@@ -2415,6 +2416,7 @@ Upload logo
if (body.brand_manifest[k] === undefined) delete body.brand_manifest[k];
});
if (Object.keys(body.brand_manifest).length === 0) delete body.brand_manifest;
+ body.organization_id = localStorage.getItem('selectedOrgId') || '';
try {
const resp = await fetch(`/api/brands/discovered/${encodeURIComponent(currentDomain)}`, {
@@ -2585,7 +2587,10 @@ Upload logo
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ to_revision: revisionNumber }),
+ body: JSON.stringify({
+ to_revision: revisionNumber,
+ organization_id: localStorage.getItem('selectedOrgId') || '',
+ }),
});
if (!resp.ok) {
diff --git a/server/public/certification.html b/server/public/certification.html
index b080e8b5bf..a1362bee0e 100644
--- a/server/public/certification.html
+++ b/server/public/certification.html
@@ -1843,7 +1843,8 @@ My protocol contributions
if (!contentEl) return;
try {
- var resp = await fetch('/api/certification/modules/' + encodeURIComponent(modId), {credentials: 'include'});
+ var selectedOrgId = localStorage.getItem('selectedOrgId') || '';
+ var resp = await fetch('/api/certification/modules/' + encodeURIComponent(modId) + '?org=' + encodeURIComponent(selectedOrgId), {credentials: 'include'});
if (!resp.ok) throw new Error('fetch failed');
var data = await resp.json();
var dims = (data.assessment_criteria && data.assessment_criteria.dimensions) || [];
diff --git a/server/public/chat.html b/server/public/chat.html
index 4bd9658934..43dfd93ac5 100644
--- a/server/public/chat.html
+++ b/server/public/chat.html
@@ -6422,7 +6422,13 @@ Ask Addie
setReadOnlyMode(true, 'video');
try {
- const res = await authFetch('/api/addie/video/session', { method: 'POST' });
+ const selectedOrgId = getSelectedOrganizationId();
+ if (!selectedOrgId) throw new Error('Select an organization before starting a video call');
+ const res = await authFetch('/api/addie/video/session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ organization_id: selectedOrgId }),
+ });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to start video session');
diff --git a/server/public/dashboard-agents.html b/server/public/dashboard-agents.html
index 1449e58eb4..3d80fa4eae 100644
--- a/server/public/dashboard-agents.html
+++ b/server/public/dashboard-agents.html
@@ -1248,7 +1248,7 @@
const encoded = encodeURIComponent(agent.url);
const orgQuery = organizationId ? `?org=${encodeURIComponent(organizationId)}` : '';
const endpoints = [
- `/api/registry/agents/${encoded}/compliance`,
+ `/api/registry/agents/${encoded}/compliance${orgQuery}`,
`/api/registry/agents/${encoded}/compliance/history?limit=10`,
`/api/registry/agents/${encoded}/auth-status${orgQuery}`,
];
@@ -1564,7 +1564,7 @@ Agents
const previous = agent.visibility || 'private';
if (previous === target) return;
try {
- const resp = await fetch(`/api/me/member-profile/agents/${index}/visibility`, {
+ const resp = await fetch(`/api/me/member-profile/agents/${index}/visibility?org=${encodeURIComponent(pageState.orgId)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
@@ -1772,7 +1772,7 @@ Agents
let rows = cachedStoryboardStatuses(agentUrl);
if (rows.length === 0) {
try {
- const res = await fetch('/api/registry/agents/' + encodeURIComponent(agentUrl) + '/storyboard-status', { credentials: 'include' });
+ const res = await fetch('/api/registry/agents/' + encodeURIComponent(agentUrl) + '/storyboard-status?org=' + encodeURIComponent(pageState.orgId), { credentials: 'include' });
if (res.ok) {
const data = await res.json();
rows = Array.isArray(data.storyboards) ? data.storyboards : [];
@@ -2526,7 +2526,7 @@ Agents
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
- body: JSON.stringify({ opt_out: !checkbox.checked }),
+ body: JSON.stringify({ opt_out: !checkbox.checked, organization_id: pageState.orgId }),
});
if (!res.ok) {
checkbox.checked = !checkbox.checked;
@@ -3189,7 +3189,7 @@ Agents
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
- body: JSON.stringify({ paused: toggle.checked }),
+ body: JSON.stringify({ paused: toggle.checked, organization_id: pageState.orgId }),
});
if (!res.ok) {
toggle.checked = !toggle.checked;
@@ -3239,7 +3239,7 @@ Agents
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
- body: JSON.stringify({ interval_hours: parseInt(select.value, 10) }),
+ body: JSON.stringify({ interval_hours: parseInt(select.value, 10), organization_id: pageState.orgId }),
});
if (!res.ok) {
select.value = previousValue;
@@ -3390,6 +3390,7 @@ Agents
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ organization_id: pageState.orgId }),
});
const data = await res.json().catch(() => ({}));
@@ -3449,7 +3450,7 @@ Agents
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
try {
- const res = await fetch(`/api/registry/agents/${encodeURIComponent(agentUrl)}/monitoring/requests?limit=50`, {
+ const res = await fetch(`/api/registry/agents/${encodeURIComponent(agentUrl)}/monitoring/requests?limit=50&org=${encodeURIComponent(pageState.orgId)}`, {
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to load');
diff --git a/server/public/dashboard-membership.html b/server/public/dashboard-membership.html
index 82e9574b9e..f0f719c12a 100644
--- a/server/public/dashboard-membership.html
+++ b/server/public/dashboard-membership.html
@@ -1579,7 +1579,8 @@ ${title}
// Load referral banner — shows discount countdown for users who accepted a referral invitation
async function loadReferralBanner() {
try {
- const res = await fetch('/api/me/referral', { credentials: 'include' });
+ if (!currentOrg?.id) return;
+ const res = await fetch('/api/me/referral?org=' + encodeURIComponent(currentOrg.id), { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
if (!data.referral || data.days_remaining === null) return;
diff --git a/server/public/join.html b/server/public/join.html
index d16a36e538..636089e188 100644
--- a/server/public/join.html
+++ b/server/public/join.html
@@ -439,9 +439,13 @@
if (!config || !config.user) {
return Promise.resolve({ user: null, existingReferral: null });
}
- return fetch('/api/me/referral', { credentials: 'include' })
+ var selectedOrgId = new URLSearchParams(window.location.search).get('org') || localStorage.getItem('selectedOrgId');
+ if (!selectedOrgId) {
+ return Promise.resolve({ user: config.user, existingReferral: null, selectedOrgId: null });
+ }
+ return fetch('/api/me/referral?org=' + encodeURIComponent(selectedOrgId), { credentials: 'include' })
.then(function (res) { return res.ok ? res.json() : { referral: null }; })
- .then(function (data) { return { user: config.user, existingReferral: data.referral, daysRemaining: data.days_remaining }; })
+ .then(function (data) { return { user: config.user, existingReferral: data.referral, daysRemaining: data.days_remaining, selectedOrgId: selectedOrgId }; })
.catch(function () { return { user: config.user, existingReferral: null }; });
}
@@ -533,12 +537,19 @@
btn.textContent = 'Accepting...';
var marketingOptIn = document.getElementById('marketingOptIn').checked;
+ var selectedOrgId = new URLSearchParams(window.location.search).get('org') || localStorage.getItem('selectedOrgId');
+ if (!selectedOrgId) {
+ btn.disabled = false;
+ btn.textContent = 'Accept invitation';
+ alert('Select an organization from your dashboard, then reopen this invitation.');
+ return;
+ }
fetch('/api/referral/' + encodeURIComponent(code) + '/accept', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ marketing_opt_in: marketingOptIn }),
+ body: JSON.stringify({ marketing_opt_in: marketingOptIn, organization_id: selectedOrgId }),
})
.then(function (res) { return res.json().then(function (body) { return { ok: res.ok, status: res.status, body: body }; }); })
.then(function (r) {
diff --git a/server/public/member-profile.html b/server/public/member-profile.html
index eeacdbffba..362ff21a36 100644
--- a/server/public/member-profile.html
+++ b/server/public/member-profile.html
@@ -2907,6 +2907,7 @@ Tags
const fd = new FormData();
fd.append('file', file);
fd.append('tags', 'primary');
+ fd.append('organization_id', getOrgIdFromUrl() || '');
const resp = await fetch(`/api/brands/${encodeURIComponent(brandDomain)}/logos`, {
method: 'POST',
body: fd,
diff --git a/server/public/my-content.html b/server/public/my-content.html
index 67d56b928f..ba90253e12 100644
--- a/server/public/my-content.html
+++ b/server/public/my-content.html
@@ -1375,14 +1375,22 @@ ${escapeHtml(item.title)}
response = await fetch(`/api/me/content/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...data, status: statusValue })
+ body: JSON.stringify({
+ ...data,
+ status: statusValue,
+ organization_id: localStorage.getItem('selectedOrgId') || '',
+ })
});
} else {
// Create new via propose endpoint
response = await fetch('/api/content/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...data, status: statusValue })
+ body: JSON.stringify({
+ ...data,
+ status: statusValue,
+ organization_id: localStorage.getItem('selectedOrgId') || '',
+ })
});
}
diff --git a/server/public/onboarding.html b/server/public/onboarding.html
index 2bcb327d69..944fa43398 100644
--- a/server/public/onboarding.html
+++ b/server/public/onboarding.html
@@ -936,7 +936,7 @@ Set up your brand
const domain = currentUser.email ? currentUser.email.split('@')[1] : '';
document.getElementById('loading').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
- showBrandSetup(companyOrg.name, domain);
+ showBrandSetup(companyOrg.name, domain, companyOrg.id);
return;
}
}
@@ -1547,7 +1547,7 @@ Set up your brand
}
// Show brand setup step instead of going directly to dashboard
- showBrandSetup(orgName, corporateDomain);
+ showBrandSetup(orgName, corporateDomain, data.organization?.id || data.id);
} catch (error) {
showError(error.message);
createBtn.disabled = false;
@@ -1623,9 +1623,11 @@ Set up your brand
// ── Brand setup step ──────────────────────────────────────────────
var createdOrgName = '';
+ var createdOrgId = '';
- function showBrandSetup(orgName, domain) {
+ function showBrandSetup(orgName, domain, orgId) {
createdOrgName = orgName;
+ createdOrgId = orgId || '';
document.getElementById('createForm').style.display = 'none';
document.getElementById('brandSetupSection').style.display = 'block';
document.getElementById('brandDomain').value = domain || '';
@@ -1676,6 +1678,7 @@ Set up your brand
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+ organization_id: createdOrgId,
domain: domain,
brand_name: createdOrgName,
logo_url: logoUrl || undefined,
diff --git a/server/public/property-viewer.html b/server/public/property-viewer.html
index 1f2a435d09..b41c615661 100644
--- a/server/public/property-viewer.html
+++ b/server/public/property-viewer.html
@@ -738,7 +738,11 @@ Edit Property
const response = await fetch(`/api/properties/hosted/${encodeURIComponent(currentDomain)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ edit_summary: editSummary, adagents_json }),
+ body: JSON.stringify({
+ edit_summary: editSummary,
+ adagents_json,
+ organization_id: localStorage.getItem('selectedOrgId') || '',
+ }),
});
if (!response.ok) {
diff --git a/server/public/video-lab.html b/server/public/video-lab.html
index 4f19a0b651..b958b2f56d 100644
--- a/server/public/video-lab.html
+++ b/server/public/video-lab.html
@@ -729,6 +729,7 @@ Event log
// so this user's settings shape this Tavus session only.
const formSettings = readSettingsFromForm();
const body = {
+ organization_id: localStorage.getItem('selectedOrgId') || undefined,
greeting: formSettings.greeting || undefined,
extraContext: formSettings.extraContext || undefined,
maxDurationSec: formSettings.durationMin ? formSettings.durationMin * 60 : undefined,
diff --git a/server/public/video.html b/server/public/video.html
index f564b36377..c015ccd1c5 100644
--- a/server/public/video.html
+++ b/server/public/video.html
@@ -394,7 +394,13 @@ Start a video conversation
let conversationUrl;
try {
- const res = await fetch('/api/addie/video/session', { method: 'POST' });
+ const organizationId = localStorage.getItem('selectedOrgId');
+ if (!organizationId) throw new Error('Select an organization in the dashboard before starting a call');
+ const res = await fetch('/api/addie/video/session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ organization_id: organizationId }),
+ });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to start session');
diff --git a/server/src/addie/mcp/billing-tools.ts b/server/src/addie/mcp/billing-tools.ts
index 6bff9015e0..9fd088ca14 100644
--- a/server/src/addie/mcp/billing-tools.ts
+++ b/server/src/addie/mcp/billing-tools.ts
@@ -149,12 +149,13 @@ This tool cannot generate payment links on behalf of other people or organizatio
input_schema: {
type: 'object' as const,
properties: {
+ organization_id: { type: 'string', description: 'Explicit selected WorkOS organization ID.' },
lookup_key: {
type: 'string',
description: 'The product lookup key from find_membership_products',
},
},
- required: ['lookup_key'],
+ required: ['organization_id', 'lookup_key'],
},
},
{
@@ -166,6 +167,7 @@ call confirm_send_invoice to send.`,
input_schema: {
type: 'object' as const,
properties: {
+ organization_id: { type: 'string', description: 'Explicit selected WorkOS organization ID.' },
lookup_key: {
type: 'string',
description: 'The product lookup key from find_membership_products',
@@ -180,7 +182,7 @@ call confirm_send_invoice to send.`,
description: 'Payment terms in days (net-30, net-45, net-60, net-90). Defaults to 30.',
},
},
- required: ['lookup_key'],
+ required: ['organization_id', 'lookup_key'],
},
},
{
@@ -192,6 +194,7 @@ on file (set via the dashboard or invite-acceptance flow).`,
input_schema: {
type: 'object' as const,
properties: {
+ organization_id: { type: 'string', description: 'Explicit selected WorkOS organization ID.' },
lookup_key: {
type: 'string',
description: 'The product lookup key from find_membership_products',
@@ -206,7 +209,7 @@ on file (set via the dashboard or invite-acceptance flow).`,
description: 'Payment terms in days (net-30, net-45, net-60, net-90). Defaults to 30.',
},
},
- required: ['lookup_key'],
+ required: ['organization_id', 'lookup_key'],
},
},
{
@@ -216,8 +219,10 @@ Use this when an owner or admin asks about receipts, invoices, billing history,
The user must be signed in and have an active owner or admin role in the selected organization.`,
input_schema: {
type: 'object' as const,
- properties: {},
- required: [],
+ properties: {
+ organization_id: { type: 'string', description: 'Explicit selected WorkOS organization ID.' },
+ },
+ required: ['organization_id'],
},
},
];
@@ -326,7 +331,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
const workosUserId = memberContext?.workos_user?.workos_user_id;
const memberEmail = memberContext?.workos_user?.email;
- const orgId = memberContext?.organization?.workos_organization_id;
+ const orgId = typeof input.organization_id === 'string' ? input.organization_id : null;
if (!workosUserId || !memberEmail) {
await recordToolError(memberContext, 'create_payment_link', 'not_signed_in', { lookup_key: lookupKey });
@@ -335,7 +340,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
error: 'Cannot create a payment link without a signed-in account. Ask the user to sign in at https://agenticadvertising.org first, then try again.',
});
}
- if (!orgId) {
+ if (!orgId || memberContext?.organization?.workos_organization_id !== orgId) {
await recordToolError(memberContext, 'create_payment_link', 'no_workspace', { lookup_key: lookupKey });
return JSON.stringify({
success: false,
@@ -346,6 +351,10 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
logger.info({ lookupKey, orgId, workosUserId }, 'Addie: Creating payment link for signed-in member');
try {
+ const { resolveUserOrgMembership } = await import('../../utils/resolve-user-org-membership.js');
+ if (!await resolveUserOrgMembership(getWorkos(), { id: workosUserId }, orgId)) {
+ return JSON.stringify({ success: false, error: 'Organization authorization was revoked.' });
+ }
const priceId = await getPriceByLookupKey(lookupKey);
if (!priceId) {
await recordToolError(memberContext, 'create_payment_link', 'unknown_lookup_key', { lookup_key: lookupKey, org_id: orgId });
@@ -359,6 +368,9 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
// Ensure a Stripe customer exists with org metadata before creating the
// checkout session so the subscription webhook can link back to the org.
+ if (!await resolveUserOrgMembership(getWorkos(), { id: workosUserId }, orgId)) {
+ return JSON.stringify({ success: false, error: 'Organization authorization was revoked.' });
+ }
const customerId = (await orgDb.getOrCreateStripeCustomer(orgId, () =>
createStripeCustomer({
email: memberEmail,
@@ -367,6 +379,9 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
})
)) || undefined;
+ if (!await resolveUserOrgMembership(getWorkos(), { id: workosUserId }, orgId)) {
+ return JSON.stringify({ success: false, error: 'Organization authorization was revoked.' });
+ }
const session = await createCheckoutSession({
priceId,
customerId,
@@ -413,7 +428,8 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
const paymentTerms = input.payment_terms as number | undefined;
const memberEmail = memberContext?.workos_user?.email;
- const orgId = memberContext?.organization?.workos_organization_id;
+ const workosUserId = memberContext?.workos_user?.workos_user_id;
+ const orgId = typeof input.organization_id === 'string' ? input.organization_id : null;
if (!memberEmail) {
await recordToolError(memberContext, 'send_invoice', 'not_signed_in', { lookup_key: lookupKey });
return JSON.stringify({
@@ -421,7 +437,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
error: 'Cannot preview an invoice without a signed-in member and a workspace. Ask the user to sign in at https://agenticadvertising.org first.',
});
}
- if (!orgId) {
+ if (!workosUserId || !orgId || memberContext?.organization?.workos_organization_id !== orgId) {
await recordToolError(memberContext, 'send_invoice', 'no_workspace', { lookup_key: lookupKey });
return JSON.stringify({
success: false,
@@ -434,6 +450,10 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
let companyName: string | undefined;
try {
+ const { resolveUserOrgMembership } = await import('../../utils/resolve-user-org-membership.js');
+ if (!await resolveUserOrgMembership(getWorkos(), { id: workosUserId }, orgId)) {
+ return JSON.stringify({ success: false, error: 'Organization authorization was revoked.' });
+ }
const org = await orgDb.getOrganization(orgId);
if (org) {
companyName = org.name;
@@ -504,7 +524,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
const memberEmail = memberContext?.workos_user?.email;
const workosUserId = memberContext?.workos_user?.workos_user_id;
- const orgId = memberContext?.organization?.workos_organization_id;
+ const orgId = typeof input.organization_id === 'string' ? input.organization_id : null;
if (!memberEmail || !workosUserId) {
await recordToolError(memberContext, 'confirm_send_invoice', 'not_signed_in', { lookup_key: lookupKey });
return JSON.stringify({
@@ -512,7 +532,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
error: 'Cannot send an invoice without a signed-in member and a workspace. Ask the user to sign in at https://agenticadvertising.org first.',
});
}
- if (!orgId) {
+ if (!orgId || memberContext?.organization?.workos_organization_id !== orgId) {
await recordToolError(memberContext, 'confirm_send_invoice', 'no_workspace', { lookup_key: lookupKey });
return JSON.stringify({
success: false,
@@ -565,6 +585,10 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
);
try {
+ const { resolveUserOrgMembership } = await import('../../utils/resolve-user-org-membership.js');
+ if (!await resolveUserOrgMembership(getWorkos(), { id: workosUserId }, orgId)) {
+ return JSON.stringify({ success: false, error: 'Organization authorization was revoked.' });
+ }
const result = await createAndSendInvoice({
lookupKey,
companyName: org.name,
@@ -605,9 +629,9 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
});
// Get billing portal link for active organization billing managers
- handlers.set('get_billing_portal', async (_input) => {
- const orgId = memberContext?.organization?.workos_organization_id;
- if (!orgId) {
+ handlers.set('get_billing_portal', async (input) => {
+ const orgId = typeof input.organization_id === 'string' ? input.organization_id : null;
+ if (!orgId || memberContext?.organization?.workos_organization_id !== orgId) {
return JSON.stringify({
success: false,
error: 'You need to be signed in with a linked account to access billing. Visit https://agenticadvertising.org/dashboard/membership to manage your billing.',
@@ -630,7 +654,7 @@ export function createBillingToolHandlers(memberContext?: MemberContext | null):
// auth middleware for the dev-user bypass, whose WorkOS constructor must
// not run while unrelated billing tools are being initialized.
const { resolveUserOrgMembership } = await import('../../utils/resolve-user-org-membership.js');
- const membership = await resolveUserOrgMembership(getWorkos(), workosUserId, orgId);
+ const membership = await resolveUserOrgMembership(getWorkos(), { id: workosUserId }, orgId);
if (!canManageOrganizationBilling(membership, orgId)) {
return JSON.stringify({
success: false,
diff --git a/server/src/addie/mcp/brand-property-tools.ts b/server/src/addie/mcp/brand-property-tools.ts
index 0782a4d53d..501fa3b66b 100644
--- a/server/src/addie/mcp/brand-property-tools.ts
+++ b/server/src/addie/mcp/brand-property-tools.ts
@@ -14,7 +14,7 @@
* list and merges it into the brand manifest by identifier.
*
* Both tools enforce the same ownership check as the HTTP route via
- * getBrandForEdit — the calling user's primary org must own the brand
+ * getBrandForEdit — the explicitly selected organization must own the brand
* domain (verified row on organization_domains).
*/
@@ -28,6 +28,8 @@ import {
VALID_RELATIONSHIPS,
type Relationship,
} from '../../services/brand-property-parse.js';
+import { getWorkos } from '../../auth/workos-client.js';
+import { resolveUserOrgMembership } from '../../utils/resolve-user-org-membership.js';
const brandDb = new BrandDatabase();
@@ -58,6 +60,10 @@ export const BRAND_PROPERTY_TOOLS: AddieTool[] = [
type: 'string',
description: "The brand domain to import properties into (e.g. 'paste-demo.example'). Caller's org must own it.",
},
+ organization_id: {
+ type: 'string',
+ description: 'The explicitly selected WorkOS organization id. It must match the active organization context.',
+ },
input: {
type: 'string',
description: 'Either pasted text containing domains/bundle IDs, or an https:// URL to fetch (set input_type accordingly).',
@@ -73,7 +79,7 @@ export const BRAND_PROPERTY_TOOLS: AddieTool[] = [
description: "Stamped onto each parsed property. Defaults to 'delegated'.",
},
},
- required: ['domain', 'input'],
+ required: ['domain', 'organization_id', 'input'],
},
},
{
@@ -89,6 +95,10 @@ export const BRAND_PROPERTY_TOOLS: AddieTool[] = [
type: 'string',
description: 'The brand domain to import properties into.',
},
+ organization_id: {
+ type: 'string',
+ description: 'The explicitly selected WorkOS organization id. It must match the active organization context.',
+ },
properties: {
type: 'array',
description: 'Property objects to merge. Each must have identifier (string) and type (one of the property type allowlist). relationship is optional but recommended.',
@@ -103,7 +113,7 @@ export const BRAND_PROPERTY_TOOLS: AddieTool[] = [
},
},
},
- required: ['domain', 'properties'],
+ required: ['domain', 'organization_id', 'properties'],
},
},
];
@@ -120,6 +130,14 @@ export function createBrandPropertyToolHandlers(
const handlers = new Map) => Promise>();
const userId = memberContext?.workos_user?.workos_user_id ?? null;
+ function selectedOrganizationId(args: Record): string | null {
+ const requested = args.organization_id;
+ if (typeof requested !== 'string' || requested.length === 0) return null;
+ return memberContext?.organization?.workos_organization_id === requested
+ ? requested
+ : null;
+ }
+
handlers.set('parse_brand_properties', async (args) => {
if (!userId) {
return JSON.stringify({
@@ -128,6 +146,10 @@ export function createBrandPropertyToolHandlers(
}
const rawDomain = args.domain;
const input = args.input;
+ const organizationId = selectedOrganizationId(args);
+ if (!organizationId) {
+ return JSON.stringify({ error: 'organization_id must match the explicitly selected active organization' });
+ }
if (typeof rawDomain !== 'string' || rawDomain.trim().length === 0) {
return JSON.stringify({ error: 'domain is required' });
}
@@ -138,10 +160,13 @@ export function createBrandPropertyToolHandlers(
const inputType = (args.input_type as string) ?? 'text';
const relationship = args.relationship as Relationship | undefined;
+ if (!await resolveUserOrgMembership(getWorkos(), { id: userId }, organizationId)) {
+ return JSON.stringify({ error: 'Organization authorization was revoked', status: 403 });
+ }
const result = await parsePropertyInputForBrand({
brandDb,
domain,
- userId,
+ organizationId,
input,
inputType: inputType as 'text' | 'url',
relationship,
@@ -177,6 +202,10 @@ export function createBrandPropertyToolHandlers(
}
const rawDomain = args.domain;
const properties = args.properties;
+ const organizationId = selectedOrganizationId(args);
+ if (!organizationId) {
+ return JSON.stringify({ error: 'organization_id must match the explicitly selected active organization' });
+ }
if (typeof rawDomain !== 'string' || rawDomain.trim().length === 0) {
return JSON.stringify({ error: 'domain is required' });
}
@@ -185,10 +214,14 @@ export function createBrandPropertyToolHandlers(
}
const domain = normalizeDomain(rawDomain);
+ if (!await resolveUserOrgMembership(getWorkos(), { id: userId }, organizationId)) {
+ return JSON.stringify({ error: 'Organization authorization was revoked', status: 403 });
+ }
+
const result = await mergeBrandProperties({
brandDb,
domain,
- userId,
+ organizationId,
properties: properties as Array>,
});
diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts
index a1cb4150eb..16b4c16a31 100644
--- a/server/src/addie/mcp/member-tools.ts
+++ b/server/src/addie/mcp/member-tools.ts
@@ -129,7 +129,6 @@ import { isValidAgentType } from '../../types.js';
import { getPool, query } from '../../db/client.js';
import { MemberSearchAnalyticsDatabase } from '../../db/member-search-analytics-db.js';
import { OrganizationDatabase } from '../../db/organization-db.js';
-import { resolvePrimaryOrganization } from '../../db/users-db.js';
import { WorkingGroupDatabase } from '../../db/working-group-db.js';
import { checkMilestones } from '../services/journey-computation.js';
import { PERSONA_LABELS } from '../../config/personas.js';
@@ -142,7 +141,7 @@ import { getGitHubAccessToken } from '../../services/pipes.js';
import { BrandDatabase } from '../../db/brand-db.js';
import { issueDomainChallenge, verifyDomainChallenge } from '../../services/brand-claim.js';
import { getWorkos } from '../../auth/workos-client.js';
-import { resolveUserRole } from '../../utils/resolve-user-role.js';
+import { resolveUserOrgMembership } from '../../utils/resolve-user-org-membership.js';
import { recordAgentTestRun } from '../../db/agent-test-db.js';
import { canonicalizeAgentUrl } from '../../db/publisher-db.js';
import {
@@ -1292,8 +1291,12 @@ async function resolveSaveAgentOrganization(
message: `This feature requires an organization. If you belong to multiple organizations, say which one to ${actionLabel}, or use the organization_id / organization_name field.`,
};
}
- const activeMemberships = await listActiveWorkosMembershipsForSaveAgent(workosUserId, contextOrgId);
- if (activeMemberships.length === 0) {
+ const effectiveMembership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: workosUserId },
+ contextOrgId,
+ );
+ if (!effectiveMembership) {
return {
ok: false,
message: `I can't ${actionLabel} ${contextOrgId} because your account is not an active member of that organization.`,
@@ -1307,15 +1310,13 @@ async function resolveSaveAgentOrganization(
};
}
- const memberships = await listActiveWorkosMembershipsForSaveAgent(workosUserId, requestedOrgId);
- const activeOrgIds = [...new Set(
- memberships
- .map((m) => m.organizationId)
- .filter(Boolean),
- )];
-
if (requestedOrgId) {
- if (activeOrgIds.length === 0) {
+ const effectiveMembership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: workosUserId },
+ requestedOrgId,
+ );
+ if (!effectiveMembership) {
return {
ok: false,
message: `I can't ${actionLabel} ${requestedOrgId} because your account is not an active member of that organization.`,
@@ -1334,7 +1335,26 @@ async function resolveSaveAgentOrganization(
};
}
- const candidates = await Promise.all(activeOrgIds.map(async (organizationId) => {
+ const memberships = await listActiveWorkosMembershipsForSaveAgent(workosUserId);
+ const activeOrgIds = new Set(
+ memberships.map((membership) => membership.organizationId).filter(Boolean),
+ );
+ try {
+ const grants = await query<{ workos_organization_id: string }>(
+ `SELECT workos_organization_id
+ FROM organization_credential_grants
+ WHERE workos_user_id = $1
+ AND revoked_at IS NULL
+ AND effective_from <= NOW()
+ AND (effective_until IS NULL OR effective_until > NOW())`,
+ [workosUserId],
+ );
+ for (const grant of grants.rows) activeOrgIds.add(grant.workos_organization_id);
+ } catch (err) {
+ logger.warn({ err, workosUserId }, 'Could not enumerate credential grants for organization-name selection');
+ }
+
+ const candidates = await Promise.all([...activeOrgIds].map(async (organizationId) => {
const localOrg = await orgDb.getOrganization(organizationId).catch(() => null);
let name = localOrg?.name ?? null;
if (!name) {
@@ -1351,6 +1371,12 @@ async function resolveSaveAgentOrganization(
);
if (matches.length === 1) {
+ if (!await resolveUserOrgMembership(getWorkos(), { id: workosUserId }, matches[0].organizationId)) {
+ return {
+ ok: false,
+ message: `I can't ${actionLabel} ${matches[0].organizationId} because your account no longer has access to that organization.`,
+ };
+ }
return {
ok: true,
organizationId: matches[0].organizationId,
@@ -1593,8 +1619,10 @@ export const MEMBER_TOOLS: AddieTool[] = [
usage_hints: 'use for "what\'s our company listing?", "our tagline", "company profile", "our directory entry"',
input_schema: {
type: 'object',
- properties: {},
- required: [],
+ properties: {
+ organization_id: { type: 'string', description: 'Explicit selected WorkOS organization ID.' },
+ },
+ required: ['organization_id'],
},
},
{
@@ -1605,6 +1633,7 @@ export const MEMBER_TOOLS: AddieTool[] = [
input_schema: {
type: 'object',
properties: {
+ organization_id: { type: 'string', description: 'Explicit selected WorkOS organization ID.' },
tagline: { type: 'string', description: 'Short tagline shown on directory card and used by Addie for search matching. Omit to leave unchanged.' },
description: { type: 'string', description: 'Longer company description' },
offerings: {
@@ -1622,7 +1651,7 @@ export const MEMBER_TOOLS: AddieTool[] = [
twitter_url: { type: 'string', description: 'Twitter/X profile URL' },
headquarters: { type: 'string', description: 'Headquarters location (e.g., "New York, NY")' },
},
- required: [],
+ required: ['organization_id'],
},
},
{
@@ -1633,6 +1662,7 @@ export const MEMBER_TOOLS: AddieTool[] = [
input_schema: {
type: 'object',
properties: {
+ organization_id: { type: 'string', description: 'Explicit selected WorkOS organization ID.' },
logo_url: {
type: 'string',
description: 'Public HTTPS URL to the logo image (PNG, JPG, SVG, WebP). Omit to leave unchanged.',
@@ -1646,7 +1676,7 @@ export const MEMBER_TOOLS: AddieTool[] = [
description: 'Required only when the brand was previously registered by another org. true = keep the prior brand identity (logos, colors, agents) as a starting point (acquisition / handoff case). false = start fresh. Omit on first call; set explicitly after the user picks.',
},
},
- required: [],
+ required: ['organization_id'],
},
},
{
@@ -3031,15 +3061,18 @@ export function createMemberToolHandlers(
// ============================================
// COMPANY LISTING (the org's directory entry)
// ============================================
- handlers.set('get_company_listing', async () => {
+ handlers.set('get_company_listing', async (input) => {
if (!memberContext?.workos_user?.workos_user_id) {
return 'You need to be logged in to see your company listing. Please log in at https://agenticadvertising.org/dashboard first.';
}
const userId = memberContext.workos_user.workos_user_id;
- const orgId = await resolvePrimaryOrganization(userId);
- if (!orgId) {
- return "Your organization doesn't have a directory listing yet. Visit https://agenticadvertising.org/member-profile to create one!";
+ const orgId = typeof input.organization_id === 'string' ? input.organization_id : null;
+ if (!orgId || memberContext.organization?.workos_organization_id !== orgId) {
+ return 'organization_id must match the explicitly selected organization.';
+ }
+ if (!await resolveUserOrgMembership(getWorkos(), { id: userId }, orgId)) {
+ return 'Organization authorization was revoked.';
}
const profileResult = await query<{
@@ -3133,9 +3166,12 @@ export function createMemberToolHandlers(
}
const userId = memberContext.workos_user.workos_user_id;
- const orgId = await resolvePrimaryOrganization(userId);
- if (!orgId) {
- return "Your organization doesn't have a directory listing yet. Visit https://agenticadvertising.org/member-profile to create one first!";
+ const orgId = typeof input.organization_id === 'string' ? input.organization_id : null;
+ if (!orgId || memberContext.organization?.workos_organization_id !== orgId) {
+ return 'organization_id must match the explicitly selected organization.';
+ }
+ if (!await resolveUserOrgMembership(getWorkos(), { id: userId }, orgId)) {
+ return 'Organization authorization was revoked.';
}
// Build parameterized UPDATE query with explicit column allowlist
@@ -3157,6 +3193,9 @@ export function createMemberToolHandlers(
values.push(orgId);
try {
+ if (!await resolveUserOrgMembership(getWorkos(), { id: userId }, orgId)) {
+ return 'Organization authorization was revoked.';
+ }
const updateResult = await query(
`UPDATE member_profiles SET ${setClauses.join(', ')}, updated_at = NOW()
WHERE workos_organization_id = $${paramIdx}
@@ -3187,9 +3226,13 @@ export function createMemberToolHandlers(
return 'Provide a logo_url or brand_color to update.';
}
- const orgId = memberContext.organization?.workos_organization_id;
- if (!orgId) {
- return "Your account isn't linked to an organization yet. Visit https://agenticadvertising.org/member-profile to set up your company listing.";
+ const userId = memberContext.workos_user.workos_user_id;
+ const orgId = typeof input.organization_id === 'string' ? input.organization_id : null;
+ if (!orgId || memberContext.organization?.workos_organization_id !== orgId) {
+ return 'organization_id must match the explicitly selected organization.';
+ }
+ if (!await resolveUserOrgMembership(getWorkos(), { id: userId }, orgId)) {
+ return 'Organization authorization was revoked.';
}
const profile = await memberDb.getProfileByOrgId(orgId);
@@ -3210,6 +3253,9 @@ export function createMemberToolHandlers(
: undefined;
try {
+ if (!await resolveUserOrgMembership(getWorkos(), { id: userId }, orgId)) {
+ return 'Organization authorization was revoked.';
+ }
const result = await updateBrandIdentity({
workosOrganizationId: orgId,
displayName: profile.display_name,
@@ -3277,21 +3323,18 @@ export function createMemberToolHandlers(
});
/**
- * Lookup the caller's role on their org via WorkOS. Brand-claim is org-state
- * mutation and only active admins/owners should run it. Filters via
- * resolveUserRole so an inactive/pending membership row that still carries
- * an admin slug cannot pass — a removed admin must not be able to claim a
- * brand on the org that removed them. (Matches /brand-claim/issue + /verify
- * route gate.)
+ * Resolve the exact credential's effective role, including an explicit
+ * organization credential grant. Brand-claim is org-state mutation and only
+ * active admins/owners should run it.
*/
async function callerIsOrgAdmin(workosUserId: string, orgId: string): Promise {
try {
- const memberships = await getWorkos().userManagement.listOrganizationMemberships({
- userId: workosUserId,
- organizationId: orgId,
- });
- const role = resolveUserRole(memberships.data);
- return role === 'admin' || role === 'owner';
+ const membership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: workosUserId },
+ orgId,
+ );
+ return membership?.role === 'admin' || membership?.role === 'owner';
} catch (err) {
logger.error({ err, workosUserId, orgId }, 'brand-claim chat tool: role lookup failed');
return false;
@@ -3582,6 +3625,7 @@ export function createMemberToolHandlers(
email: memberContext.workos_user.email,
},
{
+ organization_id: memberContext.organization?.workos_organization_id,
title,
subtitle,
content: contentBody,
@@ -7080,6 +7124,14 @@ export function createMemberToolHandlers(
: '';
const saveOrgProfileName = saveOrg.organizationName;
const saveOrgLabel = saveOrgNameForDisplay ? `${saveOrgNameForDisplay} (${saveOrgId})` : saveOrgId;
+ const saveActorCredentialId = memberContext.workos_user.workos_user_id;
+ const hasLiveSaveAuthorization = async (): Promise => Boolean(
+ await resolveUserOrgMembership(
+ getWorkos(),
+ { id: saveActorCredentialId },
+ saveOrgId,
+ )
+ );
const rawAgentUrl = input.agent_url as string;
try {
@@ -7206,6 +7258,9 @@ export function createMemberToolHandlers(
async function ensureAgentInProfile(displayName: string): Promise {
if (!saveOrgId) return { ok: false, reason: 'no-org-id' };
try {
+ if (!await hasLiveSaveAuthorization()) {
+ return { ok: false, reason: 'organization authorization was revoked' };
+ }
let profile = await memberDb.getProfileByOrgId(saveOrgId);
let createdProfile = false;
if (!profile) {
@@ -7290,6 +7345,9 @@ export function createMemberToolHandlers(
try {
// Check if agent already exists for this org
let context = await agentContextDb.getByOrgAndUrl(saveOrgId, agentUrl);
+ if (!await hasLiveSaveAuthorization()) {
+ return `I can't save this agent because your access to ${saveOrgLabel} changed. Please select the organization again and retry.`;
+ }
if (context) {
// Update existing context
@@ -7297,9 +7355,15 @@ export function createMemberToolHandlers(
await agentContextDb.update(context.id, { agent_name: agentName, protocol });
}
if (storedAuthToken) {
+ if (!await hasLiveSaveAuthorization()) {
+ return `I can't save credentials because your access to ${saveOrgLabel} was revoked.`;
+ }
await agentContextDb.saveAuthToken(context.id, storedAuthToken, authType);
}
if (clientCredentials) {
+ if (!await hasLiveSaveAuthorization()) {
+ return `I can't save credentials because your access to ${saveOrgLabel} was revoked.`;
+ }
await agentContextDb.saveOAuthClientCredentials(context.id, clientCredentials);
}
context = await agentContextDb.getById(context.id);
@@ -7324,18 +7388,27 @@ export function createMemberToolHandlers(
}
// Create new context
+ if (!await hasLiveSaveAuthorization()) {
+ return `I can't save this agent because your access to ${saveOrgLabel} was revoked.`;
+ }
context = await agentContextDb.create({
organization_id: saveOrgId,
agent_url: agentUrl,
agent_name: agentName,
protocol,
- created_by: memberContext.workos_user.workos_user_id,
+ created_by: saveActorCredentialId,
});
if (storedAuthToken) {
+ if (!await hasLiveSaveAuthorization()) {
+ return `I can't save credentials because your access to ${saveOrgLabel} was revoked.`;
+ }
await agentContextDb.saveAuthToken(context.id, storedAuthToken, authType);
}
if (clientCredentials) {
+ if (!await hasLiveSaveAuthorization()) {
+ return `I can't save credentials because your access to ${saveOrgLabel} was revoked.`;
+ }
await agentContextDb.saveOAuthClientCredentials(context.id, clientCredentials);
}
if (storedAuthToken || clientCredentials) {
@@ -7382,6 +7455,14 @@ export function createMemberToolHandlers(
}
try {
+ const membership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: memberContext.workos_user.workos_user_id },
+ listOrgId,
+ );
+ if (!membership) {
+ return `I can't list saved agents because your access to this organization is no longer active.`;
+ }
const agents = await agentContextDb.getByOrganization(listOrgId);
if (agents.length === 0) {
@@ -7487,9 +7568,25 @@ export function createMemberToolHandlers(
if (entry && (entry as any).visibility === 'public') {
return `Can't remove **${context.agent_name || agentUrl}** — it's listed publicly. Make it private first, then remove.`;
}
+ const membership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: memberContext.workos_user.workos_user_id },
+ removeOrgId,
+ );
+ if (!membership) {
+ return `I can't remove this agent because your access to this organization is no longer active.`;
+ }
await agentContextDb.delete(context.id);
}
+ const membership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: memberContext.workos_user.workos_user_id },
+ removeOrgId,
+ );
+ if (!membership) {
+ return `I can't update this agent because your access to this organization is no longer active.`;
+ }
const profileResult = await removeAgentFromProfile();
if (!context && profileResult.ok && !profileResult.removedFromProfile) {
@@ -7537,6 +7634,17 @@ export function createMemberToolHandlers(
// If user has an org, save credentials so the whole team can use them
if (setupOrgId) {
try {
+ const actorCredentialId = memberContext.workos_user.workos_user_id;
+ const hasLiveSetupAuthorization = async (): Promise => Boolean(
+ await resolveUserOrgMembership(
+ getWorkos(),
+ { id: actorCredentialId },
+ setupOrgId,
+ )
+ );
+ if (!await hasLiveSetupAuthorization()) {
+ return `I can't set up organization credentials because your access to this organization is no longer active.`;
+ }
let context = await agentContextDb.getByOrgAndUrl(setupOrgId, PUBLIC_TEST_AGENT.url);
if (context && context.has_auth_token) {
@@ -7544,15 +7652,24 @@ export function createMemberToolHandlers(
}
if (context) {
+ if (!await hasLiveSetupAuthorization()) {
+ return `I can't save organization credentials because your access to this organization was revoked.`;
+ }
await agentContextDb.saveAuthToken(context.id, PUBLIC_TEST_AGENT.token);
} else {
+ if (!await hasLiveSetupAuthorization()) {
+ return `I can't set up this agent because your access to this organization was revoked.`;
+ }
context = await agentContextDb.create({
organization_id: setupOrgId,
agent_url: PUBLIC_TEST_AGENT.url,
agent_name: PUBLIC_TEST_AGENT.name,
protocol: 'mcp',
- created_by: memberContext.workos_user.workos_user_id,
+ created_by: actorCredentialId,
});
+ if (!await hasLiveSetupAuthorization()) {
+ return `I can't save organization credentials because your access to this organization was revoked.`;
+ }
await agentContextDb.saveAuthToken(context.id, PUBLIC_TEST_AGENT.token);
}
credentialsSaved = true;
diff --git a/server/src/addie/member-context.ts b/server/src/addie/member-context.ts
index 6714cb43c6..60e25e5569 100644
--- a/server/src/addie/member-context.ts
+++ b/server/src/addie/member-context.ts
@@ -27,6 +27,7 @@ import { resolveSlackUserDisplayName } from '../slack/client.js';
import { PERSONA_LABELS } from '../config/personas.js';
import { resolveEffectiveMembership } from '../db/org-filters.js';
import { resolveUserRole } from '../utils/resolve-user-role.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
import { getAgentTestingContext } from '../db/agent-test-db.js';
import { wrapUntrustedInput } from './mcp/untrusted-input.js';
@@ -155,9 +156,19 @@ async function resolveAddieOrganization(
context?: MemberContext,
): Promise<{ organizationId: string; userRole: string; userJoinedAt: Date | null } | null> {
try {
+ if (!selectedOrganizationId) {
+ logger.info({ workosUserId }, `${logPrefix}: waiting for explicit org selection`);
+ return null;
+ }
+ const exactMembership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: workosUserId },
+ selectedOrganizationId,
+ );
+ if (!exactMembership) return null;
const activeMemberships = await listActiveWorkosMembershipsForUser(workosUserId, selectedOrganizationId);
- if (selectedOrganizationId && activeMemberships.length === 0) {
+ if (activeMemberships.length === 0 && !exactMembership.via_credential_grant) {
logger.warn(
{ workosUserId, organizationId: selectedOrganizationId },
`${logPrefix}: selected org is not backed by active WorkOS membership`,
@@ -165,22 +176,9 @@ async function resolveAddieOrganization(
return null;
}
- const uniqueActiveOrgIds = [...new Set(activeMemberships.map((m) => m.organizationId).filter(Boolean))];
- if (uniqueActiveOrgIds.length === 0) return null;
- if (uniqueActiveOrgIds.length > 1) {
- if (context) {
- context.available_organizations = await resolveAvailableOrganizationChoices(activeMemberships);
- }
- logger.info(
- { workosUserId, orgCount: uniqueActiveOrgIds.length },
- `${logPrefix}: multiple active organizations; waiting for explicit org selection`,
- );
- return null;
- }
-
- const organizationId = uniqueActiveOrgIds[0];
+ const organizationId = selectedOrganizationId;
const selectedOrgMemberships = activeMemberships.filter((m) => m.organizationId === organizationId);
- const userRole = resolveUserRole(selectedOrgMemberships) || 'member';
+ const userRole = exactMembership.role;
const activeMembership = selectedOrgMemberships[0];
const userJoinedAt = activeMembership?.createdAt ? new Date(activeMembership.createdAt) : null;
diff --git a/server/src/auth/organization-principal.ts b/server/src/auth/organization-principal.ts
new file mode 100644
index 0000000000..6bc05f5058
--- /dev/null
+++ b/server/src/auth/organization-principal.ts
@@ -0,0 +1,14 @@
+import type { WorkOSUser } from '../types.js';
+
+export type OrgAuthorizationPrincipal = Pick;
+
+/**
+ * Return the WorkOS credential that authenticated the request. `user.id` may
+ * be the identity's canonical WorkOS user for legacy person-state reads, so it
+ * is not safe to consume directly at an organization-authorization boundary.
+ */
+export function getOrganizationAuthorizationUserId(
+ principal: OrgAuthorizationPrincipal,
+): string {
+ return principal.authWorkosUserId ?? principal.id;
+}
diff --git a/server/src/conformance/token-route.ts b/server/src/conformance/token-route.ts
index 1fb6f208c5..fc5cf2d6e7 100644
--- a/server/src/conformance/token-route.ts
+++ b/server/src/conformance/token-route.ts
@@ -12,7 +12,7 @@
import { Router, type Request, type Response } from 'express';
import { requireAuth } from '../middleware/auth.js';
-import { resolveCallerOrgId } from '../routes/helpers/resolve-caller-org.js';
+import { resolveCallerOrganization } from '../routes/helpers/resolve-caller-org.js';
import { createLogger } from '../logger.js';
import { issueConformanceToken } from './token.js';
import { conformanceSessions } from './session-store.js';
@@ -31,8 +31,8 @@ export function buildConformanceTokenRouter(): Router {
const router = Router();
router.post('/token', requireAuth, async (req: Request, res: Response) => {
- const orgId = await resolveCallerOrgId(req);
- if (!orgId) {
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status !== 'authorized') {
res.status(403).json({
error: 'no_organization',
message:
@@ -40,6 +40,7 @@ export function buildConformanceTokenRouter(): Router {
});
return;
}
+ const orgId = callerOrg.organizationId;
let issued;
try {
@@ -63,7 +64,11 @@ export function buildConformanceTokenRouter(): Router {
if (process.env.NODE_ENV !== 'production') {
router.get('/_debug', requireAuth, async (req: Request, res: Response) => {
- const orgId = await resolveCallerOrgId(req);
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status === 'forbidden') {
+ return res.status(403).json({ error: 'forbidden' });
+ }
+ const orgId = callerOrg.status === 'authorized' ? callerOrg.organizationId : null;
res.json({
callerOrgId: orgId,
activeSessions: conformanceSessions.list(),
@@ -86,7 +91,12 @@ export function buildConformanceTokenRouter(): Router {
router.post('/_debug/run-storyboard', requireAuth, async (req: Request, res: Response) => {
const isStaticAdmin = (req as Request & { isStaticAdminApiKey?: boolean })
.isStaticAdminApiKey === true;
- const callerOrgId = await resolveCallerOrgId(req);
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status === 'forbidden') {
+ res.status(403).json({ error: 'forbidden' });
+ return;
+ }
+ const callerOrgId = callerOrg.status === 'authorized' ? callerOrg.organizationId : null;
const bodyOrgId = typeof req.body?.org_id === 'string' ? req.body.org_id : null;
let targetOrgId: string;
diff --git a/server/src/db/migrations/552_identity_authorization_epoch.sql b/server/src/db/migrations/552_identity_authorization_epoch.sql
new file mode 100644
index 0000000000..e465174916
--- /dev/null
+++ b/server/src/db/migrations/552_identity_authorization_epoch.sql
@@ -0,0 +1,95 @@
+-- Persist the authorization graph version for every identity. Authentication
+-- caches compare this value on every request so a credential link/unlink,
+-- primary change, or organization-membership mutation cannot leave stale
+-- authority alive until an in-memory TTL expires.
+
+ALTER TABLE identities
+ ADD COLUMN IF NOT EXISTS authorization_epoch BIGINT NOT NULL DEFAULT 1;
+
+-- Credential-local epoch serializes authority changes with binding moves.
+-- Identity epochs alone are insufficient because a membership/grant trigger
+-- can race an unlink and bump the identity the credential just left.
+ALTER TABLE identity_workos_users
+ ADD COLUMN IF NOT EXISTS authorization_epoch BIGINT NOT NULL DEFAULT 1;
+
+CREATE OR REPLACE FUNCTION bump_identity_binding_authorization_epoch()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE identities
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE id = NEW.identity_id;
+ ELSIF TG_OP = 'DELETE' THEN
+ UPDATE identities
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE id = OLD.identity_id;
+ ELSE
+ UPDATE identities
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE id IN (OLD.identity_id, NEW.identity_id);
+ END IF;
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_identity_binding_authorization_epoch
+ ON identity_workos_users;
+CREATE TRIGGER trg_identity_binding_authorization_epoch
+AFTER INSERT OR UPDATE OR DELETE ON identity_workos_users
+FOR EACH ROW EXECUTE FUNCTION bump_identity_binding_authorization_epoch();
+
+CREATE OR REPLACE FUNCTION bump_membership_authorization_epoch()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE identity_workos_users
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE workos_user_id = NEW.workos_user_id;
+ UPDATE identities i
+ SET authorization_epoch = i.authorization_epoch + 1
+ WHERE i.id IN (
+ SELECT iwu.identity_id
+ FROM identity_workos_users iwu
+ WHERE iwu.workos_user_id = NEW.workos_user_id
+ );
+ ELSIF TG_OP = 'DELETE' THEN
+ UPDATE identity_workos_users
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE workos_user_id = OLD.workos_user_id;
+ UPDATE identities i
+ SET authorization_epoch = i.authorization_epoch + 1
+ WHERE i.id IN (
+ SELECT iwu.identity_id
+ FROM identity_workos_users iwu
+ WHERE iwu.workos_user_id = OLD.workos_user_id
+ );
+ ELSIF OLD.workos_user_id IS DISTINCT FROM NEW.workos_user_id
+ OR OLD.workos_organization_id IS DISTINCT FROM NEW.workos_organization_id
+ OR OLD.role IS DISTINCT FROM NEW.role
+ OR OLD.seat_type IS DISTINCT FROM NEW.seat_type THEN
+ UPDATE identity_workos_users
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE workos_user_id IN (OLD.workos_user_id, NEW.workos_user_id);
+ UPDATE identities i
+ SET authorization_epoch = i.authorization_epoch + 1
+ WHERE i.id IN (
+ SELECT iwu.identity_id
+ FROM identity_workos_users iwu
+ WHERE iwu.workos_user_id IN (OLD.workos_user_id, NEW.workos_user_id)
+ );
+ END IF;
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_membership_authorization_epoch
+ ON organization_memberships;
+CREATE TRIGGER trg_membership_authorization_epoch
+AFTER INSERT OR UPDATE OR DELETE ON organization_memberships
+FOR EACH ROW EXECUTE FUNCTION bump_membership_authorization_epoch();
+
+COMMENT ON COLUMN identities.authorization_epoch IS
+ 'Monotonic version checked by cached sessions; bumped by credential-binding and organization-membership changes';
+
+COMMENT ON COLUMN identity_workos_users.authorization_epoch IS
+ 'Credential-local authorization version; row locking serializes membership and grant revocation with binding moves';
diff --git a/server/src/db/migrations/553_organization_credential_grants.sql b/server/src/db/migrations/553_organization_credential_grants.sql
new file mode 100644
index 0000000000..63219c8eec
--- /dev/null
+++ b/server/src/db/migrations/553_organization_credential_grants.sql
@@ -0,0 +1,83 @@
+-- Organization-approved authority for an exact WorkOS credential without
+-- copying another credential's WorkOS membership. The row is durable
+-- provenance: revocation updates it rather than deleting/re-parenting it.
+
+CREATE TABLE IF NOT EXISTS organization_credential_grants (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ workos_organization_id VARCHAR(255) NOT NULL
+ REFERENCES organizations(workos_organization_id) ON DELETE CASCADE,
+ workos_user_id VARCHAR(255) NOT NULL
+ REFERENCES users(workos_user_id) ON DELETE CASCADE,
+ role VARCHAR(20) NOT NULL DEFAULT 'member'
+ CHECK (role IN ('member', 'admin', 'owner')),
+ granted_by_workos_user_id VARCHAR(255) NOT NULL,
+ reason TEXT,
+ effective_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ effective_until TIMESTAMPTZ,
+ revoked_at TIMESTAMPTZ,
+ revoked_by_workos_user_id VARCHAR(255),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CHECK (effective_until IS NULL OR effective_until > effective_from),
+ CHECK (revoked_at IS NULL OR revoked_by_workos_user_id IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_organization_credential_grants_active
+ ON organization_credential_grants (workos_user_id, workos_organization_id)
+ WHERE revoked_at IS NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_organization_credential_grants_one_active
+ ON organization_credential_grants (workos_organization_id, workos_user_id)
+ WHERE revoked_at IS NULL;
+
+CREATE OR REPLACE FUNCTION bump_credential_grant_authorization_epoch()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ UPDATE identity_workos_users
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE workos_user_id = NEW.workos_user_id;
+ UPDATE identities i
+ SET authorization_epoch = i.authorization_epoch + 1
+ WHERE i.id IN (
+ SELECT identity_id FROM identity_workos_users
+ WHERE workos_user_id = NEW.workos_user_id
+ );
+ ELSIF TG_OP = 'DELETE' THEN
+ UPDATE identity_workos_users
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE workos_user_id = OLD.workos_user_id;
+ UPDATE identities i
+ SET authorization_epoch = i.authorization_epoch + 1
+ WHERE i.id IN (
+ SELECT identity_id FROM identity_workos_users
+ WHERE workos_user_id = OLD.workos_user_id
+ );
+ ELSIF OLD.workos_user_id IS DISTINCT FROM NEW.workos_user_id
+ OR OLD.workos_organization_id IS DISTINCT FROM NEW.workos_organization_id
+ OR OLD.role IS DISTINCT FROM NEW.role
+ OR OLD.effective_from IS DISTINCT FROM NEW.effective_from
+ OR OLD.effective_until IS DISTINCT FROM NEW.effective_until
+ OR OLD.revoked_at IS DISTINCT FROM NEW.revoked_at THEN
+ UPDATE identity_workos_users
+ SET authorization_epoch = authorization_epoch + 1
+ WHERE workos_user_id IN (OLD.workos_user_id, NEW.workos_user_id);
+ UPDATE identities i
+ SET authorization_epoch = i.authorization_epoch + 1
+ WHERE i.id IN (
+ SELECT identity_id FROM identity_workos_users
+ WHERE workos_user_id IN (OLD.workos_user_id, NEW.workos_user_id)
+ );
+ END IF;
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_credential_grant_authorization_epoch
+ ON organization_credential_grants;
+CREATE TRIGGER trg_credential_grant_authorization_epoch
+AFTER INSERT OR UPDATE OR DELETE ON organization_credential_grants
+FOR EACH ROW EXECUTE FUNCTION bump_credential_grant_authorization_epoch();
+
+COMMENT ON TABLE organization_credential_grants IS
+ 'Organization-approved authority for one exact credential; never inherited through identity linkage';
diff --git a/server/src/db/user-merge-db.ts b/server/src/db/user-merge-db.ts
index 44109dff28..463b638918 100644
--- a/server/src/db/user-merge-db.ts
+++ b/server/src/db/user-merge-db.ts
@@ -33,6 +33,336 @@ export interface UserMergePreview {
}[];
}
+export const USER_STATE_REFERENCES = [
+ { name: 'organization_memberships', col: 'workos_user_id' },
+ { name: 'working_group_memberships', col: 'workos_user_id' },
+ { name: 'learner_progress', col: 'workos_user_id' },
+ { name: 'certification_attempts', col: 'workos_user_id' },
+ { name: 'user_credentials', col: 'workos_user_id' },
+ { name: 'teaching_checkpoints', col: 'workos_user_id' },
+ { name: 'certification_learner_feedback', col: 'workos_user_id' },
+ { name: 'user_email_preferences', col: 'workos_user_id' },
+ { name: 'committee_interest', col: 'workos_user_id' },
+ { name: 'user_badges', col: 'workos_user_id' },
+ { name: 'person_relationships', col: 'workos_user_id' },
+ { name: 'community_points', col: 'workos_user_id' },
+ { name: 'connections', col: 'requester_user_id' },
+ { name: 'connections', col: 'recipient_user_id' },
+ { name: 'flagged_conversations', col: 'reviewed_by' },
+ { name: 'slack_user_mappings', col: 'workos_user_id' },
+ { name: 'email_contacts', col: 'workos_user_id' },
+ { name: 'email_events', col: 'workos_user_id' },
+ { name: 'event_registrations', col: 'workos_user_id' },
+ { name: 'event_sponsorships', col: 'purchased_by_user_id' },
+ { name: 'events', col: 'created_by_user_id' },
+ { name: 'addie_escalations', col: 'workos_user_id' },
+ { name: 'action_items', col: 'workos_user_id' },
+ { name: 'user_stakeholders', col: 'workos_user_id' },
+ { name: 'user_agreement_acceptances', col: 'workos_user_id' },
+ { name: 'member_insights', col: 'workos_user_id' },
+ { name: 'working_group_topic_subscriptions', col: 'workos_user_id' },
+ { name: 'meeting_attendees', col: 'workos_user_id' },
+ { name: 'organization_join_requests', col: 'workos_user_id' },
+ { name: 'known_media_contacts', col: 'added_by' },
+ { name: 'member_portraits', col: 'user_id' },
+ { name: 'seat_upgrade_requests', col: 'workos_user_id' },
+ { name: 'user_email_aliases', col: 'workos_user_id' },
+ { name: 'email_link_tokens', col: 'primary_workos_user_id' },
+ { name: 'agent_test_runs', col: 'workos_user_id' },
+ { name: 'certification_expectations', col: 'workos_user_id' },
+ { name: 'addie_prompt_telemetry', col: 'workos_user_id' },
+ // Keep this inventory exhaustive for credential-owned and actor/provenance
+ // references. An attached credential is hidden behind the canonical person
+ // id on many reads, so even historical actor rows make it non-empty.
+ { name: 'addie_approval_queue', col: 'target_user_id' },
+ { name: 'addie_approval_queue', col: 'reviewed_by' },
+ { name: 'addie_conversations', col: 'user_id' },
+ { name: 'addie_interactions', col: 'user_id' },
+ { name: 'addie_interactions', col: 'reviewed_by' },
+ { name: 'addie_synthesis_runs', col: 'reviewed_by' },
+ { name: 'addie_thread_messages', col: 'user_id' },
+ { name: 'addie_threads', col: 'user_id' },
+ { name: 'addie_threads', col: 'reviewed_by' },
+ { name: 'admin_credential_reissue_events', col: 'workos_user_id' },
+ { name: 'admin_credential_reissue_events', col: 'admin_user_id' },
+ { name: 'admin_module_completions', col: 'workos_user_id' },
+ { name: 'admin_module_completions', col: 'admin_user_id' },
+ { name: 'admin_stripe_customer_update_previews', col: 'actor_workos_user_id' },
+ { name: 'agent_test_history', col: 'user_id' },
+ { name: 'brands', col: 'created_by_user_id' },
+ { name: 'community_mirrors', col: 'created_by_user_id' },
+ { name: 'content_authors', col: 'user_id' },
+ { name: 'domain_classifications', col: 'added_by' },
+ { name: 'email_link_tokens', col: 'target_workos_user_id' },
+ { name: 'escalation_triage_suggestions', col: 'reviewed_by' },
+ { name: 'event_committee_links', col: 'created_by_user_id' },
+ { name: 'feed_proposals', col: 'proposed_by_workos_user_id' },
+ { name: 'feed_proposals', col: 'reviewed_by_workos_user_id' },
+ { name: 'hosted_properties', col: 'created_by_user_id' },
+ { name: 'learner_protocol_updates', col: 'workos_user_id' },
+ { name: 'meeting_series', col: 'created_by_user_id' },
+ { name: 'meetings', col: 'created_by_user_id' },
+ { name: 'newsletter_suggestions', col: 'reviewed_by' },
+ { name: 'notifications', col: 'recipient_user_id' },
+ { name: 'organizations', col: 'champion_workos_user_id' },
+ { name: 'org_stakeholders', col: 'user_id' },
+ { name: 'publishers', col: 'created_by_user_id' },
+ { name: 'referrals', col: 'referrer_user_id' },
+ { name: 'referrals', col: 'referred_user_id' },
+ { name: 'rehearsal_sessions', col: 'admin_user_id' },
+ { name: 'task_reminder_log', col: 'user_id' },
+ { name: 'user_avatar_uploads', col: 'workos_user_id' },
+ { name: 'user_dismissed_nudges', col: 'workos_user_id' },
+ { name: 'working_group_leaders', col: 'user_id' },
+ { name: 'certification_experience_events', col: 'workos_user_id' },
+ { name: 'certification_contributions', col: 'workos_user_id' },
+ { name: 'publisher_crawl_requests', col: 'requested_by_user_id' },
+ { name: 'membership_checkout_attempts', col: 'initiated_by_user_id' },
+ { name: 'adagents_authorization_overrides', col: 'approved_by_user_id' },
+ { name: 'adagents_authorization_overrides', col: 'superseded_by_user_id' },
+ { name: 'addie_escalation_updates', col: 'author_user_id' },
+ { name: 'addie_threads', col: 'impersonator_user_id' },
+ { name: 'bans', col: 'banned_by_user_id' },
+ { name: 'brand_logos', col: 'reviewed_by_user_id' },
+ { name: 'brand_logos', col: 'uploaded_by_user_id' },
+ { name: 'brand_revisions', col: 'editor_user_id' },
+ { name: 'committee_documents', col: 'added_by_user_id' },
+ { name: 'community_mirror_proposals', col: 'proposed_by_user_id' },
+ { name: 'community_mirror_proposals', col: 'reviewed_by_user_id' },
+ { name: 'email_contacts', col: 'mapped_by_user_id' },
+ { name: 'event_invites', col: 'invited_by_user_id' },
+ { name: 'manifest_references', col: 'contributed_by_user_id' },
+ { name: 'member_search_analytics', col: 'searcher_user_id' },
+ { name: 'membership_invites', col: 'accepted_by_user_id' },
+ { name: 'membership_invites', col: 'invited_by_user_id' },
+ { name: 'membership_invites', col: 'revoked_by_user_id' },
+ { name: 'newsletter_suggestions', col: 'suggested_by_user_id' },
+ { name: 'notifications', col: 'actor_user_id' },
+ { name: 'org_activities', col: 'logged_by_user_id' },
+ { name: 'org_activities', col: 'next_step_owner_user_id' },
+ { name: 'org_knowledge', col: 'set_by_user_id' },
+ { name: 'organization_credential_grants', col: 'granted_by_workos_user_id' },
+ { name: 'organization_credential_grants', col: 'revoked_by_workos_user_id' },
+ { name: 'organization_join_requests', col: 'handled_by_user_id' },
+ { name: 'organizations', col: 'pending_agreement_user_id' },
+ { name: 'perspective_assets', col: 'uploaded_by_user_id' },
+ { name: 'perspectives', col: 'author_user_id' },
+ { name: 'perspectives', col: 'proposer_user_id' },
+ { name: 'perspectives', col: 'reviewed_by_user_id' },
+ { name: 'policy_revisions', col: 'editor_user_id' },
+ { name: 'property_revisions', col: 'editor_user_id' },
+ { name: 'referral_codes', col: 'referrer_user_id' },
+ { name: 'slack_user_mappings', col: 'mapped_by_user_id' },
+ { name: 'working_group_memberships', col: 'added_by_user_id' },
+ { name: 'addie_knowledge', col: 'created_by' },
+ { name: 'addie_synthesis_runs', col: 'created_by' },
+ { name: 'agent_contexts', col: 'created_by' },
+ { name: 'catalog_agent_authorizations', col: 'created_by' },
+ { name: 'catalog_collections', col: 'created_by' },
+ { name: 'catalog_properties', col: 'created_by' },
+ { name: 'certification_goals', col: 'created_by' },
+ { name: 'email_campaigns', col: 'created_by' },
+ { name: 'member_insight_types', col: 'created_by' },
+ { name: 'member_insights', col: 'created_by' },
+ { name: 'network_alert_rules', col: 'created_by' },
+ { name: 'outreach_goals', col: 'created_by' },
+ { name: 'personal_domains', col: 'created_by' },
+ { name: 'system_settings', col: 'updated_by' },
+ { name: 'system_settings_audit', col: 'changed_by' },
+ { name: 'weekly_digests', col: 'approved_by' },
+ { name: 'build_editions', col: 'approved_by' },
+ { name: 'geo_content_briefs', col: 'approved_by' },
+ { name: 'journey_stage_history', col: 'triggered_by' },
+ { name: 'addie_thread_messages', col: 'rated_by' },
+ { name: 'addie_messages', col: 'rated_by' },
+ { name: 'action_items', col: 'resolved_by' },
+ { name: 'addie_escalations', col: 'resolved_by' },
+ { name: 'addie_insight_sources', col: 'tagged_by' },
+ { name: 'addie_interactions', col: 'rating_by' },
+ { name: 'catalog_collection_disputes', col: 'reported_by' },
+ { name: 'catalog_collection_disputes', col: 'resolved_by' },
+ { name: 'catalog_disputes', col: 'reported_by' },
+ { name: 'catalog_disputes', col: 'resolved_by' },
+ { name: 'certification_expectations', col: 'invited_by' },
+ { name: 'email_templates', col: 'last_edited_by' },
+ { name: 'organizations', col: 'discount_granted_by' },
+ { name: 'organizations', col: 'interest_level_set_by' },
+ { name: 'seat_upgrade_requests', col: 'resolved_by' },
+ { name: 'secretariat_actions', col: 'decided_by' },
+] as const;
+
+// Deliberate exceptions from the schema-level user-reference inventory.
+// These rows either define the credential/binding itself, preserve immutable
+// audit provenance, or are the explicit exact-credential grant being retained.
+export const USER_STATE_REFERENCE_EXCEPTIONS = [
+ { name: 'users', col: 'workos_user_id' },
+ { name: 'identity_workos_users', col: 'workos_user_id' },
+ { name: 'organization_credential_grants', col: 'workos_user_id' },
+ { name: 'registry_audit_log', col: 'workos_user_id' },
+ { name: 'perspective_likes', col: 'user_id' },
+ // Enum-like execution sources, not credential identifiers.
+ { name: 'agent_compliance_runs', col: 'triggered_by' },
+ { name: 'agent_storyboard_status', col: 'triggered_by' },
+ { name: 'agent_test_history', col: 'triggered_by' },
+ { name: 'admin_module_completions', col: 'completed_by' },
+ { name: 'committee_summaries', col: 'generated_by' },
+ // Entity-version links, not actor identifiers. Several are UUID/integer
+ // columns and therefore must never be queried with a WorkOS string id.
+ { name: 'catalog_collection_facts', col: 'superseded_by' },
+ { name: 'catalog_facts', col: 'superseded_by' },
+ { name: 'committee_summaries', col: 'superseded_by' },
+ { name: 'community_mirror_proposals', col: 'superseded_by' },
+ { name: 'community_mirrors', col: 'superseded_by' },
+ { name: 'member_insights', col: 'superseded_by' },
+ { name: 'org_knowledge', col: 'superseded_by' },
+] as const;
+
+export class CredentialHasStateError extends Error {
+ constructor(public readonly references: Array<{ table: string; column: string }>) {
+ super('Credential has application state and cannot be attached safely');
+ this.name = 'CredentialHasStateError';
+ }
+}
+
+export class CredentialAlreadyLinkedError extends Error {
+ constructor() {
+ super('Credential is already linked to another multi-credential identity');
+ this.name = 'CredentialAlreadyLinkedError';
+ }
+}
+
+/**
+ * Attach a credential that has no application-owned state. Unlike mergeUsers,
+ * this operation never rewrites or deletes membership, reputation, learning,
+ * conversation, billing, or audit rows.
+ */
+async function attachStateEmptyCredentialOnce(
+ hostUserId: string,
+ credentialUserId: string,
+ attachedBy: string,
+): Promise {
+ const client = await getPool().connect();
+ try {
+ await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
+ const bindings = await client.query<{
+ workos_user_id: string;
+ identity_id: string;
+ is_primary: boolean;
+ }>(
+ `SELECT workos_user_id, identity_id, is_primary
+ FROM identity_workos_users
+ WHERE workos_user_id = ANY($1)
+ ORDER BY workos_user_id
+ FOR UPDATE`,
+ [[hostUserId, credentialUserId]],
+ );
+ const host = bindings.rows.find((row) => row.workos_user_id === hostUserId);
+ const credential = bindings.rows.find((row) => row.workos_user_id === credentialUserId);
+ if (!host || !credential) throw new Error('Both users must have identity bindings');
+ if (host.identity_id === credential.identity_id) {
+ await client.query('COMMIT');
+ return;
+ }
+
+ const credentialIdentityBindings = await client.query<{ workos_user_id: string }>(
+ `SELECT workos_user_id
+ FROM identity_workos_users
+ WHERE identity_id = $1
+ ORDER BY workos_user_id
+ FOR UPDATE`,
+ [credential.identity_id],
+ );
+ if (!credential.is_primary || credentialIdentityBindings.rows.length !== 1) {
+ throw new CredentialAlreadyLinkedError();
+ }
+
+ const actorIdentity = await client.query<{ identity_id: string }>(
+ `SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1`,
+ [attachedBy],
+ );
+
+ // Establish one atomic state-empty boundary. SHARE conflicts with the
+ // ROW EXCLUSIVE lock taken by INSERT/UPDATE/DELETE: an in-flight writer
+ // finishes before our scan (and is observed), while a later writer waits
+ // until the binding move commits. Attach is rare and the lock order is
+ // stable, which keeps this safer than relying on SERIALIZABLE predicate
+ // tracking against ordinary READ COMMITTED application transactions.
+ const stateTables = [...new Set(USER_STATE_REFERENCES.map((reference) => reference.name))]
+ .sort();
+ await client.query(`LOCK TABLE ${stateTables.join(', ')} IN SHARE MODE`);
+
+ const references: Array<{ table: string; column: string }> = [];
+ for (const reference of USER_STATE_REFERENCES) {
+ const result = await client.query(
+ `SELECT 1 FROM ${reference.name} WHERE ${reference.col} = $1 LIMIT 1`,
+ [credentialUserId],
+ );
+ if (result.rowCount) {
+ references.push({ table: reference.name, column: reference.col });
+ }
+ }
+ if (references.length > 0) throw new CredentialHasStateError(references);
+
+ await client.query(
+ `UPDATE identity_workos_users
+ SET identity_id = $1, is_primary = FALSE
+ WHERE workos_user_id = $2`,
+ [host.identity_id, credentialUserId],
+ );
+ await client.query(
+ `DELETE FROM identities i
+ WHERE i.id = $1
+ AND NOT EXISTS (
+ SELECT 1 FROM identity_workos_users iwu WHERE iwu.identity_id = i.id
+ )`,
+ [credential.identity_id],
+ );
+ await client.query(
+ `INSERT INTO registry_audit_log (
+ workos_organization_id, workos_user_id, action,
+ resource_type, resource_id, details
+ ) VALUES ('system', $2, 'attach_state_empty_credential', 'user', $1, $3)`,
+ [credentialUserId, attachedBy, JSON.stringify({
+ host_user_id: hostUserId,
+ host_identity_id: host.identity_id,
+ previous_identity_id: credential.identity_id,
+ authenticated_credential_id: attachedBy,
+ resolved_identity_id: actorIdentity.rows[0]?.identity_id ?? null,
+ })],
+ );
+ await client.query('COMMIT');
+ } catch (error) {
+ await client.query('ROLLBACK').catch(() => undefined);
+ throw error;
+ } finally {
+ client.release();
+ }
+}
+
+/**
+ * Retry the complete serializable operation when PostgreSQL selects this
+ * transaction as the loser of a concurrent attach. The retry re-reads the
+ * locked bindings and therefore resolves to either idempotent success (same
+ * host) or CredentialAlreadyLinkedError (different host), never a spurious
+ * HTTP 500 from SQLSTATE 40001/40P01.
+ */
+export async function attachStateEmptyCredential(
+ hostUserId: string,
+ credentialUserId: string,
+ attachedBy: string,
+): Promise {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ await attachStateEmptyCredentialOnce(hostUserId, credentialUserId, attachedBy);
+ return;
+ } catch (error) {
+ const code = (error as { code?: string } | null)?.code;
+ if ((code === '40001' || code === '40P01') && attempt < 2) continue;
+ throw error;
+ }
+ }
+}
+
/**
* Preview what a user merge would do without modifying data.
*/
diff --git a/server/src/http.ts b/server/src/http.ts
index 6f33d16ad0..f6fc494048 100644
--- a/server/src/http.ts
+++ b/server/src/http.ts
@@ -91,6 +91,7 @@ import { invalidateMembershipCache, findClaimableProspectOrgForDomain } from "./
import * as relationshipDb from "./db/relationship-db.js";
import * as personEvents from "./db/person-events-db.js";
import { isWebUserAAOAdmin } from "./addie/mcp/admin-tools.js";
+import { getOrganizationAuthorizationUserId } from "./auth/organization-principal.js";
import { createSlackRouter } from "./routes/slack.js";
import { createWebhooksRouter } from "./routes/webhooks.js";
import { createWorkOSWebhooksRouter } from "./routes/workos-webhooks.js";
@@ -175,6 +176,27 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const logger = createLogger('http-server');
+
+function explicitOrganizationId(req: express.Request): string | null {
+ const bodyValue = req.body?.organization_id;
+ if (typeof bodyValue === 'string' && bodyValue.trim()) return bodyValue.trim();
+ const queryValue = req.query.org ?? req.query.organization_id;
+ if (typeof queryValue === 'string' && queryValue.trim()) return queryValue.trim();
+ const headerValue = req.get('x-organization-id');
+ return headerValue?.trim() || null;
+}
+
+async function resolveExplicitMemberGate(req: express.Request): Promise<{
+ authorized: boolean;
+ missingOrganization: boolean;
+}> {
+ const user = req.user;
+ if (!user) return { authorized: false, missingOrganization: false };
+ const organizationId = explicitOrganizationId(req);
+ if (!organizationId) return { authorized: false, missingOrganization: true };
+ await enrichUserWithMembership(user as any, organizationId);
+ return { authorized: Boolean((user as any).isMember), missingOrganization: false };
+}
const PUBLIC_SITE_URL = 'https://agenticadvertising.org';
const SLACK_JOIN_GUIDE_URL = 'https://docs.adcontextprotocol.org/docs/community/joining-slack';
const PERSPECTIVES_CRAWLER_LIMIT = 200;
@@ -3569,8 +3591,11 @@ export class HTTPServer {
// POST /api/brands/discovered/community - Create a new community brand (member-authenticated, pending review)
this.app.post('/api/brands/discovered/community', requireAuth, brandCreationRateLimiter, async (req, res) => {
try {
- await enrichUserWithMembership(req.user as any);
- if (!(req.user as any)?.isMember) {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
return res.status(403).json({ error: 'Membership required to create brands' });
}
@@ -3585,6 +3610,10 @@ export class HTTPServer {
return res.status(403).json({ error: 'You are banned from creating brands', reason: banCheck.ban?.reason });
}
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
const brand = await this.brandDb.createDiscoveredBrand({
domain,
brand_name,
@@ -3595,7 +3624,7 @@ export class HTTPServer {
has_brand_manifest: !!brand_manifest,
source_type: 'community',
}, {
- user_id: req.user!.id,
+ user_id: actorCredentialId,
email: req.user!.email,
name: (req.user as any).displayName || req.user!.email,
});
@@ -3609,7 +3638,7 @@ export class HTTPServer {
reviewNewRecord({
entity_type: 'brand',
domain: brand.domain,
- editor_user_id: req.user!.id,
+ editor_user_id: actorCredentialId,
editor_email: req.user!.email,
snapshot: brand as unknown as Record,
slack_thread_ts: slack_thread_ts || undefined,
@@ -3644,9 +3673,11 @@ export class HTTPServer {
// POST /api/brands/hosted - Create a hosted brand (members only)
this.app.post('/api/brands/hosted', requireAuth, async (req, res) => {
try {
- // Membership check
- await enrichUserWithMembership(req.user as any);
- if (!(req.user as any)?.isMember) {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
return res.status(403).json({ error: 'Membership required to save brands to registry' });
}
@@ -3661,10 +3692,13 @@ export class HTTPServer {
if (!validateBrandJson(brand_json, res)) return;
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
const brand = await this.brandDb.createHostedBrand({
brand_domain: brand_domain.toLowerCase(),
brand_json,
- created_by_user_id: req.user?.id,
+ created_by_user_id: getOrganizationAuthorizationUserId(req.user!),
created_by_email: req.user?.email,
});
@@ -3678,9 +3712,11 @@ export class HTTPServer {
// PUT /api/brands/hosted/:domain - Update a hosted brand (members only, owner or admin)
this.app.put('/api/brands/hosted/:domain', requireAuth, async (req, res) => {
try {
- // Membership check
- await enrichUserWithMembership(req.user as any);
- if (!(req.user as any)?.isMember) {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
return res.status(403).json({ error: 'Membership required to update brands in registry' });
}
@@ -3696,8 +3732,9 @@ export class HTTPServer {
}
// Check ownership - user must be creator or admin
- const isCreator = brand.created_by_user_id && brand.created_by_user_id === req.user?.id;
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
+ const isCreator = brand.created_by_user_id && brand.created_by_user_id === actorCredentialId;
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
if (!isCreator && !isAdmin) {
return res.status(403).json({ error: 'Not authorized to update this brand' });
}
@@ -3709,6 +3746,9 @@ export class HTTPServer {
if (!validateBrandJson(brand_json, res)) return;
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
const updated = await this.brandDb.updateHostedBrand(brand.id, { brand_json });
return res.json(updated);
} catch (error) {
@@ -3738,6 +3778,13 @@ export class HTTPServer {
// DELETE /api/brands/hosted/:domain - Delete a hosted brand
this.app.delete('/api/brands/hosted/:domain', requireAuth, async (req, res) => {
try {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
+ return res.status(403).json({ error: 'Membership required to delete brands from the registry' });
+ }
const domain = decodeURIComponent(req.params.domain);
const brand = await this.brandDb.getHostedBrandByDomain(domain);
@@ -3746,12 +3793,16 @@ export class HTTPServer {
}
// Check ownership - user must be creator or admin
- const isCreator = brand.created_by_user_id && brand.created_by_user_id === req.user?.id;
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
+ const isCreator = brand.created_by_user_id && brand.created_by_user_id === actorCredentialId;
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
if (!isCreator && !isAdmin) {
return res.status(403).json({ error: 'Not authorized to delete this brand' });
}
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
await this.brandDb.deleteHostedBrand(brand.id);
return res.json({ success: true });
} catch (error) {
@@ -3765,8 +3816,11 @@ export class HTTPServer {
// PUT /api/brands/discovered/:domain - Edit a community/enriched brand with revision tracking
this.app.put('/api/brands/discovered/:domain', requireAuth, async (req, res) => {
try {
- await enrichUserWithMembership(req.user as any);
- if (!(req.user as any)?.isMember) {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
return res.status(403).json({ error: 'Membership required to edit brands' });
}
@@ -3786,10 +3840,14 @@ export class HTTPServer {
return res.status(403).json({ error: 'You are banned from editing this brand', reason: banCheck.ban?.reason });
}
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
const { brand, revision_number } = await this.brandDb.editDiscoveredBrand(domain, {
...fields,
edit_summary,
- editor_user_id: req.user!.id,
+ editor_user_id: actorCredentialId,
editor_email: req.user!.email,
editor_name: (req.user as any).displayName || req.user!.email,
});
@@ -3808,7 +3866,7 @@ export class HTTPServer {
reviewRegistryEdit({
entity_type: 'brand',
domain,
- editor_user_id: req.user!.id,
+ editor_user_id: actorCredentialId,
editor_email: req.user!.email,
edit_summary,
old_snapshot: oldRevision?.snapshot || {},
@@ -3872,9 +3930,12 @@ export class HTTPServer {
// access for moderation and support.
this.app.post('/api/brands/discovered/:domain/rollback', requireAuth, async (req, res) => {
try {
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
- await enrichUserWithMembership(req.user as any);
- if (!isAdmin && !(req.user as any)?.isMember) {
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!isAdmin && !memberGate.authorized) {
return res.status(403).json({ error: 'Membership required to roll back brands' });
}
if ((req as any).apiKey || req.user?.id === 'admin_api_key' || req.user?.id?.startsWith('api_key_')) {
@@ -3909,8 +3970,11 @@ export class HTTPServer {
}
}
+ if (!isAdmin && !(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
const { brand, revision_number } = await this.brandDb.rollbackBrand(domain, to_revision, {
- user_id: req.user!.id,
+ user_id: getOrganizationAuthorizationUserId(req.user!),
email: req.user!.email,
name: (req.user as any).displayName || req.user!.email,
});
@@ -3979,7 +4043,7 @@ export class HTTPServer {
// GET /api/registry/requests - List unresolved registry requests (admin only)
this.app.get('/api/registry/requests', requireAuth, async (req, res) => {
try {
- const isAdmin = await isWebUserAAOAdmin(req.user!.id);
+ const isAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user!));
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
@@ -4003,7 +4067,7 @@ export class HTTPServer {
// GET /api/registry/requests/stats - Registry request statistics (admin only)
this.app.get('/api/registry/requests/stats', requireAuth, async (req, res) => {
try {
- const isAdmin = await isWebUserAAOAdmin(req.user!.id);
+ const isAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user!));
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
@@ -4136,7 +4200,7 @@ export class HTTPServer {
publisher_domain: publisher_domain.toLowerCase(),
adagents_json: adagentsJsonForStorage,
source_type: source_type || 'community',
- created_by_user_id: req.user?.id,
+ created_by_user_id: getOrganizationAuthorizationUserId(req.user!),
created_by_email: req.user?.email,
});
@@ -4155,8 +4219,11 @@ export class HTTPServer {
const requestedAdagentsJson = req.body?.adagents_json;
const adagentsJsonForStorage = scrubCommunityAuthorizedAgents(requestedAdagentsJson);
- await enrichUserWithMembership(req.user as any);
- if (!(req.user as any)?.isMember) {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
return res.status(403).json({ error: 'Membership required to create properties' });
}
@@ -4171,14 +4238,18 @@ export class HTTPServer {
return res.status(403).json({ error: 'You are banned from creating properties', reason: banCheck.ban?.reason });
}
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
const property = await this.propertyDb.createCommunityProperty({
publisher_domain: publisher_domain.toLowerCase(),
adagents_json: adagentsJsonForStorage,
source_type: 'community',
- created_by_user_id: req.user!.id,
+ created_by_user_id: actorCredentialId,
created_by_email: req.user!.email,
}, {
- user_id: req.user!.id,
+ user_id: actorCredentialId,
email: req.user!.email,
name: (req.user as any).displayName || req.user!.email,
});
@@ -4192,7 +4263,7 @@ export class HTTPServer {
reviewNewRecord({
entity_type: 'property',
domain: property.publisher_domain,
- editor_user_id: req.user!.id,
+ editor_user_id: actorCredentialId,
editor_email: req.user!.email,
snapshot: property as unknown as Record,
slack_thread_ts: slack_thread_ts || undefined,
@@ -4212,6 +4283,13 @@ export class HTTPServer {
// DELETE /api/properties/hosted/:domain - Delete a hosted property
this.app.delete('/api/properties/hosted/:domain', requireAuth, async (req, res) => {
try {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
+ return res.status(403).json({ error: 'Membership required to delete properties from the registry' });
+ }
const domain = decodeURIComponent(req.params.domain);
const property = await this.propertyDb.getHostedPropertyByDomain(domain);
@@ -4220,12 +4298,16 @@ export class HTTPServer {
}
// Check ownership
- const isCreator = property.created_by_user_id && property.created_by_user_id === req.user?.id;
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
+ const isCreator = property.created_by_user_id && property.created_by_user_id === actorCredentialId;
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
if (!isCreator && !isAdmin) {
return res.status(403).json({ error: 'Not authorized to delete this property' });
}
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
await this.propertyDb.deleteHostedProperty(property.id);
return res.json({ success: true });
} catch (error) {
@@ -4248,8 +4330,11 @@ export class HTTPServer {
? undefined
: adagentsJsonForStorage;
- await enrichUserWithMembership(req.user as any);
- if (!(req.user as any)?.isMember) {
+ const memberGate = await resolveExplicitMemberGate(req);
+ if (memberGate.missingOrganization) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!memberGate.authorized) {
return res.status(403).json({ error: 'Membership required to edit properties' });
}
@@ -4266,10 +4351,14 @@ export class HTTPServer {
return res.status(403).json({ error: 'You are banned from editing this property', reason: banCheck.ban?.reason });
}
+ if (!(await resolveExplicitMemberGate(req)).authorized) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
const { property, revision_number } = await this.propertyDb.editCommunityProperty(domain, {
adagents_json: adagentsJsonUpdate,
edit_summary,
- editor_user_id: req.user!.id,
+ editor_user_id: actorCredentialId,
editor_email: req.user!.email,
editor_name: (req.user as any).displayName || req.user!.email,
});
@@ -4288,7 +4377,7 @@ export class HTTPServer {
reviewRegistryEdit({
entity_type: 'property',
domain,
- editor_user_id: req.user!.id,
+ editor_user_id: actorCredentialId,
editor_email: req.user!.email,
edit_summary,
old_snapshot: oldRevision?.snapshot || {},
@@ -4350,7 +4439,7 @@ export class HTTPServer {
// POST /api/properties/hosted/:domain/rollback - Rollback property (admin only)
this.app.post('/api/properties/hosted/:domain/rollback', requireAuth, async (req, res) => {
try {
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
@@ -4430,7 +4519,7 @@ export class HTTPServer {
// POST /api/registry/edit-bans - Create an edit ban
this.app.post('/api/registry/edit-bans', requireAuth, async (req, res) => {
try {
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
@@ -4477,7 +4566,7 @@ export class HTTPServer {
// GET /api/registry/edit-bans - List active edit bans
this.app.get('/api/registry/edit-bans', requireAuth, async (req, res) => {
try {
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
@@ -4501,7 +4590,7 @@ export class HTTPServer {
// DELETE /api/registry/edit-bans/:id - Remove an edit ban
this.app.delete('/api/registry/edit-bans/:id', requireAuth, async (req, res) => {
try {
- const isAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const isAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
if (!isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
@@ -4646,7 +4735,7 @@ export class HTTPServer {
// Check if user can delete (admin or creator)
const devUser = getDevUser(req);
const isDevAdmin = devUser?.isAdmin === true;
- const isDbAdmin = req.user && await isWebUserAAOAdmin(req.user.id);
+ const isDbAdmin = req.user && await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
const isAdmin = isDevAdmin || isDbAdmin;
const isCreator = ref.contributed_by_email === req.user?.email;
@@ -6849,7 +6938,9 @@ export class HTTPServer {
const { slug, filename } = req.params;
const pool = getPool();
const userId = req.user?.id ?? null;
- const userIsAdmin = userId ? await isWebUserAAOAdmin(userId) : false;
+ const userIsAdmin = req.user
+ ? await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user))
+ : false;
const perspResult = await pool.query(
`SELECT p.id,
@@ -7620,12 +7711,10 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
logger.error({ error: upsertError, userId: user.id }, 'Failed to upsert user on login');
}
- // Auto-merge duplicate accounts caused by Google email aliases.
- // googlemail.com and gmail.com deliver to the same inbox, so we can
- // merge without requiring email verification — WorkOS already verified
- // ownership of the mailbox during signup.
+ // Detect duplicate accounts caused by Google email aliases. Alias
+ // equivalence is a UI signal only; it never authorizes binding or
+ // state consolidation.
let duplicateAliasEmail: string | null = null;
- let autoMerged = false;
try {
const aliasEmails = getGoogleEmailAliases(user.email);
if (aliasEmails.length > 0) {
@@ -7652,16 +7741,9 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
const workosUsers = await workos.userManagement.listUsers({ email: aliasEmail });
const match = workosUsers.data.find(u => u.id !== user.id);
if (match) {
- // Insert into local users table so mergeUsers can operate on it
- await pool.query(
- `INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified, workos_created_at, workos_updated_at, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
- ON CONFLICT (workos_user_id) DO NOTHING`,
- [match.id, match.email, match.firstName, match.lastName, match.emailVerified, match.createdAt, match.updatedAt]
- );
logger.info(
{ primaryUserId: user.id, secondaryWorkosId: match.id, secondaryEmail: match.email },
- 'Found duplicate account in WorkOS (not in local DB) — created local user for merge'
+ 'Found duplicate alias account in WorkOS; automatic consolidation disabled'
);
existing = { workos_user_id: match.id, email: match.email };
break;
@@ -7672,71 +7754,19 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
if (existing) {
duplicateAliasEmail = existing.email;
- // Claim the alias atomically — UNIQUE(LOWER(email)) prevents
- // two users from claiming the same target concurrently.
- const claimResult = await pool.query(
- `INSERT INTO user_email_aliases (workos_user_id, email)
- VALUES ($1, $2)
- ON CONFLICT DO NOTHING
- RETURNING 1`,
- [user.id, existing.email]
+ // Do not copy WorkOS memberships or consolidate application
+ // rows here. Matching an OAuth alias is only a duplicate-account
+ // signal; it is not proof that organization grants may move
+ // between credentials. The UI renders the manual-resolution
+ // banner from duplicateAliasEmail.
+ logger.info(
+ {
+ authenticatedUserId: user.id,
+ duplicateUserId: existing.workos_user_id,
+ duplicateEmail: existing.email,
+ },
+ 'Duplicate Google alias detected; automatic consolidation disabled',
);
-
- if (claimResult.rows.length > 0) {
- // The currently-logging-in user must be primary — mergeUsers
- // deletes the secondary's WorkOS account, which would invalidate
- // the session we just created if the current user were secondary.
- const primaryId = user.id;
- const secondaryId = existing.workos_user_id;
-
- try {
- // Add the primary user to any of the secondary's WorkOS orgs
- // so that membership context resolves correctly after the
- // merge deletes the secondary user from WorkOS.
- if (workos) {
- const secondaryMemberships = await workos.userManagement.listOrganizationMemberships({
- userId: secondaryId,
- limit: 100,
- });
- for (const mem of secondaryMemberships.data) {
- if (mem.status !== 'active') continue;
- try {
- await workos.userManagement.createOrganizationMembership({
- userId: primaryId,
- organizationId: mem.organizationId,
- });
- } catch (memErr: unknown) {
- // Ignore conflict — the primary may already be a member
- const status = (memErr as { status?: number })?.status;
- if (status !== 409) throw memErr;
- }
- }
- }
-
- const { mergeUsers } = await import('./db/user-merge-db.js');
- const summary = await mergeUsers(primaryId, secondaryId, 'system:google-alias-merge');
- autoMerged = true;
- logger.info(
- { primaryUserId: primaryId, secondaryUserId: secondaryId, tables: summary.tables_merged.length },
- 'Auto-merged duplicate Google email alias accounts'
- );
- } catch (mergeError) {
- // Note: org memberships already transferred to primary in WorkOS
- // are NOT rolled back. This is acceptable because both users
- // control the same inbox, and the merge will be retried on
- // next login (createOrganizationMembership will 409, which is handled).
- // Roll back the alias claim so the merge can be retried on next login
- await pool.query(
- 'DELETE FROM user_email_aliases WHERE workos_user_id = $1 AND LOWER(email) = LOWER($2)',
- [user.id, existing.email]
- ).catch(() => {});
- logger.error(
- { err: mergeError, primaryUserId: primaryId, secondaryUserId: secondaryId },
- 'Failed to auto-merge Google email alias accounts — user will see manual banner'
- );
- }
- }
- // else: another concurrent login already claimed this alias — skip
}
}
} catch (aliasCheckError) {
@@ -8015,15 +8045,11 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
}
}
- // If a Google email alias duplicate was detected, append status to the redirect.
- // Auto-merged: show success notice. Failed: show manual merge banner.
+ // If a Google email alias duplicate was detected, show the manual
+ // resolution banner. Automatic consolidation is intentionally off.
if (duplicateAliasEmail && returnTo.startsWith('/')) {
const sep = returnTo.includes('?') ? '&' : '?';
- if (autoMerged) {
- returnTo = `${returnTo}${sep}accounts_merged=${encodeURIComponent(duplicateAliasEmail)}`;
- } else {
- returnTo = `${returnTo}${sep}duplicate_email=${encodeURIComponent(duplicateAliasEmail)}`;
- }
+ returnTo = `${returnTo}${sep}duplicate_email=${encodeURIComponent(duplicateAliasEmail)}`;
}
// Redirect to dashboard or onboarding
@@ -8325,7 +8351,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
let organizations;
try {
organizations = await getCurrentUserOrganizations({
- userId: user.id,
+ principal: user,
email: user.email,
workos,
orgDb,
@@ -8345,7 +8371,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
// so the admin UI and backend agree on who sees admin surfaces.
const adminEmails = process.env.ADMIN_EMAILS?.split(',').map(e => e.trim().toLowerCase()) || [];
const isAdminByEmail = adminEmails.includes(user.email.toLowerCase());
- const isAdminByWorkingGroup = await isWebUserAAOAdmin(user.id);
+ const isAdminByWorkingGroup = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user));
const isAdmin = isAdminByWorkingGroup || isAdminByEmail;
// Check Slack sync status, seat type, and read DB names (user may have
// set a display name that differs from the WorkOS session values)
@@ -8779,6 +8805,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
const user = req.user!;
const memberDb = new MemberDatabase();
const joinRequestDb = new JoinRequestDatabase();
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
// Get user's company domain (null if free email provider)
const userDomain = getCompanyDomain(user.email);
@@ -8788,7 +8815,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
// Get user's current org memberships to exclude
const userMemberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
+ userId: authorizationUserId,
});
const userOrgIds = new Set(userMemberships.data.map(m => m.organizationId));
@@ -8877,10 +8904,11 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
}
const joinRequestDb = new JoinRequestDatabase();
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
// Check if user is already a member
const memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
+ userId: authorizationUserId,
organizationId: organization_id,
statuses: ['active', 'inactive', 'pending'],
});
@@ -8925,7 +8953,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
let membership: any;
try {
membership = await workos!.userManagement.createOrganizationMembership({
- userId: user.id,
+ userId: authorizationUserId,
organizationId: organization_id,
roleSlug,
});
@@ -8949,7 +8977,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
}
logger.info({
- userId: user.id,
+ userId: authorizationUserId,
orgId: organization_id,
domain: userDomain,
role: roleSlug,
@@ -8961,12 +8989,12 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, created_at, updated_at, synced_at)
VALUES ($1, $2, $3, $4, NOW(), NOW(), NOW())
ON CONFLICT (workos_user_id, workos_organization_id) DO UPDATE SET role = $4, updated_at = NOW()
- `, [user.id, organization_id, user.email, roleSlug]);
+ `, [authorizationUserId, organization_id, user.email, roleSlug]);
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: organization_id,
- workos_user_id: user.id,
+ workos_user_id: authorizationUserId,
action: 'member_added',
resource_type: 'membership',
resource_id: membership.id,
@@ -8995,7 +9023,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
}
// Check for existing pending request
- const existingRequest = await joinRequestDb.getPendingRequest(user.id, organization_id);
+ const existingRequest = await joinRequestDb.getPendingRequest(authorizationUserId, organization_id);
if (existingRequest) {
return res.status(400).json({
error: 'Request already pending',
@@ -9008,15 +9036,15 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
let firstName: string | undefined;
let lastName: string | undefined;
try {
- const workosUser = await workos!.userManagement.getUser(user.id);
+ const workosUser = await workos!.userManagement.getUser(authorizationUserId);
firstName = workosUser.firstName || undefined;
lastName = workosUser.lastName || undefined;
} catch (err) {
- logger.warn({ err, userId: user.id }, 'Failed to get user details from WorkOS');
+ logger.warn({ err, userId: authorizationUserId }, 'Failed to get user details from WorkOS');
}
const joinRequestInput = {
- workos_user_id: user.id,
+ workos_user_id: authorizationUserId,
user_email: user.email,
first_name: firstName,
last_name: lastName,
@@ -9036,14 +9064,14 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
const request = await joinRequestDb.createRequest(joinRequestInput);
logger.info({
- userId: user.id,
+ userId: authorizationUserId,
orgId: organization_id,
requestId: request.id,
}, 'Join request created');
await orgDb.recordAuditLog({
workos_organization_id: organization_id,
- workos_user_id: user.id,
+ workos_user_id: authorizationUserId,
action: 'join_request_created',
resource_type: 'join_request',
resource_id: request.id,
@@ -9080,7 +9108,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
if (userDomain && orgDomains.includes(userDomain)) {
logger.info({
- userId: user.id,
+ userId: authorizationUserId,
orgId: organization_id,
domain: userDomain,
}, 'Ownerless org with matching domain — auto-approving join request as owner');
@@ -9088,7 +9116,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
// Add user as owner
try {
await workos!.userManagement.createOrganizationMembership({
- userId: user.id,
+ userId: authorizationUserId,
organizationId: organization_id,
roleSlug: 'owner',
});
@@ -9105,12 +9133,12 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
const request = await createAndAuditJoinRequest();
// Mark join request as approved
- await joinRequestDb.approveRequest(request.id, user.id);
+ await joinRequestDb.approveRequest(request.id, authorizationUserId);
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: organization_id,
- workos_user_id: user.id,
+ workos_user_id: authorizationUserId,
action: 'join_request_auto_approved',
resource_type: 'join_request',
resource_id: request.id,
@@ -9136,7 +9164,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
}
logger.info({
- userId: user.id,
+ userId: authorizationUserId,
orgId: organization_id,
userDomain,
orgDomains,
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index f72e2760ef..28c6b08f99 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -12,6 +12,7 @@ import { storeRefreshedSession, getRefreshedSession, cleanExpiredRefreshes } fro
import { getPool } from '../db/client.js';
import { constantTimeEqual } from '../utils/constant-time-equal.js';
import { resolveEffectiveMembership } from '../db/org-filters.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
const logger = createLogger('auth-middleware');
@@ -727,43 +728,101 @@ export function invalidateSessionsForUsers(workosUserIds: string[]): void {
* person. The original authenticated id is preserved on `user.authWorkosUserId`.
*
* Skipped for synthetic users (admin API key, WorkOS API key) — they don't
- * represent a person. Failures are swallowed: identity resolution must
- * never block an authenticated request, and a degraded request that sees
- * only the auth user's slice of data is still better than a 500.
+ * represent a person. Callers fail closed when this lookup is unavailable;
+ * otherwise a cached pre-link authorization graph could outlive revocation.
*/
-async function attachIdentityId(user: WorkOSUser): Promise {
- if (isSyntheticUser(user.id)) return;
+type IdentityResolutionStatus = 'resolved' | 'stale' | 'unbound' | 'unavailable';
+
+async function attachIdentityId(user: WorkOSUser): Promise {
+ if (isSyntheticUser(user.id)) return 'resolved';
+ const authenticatedUserId = user.authWorkosUserId ?? user.id;
+ const previousIdentityId = user.identityId;
+ const previousAuthorizationEpoch = user.authorizationEpoch;
try {
const result = await getPool().query<{
identity_id: string;
primary_workos_user_id: string | null;
+ identity_authorization_epoch: string | number;
+ credential_authorization_epoch: string | number;
}>(
- `SELECT iwu.identity_id, primary_iwu.workos_user_id AS primary_workos_user_id
+ `SELECT iwu.identity_id,
+ primary_iwu.workos_user_id AS primary_workos_user_id,
+ i.authorization_epoch AS identity_authorization_epoch,
+ iwu.authorization_epoch AS credential_authorization_epoch
FROM identity_workos_users iwu
+ JOIN identities i ON i.id = iwu.identity_id
LEFT JOIN identity_workos_users primary_iwu
ON primary_iwu.identity_id = iwu.identity_id
AND primary_iwu.is_primary = TRUE
WHERE iwu.workos_user_id = $1`,
- [user.id]
+ [authenticatedUserId]
);
const row = result.rows[0];
- if (!row) return;
+ if (!row) return 'unbound';
user.identityId = row.identity_id;
+ user.authorizationEpoch = `${row.identity_authorization_epoch}:${row.credential_authorization_epoch}`;
+
+ // Rebuild the compatibility projection from the credential that actually
+ // authenticated. This also handles a cached session whose primary binding
+ // changed since its previous request.
+ user.id = authenticatedUserId;
+ delete user.authWorkosUserId;
- if (row.primary_workos_user_id && row.primary_workos_user_id !== user.id) {
+ if (row.primary_workos_user_id && row.primary_workos_user_id !== authenticatedUserId) {
// Non-primary binding signed in. Swap id so app-state reads see the
// canonical person; preserve the actual auth user on authWorkosUserId.
logger.debug(
- { authWorkosUserId: user.id, canonicalUserId: row.primary_workos_user_id, identityId: row.identity_id },
+ { authWorkosUserId: authenticatedUserId, canonicalUserId: row.primary_workos_user_id, identityId: row.identity_id },
'Identity id-swap: routing non-primary binding to canonical user'
);
- user.authWorkosUserId = user.id;
+ user.authWorkosUserId = authenticatedUserId;
user.id = row.primary_workos_user_id;
}
+ if ((previousIdentityId !== undefined && previousIdentityId !== user.identityId)
+ || (previousAuthorizationEpoch !== undefined
+ && previousAuthorizationEpoch !== user.authorizationEpoch)) {
+ // Organization-derived enrichments may live on the cached user object.
+ // Drop them before rejecting the stale request so its retry must
+ // recompute authority for the new epoch.
+ delete (user as WorkOSUser & { isMember?: boolean }).isMember;
+ return 'stale';
+ }
+ return 'resolved';
} catch (err) {
- logger.warn({ err, userId: user.id }, 'Failed to resolve identity_id');
+ logger.warn({ err, userId: authenticatedUserId }, 'Failed to resolve identity authorization epoch');
+ return 'unavailable';
+ }
+}
+
+function sendIdentityResolutionFailure(
+ res: Response,
+ isHtmlRequest: boolean,
+ status: Exclude,
+): void {
+ if (status === 'unavailable') {
+ res.status(503).json({
+ error: 'Authorization state unavailable',
+ message: 'Please retry in a moment.',
+ });
+ return;
+ }
+ if (status === 'stale') {
+ res.status(401).json({
+ error: 'Authorization state changed',
+ message: 'Your organization or credential access changed. Please retry the request.',
+ });
+ return;
}
+ if (isHtmlRequest) {
+ res.redirect('/auth/login');
+ return;
+ }
+ res.status(401).json({
+ error: 'Invalid session',
+ message: 'This credential is no longer bound to an active identity.',
+ login_url: '/auth/login',
+ });
}
/**
@@ -775,7 +834,7 @@ async function attachIdentityId(user: WorkOSUser): Promise {
* Automatically refreshes expired access tokens using the refresh token
*/
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
- const isHtmlRequest = req.accepts('html') && !req.originalUrl.startsWith('/api/');
+ const isHtmlRequest = Boolean(req.accepts('html')) && !req.originalUrl.startsWith('/api/');
// Check for static admin API key first (for internal tooling)
if (hasValidAdminApiKey(req)) {
@@ -839,7 +898,10 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing request through');
}
- await attachIdentityId(req.user);
+ const identityStatus = await attachIdentityId(req.user);
+ if (identityStatus !== 'resolved') {
+ return sendIdentityResolutionFailure(res, isHtmlRequest, identityStatus);
+ }
return next();
}
@@ -854,7 +916,10 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
if (devConfig) {
(req.user as unknown as Record).isMember = devConfig.isMember;
}
- await attachIdentityId(req.user);
+ const identityStatus = await attachIdentityId(req.user);
+ if (identityStatus !== 'resolved') {
+ return sendIdentityResolutionFailure(res, isHtmlRequest, identityStatus);
+ }
return next();
}
// No dev session - redirect to dev login page
@@ -910,6 +975,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
}
if (cached && cached.expiresAt > now) {
+ const identityStatus = await attachIdentityId(cached.user);
+ if (identityStatus !== 'resolved') {
+ sessionCache.delete(cacheKey);
+ return sendIdentityResolutionFailure(res, isHtmlRequest, identityStatus);
+ }
// Cache hit - use cached session data
logger.debug({ userId: cached.user.id }, 'Using cached session');
req.user = cached.user;
@@ -1149,8 +1219,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
);
}
- // Resolve identityId once before caching so cache hits inherit it.
- await attachIdentityId(user);
+ // Persisted authorization epoch is checked before every cached reuse.
+ const identityStatus = await attachIdentityId(user);
+ if (identityStatus !== 'resolved') {
+ return sendIdentityResolutionFailure(res, isHtmlRequest, identityStatus);
+ }
// Cache the validated session
sessionCache.set(cacheKey, {
@@ -1429,7 +1502,7 @@ function authorizeTenantAdminApiKey(
* Or checks if user's email is in ADMIN_EMAILS list
*/
export async function requireAdmin(req: Request, res: Response, next: NextFunction) {
- const isHtmlRequest = req.accepts('html') && !req.originalUrl.startsWith('/api/');
+ const isHtmlRequest = Boolean(req.accepts('html')) && !req.originalUrl.startsWith('/api/');
// Check for static admin API key (set by requireAuth)
if ((req as Request & { isStaticAdminApiKey?: boolean }).isStaticAdminApiKey) {
@@ -1466,7 +1539,10 @@ export async function requireAdmin(req: Request, res: Response, next: NextFuncti
if (mockUser) {
req.user = mockUser;
req.accessToken = 'dev-mode-token';
- await attachIdentityId(req.user);
+ const identityStatus = await attachIdentityId(req.user);
+ if (identityStatus !== 'resolved') {
+ return sendIdentityResolutionFailure(res, isHtmlRequest, identityStatus);
+ }
}
}
@@ -1524,11 +1600,13 @@ export async function requireAdmin(req: Request, res: Response, next: NextFuncti
});
}
- // Check admin access via aao-admin working group membership (primary)
+ // Check admin access via the exact authenticated credential's aao-admin
+ // working group membership
// or ADMIN_EMAILS env var (fallback for emergency access)
const adminEmails = process.env.ADMIN_EMAILS?.split(',').map(e => e.trim().toLowerCase()) || [];
const isAdminByEmail = adminEmails.includes(req.user.email.toLowerCase());
- const isAdminByWorkingGroup = await isWebUserAAOAdmin(req.user.id);
+ const authorizationUserId = getOrganizationAuthorizationUserId(req.user);
+ const isAdminByWorkingGroup = await isWebUserAAOAdmin(authorizationUserId);
const isAdmin = isAdminByWorkingGroup || isAdminByEmail;
if (!isAdmin) {
@@ -1850,7 +1928,10 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing optional-auth request through');
}
- await attachIdentityId(req.user);
+ const identityStatus = await attachIdentityId(req.user);
+ if (identityStatus !== 'resolved') {
+ req.user = undefined;
+ }
return next();
}
@@ -1892,6 +1973,11 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
}
if (cached && cached.expiresAt > now) {
+ const identityStatus = await attachIdentityId(cached.user);
+ if (identityStatus !== 'resolved') {
+ sessionCache.delete(cacheKey);
+ return next();
+ }
// Cache hit - use cached session data
logger.debug({ userId: cached.user.id }, 'Using cached session (optional auth)');
req.user = cached.user;
@@ -2028,7 +2114,10 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
// Resolve identityId before caching — sessionCache is shared with
// requireAuth, so skipping it here would let an optionalAuth request
// poison subsequent requireAuth cache hits with identityId=undefined.
- await attachIdentityId(user);
+ const identityStatus = await attachIdentityId(user);
+ if (identityStatus !== 'resolved') {
+ return next();
+ }
// Cache the validated session
sessionCache.set(cacheKey, {
diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts
index b315e66c13..8be0d72dfd 100644
--- a/server/src/middleware/rate-limit.ts
+++ b/server/src/middleware/rate-limit.ts
@@ -3,6 +3,7 @@ import type { IncrementResponse, Options, Store } from 'express-rate-limit';
import type { Request, Response } from 'express';
import { createLogger } from '../logger.js';
import { CachedPostgresStore, PostgresStore, type WeightedIncrementStore } from './pg-rate-limit-store.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
const logger = createLogger('rate-limit');
@@ -100,7 +101,12 @@ export const agentCardValidationRateLimiter = rateLimit({
* env var for emergency access, matching requireAdmin semantics.
*/
async function skipForAdmins(req: Request): Promise {
- const user = (req as any).user as { id?: string; email?: string; isAdmin?: boolean } | undefined;
+ const user = (req as any).user as {
+ id?: string;
+ authWorkosUserId?: string;
+ email?: string;
+ isAdmin?: boolean;
+ } | undefined;
if (!user) return false;
if (user.isAdmin === true) return true;
@@ -114,7 +120,10 @@ async function skipForAdmins(req: Request): Promise {
try {
const { isWebUserAAOAdmin } = await import('../addie/mcp/admin-tools.js');
- return await isWebUserAAOAdmin(user.id);
+ return await isWebUserAAOAdmin(getOrganizationAuthorizationUserId({
+ id: user.id,
+ authWorkosUserId: user.authWorkosUserId,
+ }));
} catch (err) {
logger.warn({ err, userId: user.id }, 'admin check failed in rate limiter; applying limit');
return false;
diff --git a/server/src/routes/account-linking.ts b/server/src/routes/account-linking.ts
index f06314c7b4..def0aa6d70 100644
--- a/server/src/routes/account-linking.ts
+++ b/server/src/routes/account-linking.ts
@@ -4,7 +4,6 @@ import rateLimit from 'express-rate-limit';
import { createLogger } from '../logger.js';
import { requireAuth } from '../middleware/auth.js';
import { query, getPool } from '../db/client.js';
-import { mergeUsers } from '../db/user-merge-db.js';
import { sendEmailLinkVerification } from '../notifications/email.js';
import { getWorkos } from '../auth/workos-client.js';
import { CachedPostgresStore } from '../middleware/pg-rate-limit-store.js';
diff --git a/server/src/routes/addie-chat.ts b/server/src/routes/addie-chat.ts
index 8e151b2722..f49ea2fdf9 100644
--- a/server/src/routes/addie-chat.ts
+++ b/server/src/routes/addie-chat.ts
@@ -966,6 +966,9 @@ export function createAddieChatRouter(options?: {
}
const { message, conversation_id, user_name, message_source: rawMessageSource, attachments: rawAttachments, organization_id } = req.body;
+ if (req.user && (typeof organization_id !== 'string' || organization_id.trim().length === 0)) {
+ return res.status(400).json({ error: 'organization_id is required for authenticated chat' });
+ }
const attachments = validateChatAttachments(rawAttachments);
if (typeof message !== "string" || (!message.trim() && attachments.length === 0)) {
@@ -1124,7 +1127,7 @@ export function createAddieChatRouter(options?: {
hasThreadCertificationContext,
} = await prepareRequestWithMemberTools(
inputValidation.sanitized,
- req.user?.id,
+ req.user ? (req.user.authWorkosUserId ?? req.user.id) : undefined,
externalId,
isAuth,
thread.thread_id,
@@ -1327,6 +1330,9 @@ export function createAddieChatRouter(options?: {
client_request_id,
retry,
} = req.body;
+ if (req.user && (typeof organization_id !== 'string' || organization_id.trim().length === 0)) {
+ return res.status(400).json({ error: 'organization_id is required for authenticated chat' });
+ }
const attachments = validateChatAttachments(rawAttachmentsStream);
const clientRequestId = typeof client_request_id === 'string' ? client_request_id : null;
const retryRequested = retry === true;
@@ -1581,7 +1587,7 @@ export function createAddieChatRouter(options?: {
certificationProgress,
} = await prepareRequestWithMemberTools(
messageForModel,
- req.user?.id,
+ req.user ? (req.user.authWorkosUserId ?? req.user.id) : undefined,
externalId,
isAuth,
thread.thread_id,
diff --git a/server/src/routes/admin/users.ts b/server/src/routes/admin/users.ts
index be86eaca19..abac9d7f5c 100644
--- a/server/src/routes/admin/users.ts
+++ b/server/src/routes/admin/users.ts
@@ -19,11 +19,16 @@ import { getPool } from '../../db/client.js';
import { backfillOrganizationMemberships, backfillUsers, backfillOrganizationDomains } from '../workos-webhooks.js';
import { sendSlackInviteEmail, hasSlackInviteBeenSent } from '../../notifications/email.js';
import { getWorkos } from '../../auth/workos-client.js';
-import { mergeUsers } from '../../db/user-merge-db.js';
+import {
+ attachStateEmptyCredential,
+ CredentialAlreadyLinkedError,
+ CredentialHasStateError,
+} from '../../db/user-merge-db.js';
import {
buildCountryMembersCsv,
type CountryMemberExportRow,
} from './country-members-export.js';
+import { getOrganizationAuthorizationUserId } from '../../auth/organization-principal.js';
const logger = createLogger('admin-users-routes');
@@ -832,7 +837,7 @@ export function createAdminUsersRouter(): Router {
// verification email is sent to the new address. Phase 3 may add one.
router.post('/:userId/linked-emails', ...requireGlobalAdmin, async (req, res) => {
const adminEmail = req.user!.email;
- const adminUserId = req.user!.id;
+ const adminUserId = getOrganizationAuthorizationUserId(req.user!);
const existingUserId = req.params.userId;
const rawEmail = (req.body?.email as string | undefined)?.trim();
@@ -906,22 +911,22 @@ export function createAdminUsersRouter(): Router {
}
// Insert into local users — fires the AFTER INSERT trigger which creates
- // a singleton identity for the new WorkOS user. mergeUsers will then
- // re-point the new user's binding to the existing user's identity.
+ // a singleton identity for the new WorkOS user. The state-empty attach
+ // operation then re-points only the credential binding.
+ let localUserCreated = false;
try {
- await pool.query(
+ const inserted = await pool.query(
`INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified,
workos_created_at, workos_updated_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
- ON CONFLICT (workos_user_id) DO NOTHING`,
+ ON CONFLICT (workos_user_id) DO NOTHING
+ RETURNING workos_user_id`,
[newWorkosUser.id, newWorkosUser.email, newWorkosUser.firstName, newWorkosUser.lastName,
newWorkosUser.emailVerified, newWorkosUser.createdAt, newWorkosUser.updatedAt]
);
+ localUserCreated = inserted.rowCount === 1;
- // Merge: moves zero data rows (the new user has nothing), rebinds the
- // new user's identity_workos_users row to the existing user's identity
- // as is_primary = FALSE, drops the new user's orphan singleton identity.
- await mergeUsers(existingUserId, newWorkosUser.id, adminUserId);
+ await attachStateEmptyCredential(existingUserId, newWorkosUser.id, adminUserId);
} catch (err) {
logger.error(
{ err, newWorkosUserId: newWorkosUser.id, existingUserId },
@@ -939,6 +944,43 @@ export function createAdminUsersRouter(): Router {
'Admin bind-email: failed to roll back WorkOS user after local-bind failure'
);
}
+ if (cleanedUp && localUserCreated) {
+ const cleanupClient = await pool.connect();
+ try {
+ await cleanupClient.query('BEGIN');
+ const binding = await cleanupClient.query<{ identity_id: string }>(
+ `SELECT identity_id
+ FROM identity_workos_users
+ WHERE workos_user_id = $1
+ FOR UPDATE`,
+ [newWorkosUser.id],
+ );
+ await cleanupClient.query(
+ `DELETE FROM users WHERE workos_user_id = $1`,
+ [newWorkosUser.id],
+ );
+ if (binding.rows[0]) {
+ await cleanupClient.query(
+ `DELETE FROM identities i
+ WHERE i.id = $1
+ AND NOT EXISTS (
+ SELECT 1 FROM identity_workos_users iwu WHERE iwu.identity_id = i.id
+ )`,
+ [binding.rows[0].identity_id],
+ );
+ }
+ await cleanupClient.query('COMMIT');
+ } catch (cleanupErr) {
+ await cleanupClient.query('ROLLBACK').catch(() => undefined);
+ cleanedUp = false;
+ logger.error(
+ { err: cleanupErr, newWorkosUserId: newWorkosUser.id },
+ 'Admin bind-email: failed to roll back local user after upstream cleanup',
+ );
+ } finally {
+ cleanupClient.release();
+ }
+ }
return res.status(500).json({
error: 'Failed to bind sign-in email',
message: cleanedUp
@@ -1011,13 +1053,12 @@ export function createAdminUsersRouter(): Router {
// user alive). Bypasses createUser, which avoids the case where WorkOS
// returns 400 because the email is already in use.
//
- // If the target WorkOS user is itself bound to a different identity with
- // its own app-state, mergeUsers moves that data to this user — admin is
- // asserting the two represent the same person. The trust model and
- // confirmation UX live on the admin frontend.
+ // A credential can only be attached when it has no application-owned
+ // state. Existing memberships and other state require the future
+ // provenance-preserving consolidation flow.
router.post('/:userId/credentials', ...requireGlobalAdmin, async (req, res) => {
const adminEmail = req.user!.email;
- const adminUserId = req.user!.id;
+ const adminUserId = getOrganizationAuthorizationUserId(req.user!);
const existingUserId = req.params.userId;
const credId = (req.body?.workos_user_id as string | undefined)?.trim();
@@ -1055,7 +1096,7 @@ export function createAdminUsersRouter(): Router {
// If credId is not in our local users table, fetch from WorkOS and
// upsert. The AFTER INSERT trigger creates a singleton identity which
- // mergeUsers will then re-point.
+ // the state-empty attach operation can safely re-point.
const credLocal = await pool.query(
`SELECT email FROM users WHERE workos_user_id = $1`,
[credId]
@@ -1081,48 +1122,26 @@ export function createAdminUsersRouter(): Router {
}
}
- // Foot-gun gate: refuse silent consolidation. If credId has any app-state
- // attached (org membership, points, certification work, working-group
- // membership), binding will MOVE it onto the host — that's a real account
- // being absorbed, not a fresh credential being added. Require explicit
- // `consolidate: true` in the body so the admin has stated intent.
- //
- // Cheap signal: check the four most user-facing tables. False negatives
- // (e.g., a Slack-only points-bearing user with no org_membership) only
- // matter if the points themselves are valuable enough to warn about, and
- // they will move forward correctly either way.
- const consolidateConfirmed = req.body?.consolidate === true;
- if (!consolidateConfirmed) {
- const stateCheck = await pool.query<{ has_state: boolean }>(
- `SELECT EXISTS (
- SELECT 1 FROM organization_memberships WHERE workos_user_id = $1
- UNION ALL
- SELECT 1 FROM working_group_memberships WHERE workos_user_id = $1
- UNION ALL
- SELECT 1 FROM certification_attempts WHERE workos_user_id = $1
- UNION ALL
- SELECT 1 FROM community_points WHERE workos_user_id = $1
- LIMIT 1
- ) AS has_state`,
- [credId]
- );
- if (stateCheck.rows[0].has_state) {
+ // Existing credentials may be attached only when they have no
+ // application-owned state. `consolidate: true` is intentionally ignored:
+ // operator confirmation cannot make a destructive merge reversible.
+ try {
+ await attachStateEmptyCredential(existingUserId, credId, adminUserId);
+ } catch (err) {
+ if (err instanceof CredentialAlreadyLinkedError) {
return res.status(409).json({
- error: 'This WorkOS user has its own AAO data',
- message: 'Binding will move that data (organization memberships, working-group memberships, certification work, community points) to the host. Re-submit with `"consolidate": true` to confirm this is the intended consolidation.',
- consolidate_confirmation_required: true,
+ error: 'credential_already_linked',
+ message: 'This credential is already linked to another identity.',
});
}
- }
-
- // mergeUsers moves any app-state from credId to existingUserId, rebinds
- // credId's identity_workos_users row to existingUserId's identity as
- // is_primary = FALSE, and drops the orphan identity. Throws if either
- // user lacks an identity binding.
- try {
- await mergeUsers(existingUserId, credId, adminUserId);
- } catch (err) {
- logger.error({ err, existingUserId, credId }, 'Admin link-credential: mergeUsers failed');
+ if (err instanceof CredentialHasStateError) {
+ return res.status(409).json({
+ error: 'credential_has_state',
+ message: 'This credential has application state and cannot be attached until the provenance-preserving merge flow is available.',
+ references: err.references,
+ });
+ }
+ logger.error({ err, existingUserId, credId }, 'Admin link-credential: state-empty attach failed');
return res.status(500).json({ error: 'Failed to bind credential' });
}
@@ -1151,7 +1170,7 @@ export function createAdminUsersRouter(): Router {
// credential to primary first (separate endpoint, not yet built).
router.delete('/:userId/credentials/:credentialId', ...requireGlobalAdmin, async (req, res) => {
const adminEmail = req.user!.email;
- const adminUserId = req.user!.id;
+ const adminUserId = getOrganizationAuthorizationUserId(req.user!);
// For non-singleton admin identities (today: nobody — admins are still
// singleton-bound — but Phase 3+ may change), record the auth credential
// separately from the canonical id so forensics can tell them apart.
@@ -1168,21 +1187,26 @@ export function createAdminUsersRouter(): Router {
try {
await client.query('BEGIN');
- const check = await client.query<{ is_primary: boolean; identity_id: string }>(
- `SELECT iwu.is_primary, iwu.identity_id
- FROM identity_workos_users iwu
- WHERE iwu.workos_user_id = $1
- AND iwu.identity_id = (
- SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $2
- )`,
- [credId, userId]
+ const bindings = await client.query<{
+ workos_user_id: string;
+ is_primary: boolean;
+ identity_id: string;
+ }>(
+ `SELECT workos_user_id, is_primary, identity_id
+ FROM identity_workos_users
+ WHERE workos_user_id = ANY($1)
+ ORDER BY workos_user_id
+ FOR UPDATE`,
+ [[credId, userId]],
);
+ const host = bindings.rows.find((row) => row.workos_user_id === userId);
+ const target = bindings.rows.find((row) => row.workos_user_id === credId);
- if (check.rows.length === 0) {
+ if (!host || !target || target.identity_id !== host.identity_id) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Credential not bound to this user' });
}
- if (check.rows[0].is_primary) {
+ if (target.is_primary) {
await client.query('ROLLBACK');
return res.status(409).json({
error: 'Cannot remove the primary credential',
@@ -1190,15 +1214,26 @@ export function createAdminUsersRouter(): Router {
});
}
- const detachedIdentityId = check.rows[0].identity_id;
+ const detachedIdentityId = target.identity_id;
+ const actorIdentity = await client.query<{ identity_id: string }>(
+ `SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1`,
+ [adminUserId],
+ );
// Unbind, then create a fresh singleton identity for the detached
// credential so the Phase 1 invariant ("every user has exactly one
// binding") holds.
- await client.query(
- `DELETE FROM identity_workos_users WHERE workos_user_id = $1`,
- [credId]
+ const deleted = await client.query(
+ `DELETE FROM identity_workos_users
+ WHERE workos_user_id = $1
+ AND identity_id = $2
+ AND is_primary = FALSE
+ RETURNING 1`,
+ [credId, detachedIdentityId],
);
+ if (deleted.rowCount !== 1) {
+ throw new Error('Credential binding changed during unlink');
+ }
const newIdentity = await client.query<{ id: string }>(
`INSERT INTO identities DEFAULT VALUES RETURNING id`
);
@@ -1208,19 +1243,13 @@ export function createAdminUsersRouter(): Router {
[credId, newIdentity.rows[0].id]
);
- // Audit log
- const auditOrg = await client.query<{ workos_organization_id: string }>(
- `SELECT workos_organization_id FROM organization_memberships
- WHERE workos_user_id = $1 LIMIT 1`,
- [userId]
- );
- const auditOrgId = auditOrg.rows[0]?.workos_organization_id || 'system';
+ // Identity operations are global, not scoped to an arbitrary membership.
await client.query(
`INSERT INTO registry_audit_log (
workos_organization_id, workos_user_id, action, resource_type, resource_id, details
) VALUES ($1, $2, 'unbind_credential', 'user', $3, $4)`,
[
- auditOrgId,
+ 'system',
adminUserId,
credId,
JSON.stringify({
@@ -1230,6 +1259,8 @@ export function createAdminUsersRouter(): Router {
// Auth credential the admin used (may differ from adminUserId
// post-id-swap if the admin has multiple bound credentials).
acting_workos_user_id: adminAuthCredentialId,
+ authenticated_credential_id: adminUserId,
+ resolved_identity_id: actorIdentity.rows[0]?.identity_id ?? null,
}),
]
);
@@ -1262,32 +1293,10 @@ export function createAdminUsersRouter(): Router {
// POST /api/admin/users/:userId/credentials/:credentialId/promote
//
- // Make :credentialId the primary credential of the host's identity.
- // Moves all of the current primary's app-state forward to :credentialId
- // (so reads keyed on the canonical workos_user_id land on the right
- // place), swaps `is_primary`, audit row.
- //
- // Use case: after a `link-existing` bind, the new credential ended up as
- // the right one for the workspace the person actually wants (e.g., a
- // work email that's a member of a paid org), but the canonical primary
- // sits on a different credential whose org_memberships are a different
- // (personal) workspace. Promote re-points the canonical so id-swap
- // routes both sign-ins to the org-bearing credential.
- //
- // Implementation note: we run mergeUsers(newPrimary, currentPrimary)
- // which moves data forward and demotes the old primary as a side
- // effect (it becomes is_primary=FALSE). Both bindings are non-primary
- // for a brief window between the mergeUsers commit and the follow-up
- // UPDATE; during that window `attachIdentityId` finds no primary and
- // skips the id-swap, so requests fall back to the auth user's slice of
- // data — degraded but not broken. A failure of the follow-up UPDATE
- // would persist that degraded state; the audit row records the intent
- // and the recovery is a one-line UPDATE.
+ // Primary promotion is disabled until it can preserve the provenance of
+ // every credential-owned row. It must never turn linked credentials into
+ // an implicit union of organization authority.
router.post('/:userId/credentials/:credentialId/promote', ...requireGlobalAdmin, async (req, res) => {
- const adminEmail = req.user!.email;
- const adminUserId = req.user!.id;
- const adminIdentityId = req.user!.identityId;
- const adminAuthCredentialId = req.user!.authWorkosUserId ?? req.user!.id;
const userId = req.params.userId;
const newPrimaryId = req.params.credentialId;
@@ -1301,21 +1310,9 @@ export function createAdminUsersRouter(): Router {
// and target's email (for the audit row + caller display).
const check = await pool.query<{
new_is_primary: boolean;
- current_primary_id: string | null;
- identity_id: string;
- target_email: string | null;
}>(
- `SELECT
- target.is_primary AS new_is_primary,
- primary_iwu.workos_user_id AS current_primary_id,
- target.identity_id,
- target_user.email AS target_email
+ `SELECT target.is_primary AS new_is_primary
FROM identity_workos_users target
- LEFT JOIN identity_workos_users primary_iwu
- ON primary_iwu.identity_id = target.identity_id
- AND primary_iwu.is_primary = TRUE
- LEFT JOIN users target_user
- ON target_user.workos_user_id = target.workos_user_id
WHERE target.workos_user_id = $1
AND target.identity_id = (
SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $2
@@ -1330,101 +1327,9 @@ export function createAdminUsersRouter(): Router {
return res.json({ promoted: true, message: 'Already primary — no change.' });
}
- const identityId = check.rows[0].identity_id;
- const currentPrimaryId = check.rows[0].current_primary_id;
- const targetEmail = check.rows[0].target_email;
-
- // Refuse self-promote: an admin shouldn't mutate their own identity via
- // this admin endpoint (it would shuffle their own session's app-state
- // mid-request). If they need to promote one of their own credentials,
- // they sign in as the target person and use the user-facing flow (or
- // another admin handles it).
- if (adminIdentityId && adminIdentityId === identityId) {
- return res.status(409).json({
- error: 'Cannot promote your own credential',
- message: 'This identity belongs to the signed-in admin. Have a different admin perform the promote.',
- });
- }
-
- // Edge case: identity has no current primary (broken invariant from a
- // prior partial promote, manual SQL, etc.). Just set the target as
- // primary; nothing to move forward.
- if (!currentPrimaryId) {
- await pool.query(
- `UPDATE identity_workos_users SET is_primary = TRUE WHERE workos_user_id = $1`,
- [newPrimaryId]
- );
- logger.info(
- { adminEmail, userId, newPrimaryId, identityId, recovered_orphan: true },
- 'Promote: identity had no current primary; set target as primary directly'
- );
- invalidateSessionsForUsers([newPrimaryId]);
- return res.json({
- promoted: true,
- message: 'Promoted (no current primary to demote — invariant repaired).',
- });
- }
-
- // Run mergeUsers with ensurePrimaryFlag so the data move, the secondary
- // rebind, AND the new primary's is_primary=TRUE flip all happen in one
- // transaction. This closes the window where the identity has zero
- // primaries.
- try {
- await mergeUsers(newPrimaryId, currentPrimaryId, adminUserId, { ensurePrimaryFlag: true });
- } catch (err) {
- logger.error(
- { err, userId, newPrimaryId, currentPrimaryId },
- 'Promote: mergeUsers failed'
- );
- return res.status(500).json({ error: 'Failed to promote credential' });
- }
-
- // Audit row. mergeUsers writes its own merge_user audit; this adds the
- // promote-specific record with target email + identity context. Failure
- // here doesn't unwind the promote (the data + primary swap are
- // committed) — log loud so we notice.
- try {
- const auditOrg = await pool.query<{ workos_organization_id: string }>(
- `SELECT workos_organization_id FROM organization_memberships
- WHERE workos_user_id = $1 LIMIT 1`,
- [newPrimaryId]
- );
- const auditOrgId = auditOrg.rows[0]?.workos_organization_id || 'system';
- await pool.query(
- `INSERT INTO registry_audit_log (
- workos_organization_id, workos_user_id, action, resource_type, resource_id, details
- ) VALUES ($1, $2, 'promote_credential_to_primary', 'user', $3, $4)`,
- [
- auditOrgId,
- adminUserId,
- newPrimaryId,
- JSON.stringify({
- host_user_id: userId,
- identity_id: identityId,
- previous_primary_id: currentPrimaryId,
- new_primary_id: newPrimaryId,
- target_email: targetEmail,
- acting_workos_user_id: adminAuthCredentialId,
- }),
- ]
- );
- } catch (err) {
- logger.error({ err, userId, newPrimaryId }, 'Promote: audit row insert failed (operation already committed)');
- }
-
- invalidateSessionsForUsers([userId, newPrimaryId, currentPrimaryId]);
-
- logger.info(
- { adminEmail, identityId, previous_primary_id: currentPrimaryId, new_primary_id: newPrimaryId },
- 'Admin promoted credential to primary'
- );
-
- return res.json({
- promoted: true,
- identity_id: identityId,
- previous_primary_id: currentPrimaryId,
- new_primary_id: newPrimaryId,
- message: 'Credential is now primary. Sign-ins via either bound credential will route here.',
+ return res.status(409).json({
+ error: 'credential_promotion_disabled',
+ message: 'Primary promotion is disabled until it can preserve application-state provenance without rewriting organization memberships.',
});
});
diff --git a/server/src/routes/api-keys.ts b/server/src/routes/api-keys.ts
index 32d43a0ad8..339e8410ae 100644
--- a/server/src/routes/api-keys.ts
+++ b/server/src/routes/api-keys.ts
@@ -98,7 +98,7 @@ async function verifyOrgMembership(
): Promise {
const membership = await resolveUserOrgMembership(
workos,
- req.user!.id,
+ req.user!,
organizationId,
);
diff --git a/server/src/routes/billing-public.ts b/server/src/routes/billing-public.ts
index ca0a7a73cb..45438d4a2b 100644
--- a/server/src/routes/billing-public.ts
+++ b/server/src/routes/billing-public.ts
@@ -63,6 +63,7 @@ import { COMPANY_TYPE_VALUES } from "../config/company-types.js";
import { notifyInvoiceSent } from "../notifications/billing.js";
import { WorkOS } from "@workos-inc/node";
import { resolveUserOrgMembership } from "../utils/resolve-user-org-membership.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
const logger = createLogger("billing-public-routes");
const orgDb = new OrganizationDatabase();
@@ -263,6 +264,7 @@ export function createPublicBillingRouter(): Router {
router.post("/invoice-request", requireAuth, async (req: Request, res: Response) => {
try {
const user = req.user!;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
const { orgId, lookupKey, billingAddress, referral_code, agreement_version } =
req.body as {
orgId: string;
@@ -311,7 +313,7 @@ export function createPublicBillingRouter(): Router {
});
}
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: "Access denied",
@@ -382,16 +384,6 @@ export function createPublicBillingRouter(): Router {
});
}
- // Atomic: record (or re-affirm) the pending agreement + store the
- // billing address in a single UPDATE so a partial failure can't leave
- // one set without the other.
- await orgDb.updateOrganization(orgId, {
- pending_agreement_version: acceptedVersion,
- pending_agreement_accepted_at: new Date(),
- pending_agreement_user_id: user.id,
- billing_address: sanitizedAddress,
- });
-
// Referral discount (same logic as checkout).
let invoiceCouponId: string | undefined;
let validatedInvoiceReferralCode: Awaited> = null;
@@ -439,11 +431,11 @@ export function createPublicBillingRouter(): Router {
billingAddress: sanitizedAddress,
lookupKey,
workosOrganizationId: orgId,
- workosUserId: user.id,
+ workosUserId: authorizationUserId,
couponId: invoiceCouponId ?? org.stripe_coupon_id ?? undefined,
};
- logger.info({ orgId, lookupKey, userId: user.id }, 'Invoice request received');
+ logger.info({ orgId, lookupKey, userId: authorizationUserId }, 'Invoice request received');
// Lock + re-guard + Stripe write must be atomic per-org. The early
// `blockIfActiveSubscription` above handles the common case fast; this
@@ -459,7 +451,7 @@ export function createPublicBillingRouter(): Router {
// Re-resolve inside the lock: a membership can be revoked or
// downgraded after the early check while the request waits. Never use
// that stale snapshot to mint a billing-management portal session.
- const currentMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const currentMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!currentMembership) return { kind: 'forbidden' };
const racedBlock = await blockIfActiveSubscription(orgId, orgDb, {
customerPortalReturnUrl: `${req.protocol}://${req.get('host')}/dashboard/membership`,
@@ -470,6 +462,14 @@ export function createPublicBillingRouter(): Router {
if (await hasPendingMembershipCheckoutAttempt(orgId)) {
return { kind: 'pendingCheckout' };
}
+ // Persist the agreement and billing address only after the exact
+ // credential's authority has been revalidated under the org lock.
+ await orgDb.updateOrganization(orgId, {
+ pending_agreement_version: acceptedVersion,
+ pending_agreement_accepted_at: new Date(),
+ pending_agreement_user_id: authorizationUserId,
+ billing_address: sanitizedAddress,
+ });
const invoiceResult = await createAndSendInvoice(invoiceData);
if (!invoiceResult) return { kind: 'invoiceFailed' };
return { kind: 'success', invoiceResult };
@@ -512,7 +512,7 @@ export function createPublicBillingRouter(): Router {
}
logger.info(
- { invoiceId: result.invoiceId, orgId, lookupKey, userId: user.id },
+ { invoiceId: result.invoiceId, orgId, lookupKey, userId: authorizationUserId },
"Invoice request processed successfully"
);
@@ -550,6 +550,7 @@ export function createPublicBillingRouter(): Router {
async (req: Request, res: Response) => {
try {
const user = req.user!;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
const { priceId, orgId, referral_code } = req.body as {
priceId: string;
orgId: string;
@@ -572,7 +573,7 @@ export function createPublicBillingRouter(): Router {
});
}
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: "Access denied",
@@ -598,7 +599,7 @@ export function createPublicBillingRouter(): Router {
// afterwards so a membership revoked or downgraded while it was in
// flight cannot carry stale billing-management authority into the
// active-subscription guard.
- const currentMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const currentMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!currentMembership) {
return res.status(403).json({
error: "Access denied",
@@ -704,7 +705,7 @@ export function createPublicBillingRouter(): Router {
successUrl: `${baseUrl}/dashboard?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${baseUrl}/dashboard?checkout=cancelled`,
workosOrganizationId: orgId,
- workosUserId: user.id,
+ workosUserId: authorizationUserId,
isPersonalWorkspace: org.is_personal || false,
// Priority: org coupon > referral coupon > org promo code > allow manual entry
couponId: org.stripe_coupon_id || referralCouponId || undefined,
@@ -721,7 +722,7 @@ export function createPublicBillingRouter(): Router {
// Recheck mutable authority and Stripe state after acquiring the
// per-org lock, then persist an immutable checkout attempt before
// performing the Stripe write outside the database transaction.
- const lockedMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const lockedMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!lockedMembership) return { kind: 'forbidden' };
const racedBlock = await blockIfActiveSubscription(orgId, orgDb, {
customerPortalReturnUrl: `${baseUrl}/dashboard/membership`,
@@ -731,7 +732,7 @@ export function createPublicBillingRouter(): Router {
if (!canManageMembershipBilling(lockedMembership.role)) return { kind: 'forbidden' };
const claim = await claimMembershipCheckoutAttempt({
organizationId: orgId,
- userId: user.id,
+ userId: authorizationUserId,
payloadFingerprint: fingerprintMembershipCheckoutPayload(checkoutData),
});
if (claim.kind === 'conflict') return { kind: 'conflict' };
@@ -793,7 +794,7 @@ export function createPublicBillingRouter(): Router {
// (above) avoids this because the code is consumed at /join/:code accept time.
if (validatedReferralCode && shouldConsumeReferral) {
try {
- await referralDb.acceptReferralCode(validatedReferralCode.code, orgId, user.id);
+ await referralDb.acceptReferralCode(validatedReferralCode.code, orgId, authorizationUserId);
} catch (err) {
logger.warn({ err, referral_code, orgId }, 'Failed to record referral at checkout — continuing');
}
@@ -803,7 +804,7 @@ export function createPublicBillingRouter(): Router {
{
sessionId: result.sessionId,
orgId,
- userId: user.id,
+ userId: authorizationUserId,
priceId,
},
"Checkout session created"
@@ -953,14 +954,7 @@ export function createPublicBillingRouter(): Router {
Object.values(DEV_USERS).some((du) => du.id === user.id) &&
orgId.startsWith("org_dev_");
if (!isDevUserBilling) {
- // Verify user is a member of this organization
- const memberships =
- await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- organizationId: orgId,
- });
-
- if (memberships.data.length === 0) {
+ if (!await resolveUserOrgMembership(workos, user, orgId)) {
return res.status(403).json({
error: "Access denied",
message: "You are not a member of this organization",
@@ -989,6 +983,9 @@ export function createPublicBillingRouter(): Router {
metadata: { workos_organization_id: orgId },
});
+ if (!isDevUserBilling && !await resolveUserOrgMembership(workos, user, orgId)) {
+ return res.status(403).json({ error: "Organization authorization was revoked" });
+ }
let stripeCustomerId = await orgDb.getOrCreateStripeCustomer(orgId, makeCustomer);
if (!stripeCustomerId) {
@@ -1124,14 +1121,7 @@ export function createPublicBillingRouter(): Router {
Object.values(DEV_USERS).some((du) => du.id === user.id) &&
orgId.startsWith("org_dev_");
if (!isDevUserBilling) {
- // Verify user is a member of this organization with admin/owner role
- const memberships =
- await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- organizationId: orgId,
- });
-
- if (memberships.data.length === 0) {
+ if (!await resolveUserOrgMembership(workos, user, orgId)) {
return res.status(403).json({
error: "Access denied",
message: "You are not a member of this organization",
@@ -1145,6 +1135,9 @@ export function createPublicBillingRouter(): Router {
if (company_type) updateData.company_type = company_type as CompanyType;
if (revenue_tier) updateData.revenue_tier = revenue_tier as RevenueTier;
+ if (!isDevUserBilling && !await resolveUserOrgMembership(workos, user, orgId)) {
+ return res.status(403).json({ error: "Organization authorization was revoked" });
+ }
await orgDb.updateOrganization(orgId, updateData);
logger.info(
@@ -1199,11 +1192,7 @@ export function createPublicBillingRouter(): Router {
Object.values(DEV_USERS).some((du) => du.id === user.id) &&
orgId.startsWith("org_dev_");
if (!isDevUserBilling) {
- const memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- organizationId: orgId,
- });
- if (memberships.data.length === 0) {
+ if (!await resolveUserOrgMembership(workos, user, orgId)) {
return res.status(403).json({
error: "Access denied",
message: "You are not a member of this organization",
@@ -1211,6 +1200,9 @@ export function createPublicBillingRouter(): Router {
}
}
+ if (!isDevUserBilling && !await resolveUserOrgMembership(workos, user, orgId)) {
+ return res.status(403).json({ error: "Organization authorization was revoked" });
+ }
await orgDb.updateOrganization(orgId, { billing_address: sanitized });
logger.info(
diff --git a/server/src/routes/brand-feeds.ts b/server/src/routes/brand-feeds.ts
index dabcad45c3..0d9a6c0511 100644
--- a/server/src/routes/brand-feeds.ts
+++ b/server/src/routes/brand-feeds.ts
@@ -5,15 +5,16 @@
* and Spotify feeds, plus bulk property/collection merge via JSON API.
*/
-import { Router } from 'express';
+import { Router, type Request } from 'express';
import { createLogger } from '../logger.js';
import { requireAuth } from '../middleware/auth.js';
import { query, getPool } from '../db/client.js';
import { BrandDatabase } from '../db/brand-db.js';
-import { resolvePrimaryOrganization } from '../db/users-db.js';
import { validateFetchUrl } from '../utils/url-security.js';
import { fetchFeed, slugify, suggestProduct, mergeInstallments } from '../services/collection-feed-sync.js';
import type { CollectionFromFeed } from '../services/collection-feed-sync.js';
+import { getWorkos } from '../auth/workos-client.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
import {
parsePropertyInputForBrand,
mergeBrandProperties,
@@ -30,7 +31,22 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
const { brandDb } = config;
// Helper: get brand and validate the user's org owns it
- async function getBrandForEdit(domain: string, userId: string) {
+ function selectedOrganization(req: Request): string | null {
+ return typeof req.query.org === 'string' && req.query.org.length > 0
+ ? req.query.org
+ : null;
+ }
+
+ async function getBrandForEdit(domain: string, req: Request) {
+ const requestedOrgId = selectedOrganization(req);
+ if (!requestedOrgId) {
+ return { error: 'org query parameter is required', status: 400 };
+ }
+ const membership = await resolveUserOrgMembership(getWorkos(), req.user!, requestedOrgId);
+ if (!membership) {
+ return { error: 'Not authorized for the requested organization', status: 403 };
+ }
+ const orgId = membership.organizationId;
const brand = await brandDb.getDiscoveredBrandByDomain(domain);
if (!brand) return { error: 'Brand not found', status: 404 };
if (brand.source_type === 'brand_json') return { error: 'Cannot edit self-hosted brand', status: 409 };
@@ -42,11 +58,6 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
// Verify the user's org owns this brand. Only verified org_domains rows
// grant edit authority — unverified rows are pending DNS challenges.
- const orgId = await resolvePrimaryOrganization(userId);
- if (!orgId) {
- return { error: 'No organization associated with your account', status: 403 };
- }
-
const orgDomains = await query<{ domain: string }>(
'SELECT domain FROM organization_domains WHERE workos_organization_id = $1 AND verified = true',
[orgId]
@@ -56,7 +67,7 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
return { error: 'You do not own this brand domain', status: 403 };
}
- return { brand };
+ return { brand, orgId };
}
// ─── Feed endpoints ────────────────────────────────────────────────
@@ -75,7 +86,7 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
return res.status(400).json({ error: 'Invalid feed URL' });
}
- const check = await getBrandForEdit(domain, req.user!.id);
+ const check = await getBrandForEdit(domain, req);
if ('error' in check) return res.status(check.status!).json({ error: check.error });
// Fetch and parse the feed
@@ -111,6 +122,11 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
);
const manifest = (locked.rows[0]?.brand_manifest as Record) || {};
+ if (!await resolveUserOrgMembership(getWorkos(), req.user!, check.orgId!)) {
+ await client.query('ROLLBACK');
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
+
// Merge collections
const collections = Array.isArray(manifest.collections) ? manifest.collections as CollectionFromFeed[] : [];
const filtered = collections.filter(c => c.collection_id !== collectionId && c.feed_url !== url);
@@ -167,8 +183,9 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
router.get('/brands/:domain/feeds', requireAuth, async (req, res) => {
try {
const domain = req.params.domain.toLowerCase();
- const brand = await brandDb.getDiscoveredBrandByDomain(domain);
- if (!brand) return res.status(404).json({ error: 'Brand not found' });
+ const check = await getBrandForEdit(domain, req);
+ if ('error' in check) return res.status(check.status!).json({ error: check.error });
+ const { brand } = check;
const manifest = (brand.brand_manifest as Record) || {};
const collections = Array.isArray(manifest.collections)
@@ -199,7 +216,7 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
const domain = req.params.domain.toLowerCase();
const collectionId = req.params.collection_id;
- const check = await getBrandForEdit(domain, req.user!.id);
+ const check = await getBrandForEdit(domain, req);
if ('error' in check) return res.status(check.status!).json({ error: check.error });
const { brand } = check;
@@ -216,6 +233,9 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
collection.last_sync_error = undefined;
manifest.collections = collections;
+ if (!await resolveUserOrgMembership(getWorkos(), req.user!, check.orgId!)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await query(
'UPDATE brands SET brand_manifest = $1::jsonb, updated_at = NOW() WHERE domain = $2',
[JSON.stringify(manifest), domain]
@@ -238,7 +258,7 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
const domain = req.params.domain.toLowerCase();
const collectionId = req.params.collection_id;
- const check = await getBrandForEdit(domain, req.user!.id);
+ const check = await getBrandForEdit(domain, req);
if ('error' in check) return res.status(check.status!).json({ error: check.error });
const { brand } = check;
@@ -246,6 +266,9 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
const collections = Array.isArray(manifest.collections) ? manifest.collections as CollectionFromFeed[] : [];
manifest.collections = collections.filter(c => c.collection_id !== collectionId);
+ if (!await resolveUserOrgMembership(getWorkos(), req.user!, check.orgId!)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await query(
'UPDATE brands SET brand_manifest = $1::jsonb, updated_at = NOW() WHERE domain = $2',
[JSON.stringify(manifest), domain]
@@ -270,10 +293,12 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
relationship?: string;
};
+ const check = await getBrandForEdit(domain, req);
+ if ('error' in check) return res.status(check.status!).json({ error: check.error });
const result = await parsePropertyInputForBrand({
brandDb,
domain,
- userId: req.user!.id,
+ organizationId: check.orgId!,
input: input ?? '',
inputType: (input_type ?? 'text') as 'text' | 'url',
relationship: relationship as Relationship | undefined,
@@ -301,10 +326,16 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
const domain = req.params.domain.toLowerCase();
const { properties } = req.body;
+ const check = await getBrandForEdit(domain, req);
+ if ('error' in check) return res.status(check.status!).json({ error: check.error });
+ if (!await resolveUserOrgMembership(getWorkos(), req.user!, check.orgId!)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
+
const result = await mergeBrandProperties({
brandDb,
domain,
- userId: req.user!.id,
+ organizationId: check.orgId!,
properties,
});
@@ -326,7 +357,7 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
if (!Array.isArray(collections)) return res.status(400).json({ error: 'collections array required' });
if (collections.length > MAX_COLLECTIONS) return res.status(400).json({ error: `Maximum ${MAX_COLLECTIONS} collections per request` });
- const check = await getBrandForEdit(domain, req.user!.id);
+ const check = await getBrandForEdit(domain, req);
if ('error' in check) return res.status(check.status!).json({ error: check.error });
const { brand } = check;
@@ -361,6 +392,9 @@ export function createBrandFeedsRouter(config: { brandDb: BrandDatabase }) {
}
manifest.collections = Array.from(byId.values());
+ if (!await resolveUserOrgMembership(getWorkos(), req.user!, check.orgId!)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await query(
'UPDATE brands SET brand_manifest = $1::jsonb, updated_at = NOW() WHERE domain = $2',
[JSON.stringify(manifest), domain]
diff --git a/server/src/routes/brand-logos.ts b/server/src/routes/brand-logos.ts
index a8de89fab4..966722e9c5 100644
--- a/server/src/routes/brand-logos.ts
+++ b/server/src/routes/brand-logos.ts
@@ -10,8 +10,6 @@ import { BrandLogoDatabase } from '../db/brand-logo-db.js';
import { BrandDatabase } from '../db/brand-db.js';
import { BansDatabase } from '../db/bans-db.js';
import { canReviewBrandLogos, isRegistryModerator, isVerifiedBrandOwner } from '../services/brand-logo-auth.js';
-import { enrichUserWithMembership } from '../utils/html-config.js';
-import { resolvePrimaryOrganization } from '../db/users-db.js';
import {
validateLogoTags,
detectContentType,
@@ -24,6 +22,9 @@ import { getBrandAssetUrl } from '../services/logo-cdn.js';
import { createLogger } from '../logger.js';
import { isUuid } from '../utils/uuid.js';
import { notifyPendingBrandLogo, notifyBrandLogoReviewed } from '../notifications/registry.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
+import { getWorkos } from '../auth/workos-client.js';
const PENDING_REVIEW_SLA_HOURS = 48;
// Per-user pending-queue threshold: how many distinct brand domains a
@@ -75,6 +76,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
try {
const domain = req.params.domain.toLowerCase();
const user = (req as any).user;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
const apiKey = (req as Request & { apiKey?: ValidatedApiKey }).apiKey;
const isStaticAdmin = Boolean(
(req as Request & { isStaticAdminApiKey?: boolean }).isStaticAdminApiKey,
@@ -84,12 +86,13 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
return res.status(400).json({ error: 'Invalid domain' });
}
- // Membership check
- if (!isStaticAdmin && !user.isMember) {
- const enriched = await enrichUserWithMembership(user);
- if (!enriched?.isMember) {
- return res.status(403).json({ error: 'Membership required to upload logos' });
- }
+ const selectedOrgId = apiKey?.organizationId
+ ?? (typeof req.body.organization_id === 'string' ? req.body.organization_id.trim() : '');
+ if (!isStaticAdmin && !selectedOrgId) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ if (!isStaticAdmin && !await resolveUserOrgMembership(getWorkos(), user, selectedOrgId)) {
+ return res.status(403).json({ error: 'You do not have access to the selected organization' });
}
// Ban check
@@ -111,7 +114,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
hostedForOwnership.workos_organization_id === apiKey.organizationId
);
const isOwner = !isStaticAdmin
- && (isApiKeyOwner || (await isVerifiedBrandOwner(user.id, domain, brandDb)));
+ && (isApiKeyOwner || (await isVerifiedBrandOwner(authorizationUserId, domain, brandDb)));
// Static-admin uploads are support actions, not owner attestations.
// They bypass the community queue, but attribution below remains tied
// to a hosted member org when one exists.
@@ -224,10 +227,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
const uploaderOrgId = isTrustedUploader
? hostedForOwnership?.workos_organization_id
?? (isOwner ? apiKey?.organizationId ?? null : null)
- : apiKey?.organizationId ?? (await resolvePrimaryOrganization(user.id).catch((err) => {
- logger.warn({ err, userId: user.id }, 'Failed to resolve uploader org for brand-logo provenance');
- return null;
- }));
+ : selectedOrgId || null;
// Verified owners and authenticated support actions are auto-approved.
// Only the former are owner-attested; `source` preserves that distinction.
@@ -240,6 +240,23 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
const source = isAttributedToMemberOrg ? 'brand_owner' : 'community';
const reviewStatus = isTrustedUploader ? 'approved' : 'pending';
+ if (!isStaticAdmin && !await resolveUserOrgMembership(getWorkos(), user, selectedOrgId)) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
+ if (isOwner) {
+ const currentHostedBrand = await brandDb.getHostedBrandByDomain(domain);
+ const remainsApiKeyOwner = Boolean(
+ apiKey?.organizationId
+ && currentHostedBrand?.domain_verified
+ && currentHostedBrand.workos_organization_id === apiKey.organizationId
+ );
+ const remainsUserOwner = !apiKey
+ && await isVerifiedBrandOwner(authorizationUserId, domain, brandDb);
+ if (!remainsApiKeyOwner && !remainsUserOwner) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
+ }
+
// Insert
const logo = await brandLogoDb.insertBrandLogo({
domain,
@@ -251,7 +268,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
height,
source,
review_status: reviewStatus,
- uploaded_by_user_id: user.id,
+ uploaded_by_user_id: authorizationUserId,
uploaded_by_org_id: uploaderOrgId ?? undefined,
uploaded_by_email: user.email,
upload_note: note,
@@ -291,7 +308,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
source_type: 'community',
},
{
- user_id: user.id,
+ user_id: authorizationUserId,
email: user.email,
name: user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : undefined,
}
@@ -317,14 +334,14 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
: 'community — pending review';
await brandDb.editDiscoveredBrand(domain, {
edit_summary: `Logo uploaded by ${user.email} (${reviewNote})`,
- editor_user_id: user.id,
+ editor_user_id: authorizationUserId,
editor_email: user.email,
});
} catch (err) {
// Audit-revision write failed — the logo is saved regardless, but
// log so we can spot drift between brand_revisions and the logo
// table (e.g. a brand that's pending review can't take revisions).
- logger.debug({ err, domain, userId: user.id }, 'Logo upload audit revision skipped');
+ logger.debug({ err, domain, userId: authorizationUserId }, 'Logo upload audit revision skipped');
}
// Fire-and-forget Slack notification for pending uploads. Owners
@@ -394,7 +411,9 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
// Reviewers see all statuses (pending, rejected) for brands they manage
const user = (req as any).user;
- const canReview = user?.id ? await canReviewBrandLogos(user.id, domain, brandDb) : false;
+ const canReview = user?.id
+ ? await canReviewBrandLogos(getOrganizationAuthorizationUserId(user), domain, brandDb)
+ : false;
const logos = await brandLogoDb.listBrandLogos(domain, {
tags: filterTags,
@@ -441,6 +460,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
const domain = req.params.domain.toLowerCase();
const logoId = req.params.id;
const user = (req as any).user;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
if (!logoDomainPattern.test(domain)) {
return res.status(400).json({ error: 'Invalid domain' });
@@ -450,7 +470,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
}
// Authorization: registry moderator or verified brand owner
- const authorized = await canReviewBrandLogos(user.id, domain, brandDb);
+ const authorized = await canReviewBrandLogos(authorizationUserId, domain, brandDb);
if (!authorized) {
return res.status(403).json({ error: 'Only registry moderators or verified brand owners can review logos' });
}
@@ -472,11 +492,14 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
// no separate pre-fetch needed. Saves a round-trip and removes a
// TOCTOU window where the row could be deleted between fetch
// and update.
+ if (!await canReviewBrandLogos(authorizationUserId, domain, brandDb)) {
+ return res.status(403).json({ error: 'Your review authorization was revoked' });
+ }
const updated = await brandLogoDb.updateLogoReviewStatus(
logoId,
domain,
status,
- user.id,
+ authorizationUserId,
note,
);
@@ -496,7 +519,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
try {
await brandDb.editDiscoveredBrand(domain, {
edit_summary: `Logo ${action}d by ${user.email}`,
- editor_user_id: user.id,
+ editor_user_id: authorizationUserId,
editor_email: user.email,
});
} catch {
@@ -544,7 +567,7 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
const user = (req as any).user;
const isStaticAdmin = (req as Request & { isStaticAdminApiKey?: boolean })
.isStaticAdminApiKey === true;
- const moderator = isStaticAdmin || await isRegistryModerator(user.id);
+ const moderator = isStaticAdmin || await isRegistryModerator(getOrganizationAuthorizationUserId(user));
if (!moderator) {
return res.status(403).json({ error: 'Brand-registry moderators only' });
}
@@ -606,8 +629,10 @@ export function createBrandLogoRouter(config: BrandLogoRoutesConfig): Router {
const row = await brandLogoDb.getBrandLogoById(logoId);
const isStaticAdmin = (req as Request & { isStaticAdminApiKey?: boolean })
.isStaticAdminApiKey === true;
- const moderator = isStaticAdmin || await isRegistryModerator(user.id);
- const owner = row ? await isVerifiedBrandOwner(user.id, row.domain, brandDb) : false;
+ const moderator = isStaticAdmin || await isRegistryModerator(getOrganizationAuthorizationUserId(user));
+ const owner = row
+ ? await isVerifiedBrandOwner(getOrganizationAuthorizationUserId(user), row.domain, brandDb)
+ : false;
// Conflate not-found and not-authorized for unauthorized callers
// so a UUID guesser can't distinguish "this id doesn't exist" from
diff --git a/server/src/routes/brand-ownership.ts b/server/src/routes/brand-ownership.ts
index 1f671e4f59..14b55091ca 100644
--- a/server/src/routes/brand-ownership.ts
+++ b/server/src/routes/brand-ownership.ts
@@ -16,9 +16,10 @@ import { Router, type Request, type Response } from 'express';
import { optionalAuth } from '../middleware/auth.js';
import { BrandDatabase } from '../db/brand-db.js';
import { OrganizationDatabase } from '../db/organization-db.js';
-import { resolvePrimaryOrganization } from '../db/users-db.js';
import { canonicalizeBrandDomain } from '../services/identifier-normalization.js';
import { createLogger } from '../logger.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
+import { getWorkos } from '../auth/workos-client.js';
const logger = createLogger('brand-ownership');
@@ -56,7 +57,7 @@ export function createBrandOwnershipRouter(config: { brandDb: BrandDatabase; org
try {
const brand = await brandDb.getDiscoveredBrandByDomain(domain);
- const user = (req as any).user as { id: string } | undefined;
+ const user = (req as any).user as { id: string; authWorkosUserId?: string } | undefined;
const verified = !!brand && brand.domain_verified === true && !!brand.workos_organization_id;
const orphaned = !!brand && brand.manifest_orphaned === true;
@@ -77,18 +78,19 @@ export function createBrandOwnershipRouter(config: { brandDb: BrandDatabase; org
let canManage = false;
let canClaim = false;
if (user?.id) {
- let userOrgId: string | null = null;
- try {
- userOrgId = await resolvePrimaryOrganization(user.id);
- } catch (err) {
- logger.warn({ err, userId: user.id }, 'Failed to resolve user primary org');
- }
- if (verified) {
- canManage = !!userOrgId && userOrgId === ownerOrgId;
- canClaim = false;
- } else {
- canManage = false;
- canClaim = true;
+ const selectedOrgId = typeof req.query.org === 'string' && req.query.org.trim()
+ ? req.query.org.trim()
+ : null;
+ if (selectedOrgId) {
+ const membership = await resolveUserOrgMembership(getWorkos(), user, selectedOrgId);
+ if (!membership) {
+ return res.status(403).json({ error: 'You do not have access to the selected organization' });
+ }
+ if (verified) {
+ canManage = selectedOrgId === ownerOrgId;
+ } else {
+ canClaim = true;
+ }
}
}
diff --git a/server/src/routes/certification.ts b/server/src/routes/certification.ts
index 827720eac7..e2410393bd 100644
--- a/server/src/routes/certification.ts
+++ b/server/src/routes/certification.ts
@@ -4,7 +4,7 @@ import { WorkOS } from '@workos-inc/node';
import { Resend } from 'resend';
import rateLimit from 'express-rate-limit';
import { createLogger } from '../logger.js';
-import { requireAuth, requireGlobalAdmin, optionalAuth, isDevModeEnabled } from '../middleware/auth.js';
+import { requireAuth, requireGlobalAdmin, optionalAuth } from '../middleware/auth.js';
import { enrichUserWithMembership } from '../utils/html-config.js';
import * as certDb from '../db/certification-db.js';
import { query } from '../db/client.js';
@@ -25,6 +25,8 @@ import {
getCertificationModuleExperience,
recordCertificationExperienceEvent,
} from '../services/certification-experience.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
const logger = createLogger('certification-routes');
@@ -190,24 +192,6 @@ async function resolveCapstoneModuleIdForAttempt(
return modules.find(m => m.format === 'capstone')?.id || null;
}
-/**
- * Check if a user belongs to an organization.
- * In dev mode, checks local DB. In production, calls WorkOS API.
- */
-async function isOrgMember(userId: string, orgId: string): Promise {
- if (isDevModeEnabled()) {
- const result = await query<{ count: string }>(
- `SELECT COUNT(*)::text AS count FROM organization_memberships
- WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, orgId]
- );
- return parseInt(result.rows[0]?.count || '0') > 0;
- }
- if (!workos) return false;
- const memberships = await workos.userManagement.listOrganizationMemberships({ userId, organizationId: orgId });
- return memberships.data.length > 0;
-}
-
/**
* Create certification routes.
* Returns publicRouter (mounted at /api/certification), userRouter (mounted at /api/me),
@@ -261,7 +245,8 @@ export function createCertificationRouters() {
const isAuthenticated = !!req.user;
if (isAuthenticated) {
- await enrichUserWithMembership(req.user as any);
+ const selectedOrgId = typeof req.query.org === 'string' ? req.query.org.trim() : '';
+ await enrichUserWithMembership(req.user as any, selectedOrgId);
}
const isMember = isAuthenticated && (req.user as any).isMember;
@@ -452,7 +437,10 @@ export function createCertificationRouters() {
}
// Check membership for gated modules
- await enrichUserWithMembership(req.user as any);
+ const selectedOrgId = typeof req.body?.organization_id === 'string'
+ ? req.body.organization_id.trim()
+ : '';
+ await enrichUserWithMembership(req.user as any, selectedOrgId);
if (!mod.is_free && !(req.user as any).isMember) {
return res.status(403).json({
error: 'Membership required',
@@ -573,18 +561,19 @@ export function createCertificationRouters() {
// GET /api/me/certification/expectation — get current user's cert expectation + org social proof
userRouter.get('/certification/expectation', async (req, res) => {
try {
- const userId = req.user!.id;
- const orgResult = await query<{ workos_organization_id: string; name: string }>(
- `SELECT om.workos_organization_id, o.name
- FROM organization_memberships om
- JOIN organizations o ON o.workos_organization_id = om.workos_organization_id
- WHERE om.workos_user_id = $1 AND o.is_personal = false
- LIMIT 1`,
- [userId]
+ const userId = getOrganizationAuthorizationUserId(req.user!);
+ const selectedOrg = typeof req.query.org === 'string' && req.query.org.length > 0
+ ? req.query.org
+ : null;
+ if (!selectedOrg) return res.status(400).json({ error: 'organization_selection_required', message: 'org query parameter is required' });
+ const membership = await resolveUserOrgMembership(workos, req.user!, selectedOrg);
+ if (!membership) return res.status(403).json({ error: 'Not authorized for the requested organization' });
+ const orgId = membership.organizationId;
+ const orgResult = await query<{ name: string }>(
+ `SELECT name FROM organizations WHERE workos_organization_id = $1 AND is_personal = false`,
+ [orgId],
);
- const orgId = orgResult.rows[0]?.workos_organization_id;
const orgName = orgResult.rows[0]?.name;
- if (!orgId) return res.json({ expectation: null, org_stats: null });
const [expectation, orgCertProgress] = await Promise.all([
certDb.getCertExpectationForUser(orgId, userId),
@@ -616,13 +605,17 @@ export function createCertificationRouters() {
// POST /api/me/certification/expectation/decline — opt out of team cert expectation
userRouter.post('/certification/expectation/decline', async (req, res) => {
try {
- const userId = req.user!.id;
- const orgResult = await query<{ workos_organization_id: string }>(
- `SELECT workos_organization_id FROM organization_memberships WHERE workos_user_id = $1 LIMIT 1`,
- [userId]
- );
- const orgId = orgResult.rows[0]?.workos_organization_id;
- if (!orgId) return res.status(404).json({ error: 'No organization found' });
+ const userId = getOrganizationAuthorizationUserId(req.user!);
+ const selectedOrg = req.body?.organization_id;
+ if (typeof selectedOrg !== 'string' || selectedOrg.length === 0) {
+ return res.status(400).json({ error: 'organization_selection_required', message: 'organization_id is required' });
+ }
+ const membership = await resolveUserOrgMembership(workos, req.user!, selectedOrg);
+ if (!membership) return res.status(403).json({ error: 'Not authorized for the requested organization' });
+ const orgId = membership.organizationId;
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await certDb.declineCertExpectation(orgId, userId);
if (!result) return res.status(404).json({ error: 'No active expectation found' });
@@ -637,14 +630,14 @@ export function createCertificationRouters() {
// POST /api/me/certification/expectation/snooze — progressive snooze (7d → 30d → auto-decline)
userRouter.post('/certification/expectation/snooze', async (req, res) => {
try {
- const userId = req.user!.id;
-
- const orgResult = await query<{ workos_organization_id: string }>(
- `SELECT workos_organization_id FROM organization_memberships WHERE workos_user_id = $1 LIMIT 1`,
- [userId]
- );
- const orgId = orgResult.rows[0]?.workos_organization_id;
- if (!orgId) return res.status(404).json({ error: 'No organization found' });
+ const userId = getOrganizationAuthorizationUserId(req.user!);
+ const selectedOrg = req.body?.organization_id;
+ if (typeof selectedOrg !== 'string' || selectedOrg.length === 0) {
+ return res.status(400).json({ error: 'organization_selection_required', message: 'organization_id is required' });
+ }
+ const membership = await resolveUserOrgMembership(workos, req.user!, selectedOrg);
+ if (!membership) return res.status(403).json({ error: 'Not authorized for the requested organization' });
+ const orgId = membership.organizationId;
// Check current expectation to determine snooze progression
const existing = await certDb.getCertExpectationForUser(orgId, userId);
@@ -657,11 +650,17 @@ export function createCertificationRouters() {
if (previouslySnoozed && existing.snooze_until && new Date(existing.snooze_until) < new Date()) {
// They've snoozed before and it expired — this is the second+ snooze
// Auto-decline instead of snoozeing indefinitely
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await certDb.declineCertExpectation(orgId, userId);
return res.json({ status: 'declined', message: 'No worries — certification is always available if you change your mind.' });
}
const days = previouslySnoozed ? 30 : 7;
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await certDb.snoozeCertExpectation(orgId, userId, days);
if (!result) return res.status(404).json({ error: 'No active expectation found' });
@@ -729,11 +728,10 @@ export function createCertificationRouters() {
// GET /api/organizations/:orgId/certification-summary — team credential overview
orgRouter.get('/:orgId/certification-summary', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
const { orgId } = req.params;
- // Verify user is a member of this org
- if (!await isOrgMember(userId, orgId)) {
+ // Authorization is scoped to the exact credential that authenticated.
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
return res.status(403).json({
error: 'Access denied',
message: 'You are not a member of this organization',
@@ -787,7 +785,7 @@ export function createCertificationRouters() {
// POST /api/organizations/:orgId/certification-invites — invite colleagues to certify
orgRouter.post('/:orgId/certification-invites', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
+ const userId = getOrganizationAuthorizationUserId(req.user!);
const { orgId } = req.params;
const { emails, credential_target } = req.body;
@@ -804,15 +802,12 @@ export function createCertificationRouters() {
}
}
- // Verify user is an admin of this org (only admins can send invitations)
- const membershipResult = await query<{ role: string }>(
- `SELECT role FROM organization_memberships WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, orgId]
- );
- if (!membershipResult.rows[0]) {
+ // Verify the exact authenticated credential is an active org admin.
+ const membershipResult = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!membershipResult) {
return res.status(403).json({ error: 'You are not a member of this organization' });
}
- if (membershipResult.rows[0].role !== 'admin') {
+ if (membershipResult.role !== 'admin') {
return res.status(403).json({ error: 'Only organization admins can send certification invitations' });
}
@@ -835,6 +830,9 @@ export function createCertificationRouters() {
}
try {
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
// Check if expectation already exists
if (existingEmails.has(email)) {
alreadyInvited++;
@@ -893,7 +891,6 @@ export function createCertificationRouters() {
// POST /api/organizations/:orgId/certification-invites/:id/resend — re-send a stale invitation
orgRouter.post('/:orgId/certification-invites/:id/resend', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
const { orgId, id } = req.params;
if (!isUuid(id)) {
@@ -901,17 +898,17 @@ export function createCertificationRouters() {
}
// Verify caller is an admin of this org
- const resendMembership = await query<{ role: string }>(
- `SELECT role FROM organization_memberships WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, orgId]
- );
- if (!resendMembership.rows[0]) {
+ const resendMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!resendMembership) {
return res.status(403).json({ error: 'You are not a member of this organization' });
}
- if (resendMembership.rows[0].role !== 'admin') {
+ if (resendMembership.role !== 'admin') {
return res.status(403).json({ error: 'Only organization admins can resend certification invitations' });
}
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const updated = await certDb.resendCertExpectation(id, orgId);
if (!updated) return res.status(404).json({ error: 'No pending invitation found' });
@@ -943,10 +940,9 @@ export function createCertificationRouters() {
// GET /api/organizations/:orgId/certification-goals — list goals with progress
orgRouter.get('/:orgId/certification-goals', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
const { orgId } = req.params;
- if (!await isOrgMember(userId, orgId)) {
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
return res.status(403).json({ error: 'You are not a member of this organization' });
}
@@ -961,7 +957,7 @@ export function createCertificationRouters() {
// POST /api/organizations/:orgId/certification-goals — create/update a goal (admin only)
orgRouter.post('/:orgId/certification-goals', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
+ const userId = getOrganizationAuthorizationUserId(req.user!);
const { orgId } = req.params;
const { credential_id, target_count, deadline } = req.body;
@@ -984,14 +980,14 @@ export function createCertificationRouters() {
}
// Verify admin role
- const membershipResult = await query<{ role: string }>(
- `SELECT role FROM organization_memberships WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, orgId]
- );
- if (!membershipResult.rows[0] || membershipResult.rows[0].role !== 'admin') {
+ const membershipResult = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!membershipResult || membershipResult.role !== 'admin') {
return res.status(403).json({ error: 'Only organization admins can set certification goals' });
}
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const goal = await certDb.createOrUpdateCertGoal(orgId, credential_id, target_count, parsedDeadline, userId);
res.json({ goal });
} catch (error) {
@@ -1003,17 +999,16 @@ export function createCertificationRouters() {
// DELETE /api/organizations/:orgId/certification-goals/:id — remove a goal (admin only)
orgRouter.delete('/:orgId/certification-goals/:id', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
const { orgId, id } = req.params;
- const membershipResult = await query<{ role: string }>(
- `SELECT role FROM organization_memberships WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, orgId]
- );
- if (!membershipResult.rows[0] || membershipResult.rows[0].role !== 'admin') {
+ const membershipResult = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!membershipResult || membershipResult.role !== 'admin') {
return res.status(403).json({ error: 'Only organization admins can delete certification goals' });
}
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const deleted = await certDb.deleteCertGoal(id, orgId);
if (!deleted) return res.status(404).json({ error: 'Goal not found' });
@@ -1031,10 +1026,9 @@ export function createCertificationRouters() {
// GET /api/organizations/:orgId/certification-stalled — count of stalled learners
orgRouter.get('/:orgId/certification-stalled', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
const { orgId } = req.params;
- if (!await isOrgMember(userId, orgId)) {
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
return res.status(403).json({ error: 'You are not a member of this organization' });
}
@@ -1049,16 +1043,13 @@ export function createCertificationRouters() {
// POST /api/organizations/:orgId/certification-nudge — nudge stalled learners
orgRouter.post('/:orgId/certification-nudge', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
+ const userId = getOrganizationAuthorizationUserId(req.user!);
const { orgId } = req.params;
const { user_ids } = req.body;
// Verify admin role
- const membershipResult = await query<{ role: string }>(
- `SELECT role FROM organization_memberships WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, orgId]
- );
- if (!membershipResult.rows[0] || membershipResult.rows[0].role !== 'admin') {
+ const membershipResult = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!membershipResult || membershipResult.role !== 'admin') {
return res.status(403).json({ error: 'Only organization admins can send certification nudges' });
}
@@ -1076,6 +1067,9 @@ export function createCertificationRouters() {
let nudged = 0;
for (const learner of stalledLearners) {
try {
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await notifyUser({
recipientUserId: learner.workos_user_id,
actorUserId: userId,
diff --git a/server/src/routes/committees.ts b/server/src/routes/committees.ts
index 0081b34c5a..a9ad3ae56f 100644
--- a/server/src/routes/committees.ts
+++ b/server/src/routes/committees.ts
@@ -21,6 +21,7 @@ import { WorkingGroupDatabase } from "../db/working-group-db.js";
import { eventsDb } from "../db/events-db.js";
import { invalidateMemberContextCache } from "../addie/index.js";
import { invalidateWebAdminStatusCache, isWebUserAAOAdmin } from "../addie/mcp/admin-tools.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
import { syncWorkingGroupMembersFromSlack, syncAllWorkingGroupMembersFromSlack } from "../slack/sync.js";
import { notifyPublishedPost } from "../notifications/slack.js";
import { notifyUser } from "../notifications/notification-service.js";
@@ -1011,7 +1012,9 @@ export function createCommitteeRouters(): {
// Private subgroups show up only for people who are direct members of
// that subgroup, or for AAO admins. Parent membership alone does not
// unlock private subgroup visibility.
- const isAAOAdmin = user?.id ? await isWebUserAAOAdmin(user.id) : false;
+ const isAAOAdmin = user?.id
+ ? await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user))
+ : false;
const allSubgroups = await workingGroupDb.listSubgroups(group.id);
const visibleSubgroups: typeof allSubgroups = [];
for (const sg of allSubgroups) {
@@ -1096,7 +1099,9 @@ export function createCommitteeRouters(): {
// Aggregate from the group plus its subgroups the caller may see.
// Private subgroups the caller isn't a member of are filtered out.
const includeSubgroups = req.query.include_subgroups !== 'false';
- const isAAOAdmin = user?.id ? await isWebUserAAOAdmin(user.id) : false;
+ const isAAOAdmin = user?.id
+ ? await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user))
+ : false;
const targetIds = includeSubgroups
? await workingGroupDb.getVisibleDescendantIds(group.id, user?.id ?? null, { isAdmin: isAAOAdmin })
: [group.id];
diff --git a/server/src/routes/community-mirrors.ts b/server/src/routes/community-mirrors.ts
index 2860469880..d8f2ac2486 100644
--- a/server/src/routes/community-mirrors.ts
+++ b/server/src/routes/community-mirrors.ts
@@ -31,7 +31,11 @@ import { isWebUserAAOAdmin } from '../addie/admin-status-lookup.js';
import { validateAdagentsDocument } from '../services/adagents-schema-validator.js';
import { registryReadRateLimiter, brandCreationRateLimiter } from '../middleware/rate-limit.js';
import { createLogger } from '../logger.js';
-import { resolveCallerOrgId } from './helpers/resolve-caller-org.js';
+import {
+ resolveCallerOrganization,
+ type CallerOrganizationResolution,
+} from './helpers/resolve-caller-org.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
import {
notifyCommunityMirrorProposalReviewed,
notifyPendingCommunityMirrorProposal,
@@ -97,14 +101,17 @@ async function canManageMirrors(userId: string): Promise {
}
async function resolveManager(
- req: { user?: { id?: string } },
+ req: { user?: { id?: string; authWorkosUserId?: string } },
res: Response
): Promise {
- const userId = req.user?.id;
- if (!userId) {
+ if (!req.user?.id) {
res.status(401).json({ error: 'Authentication required' });
return null;
}
+ const userId = getOrganizationAuthorizationUserId({
+ id: req.user.id,
+ authWorkosUserId: req.user.authWorkosUserId,
+ });
if (!(await canManageMirrors(userId))) {
res.status(403).json({
error: 'Only registry moderators or AgenticAdvertising.org administrators can manage community mirrors',
@@ -114,9 +121,11 @@ async function resolveManager(
return userId;
}
-async function callerOrganizationId(req: Request): Promise {
+async function callerOrganization(req: Request): Promise {
const attached = (req as Request & { apiKey?: { organizationId?: string } }).apiKey?.organizationId;
- return attached ?? resolveCallerOrgId(req);
+ return attached
+ ? { status: 'authorized', organizationId: attached }
+ : resolveCallerOrganization(req);
}
function reviewedContentDigest(document: Record): string {
@@ -209,7 +218,11 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig)
if (reviewQueue && !isManager) {
return res.status(403).json({ error: 'Registry moderator access is required for the review queue' });
}
- const organizationId = isManager ? null : await callerOrganizationId(req);
+ const organization = isManager ? { status: 'missing' as const } : await callerOrganization(req);
+ if (organization.status === 'forbidden') {
+ return res.status(403).json({ error: 'Organization access denied' });
+ }
+ const organizationId = organization.status === 'authorized' ? organization.organizationId : null;
const limit = req.query.limit ? parseInt(String(req.query.limit), 10) : undefined;
const offset = req.query.offset ? parseInt(String(req.query.offset), 10) : undefined;
try {
@@ -242,7 +255,11 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig)
const proposal = await mirrorDb.getProposalById(proposalId);
if (!proposal) return res.status(404).json({ error: 'Community mirror proposal not found' });
const isManager = await canManageMirrors(userId);
- const organizationId = isManager ? null : await callerOrganizationId(req);
+ const organization = isManager ? { status: 'missing' as const } : await callerOrganization(req);
+ if (organization.status === 'forbidden') {
+ return res.status(403).json({ error: 'Organization access denied' });
+ }
+ const organizationId = organization.status === 'authorized' ? organization.organizationId : null;
const ownsProposal = organizationId
? proposal.proposed_by_organization_id === organizationId
: proposal.proposed_by_user_id === userId;
@@ -455,7 +472,11 @@ export function createCommunityMirrorRouter(config: CommunityMirrorRouterConfig)
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Authentication required' });
const isManager = await canManageMirrors(userId);
- const organizationId = isManager ? null : await callerOrganizationId(req);
+ const organization = isManager ? { status: 'missing' as const } : await callerOrganization(req);
+ if (organization.status === 'forbidden') {
+ return res.status(403).json({ error: 'Organization access denied' });
+ }
+ const organizationId = organization.status === 'authorized' ? organization.organizationId : null;
if (!isManager && !organizationId) {
return res.status(403).json({ error: 'Organization context is required to propose a community mirror' });
}
diff --git a/server/src/routes/community.ts b/server/src/routes/community.ts
index 0b1bbe8e46..2a7dd7c37c 100644
--- a/server/src/routes/community.ts
+++ b/server/src/routes/community.ts
@@ -10,8 +10,7 @@ import { MemberDatabase } from "../db/member-db.js";
import { OrganizationDatabase } from "../db/organization-db.js";
import { SlackDatabase } from "../db/slack-db.js";
import { query } from "../db/client.js";
-import { resolvePrimaryOrganization } from "../db/users-db.js";
-import { VALID_MEMBER_OFFERINGS, type MemberOffering } from "../types.js";
+import { VALID_MEMBER_OFFERINGS } from "../types.js";
import { notifyUser } from "../notifications/notification-service.js";
import { validateMemberProfileUrlFields } from "../utils/member-profile-url.js";
@@ -59,7 +58,7 @@ export interface CommunityRoutesConfig {
* Returns publicRouter (mounted at /api/community) and userRouter (mounted at /api/me).
*/
export function createCommunityRouters(config: CommunityRoutesConfig) {
- const { communityDb, slackDb, memberDb, orgDb, invalidateMemberContextCache } = config;
+ const { communityDb, invalidateMemberContextCache } = config;
const publicRouter = Router();
const userRouter = Router();
@@ -280,21 +279,6 @@ export function createCommunityRouters(config: CommunityRoutesConfig) {
return res.status(404).json({ error: 'User not found' });
}
- if (memberDb && orgDb) {
- const orgId = await resolvePrimaryOrganization(user.id);
- if (orgId) {
- const org = await orgDb.getOrganization(orgId);
- if (org?.is_personal) {
- await query(
- `UPDATE member_profiles
- SET portrait_id = NULL, updated_at = NOW()
- WHERE workos_organization_id = $1`,
- [orgId]
- );
- }
- }
- }
-
communityDb.checkAndAwardBadges(user.id, 'profile').catch(
err => logger.error({ err }, 'Badge check failed')
);
@@ -440,25 +424,6 @@ export function createCommunityRouters(config: CommunityRoutesConfig) {
err => logger.error({ err }, 'Badge check failed')
);
- // For individual accounts, sync community profile → member_profiles
- if (memberDb && orgDb) {
- const memberFields: MemberDirectoryFields = {
- offerings: Array.isArray(req.body.offerings) ? req.body.offerings as MemberOffering[] : undefined,
- contact_email: typeof req.body.contact_email === 'string' ? req.body.contact_email : undefined,
- contact_website: req.body.contact_website === null
- ? null
- : typeof req.body.contact_website === 'string'
- ? req.body.contact_website
- : undefined,
- contact_phone: typeof req.body.contact_phone === 'string' ? req.body.contact_phone : undefined,
- };
- try {
- await syncIndividualMemberProfile(user.id, profile, memberFields, memberDb, orgDb, invalidateMemberContextCache);
- } catch (err) {
- logger.error({ err }, 'Member profile sync failed');
- }
- }
-
res.json(profile);
} catch (error: any) {
if (error?.constraint === 'users_slug_key') {
@@ -522,123 +487,3 @@ export function createCommunityRouters(config: CommunityRoutesConfig) {
return { publicRouter, userRouter };
}
-
-/**
- * For individual (personal) accounts, sync community profile fields to member_profiles
- * so the member directory listing stays up to date from a single profile form.
- */
-interface MemberDirectoryFields {
- offerings?: MemberOffering[];
- contact_email?: string;
- contact_website?: string | null;
- contact_phone?: string;
-}
-
-async function syncIndividualMemberProfile(
- userId: string,
- communityProfile: CommunityProfile,
- memberFields: MemberDirectoryFields,
- memberDb: MemberDatabase,
- orgDb: OrganizationDatabase,
- invalidateMemberContextCache?: () => void,
-): Promise {
- // Look up user's org
- const orgId = await resolvePrimaryOrganization(userId);
- if (!orgId) return;
-
- // Only sync for personal/individual orgs
- const org = await orgDb.getOrganization(orgId);
- if (!org?.is_personal) return;
-
- const userRow = await query<{ first_name: string; last_name: string }>(
- 'SELECT first_name, last_name FROM users WHERE workos_user_id = $1',
- [userId]
- );
- const user = userRow.rows[0];
- const displayName = [user?.first_name, user?.last_name].filter(Boolean).join(' ') || 'Member';
-
- // Build mapped fields for member_profiles
- const memberUpdates: Record = {
- display_name: displayName,
- };
-
- // Legacy community rows may contain URL values that predate the current
- // HTTPS-only policy. An unrelated profile edit must still sync its safe
- // fields, without copying those legacy values into a new member write.
- for (const [field, value] of [
- ['linkedin_url', communityProfile.linkedin_url || null],
- ['twitter_url', communityProfile.twitter_url || null],
- ] as const) {
- if (!validateMemberProfileUrlFields({ [field]: value })) {
- memberUpdates[field] = value;
- }
- }
-
- if (memberFields.contact_email !== undefined) memberUpdates.contact_email = memberFields.contact_email;
- if (
- memberFields.contact_website !== undefined
- && !validateMemberProfileUrlFields({ contact_website: memberFields.contact_website })
- ) {
- memberUpdates.contact_website = memberFields.contact_website;
- }
- if (memberFields.contact_phone !== undefined) memberUpdates.contact_phone = memberFields.contact_phone;
-
- const existingProfile = await memberDb.getProfileByOrgId(orgId);
-
- // Sync is_public: turning off always syncs; turning on requires active subscription.
- // Only check subscription when actually toggling on to avoid unnecessary DB call.
- if (communityProfile.is_public === false) {
- memberUpdates.is_public = false;
- } else if (communityProfile.is_public === true && (!existingProfile || !existingProfile.is_public)) {
- const hasSubscription = await orgDb.hasActiveSubscription(orgId);
- if (hasSubscription) {
- memberUpdates.is_public = true;
- }
- }
-
- if (existingProfile) {
- // Only sync headline→tagline and bio→description if the listing field hasn't been
- // independently customized (i.e., it's empty or already matches the community value).
- const newTagline = communityProfile.headline || null;
- const currentTagline = existingProfile.tagline || null;
- if (!currentTagline || currentTagline === newTagline) {
- memberUpdates.tagline = newTagline;
- }
-
- const newDescription = communityProfile.bio || null;
- const currentDescription = existingProfile.description || null;
- if (!currentDescription || currentDescription === newDescription) {
- memberUpdates.description = newDescription;
- }
-
- // Merge offerings: the form only edits individual-relevant offerings (consulting, other).
- // Preserve any other offerings the existing profile has (e.g. data_provider).
- if (memberFields.offerings !== undefined) {
- const individualOfferings: MemberOffering[] = ['consulting', 'other'];
- const preserved = (existingProfile.offerings || []).filter(
- (o: MemberOffering) => !individualOfferings.includes(o)
- );
- memberUpdates.offerings = [...preserved, ...memberFields.offerings];
- }
-
- await memberDb.updateProfileByOrgId(orgId, memberUpdates);
- } else {
- // Auto-create member profile for personal accounts on first save
- const slug = communityProfile.slug || displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
- const memberIsPublic = memberUpdates.is_public === true;
- await memberDb.createProfile({
- workos_organization_id: orgId,
- display_name: displayName,
- slug,
- tagline: communityProfile.headline || undefined,
- description: communityProfile.bio || undefined,
- contact_email: memberFields.contact_email,
- contact_website: memberFields.contact_website ?? undefined,
- contact_phone: memberFields.contact_phone,
- offerings: memberFields.offerings || [],
- is_public: memberIsPublic,
- });
- }
-
- invalidateMemberContextCache?.();
-}
diff --git a/server/src/routes/content.ts b/server/src/routes/content.ts
index 30de9a9d69..292c49739f 100644
--- a/server/src/routes/content.ts
+++ b/server/src/routes/content.ts
@@ -17,6 +17,7 @@ import { requireAuth } from '../middleware/auth.js';
import { contentProposeRateLimiter, contentFetchUrlRateLimiter, contentAssetUploadRateLimiter } from '../middleware/rate-limit.js';
import { getPool } from '../db/client.js';
import { isWebUserAAOAdmin } from '../addie/mcp/admin-tools.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
import { sendChannelMessage } from '../slack/client.js';
import type { SlackBlockMessage } from '../slack/types.js';
import { notifyPublishedPost, sendSocialAmplificationDM } from '../notifications/slack.js';
@@ -31,7 +32,9 @@ import { generateIllustration } from '../services/illustration-generator.js';
import { createIllustration, approveIllustration } from '../db/illustration-db.js';
import { resolveEscalationsForPerspective } from '../db/escalation-db.js';
import { listMyContent as listMyContentService, MyContentError } from '../services/my-content-service.js';
-import { checkContentSubmissionTier } from '../services/membership-tiers.js';
+import { checkOrganizationContentSubmissionTier } from '../services/membership-tiers.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
+import { getWorkos } from '../auth/workos-client.js';
import { normalizePerspectiveExternalUrl } from '../utils/perspective-url.js';
const logger = createLogger('content-routes');
@@ -88,6 +91,7 @@ interface ContentAuthor {
}
interface ProposeContentRequest {
+ organization_id?: string;
title: string;
subtitle?: string;
content?: string;
@@ -329,6 +333,15 @@ async function isCommitteeLead(committeeId: string, userId: string): Promise 0;
}
+async function canReviewWorkingGroupContent(
+ user: ContentUser,
+ workingGroupId: string,
+): Promise {
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
+ return await isWebUserAAOAdmin(authorizationUserId)
+ || await isCommitteeLead(workingGroupId, authorizationUserId);
+}
+
/**
* Get user info for author display
*/
@@ -351,6 +364,7 @@ async function getUserInfo(userId: string): Promise<{ name: string } | null> {
*/
export interface ContentUser {
id: string;
+ authWorkosUserId?: string;
email?: string;
}
@@ -374,6 +388,7 @@ export async function proposeContentForUser(
user: ContentUser,
request: ProposeContentRequest
): Promise {
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
const {
title,
subtitle,
@@ -408,8 +423,13 @@ export async function proposeContentForUser(
// Membership tier gate — Professional+ required for content submission.
// System users (system:* prefix) and site admins are exempt, matching the
// rate-limiter carve-out and the existing admin bypass pattern below.
- if (!user.id.startsWith('system:') && !(await isWebUserAAOAdmin(user.id))) {
- const eligible = await checkContentSubmissionTier(user.id);
+ if (!user.id.startsWith('system:') && !(await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user)))) {
+ const organizationId = request.organization_id?.trim();
+ if (!organizationId) {
+ return { success: false, error: 'organization_id is required for member content submissions' };
+ }
+ const membership = await resolveUserOrgMembership(getWorkos(), user, organizationId);
+ const eligible = Boolean(membership) && await checkOrganizationContentSubmissionTier(organizationId);
if (!eligible) {
logger.warn({ userId: user.id }, 'proposeContentForUser blocked — insufficient membership tier');
return {
@@ -487,15 +507,15 @@ export async function proposeContentForUser(
const acceptsPublicSubmissions = committee.accepts_public_submissions;
// Check if user can submit to this collection
- const userIsLead = await isCommitteeLead(committeeId, user.id);
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
+ const userIsLead = await isCommitteeLead(committeeId, authorizationUserId);
+ const userIsAdmin = await isWebUserAAOAdmin(authorizationUserId);
// For non-public collections, user must be a member
if (!acceptsPublicSubmissions && !userIsLead && !userIsAdmin) {
const membershipResult = await pool.query(
`SELECT 1 FROM working_group_memberships
WHERE working_group_id = $1 AND workos_user_id = $2 AND status = 'active'`,
- [committeeId, user.id]
+ [committeeId, authorizationUserId]
);
if (membershipResult.rows.length === 0) {
logger.warn({ committeeSlug, userId: user.id }, 'Content proposal failed: user not a member');
@@ -724,6 +744,7 @@ export async function listPendingContentForUser(
): Promise {
const pool = getPool();
const { committeeSlug } = opts;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
// Committees this user leads (direct workos_user_id or via slack mapping)
const leaderResult = await pool.query(
@@ -732,10 +753,10 @@ export async function listPendingContentForUser(
LEFT JOIN slack_user_mappings sm ON wgl.user_id = sm.slack_user_id AND sm.workos_user_id IS NOT NULL
JOIN working_groups wg ON wg.id = wgl.working_group_id
WHERE wgl.user_id = $1 OR sm.workos_user_id = $1`,
- [user.id]
+ [authorizationUserId]
);
const ledCommitteeIds = leaderResult.rows.map(c => c.id);
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
+ const userIsAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user));
if (!userIsAdmin && ledCommitteeIds.length === 0) {
return { items: [], summary: { total: 0, by_collection: {} } };
@@ -856,12 +877,12 @@ export async function approveContentForUser(
};
}
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
- const userIsLead = content.working_group_id
- ? await isCommitteeLead(content.working_group_id, user.id)
- : false;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
+ const canReview = content.working_group_id
+ ? await canReviewWorkingGroupContent(user, content.working_group_id)
+ : await isWebUserAAOAdmin(authorizationUserId);
- if (!userIsAdmin && !userIsLead) {
+ if (!canReview) {
return {
success: false,
error: 'permission_denied',
@@ -872,17 +893,24 @@ export async function approveContentForUser(
const newStatus: 'published' | 'draft' = publishImmediately ? 'published' : 'draft';
const publishedAt = publishImmediately ? new Date().toISOString() : null;
+ const canStillReview = content.working_group_id
+ ? await canReviewWorkingGroupContent(user, content.working_group_id)
+ : await isWebUserAAOAdmin(authorizationUserId);
+ if (!canStillReview) {
+ return { success: false, error: 'permission_denied', error_message: 'Your review access is no longer active' };
+ }
+
await pool.query(
`UPDATE perspectives
SET status = $1, published_at = $2,
reviewed_by_user_id = $3, reviewed_at = NOW()
WHERE id = $4`,
- [newStatus, publishedAt, user.id, contentId]
+ [newStatus, publishedAt, authorizationUserId, contentId]
);
logger.info({
contentId,
- reviewerId: user.id,
+ reviewerId: authorizationUserId,
newStatus,
committeeSlug: content.committee_slug,
}, 'Content approved');
@@ -926,12 +954,12 @@ export async function approveContentForUser(
// even if the escalation resolve query errors. See #2702.
resolveEscalationsForPerspective(
contentId,
- user.id,
+ authorizationUserId,
`Auto-resolved: content approved by reviewer`
).then(ids => {
if (ids.length > 0) {
logger.info(
- { contentId, reviewerId: user.id, resolvedEscalationIds: ids },
+ { contentId, reviewerId: authorizationUserId, resolvedEscalationIds: ids },
'Auto-resolved escalations linked to approved content'
);
}
@@ -988,12 +1016,12 @@ export async function rejectContentForUser(
};
}
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
- const userIsLead = content.working_group_id
- ? await isCommitteeLead(content.working_group_id, user.id)
- : false;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
+ const canReview = content.working_group_id
+ ? await canReviewWorkingGroupContent(user, content.working_group_id)
+ : await isWebUserAAOAdmin(authorizationUserId);
- if (!userIsAdmin && !userIsLead) {
+ if (!canReview) {
return {
success: false,
error: 'permission_denied',
@@ -1001,17 +1029,24 @@ export async function rejectContentForUser(
};
}
+ const canStillReview = content.working_group_id
+ ? await canReviewWorkingGroupContent(user, content.working_group_id)
+ : await isWebUserAAOAdmin(authorizationUserId);
+ if (!canStillReview) {
+ return { success: false, error: 'permission_denied', error_message: 'Your review access is no longer active' };
+ }
+
await pool.query(
`UPDATE perspectives
SET status = 'rejected', rejection_reason = $1,
reviewed_by_user_id = $2, reviewed_at = NOW()
WHERE id = $3`,
- [reason, user.id, contentId]
+ [reason, authorizationUserId, contentId]
);
logger.info({
contentId,
- reviewerId: user.id,
+ reviewerId: authorizationUserId,
reason,
committeeSlug: content.committee_slug,
}, 'Content rejected');
@@ -1061,12 +1096,12 @@ export async function requestRevisionsForUser(
};
}
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
- const userIsLead = content.working_group_id
- ? await isCommitteeLead(content.working_group_id, user.id)
- : false;
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
+ const canReview = content.working_group_id
+ ? await canReviewWorkingGroupContent(user, content.working_group_id)
+ : await isWebUserAAOAdmin(authorizationUserId);
- if (!userIsAdmin && !userIsLead) {
+ if (!canReview) {
return {
success: false,
error: 'permission_denied',
@@ -1074,17 +1109,24 @@ export async function requestRevisionsForUser(
};
}
+ const canStillReview = content.working_group_id
+ ? await canReviewWorkingGroupContent(user, content.working_group_id)
+ : await isWebUserAAOAdmin(authorizationUserId);
+ if (!canStillReview) {
+ return { success: false, error: 'permission_denied', error_message: 'Your review access is no longer active' };
+ }
+
await pool.query(
`UPDATE perspectives
SET status = 'needs_revisions', revision_notes = $1,
revision_requested_at = NOW(), reviewed_by_user_id = $2, reviewed_at = NOW()
WHERE id = $3`,
- [notes, user.id, contentId]
+ [notes, authorizationUserId, contentId]
);
logger.info({
contentId,
- reviewerId: user.id,
+ reviewerId: authorizationUserId,
committeeSlug: content.committee_slug,
}, 'Content revision requested');
@@ -1213,7 +1255,7 @@ export function createContentRouter(): Router {
try {
const user = req.user!;
const result = await proposeContentForUser(
- { id: user.id, email: user.email },
+ { id: user.id, authWorkosUserId: user.authWorkosUserId, email: user.email },
req.body as ProposeContentRequest
);
@@ -1251,7 +1293,7 @@ export function createContentRouter(): Router {
const user = req.user!;
const committeeSlug = req.query.committee_slug as string | undefined;
const result = await listPendingContentForUser(
- { id: user.id, email: user.email },
+ { id: user.id, authWorkosUserId: user.authWorkosUserId, email: user.email },
{ committeeSlug }
);
res.json(result);
@@ -1271,7 +1313,7 @@ export function createContentRouter(): Router {
const { publish_immediately = true } = req.body;
const result = await approveContentForUser(
- { id: user.id, email: user.email },
+ { id: user.id, authWorkosUserId: user.authWorkosUserId, email: user.email },
id,
{ publishImmediately: publish_immediately }
);
@@ -1396,7 +1438,7 @@ export function createContentRouter(): Router {
const { reason } = req.body;
const result = await rejectContentForUser(
- { id: user.id, email: user.email },
+ { id: user.id, authWorkosUserId: user.authWorkosUserId, email: user.email },
id,
reason
);
@@ -1435,7 +1477,7 @@ export function createContentRouter(): Router {
const { notes } = req.body;
const result = await requestRevisionsForUser(
- { id: user.id, email: user.email },
+ { id: user.id, authWorkosUserId: user.authWorkosUserId, email: user.email },
id,
notes
);
@@ -1467,7 +1509,7 @@ export function createContentRouter(): Router {
const { id } = req.params;
const result = await resubmitContentForUser(
- { id: user.id, email: user.email },
+ { id: user.id, authWorkosUserId: user.authWorkosUserId, email: user.email },
id
);
@@ -1571,7 +1613,7 @@ export function createContentRouter(): Router {
const perspectiveId = perspResult.rows[0].id;
// Check permission: must be author, proposer, or admin
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
+ const userIsAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user));
if (!userIsAdmin) {
const authorCheck = await pool.query(
`SELECT 1 FROM perspectives WHERE id = $1 AND (author_user_id = $2 OR proposer_user_id = $2)
@@ -1721,9 +1763,9 @@ export function createMyContentRouter(): Router {
[id, user.id]
).then(r => r.rows.length > 0);
const userIsLead = contentItem.working_group_id
- ? await isCommitteeLead(contentItem.working_group_id, user.id)
+ ? await isCommitteeLead(contentItem.working_group_id, getOrganizationAuthorizationUserId(user))
: false;
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
+ const userIsAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user));
if (!isProposer && !isAuthor && !userIsLead && !userIsAdmin) {
return res.status(403).json({
@@ -1972,9 +2014,9 @@ export function createMyContentRouter(): Router {
[id, user.id]
).then(r => r.rows.length > 0);
const userIsLead = contentItem.working_group_id
- ? await isCommitteeLead(contentItem.working_group_id, user.id)
+ ? await isCommitteeLead(contentItem.working_group_id, getOrganizationAuthorizationUserId(user))
: false;
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
+ const userIsAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user));
if (!isProposer && !isAuthor && !userIsLead && !userIsAdmin) {
return res.status(403).json({
@@ -2037,9 +2079,9 @@ export function createMyContentRouter(): Router {
// Check permission
const isProposer = contentItem.proposer_user_id === user.id;
const userIsLead = contentItem.working_group_id
- ? await isCommitteeLead(contentItem.working_group_id, user.id)
+ ? await isCommitteeLead(contentItem.working_group_id, getOrganizationAuthorizationUserId(user))
: false;
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
+ const userIsAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user));
if (!isProposer && !userIsLead && !userIsAdmin) {
return res.status(403).json({
@@ -2118,9 +2160,9 @@ export function createMyContentRouter(): Router {
// Check permission
const isProposer = contentItem.proposer_user_id === user.id;
const userIsLead = contentItem.working_group_id
- ? await isCommitteeLead(contentItem.working_group_id, user.id)
+ ? await isCommitteeLead(contentItem.working_group_id, getOrganizationAuthorizationUserId(user))
: false;
- const userIsAdmin = await isWebUserAAOAdmin(user.id);
+ const userIsAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user));
if (!isProposer && !userIsLead && !userIsAdmin) {
return res.status(403).json({
diff --git a/server/src/routes/current-user-organizations.ts b/server/src/routes/current-user-organizations.ts
index 06648fe098..26883a7cb0 100644
--- a/server/src/routes/current-user-organizations.ts
+++ b/server/src/routes/current-user-organizations.ts
@@ -2,6 +2,10 @@ import type { WorkOS } from '@workos-inc/node';
import { getPool } from '../db/client.js';
import type { OrganizationDatabase } from '../db/organization-db.js';
import { createLogger } from '../logger.js';
+import {
+ getOrganizationAuthorizationUserId,
+ type OrgAuthorizationPrincipal,
+} from '../utils/resolve-user-org-membership.js';
const logger = createLogger('current-user-organizations');
@@ -73,6 +77,49 @@ export async function getCachedOrganizationsForUser(userId: string): Promise {
+ const result = await getPool().query<{
+ workos_organization_id: string;
+ name: string | null;
+ role: string;
+ is_personal: boolean | null;
+ }>(
+ `SELECT g.workos_organization_id, o.name, g.role,
+ COALESCE(o.is_personal, false) AS is_personal
+ FROM organization_credential_grants g
+ LEFT JOIN organizations o
+ ON o.workos_organization_id = g.workos_organization_id
+ WHERE g.workos_user_id = $1
+ AND g.revoked_at IS NULL
+ AND g.effective_from <= NOW()
+ AND (g.effective_until IS NULL OR g.effective_until > NOW())
+ ORDER BY COALESCE(NULLIF(o.name, ''), g.workos_organization_id)`,
+ [userId],
+ );
+ return result.rows.map((row) => ({
+ id: row.workos_organization_id,
+ name: row.name?.trim() || row.workos_organization_id,
+ role: row.role,
+ status: 'active',
+ is_personal: row.is_personal || false,
+ }));
+}
+
+function mergeOrganizationAccess(
+ memberships: CurrentUserOrganization[],
+ grants: CurrentUserOrganization[],
+): CurrentUserOrganization[] {
+ const rank: Record = { member: 1, admin: 2, owner: 3 };
+ const merged = new Map();
+ for (const organization of [...memberships, ...grants]) {
+ const existing = merged.get(organization.id);
+ if (!existing || (rank[organization.role] ?? 0) > (rank[existing.role] ?? 0)) {
+ merged.set(organization.id, organization);
+ }
+ }
+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
+}
+
export async function resolveCurrentUserOrganization(
membership: CurrentUserWorkOSMembership,
orgDb: CurrentUserOrgDb,
@@ -105,43 +152,52 @@ export async function resolveCurrentUserOrganization(
}
export async function getCurrentUserOrganizations(args: {
- userId: string;
+ principal: OrgAuthorizationPrincipal;
email: string;
workos: WorkOS | null;
orgDb: CurrentUserOrgDb;
autoLinkByVerifiedDomain: AutoLinkByVerifiedDomain;
}): Promise {
+ const userId = getOrganizationAuthorizationUserId(args.principal);
if (!args.workos) {
- return getCachedOrganizationsForUser(args.userId);
+ const [memberships, grants] = await Promise.all([
+ getCachedOrganizationsForUser(userId),
+ getGrantedOrganizationsForUser(userId),
+ ]);
+ return mergeOrganizationAccess(memberships, grants);
}
try {
let memberships = await args.workos.userManagement.listOrganizationMemberships({
- userId: args.userId,
+ userId,
statuses: ['active'],
});
try {
- const linked = await args.autoLinkByVerifiedDomain(args.workos, args.userId, args.email);
+ const linked = await args.autoLinkByVerifiedDomain(args.workos, userId, args.email);
if (linked) {
memberships = await args.workos.userManagement.listOrganizationMemberships({
- userId: args.userId,
+ userId,
statuses: ['active'],
});
}
} catch (error) {
logger.warn(
- { err: error, userId: args.userId },
+ { err: error, userId },
'Auto-link by verified domain failed during /api/me; continuing with existing memberships',
);
}
- return Promise.all(
+ const [resolvedMemberships, grants] = await Promise.all([
+ Promise.all(
memberships.data.map((membership) => resolveCurrentUserOrganization(membership, args.orgDb, args.workos!))
- );
+ ),
+ getGrantedOrganizationsForUser(userId),
+ ]);
+ return mergeOrganizationAccess(resolvedMemberships, grants);
} catch (error) {
logger.warn(
- { err: error, userId: args.userId },
+ { err: error, userId },
'WorkOS organization membership lookup failed for /api/me',
);
throw new CurrentUserOrganizationsUnavailableError();
diff --git a/server/src/routes/engagement.ts b/server/src/routes/engagement.ts
index c4b121df63..af0afaa61c 100644
--- a/server/src/routes/engagement.ts
+++ b/server/src/routes/engagement.ts
@@ -8,6 +8,8 @@ import { WorkingGroupDatabase } from '../db/working-group-db.js';
import { checkMilestones } from '../addie/services/journey-computation.js';
import { getRecommendedGroupsForOrg } from '../addie/services/group-recommendations.js';
import { notifyAssessmentCompleted } from '../notifications/assessment.js';
+import { getWorkos } from '../auth/workos-client.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
const VALID_PERSONAS: Persona[] = ['molecule_builder', 'data_decoder', 'pureblood_protector', 'resops_integrator', 'ladder_climber', 'simple_starter', 'pragmatic_builder'];
@@ -25,48 +27,16 @@ export function createEngagementRouter(config: EngagementRoutesConfig): Router {
// GET /api/me/engagement - Member engagement dashboard data
router.get('/', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
const rawOrg = req.query.org;
- const requestedOrgId = typeof rawOrg === 'string' ? rawOrg : undefined;
-
- let orgId: string | undefined;
-
- if (requestedOrgId) {
- // Validate the user belongs to the requested org
- const memberCheck = await query<{ workos_organization_id: string }>(
- `SELECT workos_organization_id FROM organization_memberships
- WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, requestedOrgId]
- );
- if (memberCheck.rows.length > 0) {
- orgId = requestedOrgId;
- } else {
- return res.status(403).json({ error: 'Not a member of the requested organization' });
- }
- }
-
- if (!orgId) {
- // Fallback: resolve from memberships (no explicit org requested)
- const membershipResult = await query<{ workos_organization_id: string }>(
- `SELECT workos_organization_id FROM (
- SELECT om.workos_organization_id, 1 AS priority
- FROM organization_memberships om
- WHERE om.workos_user_id = $1
- UNION ALL
- SELECT u.primary_organization_id, 2 AS priority
- FROM users u
- WHERE u.workos_user_id = $1 AND u.primary_organization_id IS NOT NULL
- ) ranked
- ORDER BY priority, workos_organization_id
- LIMIT 1`,
- [userId]
- );
- orgId = membershipResult.rows[0]?.workos_organization_id;
+ const requestedOrgId = typeof rawOrg === 'string' && rawOrg.length > 0 ? rawOrg : null;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'organization_selection_required', message: 'org query parameter is required' });
}
-
- if (!orgId) {
- return res.status(404).json({ error: 'No organization found for user' });
+ const membership = await resolveUserOrgMembership(getWorkos(), req.user!, requestedOrgId);
+ if (!membership) {
+ return res.status(403).json({ error: 'Not a member of the requested organization' });
}
+ const orgId = membership.organizationId;
// Parallel-fetch all dashboard data with individual error isolation
const [
@@ -225,8 +195,8 @@ export function createEngagementRouter(config: EngagementRoutesConfig): Router {
// POST /api/me/persona-assessment - Save persona diagnostic result
router.post('/persona-assessment', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
- const { persona, scores } = req.body;
+ const userId = req.user!.authWorkosUserId ?? req.user!.id;
+ const { persona, scores, organization_id: requestedOrgId } = req.body;
if (!persona || !VALID_PERSONAS.includes(persona)) {
return res.status(400).json({ error: 'Invalid persona', valid: VALID_PERSONAS });
@@ -244,23 +214,17 @@ export function createEngagementRouter(config: EngagementRoutesConfig): Router {
}
}
- const membershipResult = await query<{ workos_organization_id: string }>(
- `SELECT workos_organization_id FROM (
- SELECT om.workos_organization_id, 1 AS priority
- FROM organization_memberships om WHERE om.workos_user_id = $1
- UNION ALL
- SELECT u.primary_organization_id, 2 AS priority FROM users u
- WHERE u.workos_user_id = $1 AND u.primary_organization_id IS NOT NULL
- ) ranked
- ORDER BY priority, workos_organization_id
- LIMIT 1`,
- [userId]
- );
-
- const orgId = membershipResult.rows[0]?.workos_organization_id;
- if (!orgId) {
- return res.status(404).json({ error: 'No organization found for user' });
+ if (typeof requestedOrgId !== 'string' || requestedOrgId.length === 0) {
+ return res.status(400).json({ error: 'organization_selection_required', message: 'organization_id is required' });
}
+ const membership = await resolveUserOrgMembership(getWorkos(), req.user!, requestedOrgId);
+ if (!membership) return res.status(403).json({ error: 'Not authorized for the requested organization' });
+ const orgId = membership.organizationId;
+
+ // Recheck the exact credential/org context immediately before the
+ // mutation so a revocation during validation fails closed.
+ const currentMembership = await resolveUserOrgMembership(getWorkos(), req.user!, orgId);
+ if (!currentMembership) return res.status(403).json({ error: 'Organization authorization was revoked' });
await config.orgKnowledgeDb.setPersona(orgId, persona as Persona, 'diagnostic', {
set_by_user_id: userId,
diff --git a/server/src/routes/events.ts b/server/src/routes/events.ts
index cfc4f3a7a4..43ccffeaef 100644
--- a/server/src/routes/events.ts
+++ b/server/src/routes/events.ts
@@ -43,6 +43,7 @@ import { EmailPreferencesDatabase } from "../db/email-preferences-db.js";
import { isWebUserAAOAdmin } from "../addie/mcp/admin-tools.js";
import { getWorkos } from "../auth/workos-client.js";
import { resolveUserOrgMembership } from "../utils/resolve-user-org-membership.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
/**
* Validate a speakers array. Returns an error response object if invalid, or
@@ -1743,7 +1744,7 @@ export function createEventsRouter(): {
const adminEmails = process.env.ADMIN_EMAILS?.split(',').map(e => e.trim().toLowerCase()) || [];
const isAdmin = !!user && (
adminEmails.includes(user.email.toLowerCase()) ||
- await isWebUserAAOAdmin(user.id)
+ await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user))
);
if (!isAdmin) {
return res.status(404).json({
@@ -2129,7 +2130,7 @@ export function createEventsRouter(): {
// org_id is caller-controlled. Bind the purchase to the user's current,
// active membership instead of trusting the requested organization or a
// historical organization claim from their session.
- const membership = await resolveUserOrgMembership(getWorkos(), user.id, org_id);
+ const membership = await resolveUserOrgMembership(getWorkos(), user, org_id);
if (!membership || membership.organizationId !== org_id || membership.status !== "active") {
return res.status(403).json({
error: "Organization access denied",
diff --git a/server/src/routes/helpers/resolve-caller-org.ts b/server/src/routes/helpers/resolve-caller-org.ts
index ae4cc682b6..1e67d4f2f3 100644
--- a/server/src/routes/helpers/resolve-caller-org.ts
+++ b/server/src/routes/helpers/resolve-caller-org.ts
@@ -8,17 +8,18 @@
* 2. WorkOS API key (sk_* / wos_api_key_* prefixes) — server-to-server
* integrations. Validated via the existing `validateWorkOSApiKey` helper.
* 3. Sealed session — web/native app sessions whose cookie or bearer
- * unsealed in `optionalAuth`, producing `req.user`. Organization is
- * resolved via `resolvePrimaryOrganization`, which falls back to the
- * user's organization_memberships when the cached column is NULL.
+ * unsealed in `optionalAuth`, producing `req.user`. These callers must
+ * explicitly select `x-organization-id`; the selected organization is
+ * checked against the credential that authenticated the session.
*/
import type { Request } from 'express';
import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTVerifyGetKey } from 'jose';
import { isWorkOSApiKeyFormat } from '../../middleware/api-key-format.js';
import { validateWorkOSApiKey } from '../../middleware/auth.js';
-import { resolvePrimaryOrganization } from '../../db/users-db.js';
import { createLogger } from '../../logger.js';
+import { getWorkos } from '../../auth/workos-client.js';
+import { resolveUserOrgMembership } from '../../utils/resolve-user-org-membership.js';
const logger = createLogger('resolve-caller-org');
@@ -42,23 +43,18 @@ function jwksForIssuer(iss: string): { jwks: JWTVerifyGetKey; clientId: string }
return { jwks, clientId };
}
-export type MinimalReq = Pick & { user?: { id?: string } };
+export type MinimalReq = Pick & {
+ user?: { id?: string; authWorkosUserId?: string };
+};
-/**
- * Extract and verify a WorkOS OIDC access token. Returns the `org_id` claim
- * on success, or `null` for API keys, sealed sessions, missing tokens, or
- * failed verification. Never throws.
- */
-export async function orgIdFromBearerJwt(req: MinimalReq): Promise {
+type VerifiedBearerOrg = { organizationId: string; userId: string };
+
+async function verifiedOrgFromBearerJwt(req: MinimalReq): Promise {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) return null;
const token = auth.slice(7);
- if (isWorkOSApiKeyFormat(token)) return null;
- // Sealed sessions are not JWTs — skip verification to avoid JWKS noise.
- if (!token.startsWith('eyJ')) return null;
+ if (isWorkOSApiKeyFormat(token) || !token.startsWith('eyJ')) return null;
try {
- // Decode unverified to pick the right JWKS. `jwtVerify` below re-checks
- // the signature and pins `issuer`, so an attacker can't swap iss.
const unverified = decodeJwt(token);
if (typeof unverified.iss !== 'string') {
logger.warn('bearer JWT rejected: missing iss claim');
@@ -70,37 +66,87 @@ export async function orgIdFromBearerJwt(req: MinimalReq): Promise {
+ return (await verifiedOrgFromBearerJwt(req))?.organizationId ?? null;
+}
+
+export type CallerOrganizationResolution =
+ | { status: 'authorized'; organizationId: string }
+ | { status: 'missing' }
+ | { status: 'forbidden' };
+
/**
* Resolve the caller's organization via (in order) OIDC JWT → API key →
- * sealed-session user lookup. Returns `null` when no auth shape resolves.
+ * explicitly selected sealed-session organization. The discriminated result
+ * keeps a missing selection distinct from an unauthorized explicit one.
*/
-export async function resolveCallerOrgId(req: MinimalReq): Promise {
- const jwtOrg = await orgIdFromBearerJwt(req);
- if (jwtOrg) return jwtOrg;
+export async function resolveCallerOrganization(req: MinimalReq): Promise {
+ const jwtOrg = await verifiedOrgFromBearerJwt(req);
+ if (jwtOrg) {
+ try {
+ const membership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: jwtOrg.userId },
+ jwtOrg.organizationId,
+ );
+ return membership
+ ? { status: 'authorized', organizationId: membership.organizationId }
+ : { status: 'forbidden' };
+ } catch (err) {
+ logger.warn({ err, selectedOrganizationId: jwtOrg.organizationId }, 'bearer organization membership revalidation failed');
+ return { status: 'forbidden' };
+ }
+ }
const apiKey = await validateWorkOSApiKey(req as Request);
- if (apiKey) return apiKey.organizationId;
+ if (apiKey) return { status: 'authorized', organizationId: apiKey.organizationId };
+
+ const selectedHeader = req.headers['x-organization-id'];
+ const selectedOrganizationId = typeof selectedHeader === 'string' && selectedHeader.trim()
+ ? selectedHeader.trim()
+ : null;
- if (req.user?.id) {
+ if (req.user?.id && selectedOrganizationId) {
try {
- return await resolvePrimaryOrganization(req.user.id);
+ const membership = await resolveUserOrgMembership(
+ getWorkos(),
+ { id: req.user.id, authWorkosUserId: req.user.authWorkosUserId },
+ selectedOrganizationId,
+ );
+ return membership
+ ? { status: 'authorized', organizationId: membership.organizationId }
+ : { status: 'forbidden' };
} catch (err) {
- logger.warn({ err, userId: req.user.id }, 'caller org resolution failed — falling back to public-only');
+ logger.warn(
+ { err, selectedOrganizationId },
+ 'caller org resolution failed — falling back to public-only',
+ );
+ return { status: 'forbidden' };
}
}
- return null;
+ return { status: 'missing' };
+}
+
+export async function resolveCallerOrgId(req: MinimalReq): Promise {
+ const resolution = await resolveCallerOrganization(req);
+ return resolution.status === 'authorized' ? resolution.organizationId : null;
}
/** Test hook: reset the per-client JWKS cache. */
diff --git a/server/src/routes/invites.ts b/server/src/routes/invites.ts
index 1ecbadf86c..290bb59877 100644
--- a/server/src/routes/invites.ts
+++ b/server/src/routes/invites.ts
@@ -31,6 +31,7 @@ import {
} from '../billing/active-subscription-guard.js';
import { withOrgIntakeLock } from '../billing/org-intake-lock.js';
import * as referralDb from '../db/referral-codes-db.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
const logger = createLogger('invites-routes');
const orgDb = new OrganizationDatabase();
@@ -184,26 +185,27 @@ export function createInvitesRouter(): Router {
// confirmed the person is the right one. This closes the "anyone with a
// leaked link becomes owner of an empty prospect org" escalation path.
if (workos) {
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
try {
const existing = await workos.userManagement.listOrganizationMemberships({
- userId: user.id,
+ userId: authorizationUserId,
organizationId: org.workos_organization_id,
});
if (!existing.data || existing.data.length === 0) {
try {
await workos.userManagement.createOrganizationMembership({
- userId: user.id,
+ userId: authorizationUserId,
organizationId: org.workos_organization_id,
roleSlug: 'member',
});
logger.info(
- { userId: user.id, orgId: org.workos_organization_id },
+ { userId: authorizationUserId, orgId: org.workos_organization_id },
'Added user to org via invite accept (role: member)'
);
} catch (membershipErr) {
const code = (membershipErr as { code?: string }).code;
if (code === 'organization_membership_already_exists') {
- logger.info({ userId: user.id, orgId: org.workos_organization_id },
+ logger.info({ userId: authorizationUserId, orgId: org.workos_organization_id },
'Membership already exists (race) — continuing');
} else {
throw membershipErr;
@@ -211,7 +213,7 @@ export function createInvitesRouter(): Router {
}
}
} catch (err) {
- logger.error({ err, userId: user.id, orgId: org.workos_organization_id },
+ logger.error({ err, userId: authorizationUserId, orgId: org.workos_organization_id },
'Failed to ensure org membership on invite accept');
return res.status(500).json({
error: 'Could not add you to the organization',
diff --git a/server/src/routes/me-brand-claim-suggestion.ts b/server/src/routes/me-brand-claim-suggestion.ts
index 1b44ea50d7..2e07b37029 100644
--- a/server/src/routes/me-brand-claim-suggestion.ts
+++ b/server/src/routes/me-brand-claim-suggestion.ts
@@ -18,6 +18,7 @@ import {
getSuggestionForDomain,
nudgeKey,
} from '../services/brand-claim-suggestion.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
import { getUserEmailById } from '../db/users-db.js';
import { recordNudgeDismissal } from '../db/user-nudges-db.js';
import { canonicalizeBrandDomain, assertValidBrandDomain } from '../services/identifier-normalization.js';
@@ -57,8 +58,9 @@ export function createBrandClaimSuggestionRouter(config: { brandDb: BrandDatabas
// restrict the suggestion to a specific brand.
router.get('/brand-claim-suggestion', requireAuth, async (req: Request, res: Response) => {
try {
- const user = req.user as { id: string; email?: string };
- const email = user.email ?? (await getUserEmailById(user.id));
+ const user = req.user as { id: string; authWorkosUserId?: string; email?: string };
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
+ const email = user.email ?? (await getUserEmailById(authorizationUserId));
if (!email) {
return res.json({ suggestion: null });
}
@@ -70,9 +72,9 @@ export function createBrandClaimSuggestionRouter(config: { brandDb: BrandDatabas
if (!parsed.ok) {
return res.status(400).json({ error: parsed.error });
}
- suggestion = await getSuggestionForDomain(user.id, email, parsed.domain, { brandDb });
+ suggestion = await getSuggestionForDomain(authorizationUserId, email, parsed.domain, { brandDb });
} else {
- suggestion = await getBrandClaimSuggestionForUser(user.id, email, { brandDb });
+ suggestion = await getBrandClaimSuggestionForUser(authorizationUserId, email, { brandDb });
}
return res.json({ suggestion });
} catch (error) {
diff --git a/server/src/routes/me-organization-domains.ts b/server/src/routes/me-organization-domains.ts
index 55d26c79d8..7ff2cd666a 100644
--- a/server/src/routes/me-organization-domains.ts
+++ b/server/src/routes/me-organization-domains.ts
@@ -14,7 +14,6 @@ import { Router } from 'express';
import type { WorkOS } from '@workos-inc/node';
import { createLogger } from '../logger.js';
import { requireAuth } from '../middleware/auth.js';
-import { resolvePrimaryOrganization } from '../db/users-db.js';
import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
import { getPool } from '../db/client.js';
import {
@@ -96,24 +95,23 @@ export function createMeOrganizationDomainsRouter(
? req.query.org
: null;
- if (requested) {
- const membership = await resolveUserOrgMembership(workos, req.user!.id, requested);
- if (!membership) {
- res.status(403).json({
- error: 'Not authorized',
- message: 'User is not a member of the requested organization',
- });
- return null;
- }
- return requested;
+ if (!requested) {
+ res.status(400).json({
+ error: 'organization_selection_required',
+ message: 'org query parameter is required',
+ });
+ return null;
}
- const primary = await resolvePrimaryOrganization(req.user!.id);
- if (!primary) {
- res.status(400).json({ error: 'No organization associated with this account' });
+ const membership = await resolveUserOrgMembership(workos, req.user!, requested);
+ if (!membership) {
+ res.status(403).json({
+ error: 'Not authorized',
+ message: 'User is not a member of the requested organization',
+ });
return null;
}
- return primary;
+ return membership.organizationId;
}
// GET /api/me/organization/domains — list verified domains for the caller's org.
@@ -176,7 +174,7 @@ export function createMeOrganizationDomainsRouter(
if (!orgId) return;
// Role gate: owners/admins only. Members can read but not change.
- const membership = await resolveUserOrgMembership(workos, req.user!.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, req.user!, orgId);
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
return res.status(403).json({
error: 'Not authorized',
@@ -204,6 +202,10 @@ export function createMeOrganizationDomainsRouter(
// Admin-imported / manual rows aren't DNS-proof-of-control claims and
// pending rows are pre-verification — the source allowlist below is
// the security gate.
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await setPrimaryDomain({
orgId,
domain: normalizedDomain,
@@ -262,7 +264,7 @@ export function createMeOrganizationDomainsRouter(
const orgId = await resolveTargetOrgId(req, res);
if (!orgId) return;
- const membership = await resolveUserOrgMembership(workos, req.user!.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, req.user!, orgId);
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
return res.status(403).json({
error: 'Not authorized',
@@ -314,6 +316,10 @@ export function createMeOrganizationDomainsRouter(
// WorkOS confirms DNS proof for THIS org. That's the documented
// contract for transfer-on-conflict, so upsertWorkosDomain is safe
// even if a cross-org local row exists.
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await upsertWorkosDomain({ orgId, domain: normalizedDomain, verified: true });
invalidateMemberContextCache();
return res.json({
@@ -336,6 +342,10 @@ export function createMeOrganizationDomainsRouter(
message: 'This domain is already linked to another organization.',
});
}
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await linkDomain({
orgId,
domain: normalizedDomain,
@@ -355,6 +365,10 @@ export function createMeOrganizationDomainsRouter(
// Broken state: pending but no token. Delete + recreate so the user
// gets a usable record. Same pattern as brand-claim.ts.
try {
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await workos.organizationDomains.deleteOrganizationDomain(existingEntry.id);
} catch (err) {
logger.error({ err, orgId, domain: normalizedDomain }, 'Failed to delete broken pending domain');
@@ -382,6 +396,10 @@ export function createMeOrganizationDomainsRouter(
}
try {
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const created = await workos.organizationDomains.createOrganizationDomain({
organizationId: orgId,
domain: normalizedDomain,
@@ -449,7 +467,7 @@ export function createMeOrganizationDomainsRouter(
const orgId = await resolveTargetOrgId(req, res);
if (!orgId) return;
- const membership = await resolveUserOrgMembership(workos, req.user!.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, req.user!, orgId);
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
return res.status(403).json({
error: 'Not authorized',
@@ -509,6 +527,10 @@ export function createMeOrganizationDomainsRouter(
let verifiedState = stateStr;
if (!alreadyVerified) {
try {
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const verified = await workos.organizationDomains.verifyOrganizationDomain(entry.id);
verifiedState = String(verified.state);
} catch (err: any) {
@@ -535,6 +557,10 @@ export function createMeOrganizationDomainsRouter(
});
}
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await upsertWorkosDomain({
orgId,
domain: normalizedDomain,
diff --git a/server/src/routes/member-agents.ts b/server/src/routes/member-agents.ts
index d31abe6c71..4160daef55 100644
--- a/server/src/routes/member-agents.ts
+++ b/server/src/routes/member-agents.ts
@@ -10,8 +10,8 @@
* bulk PUT path.
*
* Auth: WorkOS session OR Bearer API key (`requireAuth` handles both).
- * Multi-org callers may pass `?org=…` to target a non-primary org;
- * verification goes through `resolveUserOrgMembership`.
+ * Every caller must pass `?org=…`; verification uses the exact authenticated
+ * credential through `resolveUserOrgMembership`.
*
* Concurrency: writes go through a `SELECT … FOR UPDATE` on
* `member_profiles` so two parallel POSTs/PATCHes/DELETEs serialize
@@ -30,7 +30,6 @@ import {
hasApiAccess,
resolveMembershipTier,
} from '../db/organization-db.js';
-import { resolvePrimaryOrganization } from '../db/users-db.js';
import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
import { getPool } from '../db/client.js';
import { canonicalizeAgentUrl } from '../db/publisher-db.js';
@@ -38,9 +37,6 @@ import type { AgentConfig } from '../types.js';
import { isValidAgentType } from '../types.js';
import { resolveAgentTypes, logResolvedTypeChanges } from './member-profiles.js';
import { ensureMemberProfileExists } from '../services/member-profile-autopublish.js';
-import { performCreateOrganization } from '../services/organization-bootstrap.js';
-import { isDevModeEnabled, getDevUser } from '../middleware/auth.js';
-import { isFreeEmail, getCompanyDomain } from '../utils/email-domain.js';
import {
gateAgentVisibilityForCaller,
type VisibilityWarning,
@@ -61,10 +57,9 @@ export interface MemberAgentsRouterConfig {
memberDb: MemberDatabase;
orgDb: OrganizationDatabase;
/**
- * WorkOS client. Required when callers may pass `?org=` to target a
- * non-primary organization; verification of membership against that org
- * goes through WorkOS. Pass `null` only in dev/test where the resolver
- * can short-circuit on the local memberships cache.
+ * WorkOS client used to verify the exact authenticated credential against
+ * the explicitly selected organization. Pass `null` only in dev/test where
+ * the resolver can short-circuit on the local memberships cache.
*/
workos: WorkOS | null;
invalidateMemberContextCache: () => void;
@@ -96,11 +91,9 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
const router = Router();
/**
- * Pick the org to act on. Honors `?org=…` for multi-org callers (matching
- * the `PUT /api/me/member-profile` pattern); falls back to the user's
- * primary org when not supplied. Returns null and writes the error
- * response when the caller has no associated org or asks for an org
- * they're not a member of.
+ * Pick the explicitly selected org and verify the authenticated credential
+ * is an active member. Identity linkage and a canonical/primary org are not
+ * organization authorization inputs.
*/
async function resolveOrgOrSendError(
req: import('express').Request,
@@ -111,130 +104,27 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
? req.query.org
: null;
- if (requestedOrgId) {
- const membership = await resolveUserOrgMembership(
- workos,
- req.user!.id,
- requestedOrgId,
- );
- if (!membership) {
- res.status(403).json({
- error: 'Not authorized',
- message: 'User is not a member of the requested organization',
- });
- return null;
- }
- return requestedOrgId;
- }
-
- const orgId = await resolvePrimaryOrganization(req.user!.id);
- if (!orgId) {
- res.status(400).json({ error: 'No organization associated with this account' });
+ if (!requestedOrgId) {
+ res.status(400).json({
+ error: 'organization_selection_required',
+ message: 'org query parameter is required',
+ });
return null;
}
- return orgId;
- }
-
- /**
- * Resolve the caller's primary org, auto-bootstrapping a fresh org if the
- * caller has zero memberships. The auto-bootstrap path is the
- * "true one-call storefront" experience: a third-party app holding only
- * a user's OAuth token can `POST /api/me/agents` once and have the org,
- * member profile, and agent registration all materialize.
- *
- * `resolvePrimaryOrganization` already derives from `organization_memberships`
- * when `users.primary_organization_id` is null, so a `null` return there
- * means the user truly has zero memberships — that's the only signal we
- * need to gate auto-bootstrap.
- *
- * Returns null and writes the error response on failure.
- */
- async function resolveOrAutoBootstrapOrg(
- req: import('express').Request,
- res: import('express').Response,
- ): Promise<{ orgId: string; orgAutoCreated: boolean } | null> {
- const requestedOrgId =
- typeof req.query.org === 'string' && req.query.org.length > 0
- ? req.query.org
- : null;
- if (requestedOrgId) {
- const orgId = await resolveOrgOrSendError(req, res);
- return orgId ? { orgId, orgAutoCreated: false } : null;
- }
-
- const primaryOrgId = await resolvePrimaryOrganization(req.user!.id);
- if (primaryOrgId) return { orgId: primaryOrgId, orgAutoCreated: false };
-
- // Fresh-user path: zero memberships → auto-bootstrap.
- const user = req.user!;
- const isPersonal = isFreeEmail(user.email);
- const orgName = deriveDefaultOrgName(user, isPersonal);
-
- const outcome = await performCreateOrganization(
- {
- user: { id: user.id, email: user.email },
- organization_name: orgName,
- is_personal: isPersonal,
- // company_type / revenue_tier / marketing_opt_in: auto-bootstrap has
- // no UI to capture these. Caller can patch the org later.
- isDevUser: !!(isDevModeEnabled() && getDevUser(req)),
- requestContext: {
- ip: req.ip || (req.headers['x-forwarded-for'] as string) || 'unknown',
- userAgent: (req.headers['user-agent'] as string) || 'unknown',
- },
- },
- { workos: workos!, orgDb: config.orgDb },
+ const membership = await resolveUserOrgMembership(
+ workos,
+ req.user!,
+ requestedOrgId,
);
-
- if (outcome.kind === 'created' || outcome.kind === 'adopted') {
- return { orgId: outcome.orgId, orgAutoCreated: true };
- }
-
- // Surface the auto-bootstrap failure honestly. None of these should
- // hit a fresh user in normal flow, but mapping them keeps the contract
- // legible.
- if (outcome.kind === 'domain_taken') {
- res.status(409).json({
- error: 'Organization exists',
- message: `An organization for ${outcome.domain} already exists: "${outcome.existingOrgName}". Use the join-request flow instead of registering an agent here.`,
- existing_org_id: outcome.existingOrgId,
- existing_org_name: outcome.existingOrgName,
+ if (!membership) {
+ res.status(403).json({
+ error: 'Not authorized',
+ message: 'User is not a member of the requested organization',
});
return null;
}
- if (outcome.kind === 'corporate_email_required') {
- // Shouldn't happen — `is_personal` is derived from `isFreeEmail`.
- res.status(400).json({ error: 'Corporate email required' });
- return null;
- }
- res.status(400).json({
- error: 'Auto-bootstrap failed',
- message: `Could not auto-create an organization for this user (${outcome.kind}). Call POST /api/organizations explicitly.`,
- });
- return null;
- }
-
- function deriveDefaultOrgName(
- user: { email: string; firstName?: string; lastName?: string },
- isPersonal: boolean,
- ): string {
- if (isPersonal) {
- const suffix = "'s Workspace";
- const fullName = [user.firstName, user.lastName]
- .filter(Boolean)
- .join(' ')
- .normalize('NFC')
- .replace(/[^\p{L}\p{N} \-_'.‘’]/gu, '')
- .replace(/\s+/g, ' ')
- .trim()
- .replace(/^[^\p{L}\p{N}]+/u, '')
- .substring(0, 100 - suffix.length);
- return fullName ? `${fullName}${suffix}` : 'Personal Workspace';
- }
- const domain = getCompanyDomain(user.email) || '';
- const root = domain.split('.')[0] || 'Organization';
- return root.charAt(0).toUpperCase() + root.slice(1);
+ return membership.organizationId;
}
function isParseableUrl(value: string): boolean {
@@ -259,6 +149,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
*/
async function applyMemberAgentMutation(
orgId: string,
+ principal: import('../auth/organization-principal.js').OrgAuthorizationPrincipal,
mutate: (existing: AgentConfig[]) => RouteResult | Promise,
): Promise<{ status: number; body: Record | null }> {
const pool = getPool();
@@ -295,6 +186,16 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
const callerHasApi = hasApiAccess(resolveMembershipTier(org));
const { agents: gated, warnings } = gateAgentVisibilityForCaller(result.next, callerHasApi);
const typed = (await resolveAgentTypes(gated)) as AgentConfig[];
+
+ // Close the validation-to-write revocation window. This runs after the
+ // profile row lock and immediately before the first persistent write.
+ if (!await resolveUserOrgMembership(workos, principal, orgId)) {
+ await client.query('ROLLBACK');
+ return {
+ status: 403,
+ body: { error: 'Organization authorization was revoked' },
+ };
+ }
await logResolvedTypeChanges(gated, typed, orgId);
// Stage 2 of #4159 dropped the primary_brand_domain column; this
@@ -404,9 +305,8 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
// POST /api/me/agents — register or update a single agent (idempotent on url)
router.post('/', requireAuth, brandCreationRateLimiter, async (req, res) => {
try {
- const resolved = await resolveOrAutoBootstrapOrg(req, res);
- if (!resolved) return;
- const { orgId, orgAutoCreated } = resolved;
+ const orgId = await resolveOrgOrSendError(req, res);
+ if (!orgId) return;
const body = (req.body ?? {}) as Partial;
if (typeof body.url !== 'string' || body.url.length === 0) {
@@ -466,6 +366,9 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
// private-by-default invariant stay consistent across surfaces.
let profileAutoCreated = false;
try {
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const org = await config.orgDb.getOrganization(orgId);
const orgName = org?.name?.trim();
if (orgName) {
@@ -483,7 +386,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
logger.warn({ err, orgId }, 'POST /api/me/agents profile auto-bootstrap failed; falling through');
}
- const result = await applyMemberAgentMutation(orgId, (existing) => {
+ const result = await applyMemberAgentMutation(orgId, req.user!, (existing) => {
// Match existing rows in canonical form so a legacy non-canonical
// entry (pre-#3573) gets upgraded in place rather than duplicated.
const idx = existing.findIndex((a) => (canonicalizeAgentUrl(a.url) ?? a.url) === targetUrl);
@@ -497,9 +400,11 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
status: isUpdate ? 200 : 201,
};
});
+ if (result.status < 200 || result.status >= 300) {
+ return res.status(result.status).json(result.body ?? {});
+ }
const shaped = shapeWriteBody(result.body, targetUrl);
if (result.status >= 200 && result.status < 300) {
- if (orgAutoCreated) shaped.org_auto_created = true;
if (profileAutoCreated) shaped.profile_auto_created = true;
}
return res.status(result.status).json(shaped);
@@ -549,7 +454,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
}
}
- const result = await applyMemberAgentMutation(orgId, (existing) => {
+ const result = await applyMemberAgentMutation(orgId, req.user!, (existing) => {
// Canonical-form match so a legacy non-canonical row is still found.
const idx = existing.findIndex((a) => (canonicalizeAgentUrl(a.url) ?? a.url) === targetUrl);
if (idx === -1) {
@@ -564,6 +469,9 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
);
return { kind: 'commit' as const, next, status: 200 };
});
+ if (result.status < 200 || result.status >= 300) {
+ return res.status(result.status).json(result.body ?? {});
+ }
return res.status(result.status).json(shapeWriteBody(result.body, targetUrl));
} catch (err) {
logger.error({ err }, 'PATCH /api/me/agents/:url failed');
@@ -585,7 +493,7 @@ export function createMemberAgentsRouter(config: MemberAgentsRouterConfig): Rout
return res.status(400).json({ error: 'url is not a valid agent URL' });
}
- const result = await applyMemberAgentMutation(orgId, (existing) => {
+ const result = await applyMemberAgentMutation(orgId, req.user!, (existing) => {
const idx = existing.findIndex((a) => (canonicalizeAgentUrl(a.url) ?? a.url) === targetUrl);
if (idx === -1) {
return {
diff --git a/server/src/routes/member-profiles.ts b/server/src/routes/member-profiles.ts
index 9b778a3851..f0a41b6ae0 100644
--- a/server/src/routes/member-profiles.ts
+++ b/server/src/routes/member-profiles.ts
@@ -23,8 +23,6 @@ import { OrganizationDatabase, hasApiAccess, readMembershipTierFromClient, resol
import { canonicalizeAgentUrl } from "../db/publisher-db.js";
import { OrgKnowledgeDatabase } from "../db/org-knowledge-db.js";
import { linkDomain } from "../db/organization-domains-db.js";
-import { autoLinkByVerifiedDomain } from "../db/membership-db.js";
-import { resolvePrimaryOrganization } from "../db/users-db.js";
import { AAO_HOST } from "../config/aao.js";
import { COMPANY_TYPE_VALUES } from "../config/company-types.js";
import { getCompanyDomain } from "../utils/email-domain.js";
@@ -57,6 +55,7 @@ import { canonicalizeBrandDomain } from "../services/identifier-normalization.js
import { issueDomainChallenge, verifyDomainChallenge } from "../services/brand-claim.js";
import { resolveUserRole } from "../utils/resolve-user-role.js";
import { resolveUserOrgMembership } from "../utils/resolve-user-org-membership.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
import { updateBrandIdentity, BrandIdentityError } from "../services/brand-identity.js";
import { createEscalation } from "../db/escalation-db.js";
import { insertTypeReclassification } from "../db/type-reclassification-log-db.js";
@@ -81,17 +80,8 @@ export function selectedOrganizationMembership<
const activeMemberships = memberships.filter(
(membership) => membership.status === 'active',
);
- if (requestedOrgId) {
- return activeMemberships.find((membership) => membership.organizationId === requestedOrgId) ?? null;
- }
- return activeMemberships[0] ?? null;
-}
-
-function isOrganizationAdminOrOwner(
- membership: { role?: { slug?: string | null } | null },
-): boolean {
- const role = membership.role?.slug ?? 'member';
- return role === 'admin' || role === 'owner';
+ if (!requestedOrgId) return null;
+ return activeMemberships.find((membership) => membership.organizationId === requestedOrgId) ?? null;
}
/**
@@ -314,7 +304,11 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
async function handleBootstrapMemberProfile(req: any, res: any, startTime: number) {
try {
const user = req.user!;
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
const requestedOrgId = req.query.org as string | undefined;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'The org query parameter is required' });
+ }
const {
organization_name,
company_type,
@@ -411,16 +405,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
targetOrgId = requestedOrgId!;
} else {
- let memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
- const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
- if (linked) {
- memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
- }
- const selectedMembership = selectedOrganizationMembership(memberships.data, requestedOrgId);
+ const selectedMembership = await resolveUserOrgMembership(workos!, user, requestedOrgId);
if (!selectedMembership) {
return res.status(requestedOrgId ? 403 : 404).json({
error: requestedOrgId ? 'Not authorized' : 'No organization',
@@ -477,6 +462,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// matching email domain could clobber org-level fields a workspace
// admin had already curated.
const existingOrg = await orgDb.getOrganization(targetOrgId);
+ if (!isDevUserProfile && !await resolveUserOrgMembership(workos!, user, targetOrgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const orgUpdates: Record = {};
const metadataIgnoredApiFields: string[] = [];
// Map DB column → public API field name. Callers know about
@@ -505,6 +493,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
considerField(revenue_tier, 'revenue_tier');
considerField(membership_tier, 'membership_tier');
if (Object.keys(orgUpdates).length > 0) {
+ if (!isDevUserProfile && !await resolveUserOrgMembership(workos!, user, targetOrgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
try {
await orgDb.updateOrganization(targetOrgId, orgUpdates);
} catch (err) {
@@ -522,6 +513,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// on an admin-resolvable issue.
let domainConflictOrgId: string | null = null;
try {
+ if (!isDevUserProfile && !await resolveUserOrgMembership(workos!, user, targetOrgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await linkDomain({
orgId: targetOrgId,
domain: corporateDomain,
@@ -536,6 +530,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const slug = await pickAvailableSlug(trimmedName);
+ if (!isDevUserProfile && !await resolveUserOrgMembership(workos!, user, targetOrgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const profile = await memberDb.createProfile({
workos_organization_id: targetOrgId,
display_name: trimmedName,
@@ -552,7 +549,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
if (typeof marketing_opt_in === 'boolean') {
try {
await emailPrefsDb.setMarketingOptInIfNotSet({
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
email: user.email,
optIn: marketing_opt_in,
});
@@ -576,7 +573,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const userAgent = (req.headers['user-agent'] as string) || 'unknown';
if (tosAgreement) {
await orgDb.recordUserAgreementAcceptance({
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
email: user.email,
agreement_type: 'terms_of_service',
agreement_version: tosAgreement.version,
@@ -587,7 +584,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
if (privacyAgreement) {
await orgDb.recordUserAgreementAcceptance({
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
email: user.email,
agreement_type: 'privacy_policy',
agreement_version: privacyAgreement.version,
@@ -607,7 +604,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
try {
await orgDb.recordAuditLog({
workos_organization_id: targetOrgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_profile_bootstrapped',
resource_type: 'member_profile',
resource_id: profile.id,
@@ -678,6 +675,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
try {
const user = req.user!;
const requestedOrgId = req.query.org as string | undefined;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'The org query parameter is required' });
+ }
// Dev mode: handle dev organizations without WorkOS
const devUser = isDevModeEnabled() ? Object.values(DEV_USERS).find(du => du.id === user.id) : null;
@@ -714,20 +714,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
// Get user's organization memberships
- let memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
-
- // Auto-link any verified-domain orgs the user isn't yet in.
- // Helper short-circuits when the user is already a cached member.
- const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
- if (linked) {
- memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
- }
-
- const selectedMembership = selectedOrganizationMembership(memberships.data, requestedOrgId);
+ const selectedMembership = await resolveUserOrgMembership(workos!, user, requestedOrgId);
if (!selectedMembership) {
logger.info({ userId: user.id, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile: no organization');
return res.status(requestedOrgId ? 403 : 404).json({
@@ -793,7 +780,11 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
try {
const user = req.user!;
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
const requestedOrgId = req.query.org as string | undefined;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'The org query parameter is required' });
+ }
const {
display_name,
slug,
@@ -868,21 +859,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
targetOrgId = requestedOrgId!;
logger.info({ userId: user.id, orgId: targetOrgId }, 'POST /api/me/member-profile: dev mode bypass');
} else {
- // Get user's organization memberships
- let memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
-
- // Auto-link any verified-domain orgs the user isn't yet in.
- // Helper short-circuits when the user is already a cached member.
- const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
- if (linked) {
- memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
- }
-
- const selectedMembership = selectedOrganizationMembership(memberships.data, requestedOrgId);
+ const selectedMembership = await resolveUserOrgMembership(workos!, user, requestedOrgId);
if (!selectedMembership) {
return res.status(requestedOrgId ? 403 : 404).json({
error: requestedOrgId ? 'Not authorized' : 'No organization',
@@ -891,7 +868,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
: 'User has no active organization membership',
});
}
- callerRole = selectedMembership.role?.slug || 'member';
+ callerRole = selectedMembership.role;
targetOrgId = selectedMembership.organizationId;
}
@@ -1065,6 +1042,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
}
+ if (!isDevUserProfile && !await resolveUserOrgMembership(workos!, user, targetOrgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const profile = await memberDb.createProfile({
workos_organization_id: targetOrgId,
display_name,
@@ -1087,7 +1067,6 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// Write user-reported org knowledge (fire-and-forget)
const knowledgeWrites: Promise[] = [];
- const userId = user.id;
if (tagline) {
knowledgeWrites.push(orgKnowledgeDb.setKnowledge({
@@ -1096,7 +1075,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
value: tagline,
source: 'user_reported',
confidence: 'high',
- set_by_user_id: userId,
+ set_by_user_id: actorCredentialId,
set_by_description: 'Member profile creation',
}));
}
@@ -1108,7 +1087,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
value: description,
source: 'user_reported',
confidence: 'high',
- set_by_user_id: userId,
+ set_by_user_id: actorCredentialId,
set_by_description: 'Member profile creation',
}));
}
@@ -1120,7 +1099,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
value: offerings.join(', '),
source: 'user_reported',
confidence: 'high',
- set_by_user_id: userId,
+ set_by_user_id: actorCredentialId,
set_by_description: 'Member profile offerings',
}));
}
@@ -1132,7 +1111,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
// Record publish event if the profile was created already public
- await recordProfilePublishedIfNeeded(targetOrgId, false, profile.is_public, user.id);
+ await recordProfilePublishedIfNeeded(targetOrgId, false, profile.is_public, actorCredentialId);
// Invalidate Addie's member context cache - organization profile created
invalidateMemberContextCache();
@@ -1157,7 +1136,11 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
logger.info({ userId: req.user?.id }, 'PUT /api/me/member-profile started');
try {
const user = req.user!;
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
const requestedOrgId = req.query.org as string | undefined;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'The org query parameter is required' });
+ }
const updates = { ...(req.body as Record) };
const invalidProfileUrlField = validateMemberProfileUrlFields(updates);
@@ -1184,21 +1167,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
targetOrgId = requestedOrgId!;
logger.info({ userId: user.id, orgId: targetOrgId }, 'PUT /api/me/member-profile: dev mode bypass');
} else {
- // Get user's organization memberships
- let memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
-
- // Auto-link any verified-domain orgs the user isn't yet in.
- // Helper short-circuits when the user is already a cached member.
- const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email);
- if (linked) {
- memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
- }
-
- const selectedMembership = selectedOrganizationMembership(memberships.data, requestedOrgId);
+ const selectedMembership = await resolveUserOrgMembership(workos!, user, requestedOrgId);
if (!selectedMembership) {
return res.status(403).json({
error: 'Not authorized',
@@ -1206,7 +1175,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
});
}
targetOrgId = selectedMembership.organizationId;
- callerCanManageVisibility = isOrganizationAdminOrOwner(selectedMembership);
+ callerCanManageVisibility = selectedMembership.role === 'admin' || selectedMembership.role === 'owner';
}
// Check if profile exists
@@ -1402,6 +1371,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
}
+ if (!isDevUserProfile && !await resolveUserOrgMembership(workos!, user, targetOrgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const profile = await memberDb.updateProfileByOrgId(targetOrgId, updates);
// Trigger crawl for new/updated publisher domains (fire-and-forget)
@@ -1430,7 +1402,6 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// Write user-reported org knowledge (fire-and-forget)
const knowledgeWrites: Promise[] = [];
- const userId = user.id;
if (typeof updates.tagline === 'string' && updates.tagline) {
knowledgeWrites.push(orgKnowledgeDb.setKnowledge({
@@ -1439,7 +1410,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
value: updates.tagline,
source: 'user_reported',
confidence: 'high',
- set_by_user_id: userId,
+ set_by_user_id: actorCredentialId,
set_by_description: 'Member profile update',
}));
}
@@ -1451,7 +1422,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
value: updates.description,
source: 'user_reported',
confidence: 'high',
- set_by_user_id: userId,
+ set_by_user_id: actorCredentialId,
set_by_description: 'Member profile update',
}));
}
@@ -1463,7 +1434,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
value: updates.offerings.join(', '),
source: 'user_reported',
confidence: 'high',
- set_by_user_id: userId,
+ set_by_user_id: actorCredentialId,
set_by_description: 'Member profile offerings',
}));
}
@@ -1479,7 +1450,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
targetOrgId,
existingProfile.is_public,
profile?.is_public,
- user.id
+ actorCredentialId
);
// Invalidate Addie's member context cache - organization profile updated
@@ -1812,32 +1783,31 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
/**
- * Resolve the URL-selected organization for the authenticated user, falling
- * back to their primary organization when omitted. Returns null when the
- * response has already been sent.
+ * Resolve the explicitly URL-selected organization for the authenticated
+ * credential. Canonical identity and primary-organization pointers are not
+ * authorization inputs.
*/
async function resolveUserOrgId(req: any, res: any): Promise {
const requestedOrgId = typeof req.query?.org === 'string' && req.query.org.length > 0
? req.query.org
: null;
- if (requestedOrgId) {
- const membership = await resolveUserOrgMembership(workos, req.user!.id, requestedOrgId);
- if (!membership) {
- res.status(403).json({
- error: 'Not authorized',
- message: 'User is not a member of the requested organization',
- });
- return null;
- }
- return requestedOrgId;
+ if (!requestedOrgId) {
+ res.status(400).json({
+ error: 'organization_selection_required',
+ message: 'org query parameter is required',
+ });
+ return null;
}
- const orgId = await resolvePrimaryOrganization(req.user!.id);
- if (!orgId) {
- res.status(400).json({ error: 'No organization associated' });
+ const membership = await resolveUserOrgMembership(workos, req.user!, requestedOrgId);
+ if (!membership) {
+ res.status(403).json({
+ error: 'Not authorized',
+ message: 'User is not a member of the requested organization',
+ });
return null;
}
- return orgId;
+ return membership.organizationId;
}
/**
@@ -1870,9 +1840,12 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const orgId = await resolveUserOrgId(req, res);
if (!orgId) return;
if (!(await requireApiAccessTier(orgId, res))) return;
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await applyAgentVisibility(orgId, index, 'public', {
- user_id: req.user!.id,
+ user_id: getOrganizationAuthorizationUserId(req.user!),
email: req.user!.email,
name: req.user!.firstName ? `${req.user!.firstName} ${req.user!.lastName || ''}`.trim() : undefined,
});
@@ -1891,9 +1864,12 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const orgId = await resolveUserOrgId(req, res);
if (!orgId) return;
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await applyAgentVisibility(orgId, index, 'private', {
- user_id: req.user!.id,
+ user_id: getOrganizationAuthorizationUserId(req.user!),
email: req.user!.email,
name: req.user!.firstName ? `${req.user!.firstName} ${req.user!.lastName || ''}`.trim() : undefined,
});
@@ -1921,9 +1897,12 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const orgId = await resolveUserOrgId(req, res);
if (!orgId) return;
if (target === 'public' && !(await requireApiAccessTier(orgId, res))) return;
+ if (!await resolveUserOrgMembership(workos, req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await applyAgentVisibility(orgId, index, target, {
- user_id: req.user!.id,
+ user_id: getOrganizationAuthorizationUserId(req.user!),
email: req.user!.email,
name: req.user!.firstName ? `${req.user!.firstName} ${req.user!.lastName || ''}`.trim() : undefined,
});
@@ -2010,11 +1989,14 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const requestedOrgId = typeof req.query.org === 'string' && req.query.org.length > 0
? req.query.org
: null;
- const orgId = requestedOrgId ?? (await resolvePrimaryOrganization(req.user!.id));
- if (!orgId) {
- return res.status(400).json({ error: 'No organization associated with this account' });
+ if (!requestedOrgId) {
+ return res.status(400).json({
+ error: 'organization_selection_required',
+ message: 'org query parameter is required',
+ });
}
- const membership = await resolveUserOrgMembership(workos, req.user!.id, orgId);
+ const orgId = requestedOrgId;
+ const membership = await resolveUserOrgMembership(workos, req.user!, orgId);
if (!membership || (membership.role !== 'admin' && membership.role !== 'owner')) {
return res.status(403).json({
error: 'Not authorized',
@@ -2071,6 +2053,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
if (hosted.domain_verified && hosted.workos_organization_id && hosted.workos_organization_id !== orgId) {
return res.status(403).json({ error: 'This domain is verified by another organization' });
}
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || !['admin', 'owner'].includes(currentMembership.role)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
// Proof of domain control: claim ownership and mark verified.
// If the brand is orphaned (prior owner relinquished), atomically
// clear the orphan flag and reset the prior manifest — otherwise
@@ -2098,7 +2084,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
});
/**
- * Resolve the caller's requested org (or primary org when omitted) and
+ * Resolve the caller's explicitly requested org and
* verify they have admin/owner role — brand-claim is org-scoped
* state-mutation and shouldn't be reachable by rank-and-file members.
* Returns the orgId, or sends an appropriate error response and returns
@@ -2108,11 +2094,14 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const requestedOrgId = typeof req.query.org === 'string' && req.query.org.length > 0
? req.query.org
: null;
- const orgId = requestedOrgId ?? (await resolvePrimaryOrganization(req.user!.id));
- if (!orgId) {
- res.status(400).json({ error: 'No organization associated with this account' });
+ if (!requestedOrgId) {
+ res.status(400).json({
+ error: 'organization_selection_required',
+ message: 'org query parameter is required',
+ });
return null;
}
+ const orgId = requestedOrgId;
if (!workos) {
res.status(503).json({ error: 'Domain verification is not configured for this environment.' });
return null;
@@ -2122,7 +2111,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// resolveUserRole did, plus a dev-mode bypass — a removed admin can't
// claim a brand on the org that removed them.
try {
- const membership = await resolveUserOrgMembership(workos, req.user!.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, req.user!, orgId);
if (!membership || (membership.role !== 'admin' && membership.role !== 'owner')) {
res.status(403).json({
error: 'Not authorized',
@@ -2149,6 +2138,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
return res.status(503).json({ error: 'Domain verification is not configured for this environment.' });
}
const rawDomain = (req.body?.domain as string | undefined) ?? '';
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || !['admin', 'owner'].includes(currentMembership.role)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await issueDomainChallenge({
workos,
brandDb,
@@ -2223,6 +2216,10 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
return res.status(503).json({ error: 'Domain verification is not configured for this environment.' });
}
const rawDomain = (req.body?.domain as string | undefined) ?? '';
+ const currentMembership = await resolveUserOrgMembership(workos, req.user!, orgId);
+ if (!currentMembership || !['admin', 'owner'].includes(currentMembership.role)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const result = await verifyDomainChallenge({
workos,
brandDb,
@@ -2272,6 +2269,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
try {
const user = req.user!;
const requestedOrgId = req.query.org as string | undefined;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'The org query parameter is required' });
+ }
const { logo_url, brand_color, adopt_prior_manifest } = req.body;
// Auth: resolve target org (same pattern as /visibility route)
@@ -2285,8 +2285,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
}
targetOrgId = requestedOrgId!;
} else {
- const memberships = await workos!.userManagement.listOrganizationMemberships({ userId: user.id });
- const selectedMembership = selectedOrganizationMembership(memberships.data, requestedOrgId);
+ const selectedMembership = await resolveUserOrgMembership(workos!, user, requestedOrgId);
if (!selectedMembership) {
return res.status(requestedOrgId ? 403 : 404).json({
error: requestedOrgId ? 'Not authorized' : 'No organization',
@@ -2295,7 +2294,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
: 'User has no active organization membership',
});
}
- if (!isOrganizationAdminOrOwner(selectedMembership)) {
+ if (selectedMembership.role !== 'admin' && selectedMembership.role !== 'owner') {
return res.status(403).json({
error: 'Not authorized',
message: 'Only organization admins or owners can update brand identity',
@@ -2334,6 +2333,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
let result;
try {
+ const currentMembership = isDevUserProfile
+ ? { role: 'owner' as const }
+ : await resolveUserOrgMembership(workos!, user, targetOrgId);
+ if (!currentMembership
+ || (currentMembership.role !== 'admin' && currentMembership.role !== 'owner')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
result = await updateBrandIdentity({
workosOrganizationId: targetOrgId,
displayName,
@@ -2345,7 +2351,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
// when the brand is orphaned. Booleans only — anything else is a
// bad-request shape, but we let the service treat it as undefined.
adoptPriorManifest: typeof adopt_prior_manifest === 'boolean' ? adopt_prior_manifest : undefined,
- uploadedBy: { userId: user.id, email: user.email },
+ uploadedBy: { userId: getOrganizationAuthorizationUserId(user), email: user.email },
});
} catch (err: any) {
if (err instanceof BrandIdentityError) {
@@ -2377,7 +2383,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
const incumbentOrg = await orgDb.getOrganization(currentOwnerOrgId).catch(() => null);
const incumbentName = incumbentOrg?.name ?? currentOwnerOrgId;
const escalation = await createEscalation({
- workos_user_id: user.id,
+ workos_user_id: getOrganizationAuthorizationUserId(user),
user_email: user.email,
user_display_name: callerName,
category: 'sensitive_topic',
@@ -2437,6 +2443,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
try {
const user = req.user!;
const requestedOrgId = req.query.org as string | undefined;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'The org query parameter is required' });
+ }
const { is_public, show_in_carousel } = req.body;
// Dev mode: handle dev organizations without WorkOS
@@ -2454,19 +2463,14 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
targetOrgId = requestedOrgId!;
logger.info({ userId: user.id, orgId: targetOrgId }, 'PUT /api/me/member-profile/visibility: dev mode bypass');
} else {
- // Get user's organization memberships
- const memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
-
- const selectedMembership = selectedOrganizationMembership(memberships.data, requestedOrgId);
+ const selectedMembership = await resolveUserOrgMembership(workos!, user, requestedOrgId);
if (!selectedMembership) {
return res.status(403).json({
error: 'Not authorized',
message: 'User is not an active member of the requested organization',
});
}
- if (!isOrganizationAdminOrOwner(selectedMembership)) {
+ if (selectedMembership.role !== 'admin' && selectedMembership.role !== 'owner') {
return res.status(403).json({
error: 'Not authorized',
message: 'Only organization admins or owners can update profile visibility',
@@ -2502,6 +2506,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
if (typeof is_public === 'boolean') updates.is_public = is_public;
if (typeof show_in_carousel === 'boolean') updates.show_in_carousel = show_in_carousel;
+ const currentMembership = isDevUserProfile
+ ? { role: 'owner' as const }
+ : await resolveUserOrgMembership(workos!, user, targetOrgId);
+ if (!currentMembership
+ || (currentMembership.role !== 'admin' && currentMembership.role !== 'owner')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const profile = await memberDb.updateProfileByOrgId(targetOrgId, updates);
// Record publish event if this flipped is_public from false/null to true
@@ -2509,7 +2520,7 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
targetOrgId,
existingProfile.is_public,
profile?.is_public,
- user.id
+ getOrganizationAuthorizationUserId(user)
);
// Invalidate Addie's member context cache
@@ -2535,6 +2546,9 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
try {
const user = req.user!;
const requestedOrgId = req.query.org as string | undefined;
+ if (!requestedOrgId) {
+ return res.status(400).json({ error: 'The org query parameter is required' });
+ }
// Dev mode: handle dev organizations without WorkOS
const isDevUserProfile = isDevModeEnabled() && Object.values(DEV_USERS).some(du => du.id === user.id) && requestedOrgId?.startsWith('org_dev_');
@@ -2551,19 +2565,14 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
targetOrgId = requestedOrgId!;
logger.info({ userId: user.id, orgId: targetOrgId }, 'DELETE /api/me/member-profile: dev mode bypass');
} else {
- // Get user's organization memberships
- const memberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
- });
-
- const selectedMembership = selectedOrganizationMembership(memberships.data, requestedOrgId);
+ const selectedMembership = await resolveUserOrgMembership(workos!, user, requestedOrgId);
if (!selectedMembership) {
return res.status(403).json({
error: 'Not authorized',
message: 'User is not an active member of the requested organization',
});
}
- if (!isOrganizationAdminOrOwner(selectedMembership)) {
+ if (selectedMembership.role !== 'admin' && selectedMembership.role !== 'owner') {
return res.status(403).json({
error: 'Not authorized',
message: 'Only organization admins or owners can delete member profiles',
@@ -2581,6 +2590,13 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro
});
}
+ const currentMembership = isDevUserProfile
+ ? { role: 'owner' as const }
+ : await resolveUserOrgMembership(workos!, user, targetOrgId);
+ if (!currentMembership
+ || (currentMembership.role !== 'admin' && currentMembership.role !== 'owner')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
// Delete the profile
await memberDb.deleteProfile(existingProfile.id);
diff --git a/server/src/routes/org-health.ts b/server/src/routes/org-health.ts
index adccfd6753..4289fcda0f 100644
--- a/server/src/routes/org-health.ts
+++ b/server/src/routes/org-health.ts
@@ -2,9 +2,12 @@ import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { createLogger } from '../logger.js';
import { requireAuth } from '../middleware/auth.js';
-import { resolveOrgAccess, assembleOrgHealth } from '../services/org-health.js';
+import { assembleOrgHealth } from '../services/org-health.js';
import { query } from '../db/client.js';
import { recordEvent } from '../db/person-events-db.js';
+import { getWorkos } from '../auth/workos-client.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
const logger = createLogger('org-health-routes');
@@ -21,19 +24,23 @@ export function createOrgHealthRouter(): Router {
// GET /api/me/org-health — org health aggregation
router.get('/', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
-
- const access = await resolveOrgAccess(userId);
- if (!access) {
- return res.status(404).json({ error: 'No organization found' });
+ const orgId = typeof req.query.org === 'string' && req.query.org.trim()
+ ? req.query.org
+ : null;
+ if (!orgId) {
+ return res.status(400).json({ error: 'org query parameter is required' });
+ }
+ const membership = await resolveUserOrgMembership(getWorkos(), req.user!, orgId);
+ if (!membership) {
+ return res.status(403).json({ error: 'Not authorized for the selected organization' });
}
// Restrict to admin/owner — people table contains PII (emails)
- if (access.role !== 'admin' && access.role !== 'owner') {
+ if (membership.role !== 'admin' && membership.role !== 'owner') {
return res.status(403).json({ error: 'Org admin access required' });
}
- const health = await assembleOrgHealth(access.orgId);
+ const health = await assembleOrgHealth(membership.organizationId);
res.json(health);
} catch (error) {
logger.error({ error }, 'Failed to load org health');
@@ -44,15 +51,21 @@ export function createOrgHealthRouter(): Router {
// POST /api/me/org-health/nudge — admin requests Addie outreach for a team member
router.post('/nudge', requireAuth, nudgeRateLimiter, async (req, res) => {
try {
- const userId = req.user!.id;
+ const userId = getOrganizationAuthorizationUserId(req.user!);
const { target_user_id, topic } = req.body;
+ const orgId = typeof req.query.org === 'string' && req.query.org.trim()
+ ? req.query.org
+ : null;
if (!target_user_id || typeof target_user_id !== 'string') {
return res.status(400).json({ error: 'target_user_id required' });
}
+ if (!orgId) {
+ return res.status(400).json({ error: 'org query parameter is required' });
+ }
- const access = await resolveOrgAccess(userId);
- if (!access || (access.role !== 'admin' && access.role !== 'owner')) {
+ const membership = await resolveUserOrgMembership(getWorkos(), req.user!, orgId);
+ if (!membership || (membership.role !== 'admin' && membership.role !== 'owner')) {
return res.status(403).json({ error: 'Org admin access required' });
}
@@ -60,7 +73,7 @@ export function createOrgHealthRouter(): Router {
const memberCheck = await query<{ workos_user_id: string }>(
`SELECT workos_user_id FROM organization_memberships
WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [target_user_id, access.orgId]
+ [target_user_id, membership.organizationId]
);
if (memberCheck.rows.length === 0) {
return res.status(404).json({ error: 'User not found in your organization' });
@@ -76,6 +89,10 @@ export function createOrgHealthRouter(): Router {
return res.status(404).json({ error: 'No relationship record found for this user' });
}
+ const currentMembership = await resolveUserOrgMembership(getWorkos(), req.user!, orgId);
+ if (!currentMembership || (currentMembership.role !== 'admin' && currentMembership.role !== 'owner')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await recordEvent(personResult.rows[0].id, 'admin_nudge_requested', {
channel: 'system',
data: {
diff --git a/server/src/routes/organizations.ts b/server/src/routes/organizations.ts
index 952afcdd83..bd5e284ce5 100644
--- a/server/src/routes/organizations.ts
+++ b/server/src/routes/organizations.ts
@@ -47,6 +47,7 @@ import { emailPrefsDb } from "../db/email-preferences-db.js";
import { performCreateOrganization } from "../services/organization-bootstrap.js";
import { collectWorkOSPages } from "../services/workos-pagination.js";
import { canManageOrganizationBilling } from "../billing/billing-authorization.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
const logger = createLogger("organization-routes");
@@ -71,7 +72,9 @@ const orgDb = new OrganizationDatabase();
* Create organization routes
* Returns a router for API routes (/api/organizations/*)
*/
-export function createOrganizationsRouter(): Router {
+export function createOrganizationsRouter(
+ credentialGrantWorkos: WorkOS | null = workos,
+): Router {
const router = Router();
// =========================================================================
@@ -90,15 +93,16 @@ export function createOrganizationsRouter(): Router {
}
const joinRequestDb = new JoinRequestDatabase();
+ const authorizationUserId = getOrganizationAuthorizationUserId(user);
// Get user's current org memberships to exclude
const userMemberships = await workos!.userManagement.listOrganizationMemberships({
- userId: user.id,
+ userId: authorizationUserId,
});
const userOrgIds = userMemberships.data.map(m => m.organizationId);
// Get user's pending join requests
- const pendingRequests = await joinRequestDb.getUserPendingRequests(user.id);
+ const pendingRequests = await joinRequestDb.getUserPendingRequests(authorizationUserId);
const pendingOrgIds = new Set(pendingRequests.map(r => r.workos_organization_id));
// Search organizations
@@ -211,7 +215,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is admin/owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -255,7 +259,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is admin/owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -296,7 +300,7 @@ export function createOrganizationsRouter(): Router {
}
// Verify user is admin/owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -338,6 +342,11 @@ export function createOrganizationsRouter(): Router {
message: 'This join request has already been processed',
});
}
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
+ const currentApprovalMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentApprovalMembership || !['admin', 'owner'].includes(currentApprovalMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
// Directly add the user to the organization — join requests are only
// created by users who have already signed up, so we always have their
@@ -352,8 +361,8 @@ export function createOrganizationsRouter(): Router {
if (membershipError?.code === 'organization_membership_already_exists') {
// Previous approval attempt succeeded in WorkOS but failed before the DB
// update. Clear the stale pending row and surface the error.
- logger.info({ adminId: user.id, requestId, orgId }, 'Join request resolved — membership already existed in WorkOS');
- await joinRequestDb.approveRequest(requestId, user.id);
+ logger.info({ adminId: actorCredentialId, requestId, orgId }, 'Join request resolved — membership already existed in WorkOS');
+ await joinRequestDb.approveRequest(requestId, actorCredentialId);
return res.status(400).json({
error: 'User already a member',
message: 'This user is already a member of the organization',
@@ -372,10 +381,10 @@ export function createOrganizationsRouter(): Router {
}
// Mark request as approved
- await joinRequestDb.approveRequest(requestId, user.id);
+ await joinRequestDb.approveRequest(requestId, actorCredentialId);
logger.info({
- adminId: user.id,
+ adminId: actorCredentialId,
requestId,
orgId,
requesterId: request.workos_user_id,
@@ -385,7 +394,7 @@ export function createOrganizationsRouter(): Router {
// Record audit log for join request approval
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'join_request_approved',
resource_type: 'join_request',
resource_id: requestId,
@@ -466,7 +475,7 @@ export function createOrganizationsRouter(): Router {
const { reason } = req.body;
// Verify user is admin/owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -499,12 +508,17 @@ export function createOrganizationsRouter(): Router {
message: 'This join request has already been processed',
});
}
+ const currentRejectionMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentRejectionMembership || !['admin', 'owner'].includes(currentRejectionMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Mark request as rejected
- await joinRequestDb.rejectRequest(requestId, user.id, reason);
+ await joinRequestDb.rejectRequest(requestId, actorCredentialId, reason);
logger.info({
- adminId: user.id,
+ adminId: actorCredentialId,
requestId,
orgId,
requesterId: request.workos_user_id,
@@ -514,7 +528,7 @@ export function createOrganizationsRouter(): Router {
// Record audit log for join request rejection
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'join_request_rejected',
resource_type: 'join_request',
resource_id: requestId,
@@ -548,7 +562,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is a member of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -722,14 +736,17 @@ export function createOrganizationsRouter(): Router {
// Member of the org can flag — broader than admin/owner, since the
// report is informational and the corrective action is admin-side.
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({ error: 'Access denied', message: 'You are not a member of this organization' });
}
+ if (!await resolveUserOrgMembership(workos, user, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: getOrganizationAuthorizationUserId(user),
action: 'brand_classification_report_filed',
resource_type: 'brand',
resource_id: subject_domain.toLowerCase(),
@@ -754,7 +771,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is admin/owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -882,6 +899,7 @@ export function createOrganizationsRouter(): Router {
const adminUser = req.user!;
const { orgId } = req.params;
const { email, role } = req.body;
+ const actorCredentialId = getOrganizationAuthorizationUserId(adminUser);
if (!email || typeof email !== 'string') {
return res.status(400).json({
@@ -909,7 +927,7 @@ export function createOrganizationsRouter(): Router {
}
// Verify user is admin/owner of this organization
- const callerMembership = await resolveUserOrgMembership(workos, adminUser.id, orgId);
+ const callerMembership = await resolveUserOrgMembership(workos, adminUser, orgId);
if (!callerMembership) {
return res.status(403).json({
error: 'Access denied',
@@ -977,14 +995,19 @@ export function createOrganizationsRouter(): Router {
if (!slackUser.workos_user_id) {
// User exists in Slack but hasn't signed up yet - send invitation instead
+ const currentCallerMembership = await resolveUserOrgMembership(workos, adminUser, orgId);
+ if (!currentCallerMembership
+ || (currentCallerMembership.role !== 'admin' && currentCallerMembership.role !== 'owner')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const invitation = await workos!.userManagement.sendInvitation({
email,
organizationId: orgId,
- inviterUserId: adminUser.id,
+ inviterUserId: actorCredentialId,
roleSlug: roleToAssign,
});
- logger.info({ orgId, email, inviterId: adminUser.id }, 'Domain user invited (no WorkOS account yet)');
+ logger.info({ orgId, email, inviterId: actorCredentialId }, 'Domain user invited (no WorkOS account yet)');
return res.json({
success: true,
@@ -1020,6 +1043,11 @@ export function createOrganizationsRouter(): Router {
}
// Directly add user to organization
+ const currentCallerMembership = await resolveUserOrgMembership(workos, adminUser, orgId);
+ if (!currentCallerMembership
+ || (currentCallerMembership.role !== 'admin' && currentCallerMembership.role !== 'owner')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const membership = await workos!.userManagement.createOrganizationMembership({
userId: slackUser.workos_user_id,
organizationId: orgId,
@@ -1030,13 +1058,13 @@ export function createOrganizationsRouter(): Router {
orgId,
email,
userId: slackUser.workos_user_id,
- addedBy: adminUser.id,
+ addedBy: actorCredentialId,
}, 'Domain user directly added to organization');
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: adminUser.id,
+ workos_user_id: actorCredentialId,
action: 'member_added',
resource_type: 'membership',
resource_id: membership.id,
@@ -1050,7 +1078,7 @@ export function createOrganizationsRouter(): Router {
WHERE workos_organization_id = $2
AND LOWER(user_email) = LOWER($3)
AND status = 'pending'`,
- [adminUser.id, orgId, email]
+ [actorCredentialId, orgId, email]
);
// Notify via Slack (fire-and-forget)
@@ -1141,7 +1169,7 @@ export function createOrganizationsRouter(): Router {
// Domain verification changes organization-wide identity and automatic
// membership behavior. Keep it aligned with the canonical domain
// add/verify routes: owners and admins only.
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -1165,12 +1193,17 @@ export function createOrganizationsRouter(): Router {
}
// Generate portal link for domain verification
+ const currentMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentMembership
+ || (currentMembership.role !== 'owner' && currentMembership.role !== 'admin')) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const { link } = await workos!.adminPortal.generateLink({
organization: orgId,
intent: 'domain_verification' as any,
});
- logger.info({ organizationId: orgId, userId: user.id }, 'Generated domain verification portal link');
+ logger.info({ organizationId: orgId, userId: getOrganizationAuthorizationUserId(user) }, 'Generated domain verification portal link');
res.json({ link });
} catch (error) {
@@ -1212,7 +1245,7 @@ export function createOrganizationsRouter(): Router {
const outcome = await performCreateOrganization(
{
- user: { id: user.id, email: user.email },
+ user: { id: getOrganizationAuthorizationUserId(user), email: user.email },
organization_name,
is_personal: !!is_personal,
company_type,
@@ -1329,7 +1362,7 @@ export function createOrganizationsRouter(): Router {
const trimmedName = name.trim();
// Verify user is member of this organization with owner or admin role
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -1345,6 +1378,11 @@ export function createOrganizationsRouter(): Router {
message: 'Only organization owners and admins can rename the organization',
});
}
+ const currentRenameMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentRenameMembership || !['admin', 'owner'].includes(currentRenameMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Update in WorkOS
const updatedOrg = await workos!.organizations.updateOrganization({
@@ -1359,7 +1397,7 @@ export function createOrganizationsRouter(): Router {
// distinguish them from real-user writes.
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'organization_renamed',
resource_type: 'organization',
resource_id: orgId,
@@ -1369,7 +1407,7 @@ export function createOrganizationsRouter(): Router {
},
});
- logger.info({ orgId, newName: trimmedName, userId: user.id }, 'Organization renamed');
+ logger.info({ orgId, newName: trimmedName, userId: actorCredentialId }, 'Organization renamed');
res.json({
success: true,
@@ -1401,7 +1439,7 @@ export function createOrganizationsRouter(): Router {
} = req.body;
// Verify user is member of this organization with owner or admin role
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -1480,7 +1518,8 @@ export function createOrganizationsRouter(): Router {
if (isPrivilegeGrant && userRole !== 'owner') {
const isStaticAdminApiKey =
(req as Request & { isStaticAdminApiKey?: boolean }).isStaticAdminApiKey === true;
- const isAAOAdmin = isStaticAdminApiKey || (await isWebUserAAOAdmin(user.id));
+ const isAAOAdmin = isStaticAdminApiKey
+ || (await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user)));
if (!isAAOAdmin) {
return res.status(403).json({
error: 'Insufficient permissions',
@@ -1515,6 +1554,18 @@ export function createOrganizationsRouter(): Router {
message: 'Provide company_type, revenue_tier, auto_provision_verified_domain, or auto_provision_brand_hierarchy_children to update',
});
}
+ const currentSettingsMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentSettingsMembership || !['admin', 'owner'].includes(currentSettingsMembership.role)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
+ if (isPrivilegeGrant && currentSettingsMembership.role !== 'owner') {
+ const isStaticAdminApiKey =
+ (req as Request & { isStaticAdminApiKey?: boolean }).isStaticAdminApiKey === true;
+ if (!isStaticAdminApiKey
+ && !await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(user))) {
+ return res.status(403).json({ error: 'Organization owner authorization was revoked' });
+ }
+ }
// Update in our database
await orgDb.updateOrganization(orgId, updates);
@@ -1525,7 +1576,7 @@ export function createOrganizationsRouter(): Router {
const rawUA = req.get('user-agent');
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: getOrganizationAuthorizationUserId(user),
action: 'organization_settings_updated',
resource_type: 'organization',
resource_id: orgId,
@@ -1567,7 +1618,7 @@ export function createOrganizationsRouter(): Router {
const { confirmation } = req.body;
// Verify user is owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -1636,11 +1687,15 @@ export function createOrganizationsRouter(): Router {
organization_name: org.name,
});
}
+ const currentDeletionMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentDeletionMembership || currentDeletionMembership.role !== 'owner') {
+ return res.status(403).json({ error: 'Organization owner authorization was revoked' });
+ }
// Record audit log before deletion (while org still exists)
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: getOrganizationAuthorizationUserId(user),
action: 'organization_deleted',
resource_type: 'organization',
resource_id: orgId,
@@ -1686,7 +1741,7 @@ export function createOrganizationsRouter(): Router {
// The Stripe Customer Portal can change payment methods, tiers, and
// cancellation. Bind that authority to an active owner/admin role in
// this exact organization before any database or Stripe work.
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!canManageOrganizationBilling(membership, orgId)) {
return res.status(403).json({
error: 'Access denied',
@@ -1725,7 +1780,7 @@ export function createOrganizationsRouter(): Router {
// Re-resolve immediately before the first operation that can call
// Stripe. A cached/earlier owner role must not survive a concurrent
// demotion or membership revocation.
- const currentMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const currentMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!canManageOrganizationBilling(currentMembership, orgId)) {
return res.status(403).json({
error: 'Access denied',
@@ -1804,7 +1859,7 @@ export function createOrganizationsRouter(): Router {
}
// Verify user is member of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -1830,6 +1885,11 @@ export function createOrganizationsRouter(): Router {
message: 'Could not find or sync organization',
});
}
+ const currentAgreementMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentAgreementMembership) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Store pending agreement info in organization record using the
// server-validated version (not the raw client string) so the audit
@@ -1837,12 +1897,12 @@ export function createOrganizationsRouter(): Router {
await orgDb.updateOrganization(orgId, {
pending_agreement_version: currentAgreement.version,
pending_agreement_accepted_at: agreement_accepted_at ? new Date(agreement_accepted_at) : new Date(),
- pending_agreement_user_id: user.id,
+ pending_agreement_user_id: actorCredentialId,
});
logger.info({
orgId,
- userId: user.id,
+ userId: actorCredentialId,
version: currentAgreement.version
}, 'Pending agreement info stored (will be recorded on payment success)');
@@ -1867,7 +1927,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -1891,6 +1951,10 @@ export function createOrganizationsRouter(): Router {
message: 'This workspace is already a team workspace',
});
}
+ const currentConversionMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentConversionMembership || currentConversionMembership.role !== 'owner') {
+ return res.status(403).json({ error: 'Organization owner authorization was revoked' });
+ }
// Convert to team by setting is_personal to false
await orgDb.updateOrganization(orgId, { is_personal: false });
@@ -1898,7 +1962,7 @@ export function createOrganizationsRouter(): Router {
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: getOrganizationAuthorizationUserId(user),
action: 'convert_to_team',
resource_type: 'organization',
resource_id: orgId,
@@ -1929,7 +1993,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is owner of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -1976,6 +2040,10 @@ export function createOrganizationsRouter(): Router {
member_count: totalMembers,
});
}
+ const currentConversionMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentConversionMembership || currentConversionMembership.role !== 'owner') {
+ return res.status(403).json({ error: 'Organization owner authorization was revoked' });
+ }
// Convert to individual by setting is_personal to true
await orgDb.updateOrganization(orgId, { is_personal: true });
@@ -1983,7 +2051,7 @@ export function createOrganizationsRouter(): Router {
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: getOrganizationAuthorizationUserId(user),
action: 'convert_to_individual',
resource_type: 'organization',
resource_id: orgId,
@@ -2153,7 +2221,7 @@ export function createOrganizationsRouter(): Router {
let workosFailed = false;
try {
await workos.userManagement.createOrganizationMembership({
- userId: user.id,
+ userId: getOrganizationAuthorizationUserId(user),
organizationId: orgId,
roleSlug: 'admin',
});
@@ -2162,7 +2230,7 @@ export function createOrganizationsRouter(): Router {
if (code === 'organization_membership_already_exists') {
// Benign — they're already a member. Treat as success.
} else {
- logger.error({ err, orgId, userId: user.id }, 'WorkOS createOrganizationMembership failed during claim');
+ logger.error({ err, orgId, userId: getOrganizationAuthorizationUserId(user) }, 'WorkOS createOrganizationMembership failed during claim');
workosFailed = true;
}
}
@@ -2201,7 +2269,7 @@ export function createOrganizationsRouter(): Router {
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: getOrganizationAuthorizationUserId(user),
action: 'organization_claimed',
resource_type: 'organization',
resource_id: orgId,
@@ -2212,7 +2280,7 @@ export function createOrganizationsRouter(): Router {
});
logger.info(
- { orgId, userId: user.id, email: user.email },
+ { orgId, userId: getOrganizationAuthorizationUserId(user), email: user.email },
'User claimed prospect org',
);
@@ -2251,7 +2319,7 @@ export function createOrganizationsRouter(): Router {
}
// Verify user is member of this organization
- const callerMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const callerMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!callerMembership) {
return res.status(403).json({
error: 'Access denied',
@@ -2434,7 +2502,7 @@ export function createOrganizationsRouter(): Router {
}
// Verify user is member of this organization
- const callerMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const callerMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!callerMembership) {
return res.status(403).json({
error: 'Access denied',
@@ -2468,12 +2536,17 @@ export function createOrganizationsRouter(): Router {
message: seatCheck.reason,
});
}
+ const currentInviteMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentInviteMembership || !['admin', 'owner'].includes(currentInviteMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Send invitation via WorkOS
const invitation = await workos!.userManagement.sendInvitation({
email,
organizationId: orgId,
- inviterUserId: user.id,
+ inviterUserId: actorCredentialId,
roleSlug: role || 'member',
});
@@ -2485,12 +2558,12 @@ export function createOrganizationsRouter(): Router {
[invitation.id, orgId, email, seatType]
);
- logger.info({ orgId, email, inviterId: user.id, seatType }, 'Invitation sent');
+ logger.info({ orgId, email, inviterId: actorCredentialId, seatType }, 'Invitation sent');
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_invited',
resource_type: 'invitation',
resource_id: invitation.id,
@@ -2539,7 +2612,7 @@ export function createOrganizationsRouter(): Router {
const { orgId, invitationId } = req.params;
// Verify user is member of this organization
- const callerMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const callerMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!callerMembership) {
return res.status(403).json({
error: 'Access denied',
@@ -2564,6 +2637,11 @@ export function createOrganizationsRouter(): Router {
message: 'This invitation does not belong to this organization',
});
}
+ const currentRevokeMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentRevokeMembership || !['admin', 'owner'].includes(currentRevokeMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Revoke the invitation
await workos!.userManagement.revokeInvitation(invitationId);
@@ -2571,12 +2649,12 @@ export function createOrganizationsRouter(): Router {
// Clean up seat_type intent
await query('DELETE FROM invitation_seat_types WHERE workos_invitation_id = $1', [invitationId]);
- logger.info({ orgId, invitationId, revokerId: user.id }, 'Invitation revoked');
+ logger.info({ orgId, invitationId, revokerId: actorCredentialId }, 'Invitation revoked');
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'invitation_revoked',
resource_type: 'invitation',
resource_id: invitationId,
@@ -2602,7 +2680,7 @@ export function createOrganizationsRouter(): Router {
const { orgId, invitationId } = req.params;
// Verify user is member of this organization
- const callerMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const callerMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!callerMembership) {
return res.status(403).json({
error: 'Access denied',
@@ -2627,6 +2705,11 @@ export function createOrganizationsRouter(): Router {
message: 'This invitation does not belong to this organization',
});
}
+ const currentResendMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentResendMembership || !['admin', 'owner'].includes(currentResendMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Preserve seat_type from old invitation before revoking
const oldSeatResult = await query<{ seat_type: string }>(
@@ -2640,7 +2723,7 @@ export function createOrganizationsRouter(): Router {
const newInvitation = await workos!.userManagement.sendInvitation({
email: invitation.email,
organizationId: orgId,
- inviterUserId: user.id,
+ inviterUserId: actorCredentialId,
roleSlug: 'member',
});
@@ -2651,12 +2734,12 @@ export function createOrganizationsRouter(): Router {
[newInvitation.id, orgId, invitation.email, preservedSeatType]
);
- logger.info({ orgId, email: invitation.email, inviterId: user.id, seatType: preservedSeatType }, 'Invitation resent');
+ logger.info({ orgId, email: invitation.email, inviterId: actorCredentialId, seatType: preservedSeatType }, 'Invitation resent');
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'invitation_resent',
resource_type: 'invitation',
resource_id: newInvitation.id,
@@ -2771,16 +2854,30 @@ export function createOrganizationsRouter(): Router {
// verified the key matches ADMIN_API_KEY before reaching this point.
const isStaticAdminApiKey =
(req as Request & { isStaticAdminApiKey?: boolean }).isStaticAdminApiKey === true;
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
+
+ const hasCurrentMemberMutationAuthority = async (requiresOwner: boolean): Promise => {
+ if (isStaticAdminApiKey) return true;
+ const [currentMembership, currentIsAAOAdmin] = await Promise.all([
+ resolveUserOrgMembership(workos, user, orgId),
+ isWebUserAAOAdmin(actorCredentialId),
+ ]);
+ if (currentIsAAOAdmin) return true;
+ if (!currentMembership) return false;
+ return requiresOwner
+ ? currentMembership.role === 'owner'
+ : currentMembership.role === 'owner' || currentMembership.role === 'admin';
+ };
// Resolve caller authority: org role + AAO super-admin override.
// Skip membership lookup for the static admin API key — 'admin_api_key'
// is a synthetic user id and won't have memberships.
const callerMembership = isStaticAdminApiKey
? null
- : await resolveUserOrgMembership(workos, user.id, orgId);
+ : await resolveUserOrgMembership(workos, user, orgId);
const callerOrgRole = callerMembership?.role ?? null;
- const isAAOAdmin =
- isStaticAdminApiKey || (await isWebUserAAOAdmin(user.id));
+ const isAAOAdmin = isStaticAdminApiKey
+ || (await isWebUserAAOAdmin(actorCredentialId));
const isOrgAdminOrOwner = callerOrgRole === 'admin' || callerOrgRole === 'owner';
const isOrgOwner = callerOrgRole === 'owner';
@@ -2820,6 +2917,9 @@ export function createOrganizationsRouter(): Router {
if (!seatCheck.allowed) {
return res.status(403).json({ error: 'Seat limit reached', message: seatCheck.reason });
}
+ if (!(await hasCurrentMemberMutationAuthority(role === 'owner'))) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
// The static ADMIN_API_KEY auth path uses a synthetic user id
// ('admin_api_key') that WorkOS does not recognize; passing it as
@@ -2828,7 +2928,7 @@ export function createOrganizationsRouter(): Router {
const invitation = await workos!.userManagement.sendInvitation({
email: normalizedEmail,
organizationId: orgId,
- ...(isStaticAdminApiKey ? {} : { inviterUserId: user.id }),
+ ...(isStaticAdminApiKey ? {} : { inviterUserId: actorCredentialId }),
roleSlug: 'member',
});
@@ -2844,7 +2944,7 @@ export function createOrganizationsRouter(): Router {
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_invited',
resource_type: 'invitation',
resource_id: invitation.id,
@@ -2859,7 +2959,7 @@ export function createOrganizationsRouter(): Router {
});
logger.info(
- { orgId, email: normalizedEmail, requestedRole: role, seatType, inviterId: user.id },
+ { orgId, email: normalizedEmail, requestedRole: role, seatType, inviterId: actorCredentialId },
'Invited member by email (no WorkOS account yet)',
);
@@ -2902,6 +3002,9 @@ export function createOrganizationsRouter(): Router {
if (!seatCheck.allowed) {
return res.status(403).json({ error: 'Seat limit reached', message: seatCheck.reason });
}
+ if (!(await hasCurrentMemberMutationAuthority(role === 'owner'))) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
// Stage seat_type for the membership.created webhook handler to consume.
//
@@ -2925,6 +3028,10 @@ export function createOrganizationsRouter(): Router {
let membership;
try {
+ if (!(await hasCurrentMemberMutationAuthority(role === 'owner'))) {
+ await query('DELETE FROM invitation_seat_types WHERE workos_invitation_id = $1', [stagingKey]);
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
membership = await workos!.userManagement.createOrganizationMembership({
userId: targetUserId,
organizationId: orgId,
@@ -2958,7 +3065,7 @@ export function createOrganizationsRouter(): Router {
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_added',
resource_type: 'membership',
resource_id: membership.id,
@@ -2973,7 +3080,7 @@ export function createOrganizationsRouter(): Router {
});
logger.info(
- { orgId, targetUserId, email: normalizedEmail, role, seatType, actorId: user.id },
+ { orgId, targetUserId, email: normalizedEmail, role, seatType, actorId: actorCredentialId },
'Added member by email',
);
@@ -2996,7 +3103,7 @@ export function createOrganizationsRouter(): Router {
// mirroring it here closes the parallel path. AAO super-admins are
// permitted to act on themselves only when they're not also an org
// member of this org (handled by the org-member check above).
- if (targetUserId === user.id) {
+ if (targetUserId === actorCredentialId) {
return res.status(400).json({
error: 'Cannot change own role',
message: 'You cannot change your own role',
@@ -3073,6 +3180,9 @@ export function createOrganizationsRouter(): Router {
}
}
+ if (!(await hasCurrentMemberMutationAuthority(role === 'owner' || targetIsOwner))) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
await workos!.userManagement.updateOrganizationMembership(membershipId, { roleSlug: role });
await pool.query(
@@ -3084,7 +3194,7 @@ export function createOrganizationsRouter(): Router {
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_role_changed',
resource_type: 'membership',
resource_id: membershipId,
@@ -3099,7 +3209,7 @@ export function createOrganizationsRouter(): Router {
});
logger.info(
- { orgId, targetUserId, email: normalizedEmail, oldRole: rawCurrentRole, newRole: role, actorId: user.id },
+ { orgId, targetUserId, email: normalizedEmail, oldRole: rawCurrentRole, newRole: role, actorId: actorCredentialId },
'Updated member role by email',
);
@@ -3128,6 +3238,7 @@ export function createOrganizationsRouter(): Router {
router.patch('/:orgId/members/:membershipId', requireAuth, async (req, res) => {
try {
const user = req.user!;
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
const { orgId, membershipId } = req.params;
const { role, seat_type } = req.body;
@@ -3155,7 +3266,7 @@ export function createOrganizationsRouter(): Router {
}
// Verify user is member of this organization
- const callerMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const callerMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!callerMembership) {
return res.status(403).json({
error: 'Access denied',
@@ -3189,7 +3300,7 @@ export function createOrganizationsRouter(): Router {
}
// Cannot change own role
- if (role && membership.userId === user.id) {
+ if (role && membership.userId === actorCredentialId) {
return res.status(400).json({
error: 'Cannot change own role',
message: 'You cannot change your own role',
@@ -3232,6 +3343,13 @@ export function createOrganizationsRouter(): Router {
// Update role via WorkOS if requested
let updatedRole = membership.role?.slug || 'member';
if (role) {
+ const currentMutationMembership = await resolveUserOrgMembership(workos, user, orgId);
+ const requiresOwner = role === 'owner' || (membership.role?.slug || 'member') === 'owner';
+ if (!currentMutationMembership || (requiresOwner
+ ? currentMutationMembership.role !== 'owner'
+ : !['admin', 'owner'].includes(currentMutationMembership.role))) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
const updatedMembership = await workos!.userManagement.updateOrganizationMembership(
membershipId,
{ roleSlug: role }
@@ -3249,6 +3367,11 @@ export function createOrganizationsRouter(): Router {
);
const oldSeatType = oldResult.rows[0]?.seat_type || 'community_only';
+ const currentSeatMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentSeatMembership || !['admin', 'owner'].includes(currentSeatMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+
await query(
`UPDATE organization_memberships SET seat_type = $1, updated_at = NOW()
WHERE workos_organization_id = $2 AND workos_user_id = $3`,
@@ -3259,7 +3382,7 @@ export function createOrganizationsRouter(): Router {
if (oldSeatType !== seat_type) {
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_seat_type_changed',
resource_type: 'membership',
resource_id: membershipId,
@@ -3308,11 +3431,11 @@ export function createOrganizationsRouter(): Router {
}
if (role) {
- logger.info({ orgId, membershipId, newRole: role, changedBy: user.id }, 'Member role updated');
+ logger.info({ orgId, membershipId, newRole: role, changedBy: actorCredentialId }, 'Member role updated');
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_role_changed',
resource_type: 'membership',
resource_id: membershipId,
@@ -3347,10 +3470,11 @@ export function createOrganizationsRouter(): Router {
router.delete('/:orgId/members/:membershipId', requireAuth, async (req, res) => {
try {
const user = req.user!;
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
const { orgId, membershipId } = req.params;
// Verify user is member of this organization
- const callerMembership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const callerMembership = await resolveUserOrgMembership(workos, user, orgId);
if (!callerMembership) {
return res.status(403).json({
error: 'Access denied',
@@ -3377,7 +3501,7 @@ export function createOrganizationsRouter(): Router {
}
// Cannot remove self
- if (membership.userId === user.id) {
+ if (membership.userId === actorCredentialId) {
return res.status(400).json({
error: 'Cannot remove self',
message: 'You cannot remove yourself from the organization',
@@ -3411,6 +3535,14 @@ export function createOrganizationsRouter(): Router {
}
const removedSeatType = await getUserSeatType(membership.userId);
+ const currentRemovalMembership = await resolveUserOrgMembership(workos, user, orgId);
+ const removalRequiresOwner = targetRole === 'admin';
+ if (!currentRemovalMembership || (removalRequiresOwner
+ ? currentRemovalMembership.role !== 'owner'
+ : !['admin', 'owner'].includes(currentRemovalMembership.role))) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+
// Delete the membership from WorkOS
await workos!.userManagement.deleteOrganizationMembership(membershipId);
@@ -3431,12 +3563,12 @@ export function createOrganizationsRouter(): Router {
logger.warn({ error: cleanupError, userId: membership.userId, orgId }, 'Failed to clean up local organization_memberships');
}
- logger.info({ orgId, membershipId, removedBy: user.id }, 'Member removed');
+ logger.info({ orgId, membershipId, removedBy: actorCredentialId }, 'Member removed');
// Record audit log
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'member_removed',
resource_type: 'membership',
resource_id: membershipId,
@@ -3490,7 +3622,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is member of this organization
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -3542,7 +3674,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
const { target_org_id } = req.body;
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({ error: 'You are not a member of this organization' });
}
@@ -3572,10 +3704,14 @@ export function createOrganizationsRouter(): Router {
// Hardcode: single-use, 30-day expiry
const expires_at = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
+ if (!await resolveUserOrgMembership(workos, user, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
const code = await referralDb.createReferralCode({
referrer_org_id: orgId,
- referrer_user_id: user.id,
+ referrer_user_id: actorCredentialId,
referrer_user_name: [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email,
referrer_user_email: user.email,
target_company_name,
@@ -3593,7 +3729,7 @@ export function createOrganizationsRouter(): Router {
`INSERT INTO org_stakeholders (organization_id, user_id, user_name, user_email, role, notes)
VALUES ($1, $2, $3, $4, 'interested', $5)
ON CONFLICT (organization_id, user_id) DO NOTHING`,
- [target_org_id, user.id, userName, user.email || null, notes]
+ [target_org_id, actorCredentialId, userName, user.email || null, notes]
).catch(err => logger.warn({ err }, 'Failed to add stakeholder on referral code creation'));
}
@@ -3610,7 +3746,7 @@ export function createOrganizationsRouter(): Router {
const user = req.user!;
const { orgId } = req.params;
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({ error: 'You are not a member of this organization' });
}
@@ -3675,10 +3811,13 @@ export function createOrganizationsRouter(): Router {
const user = req.user!;
const { orgId, codeId } = req.params;
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({ error: 'You are not a member of this organization' });
}
+ if (!await resolveUserOrgMembership(workos, user, orgId)) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
const revoked = await referralDb.revokeReferralCode(parseInt(codeId, 10), orgId);
@@ -3721,16 +3860,17 @@ export function createOrganizationsRouter(): Router {
}
// Verify user is a member of this org
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
message: 'You are not a member of this organization',
});
}
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Verify user is community_only
- const seatType = await getUserSeatType(user.id);
+ const seatType = await getUserSeatType(actorCredentialId);
if (seatType === 'contributor') {
return res.status(400).json({
error: 'Already a contributor',
@@ -3739,7 +3879,7 @@ export function createOrganizationsRouter(): Router {
}
// Check for existing pending request for this resource
- const hasPending = await hasPendingSeatRequest(orgId, user.id, resource_type, resource_id);
+ const hasPending = await hasPendingSeatRequest(orgId, actorCredentialId, resource_type, resource_id);
if (hasPending) {
return res.status(409).json({
error: 'Request already pending',
@@ -3747,9 +3887,12 @@ export function createOrganizationsRouter(): Router {
});
}
+ if (!await resolveUserOrgMembership(workos, user, orgId)) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
const request = await createSeatUpgradeRequest({
orgId,
- userId: user.id,
+ userId: actorCredentialId,
resourceType: resource_type,
resourceId: resource_id,
resourceName: resource_name,
@@ -3764,7 +3907,7 @@ export function createOrganizationsRouter(): Router {
let memberName = user.email;
try {
- const workosUser = await workos!.userManagement.getUser(user.id);
+ const workosUser = await workos!.userManagement.getUser(actorCredentialId);
if (workosUser.firstName && workosUser.lastName) {
memberName = `${workosUser.firstName} ${workosUser.lastName}`;
}
@@ -3788,7 +3931,7 @@ export function createOrganizationsRouter(): Router {
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'seat_upgrade_requested',
resource_type: 'seat_upgrade_request',
resource_id: request.id,
@@ -3816,7 +3959,7 @@ export function createOrganizationsRouter(): Router {
const { orgId } = req.params;
// Verify user is a member of this org
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({
error: 'Access denied',
@@ -3830,7 +3973,7 @@ export function createOrganizationsRouter(): Router {
// Admins see all pending requests; members see their own
const requests = isAdmin
? await listSeatUpgradeRequests(orgId, { status: 'pending' })
- : await listSeatUpgradeRequests(orgId, { userId: user.id });
+ : await listSeatUpgradeRequests(orgId, { userId: getOrganizationAuthorizationUserId(user) });
res.json({ requests });
} catch (error) {
@@ -3846,7 +3989,7 @@ export function createOrganizationsRouter(): Router {
const { orgId, requestId } = req.params;
// Verify admin/owner
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({ error: 'Access denied' });
}
@@ -3871,9 +4014,14 @@ export function createOrganizationsRouter(): Router {
message: seatCheck.reason,
});
}
+ const currentApprovalMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentApprovalMembership || !['admin', 'owner'].includes(currentApprovalMembership.role)) {
+ return res.status(403).json({ error: 'Your organization authorization was revoked' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
// Approve the request (atomic: only succeeds if still pending)
- const resolved = await resolveSeatUpgradeRequest(requestId, 'approved', user.id);
+ const resolved = await resolveSeatUpgradeRequest(requestId, 'approved', actorCredentialId);
if (!resolved) {
return res.status(409).json({ error: 'Request was already resolved' });
}
@@ -3887,7 +4035,7 @@ export function createOrganizationsRouter(): Router {
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'seat_upgrade_approved',
resource_type: 'seat_upgrade_request',
resource_id: requestId,
@@ -3937,7 +4085,7 @@ export function createOrganizationsRouter(): Router {
const { orgId, requestId } = req.params;
// Verify admin/owner
- const membership = await resolveUserOrgMembership(workos, user.id, orgId);
+ const membership = await resolveUserOrgMembership(workos, user, orgId);
if (!membership) {
return res.status(403).json({ error: 'Access denied' });
}
@@ -3953,15 +4101,20 @@ export function createOrganizationsRouter(): Router {
if (request.status !== 'pending') {
return res.status(400).json({ error: 'Request already resolved' });
}
+ const currentDenialMembership = await resolveUserOrgMembership(workos, user, orgId);
+ if (!currentDenialMembership || !['admin', 'owner'].includes(currentDenialMembership.role)) {
+ return res.status(403).json({ error: 'Access denied', message: 'Your organization authority changed. Please try again.' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(user);
- const resolved = await resolveSeatUpgradeRequest(requestId, 'denied', user.id);
+ const resolved = await resolveSeatUpgradeRequest(requestId, 'denied', actorCredentialId);
if (!resolved) {
return res.status(409).json({ error: 'Request was already resolved' });
}
await orgDb.recordAuditLog({
workos_organization_id: orgId,
- workos_user_id: user.id,
+ workos_user_id: actorCredentialId,
action: 'seat_upgrade_denied',
resource_type: 'seat_upgrade_request',
resource_id: requestId,
@@ -3982,5 +4135,173 @@ export function createOrganizationsRouter(): Router {
}
});
+ // POST /api/organizations/:orgId/credential-grants
+ // Grant one exact WorkOS credential access without copying or rewriting
+ // any WorkOS organization membership.
+ router.post('/:orgId/credential-grants', requireAuth, async (req, res) => {
+ const { orgId } = req.params;
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
+ const targetCredentialId = req.body?.workos_user_id;
+ const role = req.body?.role ?? 'member';
+ const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : null;
+ const effectiveUntil = req.body?.effective_until;
+
+ if (typeof targetCredentialId !== 'string' || !targetCredentialId.startsWith('user_')) {
+ return res.status(400).json({ error: 'workos_user_id is required' });
+ }
+ if (!['member', 'admin', 'owner'].includes(role)) {
+ return res.status(400).json({ error: 'role must be member, admin, or owner' });
+ }
+ let effectiveUntilDate: Date | null = null;
+ if (effectiveUntil !== undefined && effectiveUntil !== null) {
+ effectiveUntilDate = new Date(effectiveUntil);
+ if (!Number.isFinite(effectiveUntilDate.getTime()) || effectiveUntilDate <= new Date()) {
+ return res.status(400).json({ error: 'effective_until must be a future timestamp' });
+ }
+ }
+
+ const actorMembership = await resolveUserOrgMembership(credentialGrantWorkos, req.user!, orgId);
+ if (!actorMembership || !['admin', 'owner'].includes(actorMembership.role)) {
+ return res.status(403).json({ error: 'Only organization owners and admins can grant credential access' });
+ }
+ if (role === 'owner' && actorMembership.role !== 'owner') {
+ return res.status(403).json({ error: 'Only an organization owner can grant owner access' });
+ }
+
+ const pool = getPool();
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ const target = await client.query(
+ `SELECT 1 FROM users WHERE workos_user_id = $1 FOR UPDATE`,
+ [targetCredentialId],
+ );
+ if (target.rowCount === 0) {
+ await client.query('ROLLBACK');
+ return res.status(404).json({ error: 'Credential not found' });
+ }
+ const active = await client.query(
+ `SELECT 1 FROM organization_credential_grants
+ WHERE workos_organization_id = $1 AND workos_user_id = $2 AND revoked_at IS NULL
+ FOR UPDATE`,
+ [orgId, targetCredentialId],
+ );
+ if (active.rowCount) {
+ await client.query('ROLLBACK');
+ return res.status(409).json({ error: 'An active credential grant already exists' });
+ }
+ const currentActorMembership = await resolveUserOrgMembership(credentialGrantWorkos, req.user!, orgId);
+ if (!currentActorMembership || !['admin', 'owner'].includes(currentActorMembership.role)) {
+ await client.query('ROLLBACK');
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
+ if (role === 'owner' && currentActorMembership.role !== 'owner') {
+ await client.query('ROLLBACK');
+ return res.status(403).json({ error: 'Only an organization owner can grant owner access' });
+ }
+ const grant = await client.query<{ id: string }>(
+ `INSERT INTO organization_credential_grants (
+ workos_organization_id, workos_user_id, role,
+ granted_by_workos_user_id, reason, effective_until
+ ) VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id`,
+ [orgId, targetCredentialId, role, actorCredentialId, reason, effectiveUntilDate],
+ );
+ const actorIdentity = await client.query<{ identity_id: string }>(
+ `SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1`,
+ [actorCredentialId],
+ );
+ await client.query(
+ `INSERT INTO registry_audit_log (
+ workos_organization_id, workos_user_id, action, resource_type, resource_id, details
+ ) VALUES ($1, $2, 'credential_grant_created', 'credential_grant', $3, $4)`,
+ [orgId, actorCredentialId, grant.rows[0].id, JSON.stringify({
+ authenticated_credential_id: actorCredentialId,
+ resolved_identity_id: actorIdentity.rows[0]?.identity_id ?? null,
+ target_credential_id: targetCredentialId,
+ role,
+ effective_until: effectiveUntilDate?.toISOString() ?? null,
+ })],
+ );
+ await client.query('COMMIT');
+ return res.status(201).json({ grant_id: grant.rows[0].id, workos_user_id: targetCredentialId, role });
+ } catch (error) {
+ await client.query('ROLLBACK').catch(() => undefined);
+ logger.error({ error, orgId, targetCredentialId }, 'Failed to create credential grant');
+ return res.status(500).json({ error: 'Failed to create credential grant' });
+ } finally {
+ client.release();
+ }
+ });
+
+ // DELETE /api/organizations/:orgId/credential-grants/:grantId
+ // Revocation is an update so original grant provenance remains durable.
+ router.delete('/:orgId/credential-grants/:grantId', requireAuth, async (req, res) => {
+ const { orgId, grantId } = req.params;
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user!);
+ const actorMembership = await resolveUserOrgMembership(credentialGrantWorkos, req.user!, orgId);
+ if (!actorMembership || !['admin', 'owner'].includes(actorMembership.role)) {
+ return res.status(403).json({ error: 'Only organization owners and admins can revoke credential access' });
+ }
+
+ const pool = getPool();
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ const grant = await client.query<{ workos_user_id: string; role: string }>(
+ `SELECT workos_user_id, role
+ FROM organization_credential_grants
+ WHERE id = $1 AND workos_organization_id = $2 AND revoked_at IS NULL
+ FOR UPDATE`,
+ [grantId, orgId],
+ );
+ if (!grant.rows[0]) {
+ await client.query('ROLLBACK');
+ return res.status(404).json({ error: 'Active credential grant not found' });
+ }
+ if (grant.rows[0].role === 'owner' && actorMembership.role !== 'owner') {
+ await client.query('ROLLBACK');
+ return res.status(403).json({ error: 'Only an organization owner can revoke owner access' });
+ }
+ const currentActorMembership = await resolveUserOrgMembership(credentialGrantWorkos, req.user!, orgId);
+ if (!currentActorMembership || !['admin', 'owner'].includes(currentActorMembership.role)) {
+ await client.query('ROLLBACK');
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
+ if (grant.rows[0].role === 'owner' && currentActorMembership.role !== 'owner') {
+ await client.query('ROLLBACK');
+ return res.status(403).json({ error: 'Only an organization owner can revoke owner access' });
+ }
+ await client.query(
+ `UPDATE organization_credential_grants
+ SET revoked_at = NOW(), revoked_by_workos_user_id = $1, updated_at = NOW()
+ WHERE id = $2`,
+ [actorCredentialId, grantId],
+ );
+ const actorIdentity = await client.query<{ identity_id: string }>(
+ `SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1`,
+ [actorCredentialId],
+ );
+ await client.query(
+ `INSERT INTO registry_audit_log (
+ workos_organization_id, workos_user_id, action, resource_type, resource_id, details
+ ) VALUES ($1, $2, 'credential_grant_revoked', 'credential_grant', $3, $4)`,
+ [orgId, actorCredentialId, grantId, JSON.stringify({
+ authenticated_credential_id: actorCredentialId,
+ resolved_identity_id: actorIdentity.rows[0]?.identity_id ?? null,
+ target_credential_id: grant.rows[0].workos_user_id,
+ })],
+ );
+ await client.query('COMMIT');
+ return res.json({ revoked: true, grant_id: grantId });
+ } catch (error) {
+ await client.query('ROLLBACK').catch(() => undefined);
+ logger.error({ error, orgId, grantId }, 'Failed to revoke credential grant');
+ return res.status(500).json({ error: 'Failed to revoke credential grant' });
+ } finally {
+ client.release();
+ }
+ });
+
return router;
}
diff --git a/server/src/routes/referrals.ts b/server/src/routes/referrals.ts
index 676ed6ef20..47d93ff6f3 100644
--- a/server/src/routes/referrals.ts
+++ b/server/src/routes/referrals.ts
@@ -4,9 +4,11 @@ import { getReferralCode, acceptReferralCode, getAcceptedReferralForOrg } from '
import { MemberDatabase } from '../db/member-db.js';
import { BrandDatabase, resolveBrandFromJson } from '../db/brand-db.js';
import { requireAuth } from '../middleware/auth.js';
-import { resolvePrimaryOrganization } from '../db/users-db.js';
import { getBrandPrimaryDomain } from '../services/brand-domain-resolver.js';
import { emailPrefsDb } from '../db/email-preferences-db.js';
+import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
+import { getWorkos } from '../auth/workos-client.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
const logger = createLogger('referral-routes');
const memberDb = new MemberDatabase();
@@ -90,13 +92,17 @@ export function createReferralsRouter(): Router {
router.post('/referral/:code/accept', requireAuth, async (req, res) => {
try {
const { code } = req.params;
- const { marketing_opt_in } = req.body || {};
- const userId = req.user!.id;
+ const { marketing_opt_in, organization_id } = req.body || {};
+ const userId = getOrganizationAuthorizationUserId(req.user!);
- const orgId = await resolvePrimaryOrganization(userId);
- if (!orgId) {
- return res.status(400).json({ error: 'No organization associated with your account' });
+ if (typeof organization_id !== 'string' || !organization_id.trim()) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ const membership = await resolveUserOrgMembership(getWorkos(), req.user!, organization_id);
+ if (!membership) {
+ return res.status(403).json({ error: 'Not authorized for the selected organization' });
}
+ const orgId = membership.organizationId;
// Check if this org already has an active accepted referral
const existing = await getAcceptedReferralForOrg(orgId);
@@ -109,6 +115,9 @@ export function createReferralsRouter(): Router {
});
}
+ if (!await resolveUserOrgMembership(getWorkos(), req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
const referral = await acceptReferralCode(code, orgId, userId);
if (!referral) {
@@ -160,14 +169,18 @@ export function createReferralsRouter(): Router {
// Returns null referral if none exists
router.get('/me/referral', requireAuth, async (req, res) => {
try {
- const userId = req.user!.id;
-
- const orgId = await resolvePrimaryOrganization(userId);
+ const orgId = typeof req.query.org === 'string' && req.query.org.trim()
+ ? req.query.org
+ : null;
if (!orgId) {
- return res.json({ referral: null });
+ return res.status(400).json({ error: 'org query parameter is required' });
+ }
+ const membership = await resolveUserOrgMembership(getWorkos(), req.user!, orgId);
+ if (!membership) {
+ return res.status(403).json({ error: 'Not authorized for the selected organization' });
}
- const referral = await getAcceptedReferralForOrg(orgId);
+ const referral = await getAcceptedReferralForOrg(membership.organizationId);
res.json({
referral: referral || null,
discount_percent: referral?.discount_percent ?? null,
diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts
index 85ffdc7ca9..bb18d57771 100644
--- a/server/src/routes/registry-api.ts
+++ b/server/src/routes/registry-api.ts
@@ -12,7 +12,6 @@ import { z } from "zod";
import escapeHtml from "escape-html";
import {
findOwnedAgentVisibility,
- findOwnerOrgForUser,
isOrgOwnerOfAgent,
resolveOwnerOrgForUser,
} from "../services/agent-ownership.js";
@@ -22,7 +21,9 @@ import type { Agent, AgentType, AgentWithStats } from "../types.js";
import { isValidAgentType } from "../types.js";
import { MemberDatabase } from "../db/member-db.js";
import { query, withDatabaseDeadline } from "../db/client.js";
-import { resolvePrimaryOrganization } from "../db/users-db.js";
+import { getWorkos } from "../auth/workos-client.js";
+import { resolveUserOrgMembership } from "../utils/resolve-user-org-membership.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
import * as manifestRefsDb from "../db/manifest-refs-db.js";
import { isUuid } from "../utils/uuid.js";
import { AsyncSemaphore, SemaphoreOverloadedError } from "../utils/async-semaphore.js";
@@ -157,7 +158,7 @@ import { classifyProbeError } from "../utils/probe-error.js";
import { isWebUserAAOAdmin } from "../addie/admin-status-lookup.js";
import { getDevUser, isDevModeEnabled } from "../middleware/auth.js";
import { OrganizationDatabase, hasApiAccess, resolveMembershipTier } from "../db/organization-db.js";
-import { resolveCallerOrgId } from "./helpers/resolve-caller-org.js";
+import { resolveCallerOrganization } from "./helpers/resolve-caller-org.js";
import { canonicalizeAgentUrl, PublisherDatabase } from "../db/publisher-db.js";
import { buildCreativeCapabilities } from "../creative-agent/task-handlers.js";
import {
@@ -3366,6 +3367,9 @@ registry.registerPath({
params: z.object({
encodedUrl: z.string().openapi({ description: "URL-encoded agent URL", example: "https%3A%2F%2Fexample.com%2Fmcp" }),
}),
+ query: z.object({
+ org: z.string().openapi({ description: "Explicit organization used for member access." }),
+ }),
},
responses: {
200: { description: "Compliance detail", content: { "application/json": { schema: AgentComplianceDetailSchema } } },
@@ -3584,6 +3588,7 @@ registry.registerPath({
"application/json": {
schema: z.object({
agent_urls: z.array(z.string()).max(100).openapi({ description: "Agent URLs to fetch storyboard status for" }),
+ organization_id: z.string().openapi({ description: "Explicit organization used for member access." }),
}),
},
},
@@ -3663,6 +3668,7 @@ registry.registerPath({
"application/json": {
schema: z.object({
lifecycle_stage: z.enum(["development", "testing", "production", "deprecated"]),
+ organization_id: z.string().openapi({ description: "Explicit organization that owns the agent." }),
}),
},
},
@@ -3695,6 +3701,7 @@ registry.registerPath({
"application/json": {
schema: z.object({
opt_out: z.boolean(),
+ organization_id: z.string().openapi({ description: "Explicit organization that owns the agent." }),
}),
},
},
@@ -3724,6 +3731,9 @@ registry.registerPath({
params: z.object({
encodedUrl: z.string().openapi({ description: "URL-encoded agent URL" }),
}),
+ query: z.object({
+ org: z.string().openapi({ description: "Explicit organization that owns the agent." }),
+ }),
},
responses: {
200: { description: "Monitoring settings", content: { "application/json": { schema: MonitoringSettingsSchema } } },
@@ -3752,6 +3762,7 @@ registry.registerPath({
"application/json": {
schema: z.object({
paused: z.boolean(),
+ organization_id: z.string().openapi({ description: "Explicit organization that owns the agent." }),
}),
},
},
@@ -3784,6 +3795,7 @@ registry.registerPath({
"application/json": {
schema: z.object({
interval_hours: z.number().int().min(6).max(168),
+ organization_id: z.string().openapi({ description: "Explicit organization that owns the agent." }),
}),
},
},
@@ -3811,6 +3823,15 @@ registry.registerPath({
params: z.object({
encodedUrl: z.string().openapi({ description: "URL-encoded agent URL" }),
}),
+ body: {
+ content: {
+ "application/json": {
+ schema: z.object({
+ organization_id: z.string().openapi({ description: "Explicit organization that owns the agent." }),
+ }),
+ },
+ },
+ },
},
responses: {
200: { description: "Agent requeued", content: { "application/json": { schema: z.object({ requeued: z.boolean() }) } } },
@@ -3836,6 +3857,7 @@ registry.registerPath({
encodedUrl: z.string().openapi({ description: "URL-encoded agent URL" }),
}),
query: z.object({
+ org: z.string().openapi({ description: "Explicit organization that owns the agent." }),
run_id: z.string().optional().openapi({ description: "Specific compliance run UUID. Defaults to latest." }),
limit: z.string().optional().openapi({ description: "Max rows (default 500, max 1000)" }),
}),
@@ -3875,6 +3897,7 @@ registry.registerPath({
encodedUrl: z.string().openapi({ description: "URL-encoded agent URL" }),
}),
query: z.object({
+ org: z.string().openapi({ description: "Explicit organization that owns the agent." }),
limit: z.string().optional().openapi({ description: "Max results (default 50, max 200)" }),
since: z.string().optional().openapi({ description: "ISO 8601 timestamp to filter from" }),
}),
@@ -3917,7 +3940,7 @@ registry.registerPath({
content: {
"application/json": {
schema: z.object({
- organization_id: z.string().optional().openapi({ description: "Selected organization ID. The caller must own the agent in this organization before its credentials are used." }),
+ organization_id: z.string().openapi({ description: "Selected organization ID. The caller must own the agent in this organization before its credentials are used." }),
}),
},
},
@@ -3991,13 +4014,14 @@ registry.registerPath({
encodedUrl: z.string().openapi({ description: "URL-encoded agent URL" }),
}),
query: z.object({
- org: z.string().optional().openapi({ description: "Selected organization ID. Required to disambiguate an agent URL registered by multiple organizations." }),
+ org: z.string().openapi({ description: "Selected organization ID. Required to authorize the exact credential." }),
}),
},
responses: {
200: { description: "Auth status", content: { "application/json": { schema: AgentAuthStatusSchema } } },
400: { description: "Invalid agent URL", content: { "application/json": { schema: ErrorSchema } } },
401: { description: "Authentication required", content: { "application/json": { schema: ErrorSchema } } },
+ 403: { description: "Not authorized for the selected organization", content: { "application/json": { schema: ErrorSchema } } },
500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } },
},
});
@@ -4021,7 +4045,7 @@ registry.registerPath({
schema: z.object({
auth_token: z.string().max(4096).optional().openapi({ description: "Bearer or basic auth token" }),
auth_type: z.enum(["bearer", "basic"]).optional().openapi({ description: "Auth type (default: bearer)" }),
- organization_id: z.string().optional().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
+ organization_id: z.string().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
}),
},
},
@@ -4071,7 +4095,7 @@ registry.registerPath({
resource: z.union([z.string().max(2048), z.array(z.string().max(2048)).min(1).max(8)]).optional().openapi({ description: 'RFC 8707 resource indicator. Accepts a single URI string or an array of 1–8 URI strings for multi-resource authorization servers (Keycloak strict, AWS Cognito multi-RS).' }),
audience: z.string().max(2048).optional().openapi({ description: "Audience parameter for audience-validating authorization servers." }),
auth_method: z.enum(["basic", "body"]).optional().openapi({ description: "Client-credentials placement: basic (HTTP Basic header, RFC 6749 §2.3.1 preferred) or body (form fields). SDK default is basic." }),
- organization_id: z.string().optional().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
+ organization_id: z.string().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
}),
},
},
@@ -4118,7 +4142,7 @@ registry.registerPath({
content: {
"application/json": {
schema: z.object({
- organization_id: z.string().optional().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
+ organization_id: z.string().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
}),
},
},
@@ -4172,7 +4196,7 @@ registry.registerPath({
encodedUrl: z.string().openapi({ description: "URL-encoded agent URL" }),
}),
query: z.object({
- org: z.string().optional().openapi({ description: "Selected organization ID. Required to disambiguate an agent URL registered by multiple organizations." }),
+ org: z.string().openapi({ description: "Selected organization ID. Required to authorize the exact credential." }),
}),
},
responses: {
@@ -4360,6 +4384,9 @@ registry.registerPath({
schema: z.object({
domain: z.string().openapi({ example: "acmecorp.com" }),
brand_name: z.string(),
+ organization_id: z.string().min(1).openapi({
+ description: "Organization explicitly selected for this mutation. The authenticated credential must have an active membership in this organization.",
+ }),
brand_json: z.record(z.string(), z.any()).optional().openapi({
description: "Optional full brand.json draft to host in the registry. Every recognized logo URL must be an absolute HTTPS URL of at most 2048 characters without userinfo credentials, backslashes, or markup-significant characters. The primary brand color must use #RRGGBB format.",
}),
@@ -4522,7 +4549,7 @@ registry.registerPath({
schema: z.object({
context: z.record(z.string(), z.unknown()).optional().openapi({ description: "Optional context object for the step" }),
dry_run: z.boolean().optional().openapi({ description: "Dry run mode (default: true)" }),
- organization_id: z.string().optional().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
+ organization_id: z.string().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
}),
},
},
@@ -4628,7 +4655,7 @@ registry.registerPath({
content: {
"application/json": {
schema: z.object({
- organization_id: z.string().optional().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
+ organization_id: z.string().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
}),
},
},
@@ -4692,7 +4719,7 @@ registry.registerPath({
content: {
"application/json": {
schema: z.object({
- organization_id: z.string().optional().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
+ organization_id: z.string().openapi({ description: "Selected organization ID. The caller must own the agent in this organization." }),
}),
},
},
@@ -4732,9 +4759,8 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router {
}
function parseRequestedOrganizationId(value: unknown):
- | { ok: true; organizationId: string | undefined }
+ | { ok: true; organizationId: string }
| { ok: false } {
- if (value === undefined) return { ok: true, organizationId: undefined };
if (
typeof value !== "string" ||
value.length === 0 ||
@@ -4747,7 +4773,7 @@ function parseRequestedOrganizationId(value: unknown):
}
function parseRequestedOrganizationQuery(query: Record):
- | { ok: true; organizationId: string | undefined }
+ | { ok: true; organizationId: string }
| { ok: false } {
// Express's simple query parser preserves bracketed keys literally, so
// `?org[]=...` would otherwise look like an omitted `org`. Reject alternate
@@ -5664,7 +5690,11 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
// (bind-on-verify), only that owner may edit the record. Unverified
// community rows remain openly editable (the contribute-back path).
if (existing.origin_verified_at && existing.workos_organization_id) {
- const callerOrgId = await resolveCallerOrgId(req);
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status === 'forbidden') {
+ return res.status(403).json({ error: 'Not authorized for the explicitly selected organization' });
+ }
+ const callerOrgId = callerOrg.status === 'authorized' ? callerOrg.organizationId : null;
if (existing.workos_organization_id !== callerOrgId) {
return res.status(403).json({
error: "Domain is locked to its verified owner; only the owner can edit this record",
@@ -5730,7 +5760,11 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (!isValidDomain(domain)) {
return res.status(400).json({ error: 'Invalid domain' });
}
- const callerOrgId = await resolveCallerOrgId(req);
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status === 'forbidden') {
+ return res.status(403).json({ error: 'Not authorized for the explicitly selected organization' });
+ }
+ const callerOrgId = callerOrg.status === 'authorized' ? callerOrg.organizationId : null;
if (!callerOrgId) {
return res.status(403).json({ error: 'Claiming a domain requires membership in an organization' });
}
@@ -5786,7 +5820,11 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
// Disclose bound_org_id only to the org that bound it. Binding is
// token-driven, so a third party may trigger verification — but it
// shouldn't learn which org just bound the domain.
- const callerOrgId = await resolveCallerOrgId(req);
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status === 'forbidden') {
+ return res.status(403).json({ error: 'Not authorized for the explicitly selected organization' });
+ }
+ const callerOrgId = callerOrg.status === 'authorized' ? callerOrg.organizationId : null;
if (outcome.bound_org_id !== callerOrgId) {
outcome.bound_org_id = undefined;
}
@@ -6225,7 +6263,11 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
// members (Professional+). Crawlers and anonymous callers only see
// public agents.
let includeMembersOnly = false;
- const callerOrgId = await resolveCallerOrgId(req);
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status === 'forbidden') {
+ return res.status(403).json({ error: 'Not authorized for the explicitly selected organization' });
+ }
+ const callerOrgId = callerOrg.status === 'authorized' ? callerOrg.organizationId : null;
if (callerOrgId) {
const org = await orgDb.getOrganization(callerOrgId);
if (org && hasApiAccess(resolveMembershipTier(org))) {
@@ -6580,8 +6622,13 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
const userId = req.user?.id;
let ownerMembership;
try {
+ const ownerOrgSelection = parseRequestedOrganizationQuery(req.query);
ownerMembership = await resolveOwnerMembership(userId, agentUrl, {
- resolveOwnerOrgId: resolveAgentOwnerOrg,
+ resolveOwnerOrgId: async () => (
+ req.user && ownerOrgSelection.ok
+ ? resolveOwnerOrgForUser(req.user, agentUrl, ownerOrgSelection.organizationId)
+ : null
+ ),
fetchOrgMembership: async (orgId) => {
const orgRow = await query<{ membership_tier: string | null; subscription_status: string | null }>(
`SELECT membership_tier, subscription_status
@@ -7015,7 +7062,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
async function isRegistryAdminRequest(req: Request): Promise {
if (isStaticAdminRequest(req)) return true;
- const user = req.user as ({ id?: string; email?: string; isAdmin?: boolean } | undefined);
+ const user = req.user as ({ id?: string; authWorkosUserId?: string; email?: string; isAdmin?: boolean } | undefined);
if (!user) return false;
if (user.isAdmin === true) return true;
@@ -7025,7 +7072,10 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
const adminEmails = process.env.ADMIN_EMAILS?.split(',').map(e => e.trim().toLowerCase()) ?? [];
if (user.email && adminEmails.includes(user.email.toLowerCase())) return true;
if (!user.id) return false;
- return isWebUserAAOAdmin(user.id);
+ return isWebUserAAOAdmin(getOrganizationAuthorizationUserId({
+ id: user.id,
+ authWorkosUserId: user.authWorkosUserId,
+ }));
}
router.get(
@@ -7043,7 +7093,11 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
}
if (!isStaticAdminRequest(req)) {
- await enrichUserWithMembership(req.user as any);
+ const orgSelection = parseRequestedOrganizationQuery(req.query);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "org must be a non-empty organization ID" });
+ }
+ await enrichUserWithMembership(req.user as any, orgSelection.organizationId);
}
if (!isStaticAdminRequest(req) && !(req.user as any).isMember) {
return res.status(403).json({
@@ -7092,7 +7146,11 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
}
if (!isStaticAdminRequest(req)) {
- await enrichUserWithMembership(req.user as any);
+ const orgSelection = parseRequestedOrganizationId(req.body?.organization_id);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
+ }
+ await enrichUserWithMembership(req.user as any, orgSelection.organizationId);
}
if (!isStaticAdminRequest(req) && !(req.user as any).isMember) {
return res.status(403).json({
@@ -7152,19 +7210,12 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
const complianceWriteMiddleware = authMiddleware ? [authMiddleware] : [];
- // `resolveAgentOwnerOrg` is now a thin alias for the shared helper. The
- // closure-scoped alias is kept so existing call sites inside this factory
- // don't need to thread the import.
- const resolveAgentOwnerOrg = findOwnerOrgForUser;
-
- async function verifyAgentOwnership(userId: string, agentUrl: string): Promise {
- return (await resolveAgentOwnerOrg(userId, agentUrl)) !== null;
- }
-
async function canViewAgentDebugData(req: Request, agentUrl: string): Promise {
if (isStaticAdminRequest(req)) return true;
if (!req.user) return false;
- return verifyAgentOwnership(req.user.id, agentUrl);
+ const orgSelection = parseRequestedOrganizationQuery(req.query);
+ if (!orgSelection.ok) return false;
+ return (await resolveOwnerOrgForUser(req.user, agentUrl, orgSelection.organizationId)) !== null;
}
// Shared SSRF-resistant URL validator lives in utils/url-security.ts so the
@@ -7178,11 +7229,15 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
* `/api/oauth/agent/start?agent_context_id=...` link even if they never
* opened the connect form. Idempotent.
*/
- async function ensureAgentContextId(orgId: string, agentUrl: string, userId: string): Promise {
+ async function ensureAgentContextId(
+ orgId: string,
+ agentUrl: string,
+ principal: NonNullable,
+ ): Promise {
try {
const canonicalUrl = canonicalizeAgentUrl(agentUrl);
if (!canonicalUrl) return null;
- if (!(await isOrgOwnerOfAgent(orgId, userId, canonicalUrl))) {
+ if ((await resolveOwnerOrgForUser(principal, canonicalUrl, orgId)) !== orgId) {
logger.warn({ orgId, agentUrl: canonicalUrl }, "Refusing to create agent context outside owning organization");
return null;
}
@@ -7191,7 +7246,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
context = await agentContextDb.create({
organization_id: orgId,
agent_url: canonicalUrl,
- created_by: userId,
+ created_by: getOrganizationAuthorizationUserId(principal),
});
}
return context.id;
@@ -7212,8 +7267,12 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(401).json({ error: "Authentication required" });
}
- const isOwner = await verifyAgentOwnership(req.user.id, agentUrl);
- if (!isOwner) {
+ const orgSelection = parseRequestedOrganizationId(req.body?.organization_id);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
+ }
+ const ownerOrgId = await resolveOwnerOrgForUser(req.user, agentUrl, orgSelection.organizationId);
+ if (!ownerOrgId) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
@@ -7223,6 +7282,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (!lifecycle_stage || !validStages.includes(lifecycle_stage)) {
return res.status(400).json({ error: `lifecycle_stage must be one of: ${validStages.join(", ")}` });
}
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, ownerOrgId)) !== ownerOrgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
const metadata = await complianceDb.upsertRegistryMetadata(agentUrl, {
lifecycle_stage: lifecycle_stage as LifecycleStage,
@@ -7248,8 +7310,12 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(401).json({ error: "Authentication required" });
}
- const isOwner = await verifyAgentOwnership(req.user.id, agentUrl);
- if (!isOwner) {
+ const orgSelection = parseRequestedOrganizationId(req.body?.organization_id);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
+ }
+ const ownerOrgId = await resolveOwnerOrgForUser(req.user, agentUrl, orgSelection.organizationId);
+ if (!ownerOrgId) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
@@ -7259,12 +7325,15 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "opt_out must be a boolean" });
}
- const eventActor = `user:${req.user.id}`;
- const agentVisibility = await findOwnedAgentVisibility(req.user.id, agentUrl);
+ const eventActor = `user:${getOrganizationAuthorizationUserId(req.user)}`;
+ const agentVisibility = await findOwnedAgentVisibility(ownerOrgId, agentUrl);
if (!agentVisibility) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
const isPublicAgent = agentVisibility === 'public';
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, ownerOrgId)) !== ownerOrgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
const transition = await complianceDb.setComplianceOptOut(
agentUrl,
opt_out,
@@ -7310,8 +7379,12 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (!req.user) {
return res.status(401).json({ error: "Authentication required" });
}
- const isOwner = await verifyAgentOwnership(req.user.id, agentUrl);
- if (!isOwner) {
+ const orgSelection = parseRequestedOrganizationQuery(req.query);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "org must be a non-empty organization ID" });
+ }
+ const ownerOrgId = await resolveOwnerOrgForUser(req.user, agentUrl, orgSelection.organizationId);
+ if (!ownerOrgId) {
return res.status(403).json({ error: "You do not have permission to view this agent" });
}
@@ -7332,8 +7405,12 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (!req.user) {
return res.status(401).json({ error: "Authentication required" });
}
- const isOwner = await verifyAgentOwnership(req.user.id, agentUrl);
- if (!isOwner) {
+ const orgSelection = parseRequestedOrganizationId(req.body?.organization_id);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
+ }
+ const ownerOrgId = await resolveOwnerOrgForUser(req.user, agentUrl, orgSelection.organizationId);
+ if (!ownerOrgId) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
@@ -7341,6 +7418,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (typeof paused !== "boolean") {
return res.status(400).json({ error: "paused must be a boolean" });
}
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, ownerOrgId)) !== ownerOrgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
await complianceDb.updateMonitoringPaused(agentUrl, paused);
const settings = await complianceDb.getMonitoringSettings(agentUrl);
@@ -7360,8 +7440,12 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (!req.user) {
return res.status(401).json({ error: "Authentication required" });
}
- const isOwner = await verifyAgentOwnership(req.user.id, agentUrl);
- if (!isOwner) {
+ const orgSelection = parseRequestedOrganizationId(req.body?.organization_id);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
+ }
+ const ownerOrgId = await resolveOwnerOrgForUser(req.user, agentUrl, orgSelection.organizationId);
+ if (!ownerOrgId) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
@@ -7369,6 +7453,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (typeof interval_hours !== "number" || !Number.isInteger(interval_hours) || interval_hours < 6 || interval_hours > 168) {
return res.status(400).json({ error: "interval_hours must be an integer between 6 and 168" });
}
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, ownerOrgId)) !== ownerOrgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
await complianceDb.updateCheckInterval(agentUrl, interval_hours);
const settings = await complianceDb.getMonitoringSettings(agentUrl);
@@ -7402,8 +7489,12 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
if (!req.user) {
return res.status(401).json({ error: "Authentication required" });
}
- const isOwner = await verifyAgentOwnership(req.user.id, agentUrl);
- if (!isOwner) {
+ const orgSelection = parseRequestedOrganizationId(req.body?.organization_id);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
+ }
+ const ownerOrgId = await resolveOwnerOrgForUser(req.user, agentUrl, orgSelection.organizationId);
+ if (!ownerOrgId) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
@@ -7413,8 +7504,10 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
const retryAfter = Math.ceil((REQUEUE_AGENT_RATE_LIMIT_MS - (now - lastRequeue)) / 1000);
return res.status(429).json({ error: "Rate limited", retry_after: retryAfter });
}
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, ownerOrgId)) !== ownerOrgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
requeueAgentRateLimits.set(agentUrl, now);
-
await complianceDb.requeueForHeartbeat(agentUrl);
res.json({ requeued: true });
} catch (error) {
@@ -7462,7 +7555,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
}
const ownerOrgId = await resolveOwnerOrgForUser(
- req.user.id,
+ req.user,
agentUrl,
orgSelection.organizationId,
);
@@ -7474,7 +7567,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
// (no DEV_USER_EMAIL/DEV_USER_ID) this branch never fires.
const isStaticAdmin = isStaticAdminRequest(req);
const isOwner = ownerOrgId !== null;
- const isAaoAdmin = await isWebUserAAOAdmin(req.user.id);
+ const isAaoAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId(req.user));
const isDevAdmin = isDevModeEnabled() && getDevUser(req)?.isAdmin === true;
if (!isOwner && !isAaoAdmin && !isDevAdmin && !isStaticAdmin) {
return res.status(403).json({ error: "You do not have permission to refresh this agent" });
@@ -7611,6 +7704,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
);
dbInput.dry_run = false;
dbInput.triggered_org_id = ownerOrgId;
+ if (ownerOrgId && (await resolveOwnerOrgForUser(req.user, agentUrl, ownerOrgId)) !== ownerOrgId) {
+ throw new Error('Organization authorization was revoked before the compliance result could be persisted');
+ }
const { run, storyboardStatuses } = await complianceDb.recordComplianceRun(dbInput);
const passing = storyboardStatuses.filter(s => s.status === 'passing').length;
complianceSummary = {
@@ -7787,9 +7883,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "org must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
- return res.json(noAuthResponse);
+ return res.status(403).json({ error: "You do not have permission to view this agent" });
}
const context = await agentContextDb.getByOrgAndUrl(orgId, agentUrl);
@@ -7869,10 +7965,13 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, orgId)) !== orgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
// Get or create agent context
let context = await agentContextDb.getByOrgAndUrl(orgId, agentUrl);
@@ -7880,12 +7979,15 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
context = await agentContextDb.create({
organization_id: orgId,
agent_url: agentUrl,
- created_by: req.user.id,
+ created_by: getOrganizationAuthorizationUserId(req.user),
});
}
// Save auth token if provided
if (authTokenToStore) {
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, orgId)) !== orgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
await agentContextDb.saveAuthToken(context.id, authTokenToStore, resolvedAuthType);
}
@@ -7957,20 +8059,26 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
return res.status(403).json({ error: "You do not have permission to modify this agent" });
}
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, orgId)) !== orgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
let context = await agentContextDb.getByOrgAndUrl(orgId, agentUrl);
if (!context) {
context = await agentContextDb.create({
organization_id: orgId,
agent_url: agentUrl,
- created_by: req.user.id,
+ created_by: getOrganizationAuthorizationUserId(req.user),
});
}
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, orgId)) !== orgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
await agentContextDb.saveOAuthClientCredentials(context.id, parsed.creds);
// Re-probe with the freshly-saved credentials so the stored
@@ -8031,7 +8139,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
return res.status(403).json({ error: "You do not have permission to test this agent" });
}
@@ -8139,7 +8247,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "org must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
return res.status(403).json({ error: "You do not have permission to test this agent" });
}
@@ -8162,7 +8270,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
// flow instead of rendering a storyboard list they can't run.
const probeStep = caps.steps?.[0];
if (probeStep && !probeStep.passed && isOAuthRequiredErrorMessage(probeStep.error)) {
- const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user.id);
+ const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user);
return res.status(422).json({
error: "This agent requires OAuth authorization. Connect via OAuth to run storyboards.",
needs_oauth: true,
@@ -8306,7 +8414,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
return res.status(403).json({ error: "You do not have permission to test this agent" });
}
@@ -8366,7 +8474,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
);
if (!result.passed && isOAuthRequiredErrorMessage(result.error)) {
- const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user.id);
+ const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user);
return res.json({
requested_compliance_target: runTarget.requested,
adcp_version: runTarget.version,
@@ -8443,7 +8551,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
return res.status(403).json({ error: "You do not have permission to test this agent" });
}
@@ -8480,7 +8588,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
const complyResult = await comply(agentUrl, complyOptions, runTarget);
if (complyResult.overall_status === 'auth_required') {
- const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user.id);
+ const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user);
return res.status(422).json({
error: "Agent requires OAuth authorization. Connect via OAuth to run this storyboard.",
needs_oauth: true,
@@ -8509,6 +8617,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
"owner_test",
[req.params.storyboardId],
);
+ if ((await resolveOwnerOrgForUser(req.user, agentUrl, orgId)) !== orgId) {
+ return res.status(403).json({ error: "Your organization authorization was revoked" });
+ }
const { run } = await complianceDb.recordComplianceRun({
...dbInput,
triggered_org_id: orgId,
@@ -8646,7 +8757,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
}
const requestedOrgId = orgSelection.organizationId;
- const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, requestedOrgId);
+ const orgId = await resolveOwnerOrgForUser(req.user, agentUrl, requestedOrgId);
if (!orgId) {
return res.status(403).json({ error: "You do not have permission to test this agent" });
}
@@ -8675,7 +8786,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
]);
if (userResult.overall_status === 'auth_required') {
- const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user.id);
+ const agentContextId = await ensureAgentContextId(orgId, agentUrl, req.user);
return res.status(422).json({
error: "Agent requires OAuth authorization. Connect via OAuth to compare against the reference agent.",
needs_oauth: true,
@@ -8827,7 +8938,11 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
}
: null;
- const callerOrgId = await resolveCallerOrgId(req);
+ const callerOrg = await resolveCallerOrganization(req);
+ if (callerOrg.status === 'forbidden') {
+ return res.status(403).json({ error: 'Not authorized for the explicitly selected organization' });
+ }
+ const callerOrgId = callerOrg.status === 'authorized' ? callerOrg.organizationId : null;
// `scope` is a narrowing filter — it picks WHICH visibility buckets the
// caller wants, but each bucket is still gated by auth (it can never
@@ -10481,6 +10596,14 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
}
}
+ const orgSelection = parseRequestedOrganizationId(req.body.organization_id);
+ if (!orgSelection.ok) {
+ return res.status(400).json({ error: "organization_id must be a non-empty organization ID" });
+ }
+ if (!orgSelection.organizationId) {
+ return res.status(400).json({ error: "organization_id is required" });
+ }
+
try {
// Check whether brand.json is already live on their domain
let hasBrandJson = false;
@@ -10491,17 +10614,24 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
// Validation failure is non-fatal — domain just doesn't have brand.json yet
}
- // Look up the user's primary org once — used for both hosted brand creation and profile linking
- const orgId = await resolvePrimaryOrganization(req.user!.id);
+ // Bind this mutation to the organization explicitly selected by the
+ // exact credential that authenticated. Linked/primary credentials are
+ // not authorization inputs.
+ const membership = await resolveUserOrgMembership(
+ getWorkos(),
+ req.user!,
+ orgSelection.organizationId,
+ );
+ if (!membership) {
+ return res.status(403).json({
+ error: 'The authenticated credential is not an active member of the selected organization',
+ });
+ }
+ const orgId = membership.organizationId;
// Verify the requested domain belongs to this org (matches a WorkOS-verified domain or subdomain).
// Skipped in dev mode (DEV_USER_EMAIL set) since dev orgs are not in WorkOS.
const devMode = !!(process.env.DEV_USER_EMAIL && process.env.DEV_USER_ID);
- if (!devMode && !orgId) {
- return res.status(403).json({
- error: 'A verified organization is required to set up a brand',
- });
- }
if (!devMode) {
const orgDomainsResult = await query<{ domain: string }>(
'SELECT domain FROM organization_domains WHERE workos_organization_id = $1 AND verified = true',
@@ -10553,6 +10683,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R
}
const existing = await brandDb.getHostedBrandByDomain(domain);
+ if (!await resolveUserOrgMembership(getWorkos(), req.user!, orgId)) {
+ return res.status(403).json({ error: 'Organization authorization was revoked' });
+ }
if (existing) {
// Only lock once domain_verified=true — unverified claims can be overwritten.
// A verified domain with no org (e.g. crawler-verified before setup) is also locked.
diff --git a/server/src/routes/tavus.ts b/server/src/routes/tavus.ts
index c1126cfca7..c2dfe191d6 100644
--- a/server/src/routes/tavus.ts
+++ b/server/src/routes/tavus.ts
@@ -45,6 +45,9 @@ import {
createAdminToolHandlers,
isWebUserAAOAdmin,
} from "../addie/mcp/admin-tools.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
+import { resolveUserOrgMembership } from "../utils/resolve-user-org-membership.js";
+import { getWorkos } from "../auth/workos-client.js";
import {
EVENT_READONLY_TOOLS,
EVENT_ADMIN_TOOLS,
@@ -175,10 +178,11 @@ function validateLlmSecret(req: Request): boolean {
async function buildVoiceRequestTools(
userId: string,
threadId: string,
+ selectedOrganizationId: string,
): Promise<{ requestTools: RequestTools; requestContext: string; memberContext: MemberContext | null }> {
let memberContext: MemberContext | null = null;
try {
- memberContext = await getWebMemberContext(userId);
+ memberContext = await getWebMemberContext(userId, selectedOrganizationId);
} catch (error) {
logger.warn({ error, userId }, "Tavus: Failed to get member context");
}
@@ -371,8 +375,10 @@ export function createTavusRouter() {
// dashboard / direct API call.
return res.status(404).json({ error: "Conversation not found" });
}
- if (thread.user_id !== req.user.id) {
- const userIsAdmin = await isWebUserAAOAdmin(req.user.id).catch(() => false);
+ if (thread.user_id !== getOrganizationAuthorizationUserId(req.user)) {
+ const userIsAdmin = await isWebUserAAOAdmin(
+ getOrganizationAuthorizationUserId(req.user),
+ ).catch(() => false);
if (!userIsAdmin) {
return res.status(403).json({ error: "Forbidden" });
}
@@ -446,6 +452,7 @@ export function createTavusRouter() {
// these via an Advanced Settings panel; the standard /video page sends
// none of them and gets the defaults below.
const settings = (req.body ?? {}) as {
+ organization_id?: string;
greeting?: string;
extraContext?: string;
maxDurationSec?: number;
@@ -453,6 +460,16 @@ export function createTavusRouter() {
language?: string;
disableFillers?: boolean;
};
+ const selectedOrganizationId = typeof settings.organization_id === 'string'
+ ? settings.organization_id.trim()
+ : '';
+ if (!selectedOrganizationId) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+ const actorCredentialId = getOrganizationAuthorizationUserId(req.user);
+ if (!await resolveUserOrgMembership(getWorkos(), req.user, selectedOrganizationId)) {
+ return res.status(403).json({ error: 'You do not have access to the selected organization' });
+ }
const greetingOverride = boundedTrimmedTavusSetting(
settings.greeting,
TAVUS_SETTING_LIMITS.greeting,
@@ -475,7 +492,11 @@ export function createTavusRouter() {
// Create a thread to track this video conversation
const threadService = getThreadService();
- const threadContext: Record = { persona_id: personaId };
+ const threadContext: Record = {
+ persona_id: personaId,
+ authorization_workos_user_id: actorCredentialId,
+ selected_organization_id: selectedOrganizationId,
+ };
if (disableFillers) threadContext.disable_fillers = true;
if (sessionGuidance) {
threadContext.video_session_guidance = sessionGuidance;
@@ -484,7 +505,7 @@ export function createTavusRouter() {
channel: "video",
external_id: conversationName,
user_type: "workos",
- user_id: req.user.id,
+ user_id: actorCredentialId,
user_display_name: displayName,
context: threadContext,
});
@@ -618,7 +639,20 @@ export function createTavusRouter() {
sessionGuidance = readTavusSessionGuidance(
thread.context?.video_session_guidance
);
- const result = await buildVoiceRequestTools(thread.user_id, threadId);
+ const selectedOrganizationId = typeof thread.context?.selected_organization_id === 'string'
+ ? thread.context.selected_organization_id
+ : null;
+ const authorizationUserId = typeof thread.context?.authorization_workos_user_id === 'string'
+ ? thread.context.authorization_workos_user_id
+ : thread.user_id;
+ if (!selectedOrganizationId) {
+ throw new Error('Video session is missing explicit organization context');
+ }
+ const result = await buildVoiceRequestTools(
+ authorizationUserId,
+ threadId,
+ selectedOrganizationId,
+ );
voiceRequestTools = result.requestTools;
memberRequestContext = result.requestContext;
logger.debug(
diff --git a/server/src/schemas/member-agents-openapi.ts b/server/src/schemas/member-agents-openapi.ts
index d02a171afd..ed930bbc67 100644
--- a/server/src/schemas/member-agents-openapi.ts
+++ b/server/src/schemas/member-agents-openapi.ts
@@ -10,9 +10,9 @@ import { z } from 'zod';
import { registry, ErrorSchema } from './registry.js';
const OrgQuerySchema = z.object({
- org: z.string().optional().openapi({
+ org: z.string().openapi({
description:
- "WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.",
+ 'Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.',
example: 'org_01HXZAB123',
}),
});
@@ -112,10 +112,6 @@ const MemberAgentResponseSchema = z
.object({
agent: MemberAgentSchema,
warnings: z.array(MemberAgentVisibilityWarningSchema).optional(),
- org_auto_created: z.boolean().optional().openapi({
- description:
- "Set to `true` when this `POST` was the caller's first interaction with the registry and the server auto-created the organization (display name derived from the user's email domain for corporate emails, or `'s Workspace` for free-email providers). Combined with `profile_auto_created`, this is the one-call storefront experience: a third-party app holding only an OAuth token gets the org, profile, and registered agent in a single request.",
- }),
profile_auto_created: z.boolean().optional().openapi({
description:
'Set to `true` when this `POST` was the first agent registration on the caller\'s organization and the server auto-created a private member profile (display name = organization name, `is_public: false`). Absent on subsequent calls and on update-in-place. Surfaced so storefront-style integrations can show a "we set up your profile" hint without needing to detect the prior 404 → bootstrap → retry shape.',
@@ -145,7 +141,7 @@ registry.registerPath({
content: { 'application/json': { schema: MemberAgentListResponseSchema } },
},
400: {
- description: 'No organization associated with this account',
+ description: 'The required `org` query parameter is missing.',
content: { 'application/json': { schema: ErrorSchema } },
},
401: {
@@ -154,7 +150,7 @@ registry.registerPath({
},
403: {
description:
- '`?org=` was supplied but the caller is not a member of that organization.',
+ 'The exact authenticated credential is not authorized for the selected organization.',
content: { 'application/json': { schema: ErrorSchema } },
},
404: {
@@ -173,10 +169,7 @@ registry.registerPath({
description: [
"Register an agent on the caller's organization member profile.",
'Idempotent on `url`: re-posting the same `url` updates the entry in place rather than creating a duplicate. New entries return `201`; updates return `200`.',
- "**True one-call storefront experience.** A third-party app holding only a user's OAuth token can `POST /api/me/agents` once and have the entire bootstrap chain materialize:",
- "- If the caller has zero org memberships, the server auto-creates an organization (corporate or personal workspace based on the user's email domain) and the response includes `org_auto_created: true`.",
- "- If the caller's org has no member profile, the server auto-creates a private profile (display name = organization name, `is_public: false`) and the response includes `profile_auto_created: true`.",
- "Both auto-bootstraps are best-effort fallbacks. To customize org name / company_type / revenue_tier, or to control profile slug / brand identity / tagline, call `POST /api/organizations` and `POST /api/me/member-profile` explicitly before registering the agent. Tier transitions never happen via this path — go through the billing flow.",
+ 'The `org` query parameter is required. If the selected organization has no member profile, the server creates a private profile (display name = organization name, `is_public: false`) and includes `profile_auto_created: true`.',
"`type` is required and declared by the caller — the server does not infer it. Server-side smuggle protection still cross-checks the declared type against the agent's capability snapshot when one exists; if the snapshot contradicts the declaration without classifying it, the stored value is `unknown` and the dashboard surfaces the conflict for the owner to resolve.",
'`visibility: "public"` requires a paid AAO tier (Professional, Builder, Member, or Leader) and a verified primary domain on the organization (set via the Linked Domains UI). Non-API-tier callers (Explorer or no tier) who request `public` will have the entry stored as `members_only` instead, and the response will include a `visibility_downgraded` warning describing the coercion.',
].join('\n\n'),
@@ -198,7 +191,7 @@ registry.registerPath({
},
400: {
description:
- 'Missing or invalid `url`, missing/invalid `type`, or the caller has memberships in other orgs but no primary org set — pass `?org=` to target one explicitly. (Fresh users with no memberships at all hit the org auto-bootstrap path and do not see this error.)',
+ 'Missing required `org`, missing or invalid `url`, or missing/invalid `type`.',
content: { 'application/json': { schema: ErrorSchema } },
},
401: {
@@ -207,12 +200,12 @@ registry.registerPath({
},
403: {
description:
- '`?org=` was supplied but the caller is not a member of that organization.',
+ 'The exact authenticated credential is not authorized for the selected organization.',
content: { 'application/json': { schema: ErrorSchema } },
},
404: {
description:
- 'Auto-bootstrap could not run (e.g. the organization has no name yet). Call `POST /api/me/member-profile` to create a profile explicitly, then retry.',
+ 'No member profile exists and a private profile could not be created.',
content: { 'application/json': { schema: ErrorSchema } },
},
429: {
@@ -248,7 +241,7 @@ registry.registerPath({
},
400: {
description:
- 'No organization associated with this account, or `body.url` differs from the path (`url_immutable`).',
+ 'The required `org` query parameter is missing, or `body.url` differs from the path (`url_immutable`).',
content: { 'application/json': { schema: ErrorSchema } },
},
401: {
@@ -257,7 +250,7 @@ registry.registerPath({
},
403: {
description:
- '`?org=` was supplied but the caller is not a member of that organization.',
+ 'The exact authenticated credential is not authorized for the selected organization.',
content: { 'application/json': { schema: ErrorSchema } },
},
404: {
@@ -290,7 +283,7 @@ registry.registerPath({
responses: {
204: { description: 'Agent removed.' },
400: {
- description: 'No organization associated with this account',
+ description: 'The required `org` query parameter is missing.',
content: { 'application/json': { schema: ErrorSchema } },
},
401: {
@@ -299,7 +292,7 @@ registry.registerPath({
},
403: {
description:
- '`?org=` was supplied but the caller is not a member of that organization.',
+ 'The exact authenticated credential is not authorized for the selected organization.',
content: { 'application/json': { schema: ErrorSchema } },
},
404: {
diff --git a/server/src/services/agent-ownership.ts b/server/src/services/agent-ownership.ts
index fc5d16e6e5..cbd0ec6b9a 100644
--- a/server/src/services/agent-ownership.ts
+++ b/server/src/services/agent-ownership.ts
@@ -1,42 +1,25 @@
/**
- * Agent-ownership helpers — single source of truth for "who owns this agent."
+ * Agent ownership is a two-part authorization decision:
*
- * The ownership relation has three distinct semantic uses:
+ * 1. the exact authenticated credential must have live access to an explicit
+ * organization; and
+ * 2. that organization must own the requested agent URL.
*
- * 1. `findOwnerOrgForUser(userId, agentUrl)` — "what org owns this agent
- * for this user?" Returns the org_id (or null) for ANY org the user
- * is a member of that has the agent in its member_profile. Used by
- * callers that only need to establish ownership by any organization.
- *
- * 2. `findSoleOwnerOrgForUser(userId, agentUrl)` — compatibility lookup
- * for credential-bearing routes whose caller omitted org context. It
- * succeeds only when exactly one organization matches.
- *
- * 3. `isOrgOwnerOfAgent(orgId, userId, agentUrl)` — "is THIS specific
- * org the one that owns the agent for this user?" Tighter predicate
- * than (1): requires the resolved org context to match the agent's
- * owning org. Used by `evaluate_agent_quality`'s canonical-write
- * gate where the calling-context org is known and must be confirmed
- * as the owner (not "some org the user belongs to").
- *
- * All queries join `member_profiles.agents` against `organization_memberships`
- * — the canonical ownership relation. The shared helpers exist because
- * inlining the JOIN at every call site is a drift surface (PR #4250 review
- * flagged the duplication); a single shared helper keeps the predicate in
- * one place.
- *
- * Note on active-membership filtering: `organization_memberships` has no
- * status column in this schema — removed members get their row deleted, not
- * status-flipped. Row existence is the membership signal.
+ * The member profile is the ownership record. Local membership mirrors are
+ * deliberately not joined here because they are neither revocation-aware nor
+ * safe to union across credentials linked to the same identity.
*/
import { query } from '../db/client.js';
import { canonicalizeAgentUrl } from '../db/publisher-db.js';
+import { getWorkos } from '../auth/workos-client.js';
+import type { OrgAuthorizationPrincipal } from '../auth/organization-principal.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
import type { AgentVisibility } from '../types.js';
-/** Resolve the owned agent's directory visibility without exposing it publicly. */
+/** Resolve an owned agent's visibility inside an already-authorized org. */
export async function findOwnedAgentVisibility(
- userId: string,
+ organizationId: string,
agentUrl: string,
): Promise {
try {
@@ -44,13 +27,11 @@ export async function findOwnedAgentVisibility(
const result = await query<{ visibility: string | null }>(
`SELECT agent->>'visibility' AS visibility
FROM member_profiles mp
- JOIN organization_memberships om
- ON om.workos_organization_id = mp.workos_organization_id
CROSS JOIN LATERAL jsonb_array_elements(mp.agents) agent
- WHERE agent->>'url' = $1
- AND om.workos_user_id = $2
+ WHERE mp.workos_organization_id = $1
+ AND agent->>'url' = $2
LIMIT 1`,
- [lookupAgentUrl, userId],
+ [organizationId, lookupAgentUrl],
);
if (!result.rows[0]) return null;
const visibility = result.rows[0].visibility;
@@ -62,91 +43,20 @@ export async function findOwnedAgentVisibility(
}
}
-/**
- * Find the org id of any org the user is a member of that owns the agent.
- * Returns null if no such org exists (user is not the owner, or anonymous).
- *
- * Used for permission checks where we don't yet know which org context the
- * caller is acting from — the resolver discovers it via the join.
- */
-export async function findOwnerOrgForUser(
- userId: string,
- agentUrl: string,
-): Promise {
- try {
- const lookupAgentUrl = canonicalizeAgentUrl(agentUrl) ?? agentUrl;
- const result = await query<{ workos_organization_id: string }>(
- `SELECT mp.workos_organization_id
- FROM member_profiles mp
- JOIN organization_memberships om
- ON om.workos_organization_id = mp.workos_organization_id
- WHERE mp.agents @> $1::jsonb
- AND om.workos_user_id = $2
- LIMIT 1`,
- [JSON.stringify([{ url: lookupAgentUrl }]), userId],
- );
- return result.rows[0]?.workos_organization_id ?? null;
- } catch {
- return null;
- }
-}
-
-/**
- * Resolve an owner only when the URL maps to exactly one organization for
- * this user. Credential-bearing routes use this compatibility path when an
- * older caller omits its organization selection. Multiple matches must fail
- * closed: choosing either tenant would select the wrong encryption context.
- */
-export async function findSoleOwnerOrgForUser(
- userId: string,
- agentUrl: string,
-): Promise {
- try {
- const lookupAgentUrl = canonicalizeAgentUrl(agentUrl) ?? agentUrl;
- const result = await query<{ workos_organization_id: string }>(
- `SELECT DISTINCT mp.workos_organization_id
- FROM member_profiles mp
- JOIN organization_memberships om
- ON om.workos_organization_id = mp.workos_organization_id
- WHERE mp.agents @> $1::jsonb
- AND om.workos_user_id = $2
- LIMIT 2`,
- [JSON.stringify([{ url: lookupAgentUrl }]), userId],
- );
- return result.rows.length === 1
- ? result.rows[0].workos_organization_id
- : null;
- } catch {
- return null;
- }
-}
-
-/**
- * Verify the given org is the org that owns the agent for the given user.
- * Tighter than `findOwnerOrgForUser` — requires the org_id in the calling
- * context (e.g., the resolved member-context organization) to match the
- * agent's owning org.
- *
- * Used by canonical-state writers (owner-test path in evaluate_agent_quality)
- * to ensure the acting principal's resolved org is actually the owner before
- * persisting public-state changes.
- */
+/** Check only the ownership record; caller authorization is resolved separately. */
export async function isOrgOwnerOfAgent(
orgId: string,
- userId: string,
+ _userId: string,
agentUrl: string,
): Promise {
try {
const lookupAgentUrl = canonicalizeAgentUrl(agentUrl) ?? agentUrl;
const result = await query(
- `SELECT 1 FROM member_profiles mp
- JOIN organization_memberships om
- ON om.workos_organization_id = mp.workos_organization_id
- WHERE mp.workos_organization_id = $1
- AND mp.agents @> $2::jsonb
- AND om.workos_user_id = $3
+ `SELECT 1 FROM member_profiles
+ WHERE workos_organization_id = $1
+ AND agents @> $2::jsonb
LIMIT 1`,
- [orgId, JSON.stringify([{ url: lookupAgentUrl }]), userId],
+ [orgId, JSON.stringify([{ url: lookupAgentUrl }])],
);
return result.rows.length > 0;
} catch {
@@ -155,25 +65,21 @@ export async function isOrgOwnerOfAgent(
}
/**
- * Resolve the owning organization for an agent operation, honoring an
- * explicit dashboard organization when one was supplied.
- *
- * A user can belong to more than one organization that registers the same
- * agent URL. In that case `findOwnerOrgForUser` is intentionally ambiguous;
- * dashboard writes must instead stay inside the organization the user
- * selected. The explicit path therefore fails closed when that organization
- * does not own the agent, rather than silently falling back to another org.
+ * Resolve an explicit owning organization for the exact request credential.
+ * Missing organization context always fails closed; there is no sole/primary
+ * organization compatibility fallback.
*/
export async function resolveOwnerOrgForUser(
- userId: string,
+ principal: OrgAuthorizationPrincipal,
agentUrl: string,
- requestedOrgId?: string,
+ requestedOrgId: string | undefined,
): Promise {
- if (requestedOrgId === undefined) {
- return findSoleOwnerOrgForUser(userId, agentUrl);
- }
+ if (!requestedOrgId) return null;
+
+ const membership = await resolveUserOrgMembership(getWorkos(), principal, requestedOrgId);
+ if (!membership) return null;
- return (await isOrgOwnerOfAgent(requestedOrgId, userId, agentUrl))
+ return (await isOrgOwnerOfAgent(requestedOrgId, principal.id, agentUrl))
? requestedOrgId
: null;
}
diff --git a/server/src/services/brand-logo-auth.ts b/server/src/services/brand-logo-auth.ts
index c5db921c22..6c4691cabd 100644
--- a/server/src/services/brand-logo-auth.ts
+++ b/server/src/services/brand-logo-auth.ts
@@ -8,8 +8,9 @@
import { WorkingGroupDatabase } from '../db/working-group-db.js';
import { BrandDatabase } from '../db/brand-db.js';
-import { query } from '../db/client.js';
import { createLogger } from '../logger.js';
+import { getWorkos } from '../auth/workos-client.js';
+import { resolveUserOrgMembership } from '../utils/resolve-user-org-membership.js';
const logger = createLogger('brand-logo-auth');
@@ -54,17 +55,14 @@ export async function isVerifiedBrandOwner(userId: string, domain: string, brand
const hosted = await brandDb.getHostedBrandByDomain(domain);
if (!hosted || !hosted.domain_verified) return false;
- // Check if user belongs to the org that owns this brand
+ // Recheck the exact authenticated credential against the live membership
+ // source (or an explicit credential grant) for the owning organization.
if (!hosted.workos_organization_id) return false;
-
- const result = await query<{ exists: boolean }>(
- `SELECT EXISTS(
- SELECT 1 FROM organization_memberships
- WHERE workos_user_id = $1 AND workos_organization_id = $2
- ) AS exists`,
- [userId, hosted.workos_organization_id]
- );
- return result.rows[0]?.exists ?? false;
+ return Boolean(await resolveUserOrgMembership(
+ getWorkos(),
+ { id: userId },
+ hosted.workos_organization_id,
+ ));
} catch (err) {
logger.error({ err, userId, domain }, 'Error checking brand ownership');
return false;
diff --git a/server/src/services/brand-property-parse.ts b/server/src/services/brand-property-parse.ts
index f3a4748e55..52413b9bf0 100644
--- a/server/src/services/brand-property-parse.ts
+++ b/server/src/services/brand-property-parse.ts
@@ -16,8 +16,6 @@ import Anthropic from '@anthropic-ai/sdk';
import { createLogger } from '../logger.js';
import { query } from '../db/client.js';
import { BrandDatabase } from '../db/brand-db.js';
-import { resolvePrimaryOrganization } from '../db/users-db.js';
-import { getBrandPrimaryDomain } from './brand-domain-resolver.js';
import { validateFetchUrl, safeFetch, sanitizeUrl } from '../utils/url-security.js';
import { ModelConfig } from '../config/models.js';
@@ -79,7 +77,7 @@ function getAnthropicClient(): Anthropic {
export async function getBrandForEdit(
brandDb: BrandDatabase,
domain: string,
- userId: string,
+ organizationId: string,
): Promise<
| { ok: true; brand: NonNullable>> }
| { ok: false; status: number; error: string }
@@ -97,24 +95,12 @@ export async function getBrandForEdit(
};
}
- const orgId = await resolvePrimaryOrganization(userId);
- if (!orgId) {
- return { ok: false, status: 403, error: 'No organization associated with your account' };
- }
-
- // Same TODO(#4159) trust gap as brand-feeds.ts: orgDomains walk doesn't
- // filter on verified=true, and the resolver fallback path uses
- // member_profiles for orgs Stage 0 missed. Stage 2 should add the
- // verified gate when the column drops.
const orgDomains = await query<{ domain: string }>(
- 'SELECT domain FROM organization_domains WHERE workos_organization_id = $1',
- [orgId],
+ `SELECT domain FROM organization_domains
+ WHERE workos_organization_id = $1 AND verified = TRUE`,
+ [organizationId],
);
- const brandPrimary = await getBrandPrimaryDomain(orgId);
- const ownedDomains = new Set([
- ...orgDomains.rows.map((r) => r.domain.toLowerCase()),
- ...(brandPrimary ? [brandPrimary.toLowerCase()] : []),
- ]);
+ const ownedDomains = new Set(orgDomains.rows.map((r) => r.domain.toLowerCase()));
if (!ownedDomains.has(domain.toLowerCase())) {
return { ok: false, status: 403, error: 'You do not own this brand domain' };
}
@@ -308,12 +294,12 @@ export async function fetchUrlForParse(
export async function parsePropertyInputForBrand(args: {
brandDb: BrandDatabase;
domain: string;
- userId: string;
+ organizationId: string;
input: string;
inputType: 'text' | 'url';
relationship?: Relationship;
}): Promise {
- const { brandDb, domain, userId, input, inputType } = args;
+ const { brandDb, domain, organizationId, input, inputType } = args;
if (typeof input !== 'string' || input.trim().length === 0) {
return { ok: false, status: 400, error: 'input required' };
}
@@ -330,7 +316,7 @@ export async function parsePropertyInputForBrand(args: {
const relationship: Relationship = args.relationship ?? 'delegated';
// Verify brand ownership before any outbound fetch or LLM spend.
- const auth = await getBrandForEdit(brandDb, domain, userId);
+ const auth = await getBrandForEdit(brandDb, domain, organizationId);
if (!auth.ok) return auth;
let rawText = input.trim();
@@ -368,10 +354,10 @@ export interface MergeReport {
export async function mergeBrandProperties(args: {
brandDb: BrandDatabase;
domain: string;
- userId: string;
+ organizationId: string;
properties: Array<{ identifier?: unknown; type?: unknown; relationship?: unknown; [k: string]: unknown }>;
}): Promise<{ ok: true; report: MergeReport } | { ok: false; status: number; error: string }> {
- const { brandDb, domain, userId, properties } = args;
+ const { brandDb, domain, organizationId, properties } = args;
if (!Array.isArray(properties)) {
return { ok: false, status: 400, error: 'properties array required' };
}
@@ -379,7 +365,7 @@ export async function mergeBrandProperties(args: {
return { ok: false, status: 400, error: `Maximum ${MAX_PROPERTIES} properties per request` };
}
- const auth = await getBrandForEdit(brandDb, domain, userId);
+ const auth = await getBrandForEdit(brandDb, domain, organizationId);
if (!auth.ok) return auth;
const brand = auth.brand;
diff --git a/server/src/services/membership-tiers.ts b/server/src/services/membership-tiers.ts
index 1342f2c277..b630b43a3e 100644
--- a/server/src/services/membership-tiers.ts
+++ b/server/src/services/membership-tiers.ts
@@ -219,3 +219,11 @@ export async function checkContentSubmissionTier(
// active/non-canceled global membership resolver.
return membership.is_member && isApiAccessTier(membership.membership_tier);
}
+
+/** Check content eligibility for an already-authorized explicit organization. */
+export async function checkOrganizationContentSubmissionTier(orgId: string): Promise {
+ const directMembership = await fetchDirectMembership(orgId);
+ if (isContentSubmissionMembershipEligible(directMembership)) return true;
+ const membership = await resolveEffectiveMembership(orgId);
+ return membership.is_member && isApiAccessTier(membership.membership_tier);
+}
diff --git a/server/src/types.ts b/server/src/types.ts
index c6d894a67d..f2ceb396c0 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -413,6 +413,8 @@ export interface WorkOSUser {
* across surfaces would let a client tie multiple emails to one person.
*/
identityId?: string;
+ /** Persisted authorization graph version checked before cached auth reuse. */
+ authorizationEpoch?: string;
/**
* The actual authenticated WorkOS user, before any identity-aware id
* swap. Set whenever {@link id} differs from the WorkOS-authenticated
diff --git a/server/src/utils/html-config.ts b/server/src/utils/html-config.ts
index 6f63558d2d..ebb8ed379b 100644
--- a/server/src/utils/html-config.ts
+++ b/server/src/utils/html-config.ts
@@ -12,9 +12,11 @@ import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { resolveEffectiveMembership } from "../db/org-filters.js";
-import { resolvePrimaryOrganization } from "../db/users-db.js";
import { createLogger } from "../logger.js";
import { isWebUserAAOAdmin } from "../addie/mcp/admin-tools.js";
+import { getOrganizationAuthorizationUserId } from "../auth/organization-principal.js";
+import { resolveUserOrgMembership } from "./resolve-user-org-membership.js";
+import { getWorkos } from "../auth/workos-client.js";
const logger = createLogger('html-config');
@@ -34,6 +36,7 @@ const POSTHOG_HOST = process.env.POSTHOG_HOST || 'https://us.i.posthog.com';
interface AppUser {
id?: string;
+ authWorkosUserId?: string;
email: string;
firstName?: string | null;
lastName?: string | null;
@@ -179,7 +182,10 @@ export async function enrichUserWithAdmin(user: AppUser | null | undefined): Pro
if (user.id) {
try {
- user.isAdmin = await isWebUserAAOAdmin(user.id);
+ user.isAdmin = await isWebUserAAOAdmin(getOrganizationAuthorizationUserId({
+ id: user.id,
+ authWorkosUserId: user.authWorkosUserId,
+ }));
} catch (error) {
logger.warn({ error, userId: user.id }, 'Failed to resolve isAdmin via working group; defaulting to false');
user.isAdmin = false;
@@ -194,12 +200,20 @@ export async function enrichUserWithAdmin(user: AppUser | null | undefined): Pro
* Enrich a user object with membership status from the database.
* Checks both direct and inherited membership via the brand registry hierarchy.
*/
-export async function enrichUserWithMembership(user: AppUser | null | undefined): Promise {
- if (!user?.id || user.isMember !== undefined) return user;
+export async function enrichUserWithMembership(
+ user: AppUser | null | undefined,
+ selectedOrganizationId?: string | null,
+): Promise {
+ if (!user?.id) return user;
+ if (!selectedOrganizationId) {
+ user.isMember = false;
+ return user;
+ }
try {
- const orgId = await resolvePrimaryOrganization(user.id);
- if (orgId) {
- const membership = await resolveEffectiveMembership(orgId);
+ const principal = { id: user.id, authWorkosUserId: user.authWorkosUserId };
+ const directAccess = await resolveUserOrgMembership(getWorkos(), principal, selectedOrganizationId);
+ if (directAccess) {
+ const membership = await resolveEffectiveMembership(selectedOrganizationId);
user.isMember = membership.is_member;
} else {
user.isMember = false;
diff --git a/server/src/utils/resolve-user-org-membership.ts b/server/src/utils/resolve-user-org-membership.ts
index cfe0568d71..7b9f0f465b 100644
--- a/server/src/utils/resolve-user-org-membership.ts
+++ b/server/src/utils/resolve-user-org-membership.ts
@@ -24,6 +24,15 @@ import { DEV_USERS, isDevModeEnabled } from '../middleware/auth.js';
import { resolveUserRole } from './resolve-user-role.js';
import { query } from '../db/client.js';
import { createLogger } from '../logger.js';
+import {
+ getOrganizationAuthorizationUserId,
+ type OrgAuthorizationPrincipal,
+} from '../auth/organization-principal.js';
+
+export {
+ getOrganizationAuthorizationUserId,
+ type OrgAuthorizationPrincipal,
+} from '../auth/organization-principal.js';
const logger = createLogger('resolve-user-org-membership');
@@ -36,6 +45,8 @@ export interface UserOrgMembership {
role: MembershipRole;
/** Membership status from WorkOS or 'active' for dev memberships. */
status: 'active' | 'pending' | 'inactive';
+ /** True when authority comes from an explicit organization credential grant. */
+ via_credential_grant: boolean;
/**
* True when the membership was resolved via the dev-mode bypass (local
* organization_memberships seed) rather than a live WorkOS lookup. Callers
@@ -46,7 +57,56 @@ export interface UserOrgMembership {
via_dev_bypass: boolean;
}
+export type OrgAuthorizationSource = 'workos' | 'credential_grant' | 'dev_membership_cache';
+
+export type UserOrgAuthorizationResolution =
+ | {
+ status: 'authorized';
+ membership: UserOrgMembership;
+ /** False when another authority source could not be consulted. */
+ complete: boolean;
+ unavailableSources: OrgAuthorizationSource[];
+ }
+ | {
+ status: 'forbidden';
+ complete: true;
+ unavailableSources: [];
+ }
+ | {
+ status: 'unavailable';
+ complete: false;
+ unavailableSources: OrgAuthorizationSource[];
+ };
+
+export type UserOrgRoleAuthorization =
+ | { status: 'authorized'; membership: UserOrgMembership }
+ | { status: 'forbidden' }
+ | { status: 'unavailable'; unavailableSources: OrgAuthorizationSource[] };
+
const VALID_ROLES: ReadonlySet = new Set(['owner', 'admin', 'member']);
+const ROLE_RANK: Record = { member: 1, admin: 2, owner: 3 };
+
+/**
+ * Apply a minimum-role requirement without converting an authority-source
+ * outage into a denial. A known sufficient role is safe to accept even when
+ * another independent source is unavailable. A known insufficient role is
+ * only a definitive 403 when every source was consulted successfully.
+ */
+export function evaluateUserOrgRoleAuthorization(
+ resolution: UserOrgAuthorizationResolution,
+ minimumRole: MembershipRole = 'member',
+): UserOrgRoleAuthorization {
+ if (resolution.status === 'forbidden') return { status: 'forbidden' };
+ if (resolution.status === 'unavailable') {
+ return { status: 'unavailable', unavailableSources: resolution.unavailableSources };
+ }
+ if (ROLE_RANK[resolution.membership.role] >= ROLE_RANK[minimumRole]) {
+ return { status: 'authorized', membership: resolution.membership };
+ }
+ return resolution.complete
+ ? { status: 'forbidden' }
+ : { status: 'unavailable', unavailableSources: resolution.unavailableSources };
+}
/**
* Resolve the caller's membership in the given org. Returns null when the
@@ -56,63 +116,147 @@ const VALID_ROLES: ReadonlySet = new Set(['owner', 'admin', 'member']);
* which dev-setup.ts seeds at boot — WorkOS doesn't know about dev users,
* so we can't defer to it. Production still goes through WorkOS.
*/
-export async function resolveUserOrgMembership(
+export async function resolveUserOrgAuthorization(
workos: WorkOS | null,
- userId: string,
+ principal: OrgAuthorizationPrincipal,
organizationId: string,
-): Promise {
+): Promise {
+ const userId = getOrganizationAuthorizationUserId(principal);
+ let directMembership: UserOrgMembership | null = null;
// Dev mode bypass: local membership cache is the source of truth.
if (isDevModeEnabled()) {
const devUser = Object.values(DEV_USERS).find((du) => du.id === userId);
if (devUser) {
- const result = await query<{ workos_organization_id: string; role: string }>(
- `SELECT workos_organization_id, role FROM organization_memberships
- WHERE workos_user_id = $1 AND workos_organization_id = $2`,
- [userId, organizationId],
- );
- if (result.rows.length === 0) return null;
- const membershipRow = result.rows[0];
- const rawRole = membershipRow.role || 'member';
- const role = (VALID_ROLES.has(rawRole) ? rawRole : 'member') as MembershipRole;
- return {
- organizationId: membershipRow.workos_organization_id,
- role,
- status: 'active',
- via_dev_bypass: true,
- };
+ try {
+ const result = await query<{ workos_organization_id: string; role: string }>(
+ `SELECT workos_organization_id, role FROM organization_memberships
+ WHERE workos_user_id = $1 AND workos_organization_id = $2`,
+ [userId, organizationId],
+ );
+ if (result.rows.length > 0) {
+ const membershipRow = result.rows[0];
+ const rawRole = membershipRow.role || 'member';
+ const role = (VALID_ROLES.has(rawRole) ? rawRole : 'member') as MembershipRole;
+ return {
+ status: 'authorized',
+ membership: {
+ organizationId: membershipRow.workos_organization_id,
+ role,
+ status: 'active',
+ via_dev_bypass: true,
+ via_credential_grant: false,
+ },
+ complete: true,
+ unavailableSources: [],
+ };
+ }
+ return { status: 'forbidden', complete: true, unavailableSources: [] };
+ } catch (err) {
+ logger.warn({ err, userId, organizationId }, 'Dev membership cache lookup failed');
+ return {
+ status: 'unavailable',
+ complete: false,
+ unavailableSources: ['dev_membership_cache'],
+ };
+ }
}
// Real users in dev mode (e.g. someone running tsx with their actual
// WorkOS account) fall through to the WorkOS path below.
}
// Prod path: WorkOS is the source of truth.
- if (!workos) {
- logger.warn({ userId, organizationId }, 'WorkOS client not available — cannot resolve membership');
- return null;
+ let workosAvailable = false;
+ if (workos) {
+ try {
+ const memberships = await workos.userManagement.listOrganizationMemberships({
+ userId,
+ organizationId,
+ });
+ const matchingMemberships = memberships.data.filter(
+ (membership) => membership.organizationId === organizationId,
+ );
+ const activeRow = matchingMemberships.find((membership) => membership.status === 'active');
+ const roleSlug = resolveUserRole(matchingMemberships);
+ if (activeRow && roleSlug && VALID_ROLES.has(roleSlug)) {
+ directMembership = {
+ organizationId: activeRow.organizationId,
+ role: roleSlug as MembershipRole,
+ status: 'active',
+ via_dev_bypass: false,
+ via_credential_grant: false,
+ };
+ }
+ workosAvailable = true;
+ } catch (err) {
+ logger.warn({ err, userId, organizationId }, 'WorkOS membership lookup failed; checking explicit credential grant');
+ }
+ } else {
+ logger.warn({ userId, organizationId }, 'WorkOS client not available; checking explicit credential grant');
+ }
+
+ let grantAvailable = false;
+ let grant: { rows: Array<{ workos_organization_id: string; role: string }> };
+ try {
+ grant = await query<{ workos_organization_id: string; role: string }>(
+ `SELECT workos_organization_id, role
+ FROM organization_credential_grants
+ WHERE workos_user_id = $1
+ AND workos_organization_id = $2
+ AND revoked_at IS NULL
+ AND effective_from <= NOW()
+ AND (effective_until IS NULL OR effective_until > NOW())
+ LIMIT 1`,
+ [userId, organizationId],
+ );
+ grantAvailable = true;
+ } catch (err) {
+ logger.warn({ err, userId, organizationId }, 'Credential grant lookup failed');
+ grant = { rows: [] };
+ }
+ const grantRow = grant.rows[0];
+ let effectiveMembership = directMembership;
+ if (grantRow && VALID_ROLES.has(grantRow.role)) {
+ const grantMembership: UserOrgMembership = {
+ organizationId: grantRow.workos_organization_id,
+ role: grantRow.role as MembershipRole,
+ status: 'active',
+ via_dev_bypass: false,
+ via_credential_grant: true,
+ };
+ if (!effectiveMembership || ROLE_RANK[grantMembership.role] > ROLE_RANK[effectiveMembership.role]) {
+ effectiveMembership = grantMembership;
+ }
+ }
+
+ const unavailableSources: OrgAuthorizationSource[] = [];
+ if (!workosAvailable) unavailableSources.push('workos');
+ if (!grantAvailable) unavailableSources.push('credential_grant');
+
+ if (effectiveMembership) {
+ return {
+ status: 'authorized',
+ membership: effectiveMembership,
+ complete: unavailableSources.length === 0,
+ unavailableSources,
+ };
}
+ if (unavailableSources.length > 0) {
+ return { status: 'unavailable', complete: false, unavailableSources };
+ }
+ return { status: 'forbidden', complete: true, unavailableSources: [] };
+}
- const memberships = await workos.userManagement.listOrganizationMemberships({
- userId,
- organizationId,
- });
-
- // Bind authorization to the organization ID returned by the authoritative
- // membership row instead of relying solely on the request filter.
- const matchingMemberships = memberships.data.filter(
- (membership) => membership.organizationId === organizationId,
- );
- if (matchingMemberships.length === 0) return null;
-
- const roleSlug = resolveUserRole(matchingMemberships);
- if (!roleSlug || !VALID_ROLES.has(roleSlug)) return null;
-
- const activeRow = matchingMemberships.find((m) => m.status === 'active');
- if (!activeRow) return null;
-
- return {
- organizationId: activeRow.organizationId,
- role: roleSlug as MembershipRole,
- status: 'active',
- via_dev_bypass: false,
- };
+/**
+ * Backward-compatible membership facade. Existing routes intentionally keep
+ * their current null/403 behavior until they are moved behind a rollout
+ * canary. New enforcement code must use resolveUserOrgAuthorization plus
+ * evaluateUserOrgRoleAuthorization so unavailable maps to 503.
+ */
+export async function resolveUserOrgMembership(
+ workos: WorkOS | null,
+ principal: OrgAuthorizationPrincipal,
+ organizationId: string,
+): Promise {
+ const resolution = await resolveUserOrgAuthorization(workos, principal, organizationId);
+ return resolution.status === 'authorized' ? resolution.membership : null;
}
diff --git a/server/tests/integration/addie-brand-property-tools.test.ts b/server/tests/integration/addie-brand-property-tools.test.ts
index ff5584f13a..ba4a4451d9 100644
--- a/server/tests/integration/addie-brand-property-tools.test.ts
+++ b/server/tests/integration/addie-brand-property-tools.test.ts
@@ -31,6 +31,11 @@ process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID ?? 'client_test';
const mocks = vi.hoisted(() => ({
anthropicCreate: vi.fn(),
+ resolveUserOrgMembership: vi.fn(),
+}));
+
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: (...args: unknown[]) => mocks.resolveUserOrgMembership(...args),
}));
vi.mock('@anthropic-ai/sdk', () => {
@@ -58,11 +63,13 @@ const OWNER_USER = `user_addie_owner_${SUFFIX}`;
const OUTSIDER_USER = `user_addie_outsider_${SUFFIX}`;
function memberCtx(userId: string): MemberContext {
+ const organizationId = userId === OWNER_USER ? OWNER_ORG : OUTSIDER_ORG;
return {
is_mapped: true,
is_member: true,
slack_linked: false,
workos_user: { workos_user_id: userId, email: `${userId}@test.example` },
+ organization: { workos_organization_id: organizationId, name: 'Test Org' },
} as unknown as MemberContext;
}
@@ -142,6 +149,7 @@ describe('Addie brand-property tools — integration', () => {
);
mocks.anthropicCreate.mockReset();
+ mocks.resolveUserOrgMembership.mockResolvedValue({ role: 'admin', source: 'workos' });
mocks.anthropicCreate.mockRejectedValue(
new Error('anthropic.messages.create was not stubbed for this test'),
);
@@ -154,6 +162,7 @@ describe('Addie brand-property tools — integration', () => {
const result = JSON.parse(
await handlers.get('parse_brand_properties')!({
domain: TEST_DOMAIN,
+ organization_id: OUTSIDER_ORG,
input: 'cnn.com\nbbc.co.uk',
input_type: 'text',
}),
@@ -168,6 +177,7 @@ describe('Addie brand-property tools — integration', () => {
const result = JSON.parse(
await handlers.get('import_brand_properties')!({
domain: TEST_DOMAIN,
+ organization_id: OUTSIDER_ORG,
properties: [{ identifier: 'cnn.com', type: 'website' }],
}),
);
@@ -191,6 +201,7 @@ describe('Addie brand-property tools — integration', () => {
const result = JSON.parse(
await handlers.get('parse_brand_properties')!({
domain: TEST_DOMAIN.toUpperCase(),
+ organization_id: OWNER_ORG,
input: 'cnn.com',
input_type: 'text',
}),
@@ -220,6 +231,7 @@ describe('Addie brand-property tools — integration', () => {
const preview = JSON.parse(
await handlers.get('parse_brand_properties')!({
domain: TEST_DOMAIN,
+ organization_id: OWNER_ORG,
input: 'CNN.com\ncom.example.app',
input_type: 'text',
}),
@@ -231,6 +243,7 @@ describe('Addie brand-property tools — integration', () => {
const commit = JSON.parse(
await handlers.get('import_brand_properties')!({
domain: TEST_DOMAIN,
+ organization_id: OWNER_ORG,
properties: preview.properties,
}),
);
@@ -258,6 +271,7 @@ describe('Addie brand-property tools — integration', () => {
const result = JSON.parse(
await handlers.get('import_brand_properties')!({
domain: TEST_DOMAIN,
+ organization_id: OWNER_ORG,
properties: [
{ identifier: 'direct.example', type: 'website', relationship: 'owned' },
],
@@ -282,6 +296,7 @@ describe('Addie brand-property tools — integration', () => {
const result = JSON.parse(
await handlers.get('import_brand_properties')!({
domain: TEST_DOMAIN,
+ organization_id: OWNER_ORG,
properties: [
{ identifier: 'cnn.com', type: 'website', relationship: 'owned' }, // update
{ identifier: 'bbc.co.uk', type: 'website', relationship: 'owned' }, // update
diff --git a/server/tests/integration/admin-bind-email.test.ts b/server/tests/integration/admin-bind-email.test.ts
index c82f7549f0..120e9b7c47 100644
--- a/server/tests/integration/admin-bind-email.test.ts
+++ b/server/tests/integration/admin-bind-email.test.ts
@@ -4,8 +4,8 @@
* Exercises POST /api/admin/users/:userId/linked-emails:
* - Creates a fresh WorkOS user for the new email (mocked).
* - Inserts into local users (trigger fires, creating a singleton identity).
- * - mergeUsers re-points the new user's binding to the existing user's
- * identity as is_primary = FALSE; drops the orphan singleton.
+ * - The state-empty attach operation re-points only the new credential's
+ * binding as is_primary = FALSE; drops the orphan singleton.
*
* After this, the existing user has two bound WorkOS users; the auth
* middleware will id-swap a non-primary login to the canonical id.
@@ -229,11 +229,22 @@ describe('POST /api/admin/users/:userId/linked-emails (admin bind)', () => {
it('rolls back the WorkOS user when local bind fails after createUser succeeded', async () => {
// Pre-create the new WorkOS user id locally so the post-createUser INSERT
- // would succeed, but force mergeUsers to fail by deleting the trigger-
+ // would succeed, but force the state-empty attach to fail by deleting the trigger-
// created identity binding for the existing user (Phase 1 trigger
// guarantees normally — we break the invariant to simulate a partial
// failure path).
- await pool.query(`DELETE FROM identity_workos_users WHERE workos_user_id = $1`, [EXISTING_USER_ID]);
+ await pool.query(
+ `DELETE FROM identities
+ WHERE id = (SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1)`,
+ [EXISTING_USER_ID],
+ );
+ const orphanBaseline = await pool.query<{ count: string }>(
+ `SELECT COUNT(*)::text AS count
+ FROM identities i
+ WHERE NOT EXISTS (
+ SELECT 1 FROM identity_workos_users iwu WHERE iwu.identity_id = i.id
+ )`,
+ );
const response = await request(app)
.post(`/api/admin/users/${EXISTING_USER_ID}/linked-emails`)
@@ -243,5 +254,18 @@ describe('POST /api/admin/users/:userId/linked-emails (admin bind)', () => {
expect(response.body.error).toMatch(/failed to bind/i);
expect(response.body.message).toMatch(/rolled back|retry/i);
expect(mockDeleteUser).toHaveBeenCalledWith(MOCK_NEW_WORKOS_USER_ID);
+ const local = await pool.query(
+ `SELECT 1 FROM users WHERE workos_user_id = $1`,
+ [MOCK_NEW_WORKOS_USER_ID],
+ );
+ expect(local.rows).toEqual([]);
+ const orphanIdentities = await pool.query<{ count: string }>(
+ `SELECT COUNT(*)::text AS count
+ FROM identities i
+ WHERE NOT EXISTS (
+ SELECT 1 FROM identity_workos_users iwu WHERE iwu.identity_id = i.id
+ )`,
+ );
+ expect(Number(orphanIdentities.rows[0].count)).toBe(Number(orphanBaseline.rows[0].count));
});
});
diff --git a/server/tests/integration/admin-link-unlink-credential.test.ts b/server/tests/integration/admin-link-unlink-credential.test.ts
index 3a5a8430af..a73ac03b0b 100644
--- a/server/tests/integration/admin-link-unlink-credential.test.ts
+++ b/server/tests/integration/admin-link-unlink-credential.test.ts
@@ -95,6 +95,12 @@ describe('admin link / unlink credential', () => {
});
async function cleanup() {
+ await pool.query(
+ `DELETE FROM registry_audit_log
+ WHERE resource_id = ANY($1)
+ AND action IN ('attach_state_empty_credential', 'unbind_credential')`,
+ [[HOST_USER_ID, TARGET_USER_ID]],
+ );
await pool.query(`DELETE FROM users WHERE workos_user_id IN ($1, $2)`, [HOST_USER_ID, TARGET_USER_ID]);
}
@@ -215,7 +221,7 @@ describe('admin link / unlink credential', () => {
expect(response.body.error).toMatch(/not found/i);
});
- it('refuses to consolidate a cred with existing app-state without explicit opt-in', async () => {
+ it('refuses to consolidate a credential with state even when consolidate is requested', async () => {
// Insert cred locally + give it an organization_membership (real AAO data)
await pool.query(
`INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified,
@@ -238,24 +244,21 @@ describe('admin link / unlink credential', () => {
try {
const refused = await request(app)
- .post(`/api/admin/users/${HOST_USER_ID}/credentials`)
- .send({ workos_user_id: TARGET_USER_ID })
- .expect(409);
- expect(refused.body.consolidate_confirmation_required).toBe(true);
- expect(refused.body.message).toMatch(/consolidate/i);
-
- // Same call with consolidate: true succeeds and moves the membership
- await request(app)
.post(`/api/admin/users/${HOST_USER_ID}/credentials`)
.send({ workos_user_id: TARGET_USER_ID, consolidate: true })
- .expect(201);
+ .expect(409);
+ expect(refused.body.error).toBe('credential_has_state');
+ expect(refused.body.references).toContainEqual({
+ table: 'organization_memberships',
+ column: 'workos_user_id',
+ });
- const moved = await pool.query(
+ const unchanged = await pool.query(
`SELECT workos_user_id FROM organization_memberships WHERE workos_organization_id = $1`,
[orgId]
);
- expect(moved.rows).toHaveLength(1);
- expect(moved.rows[0].workos_user_id).toBe(HOST_USER_ID);
+ expect(unchanged.rows).toHaveLength(1);
+ expect(unchanged.rows[0].workos_user_id).toBe(TARGET_USER_ID);
} finally {
await pool.query(`DELETE FROM organization_memberships WHERE workos_organization_id = $1`, [orgId]);
await pool.query(`DELETE FROM organizations WHERE workos_organization_id = $1`, [orgId]);
@@ -342,6 +345,49 @@ describe('admin link / unlink credential', () => {
expect(audit.rows[0].details.host_user_id).toBe(HOST_USER_ID);
});
+ it('leaves credential-scoped membership provenance unchanged when unbinding', async () => {
+ const orgId = 'org_test_unlink_provenance';
+ await pool.query(
+ `INSERT INTO organizations (workos_organization_id, name, created_at, updated_at)
+ VALUES ($1, 'Unlink Provenance Org', NOW(), NOW())
+ ON CONFLICT (workos_organization_id) DO NOTHING`,
+ [orgId],
+ );
+ await pool.query(
+ `INSERT INTO organization_memberships
+ (workos_user_id, workos_organization_id, workos_membership_id, email, role,
+ seat_type, provisioning_source, created_at, updated_at)
+ VALUES ($1, $2, 'om_unlink_provenance', 'target@test.example', 'admin',
+ 'contributor', 'admin_added', NOW(), NOW())`,
+ [TARGET_USER_ID, orgId],
+ );
+ try {
+ const before = await pool.query(
+ `SELECT id, workos_user_id, workos_organization_id, workos_membership_id,
+ email, role, seat_type, provisioning_source, created_at
+ FROM organization_memberships
+ WHERE workos_user_id = $1 AND workos_organization_id = $2`,
+ [TARGET_USER_ID, orgId],
+ );
+
+ await request(app)
+ .delete(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}`)
+ .expect(200);
+
+ const after = await pool.query(
+ `SELECT id, workos_user_id, workos_organization_id, workos_membership_id,
+ email, role, seat_type, provisioning_source, created_at
+ FROM organization_memberships
+ WHERE workos_user_id = $1 AND workos_organization_id = $2`,
+ [TARGET_USER_ID, orgId],
+ );
+ expect(after.rows).toEqual(before.rows);
+ } finally {
+ await pool.query(`DELETE FROM organization_memberships WHERE workos_organization_id = $1`, [orgId]);
+ await pool.query(`DELETE FROM organizations WHERE workos_organization_id = $1`, [orgId]);
+ }
+ });
+
it('refuses to remove the primary credential', async () => {
const response = await request(app)
.delete(`/api/admin/users/${HOST_USER_ID}/credentials/${HOST_USER_ID}`)
@@ -381,5 +427,49 @@ describe('admin link / unlink credential', () => {
.expect(404);
expect(response.body.error).toMatch(/not bound/i);
});
+
+ it('serializes concurrent unlink replays without orphan identities or duplicate success audits', async () => {
+ const orphanBaseline = await pool.query<{ count: string }>(
+ `SELECT COUNT(*)::text AS count
+ FROM identities i
+ WHERE NOT EXISTS (
+ SELECT 1 FROM identity_workos_users iwu WHERE iwu.identity_id = i.id
+ )`,
+ );
+ const hostIdentity = await pool.query<{ identity_id: string }>(
+ `SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1`,
+ [HOST_USER_ID],
+ );
+ const responses = await Promise.all([
+ request(app).delete(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}`),
+ request(app).delete(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}`),
+ ]);
+ expect(responses.map((response) => response.status).sort()).toEqual([200, 404]);
+
+ const targetBinding = await pool.query<{ identity_id: string; is_primary: boolean }>(
+ `SELECT identity_id, is_primary FROM identity_workos_users WHERE workos_user_id = $1`,
+ [TARGET_USER_ID],
+ );
+ expect(targetBinding.rows).toHaveLength(1);
+ expect(targetBinding.rows[0].is_primary).toBe(true);
+ expect(targetBinding.rows[0].identity_id).not.toBe(hostIdentity.rows[0].identity_id);
+
+ const orphanCount = await pool.query<{ count: string }>(
+ `SELECT COUNT(*)::text AS count
+ FROM identities i
+ WHERE NOT EXISTS (
+ SELECT 1 FROM identity_workos_users iwu WHERE iwu.identity_id = i.id
+ )`,
+ );
+ expect(Number(orphanCount.rows[0].count)).toBe(Number(orphanBaseline.rows[0].count));
+
+ const audits = await pool.query<{ count: string }>(
+ `SELECT COUNT(*)::text AS count
+ FROM registry_audit_log
+ WHERE action = 'unbind_credential' AND resource_id = $1`,
+ [TARGET_USER_ID],
+ );
+ expect(Number(audits.rows[0].count)).toBe(1);
+ });
});
});
diff --git a/server/tests/integration/admin-promote-credential.test.ts b/server/tests/integration/admin-promote-credential.test.ts
index c03167bf66..e810306d16 100644
--- a/server/tests/integration/admin-promote-credential.test.ts
+++ b/server/tests/integration/admin-promote-credential.test.ts
@@ -1,11 +1,6 @@
/**
- * Admin "promote credential to primary" integration test.
- *
- * Exercises POST /api/admin/users/:userId/credentials/:credentialId/promote.
- * The new primary should:
- * - hold all app-state previously on the old primary (org_memberships, etc.)
- * - have is_primary = TRUE; the old primary, FALSE
- * - record an audit row
+ * Primary-credential promotion remains disabled until every credential-owned
+ * row can retain its authority and provenance. These tests protect that gate.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
@@ -19,8 +14,7 @@ vi.hoisted(() => {
});
vi.mock('../../src/auth/workos-client.js', () => {
- const mockUserManagement = { getUser: vi.fn(), createUser: vi.fn(), deleteUser: vi.fn() };
- const mockWorkos = { userManagement: mockUserManagement };
+ const mockWorkos = { userManagement: { getUser: vi.fn() } };
return { workos: mockWorkos, getWorkos: () => mockWorkos };
});
@@ -32,10 +26,6 @@ vi.mock('../../src/middleware/auth.js', async (importOriginal) => {
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
- // Tests can set X-Test-Admin-Identity header to simulate an admin
- // who is bound to the same identity as the target — exercises the
- // self-promote guard.
- identityId: req.headers['x-test-admin-identity'] || undefined,
};
next();
};
@@ -45,10 +35,6 @@ vi.mock('../../src/middleware/auth.js', async (importOriginal) => {
requireAuth: mockedRequireAuth,
requireAdmin: passThrough,
optionalAuth: passThrough,
- // The exported `requireGlobalAdmin` array snapshots its element
- // references at module-load time, so the per-export mocks above
- // don't propagate. Re-build the array so admin/users routes
- // (`...requireGlobalAdmin`) reach the mocked handlers.
requireGlobalAdmin: [mockedRequireAuth, passThrough, passThrough],
};
});
@@ -57,8 +43,9 @@ vi.mock('../../src/middleware/csrf.js', () => ({
csrfProtection: (_req: any, _res: any, next: any) => next(),
}));
-import { initializeDatabase, closeDatabase, getPool } from '../../src/db/client.js';
+import { initializeDatabase, closeDatabase } from '../../src/db/client.js';
import { runMigrations } from '../../src/db/migrate.js';
+import { attachStateEmptyCredential } from '../../src/db/user-merge-db.js';
import { HTTPServer } from '../../src/http.js';
const HOST_USER_ID = 'user_test_promote_host';
@@ -66,7 +53,7 @@ const TARGET_USER_ID = 'user_test_promote_target';
const HOST_ORG_ID = 'org_test_promote_host';
const TARGET_ORG_ID = 'org_test_promote_target';
-describe('admin promote credential to primary', () => {
+describe('admin promote credential gate', () => {
let server: HTTPServer;
let app: any;
let pool: Pool;
@@ -89,204 +76,101 @@ describe('admin promote credential to primary', () => {
beforeEach(async () => {
await cleanup();
- // Insert two users; trigger creates a singleton identity for each
await pool.query(
`INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified,
workos_created_at, workos_updated_at, created_at, updated_at)
VALUES ($1, 'host@test.example', 'Host', 'User', true, NOW(), NOW(), NOW(), NOW()),
($2, 'target@test.example', 'Target', 'User', true, NOW(), NOW(), NOW(), NOW())`,
- [HOST_USER_ID, TARGET_USER_ID]
+ [HOST_USER_ID, TARGET_USER_ID],
);
+ await attachStateEmptyCredential(HOST_USER_ID, TARGET_USER_ID, 'user_test_admin_promote');
await pool.query(
`INSERT INTO organizations (workos_organization_id, name, created_at, updated_at)
- VALUES ($1, 'Host Org', NOW(), NOW()),
- ($2, 'Target Org', NOW(), NOW())
+ VALUES ($1, 'Host Org', NOW(), NOW()), ($2, 'Target Org', NOW(), NOW())
ON CONFLICT (workos_organization_id) DO NOTHING`,
- [HOST_ORG_ID, TARGET_ORG_ID]
+ [HOST_ORG_ID, TARGET_ORG_ID],
+ );
+ await pool.query(
+ `INSERT INTO organization_memberships
+ (workos_user_id, workos_organization_id, email, role, created_at, updated_at)
+ VALUES ($1, $2, 'host@test.example', 'admin', NOW(), NOW()),
+ ($3, $4, 'target@test.example', 'member', NOW(), NOW())`,
+ [HOST_USER_ID, HOST_ORG_ID, TARGET_USER_ID, TARGET_ORG_ID],
);
});
async function cleanup() {
await pool.query(
`DELETE FROM organization_memberships WHERE workos_organization_id IN ($1, $2)`,
- [HOST_ORG_ID, TARGET_ORG_ID]
+ [HOST_ORG_ID, TARGET_ORG_ID],
);
await pool.query(
`DELETE FROM organizations WHERE workos_organization_id IN ($1, $2)`,
- [HOST_ORG_ID, TARGET_ORG_ID]
+ [HOST_ORG_ID, TARGET_ORG_ID],
);
await pool.query(
`DELETE FROM users WHERE workos_user_id IN ($1, $2)`,
- [HOST_USER_ID, TARGET_USER_ID]
+ [HOST_USER_ID, TARGET_USER_ID],
);
}
- /**
- * Replicates the Ahmed shape: a host with one org, a bound target with
- * its own org, target gets promoted to primary; both orgs end up on
- * target's workos_user_id.
- */
- async function setupBoundPair() {
- // Host has Host Org
- await pool.query(
- `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, created_at, updated_at)
- VALUES ($1, $2, 'host@test.example', 'admin', NOW(), NOW())`,
- [HOST_USER_ID, HOST_ORG_ID]
- );
- // Target has Target Org
- await pool.query(
- `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, created_at, updated_at)
- VALUES ($1, $2, 'target@test.example', 'admin', NOW(), NOW())`,
- [TARGET_USER_ID, TARGET_ORG_ID]
+ it('returns 409 and leaves bindings and memberships unchanged', async () => {
+ const bindingsBefore = await pool.query(
+ `SELECT workos_user_id, identity_id, is_primary
+ FROM identity_workos_users
+ WHERE workos_user_id IN ($1, $2)
+ ORDER BY workos_user_id`,
+ [HOST_USER_ID, TARGET_USER_ID],
);
- // Bind target as non-primary under host's identity (mergeUsers does this);
- // mergeUsers also moves target's data to host, so we set it up by hand
- // post-bind to keep target_org membership on the target's workos_user_id.
- await request(app)
- .post(`/api/admin/users/${HOST_USER_ID}/credentials`)
- .send({ workos_user_id: TARGET_USER_ID, consolidate: true })
- .expect(201);
- // After bind, target_org_membership was moved to HOST_USER_ID. Move it
- // back to TARGET_USER_ID to simulate the post-resync Ahmed state where
- // an org membership lands on the non-primary credential (because WorkOS
- // org_membership webhook routed it to that user_id).
- await pool.query(
- `UPDATE organization_memberships SET workos_user_id = $1
- WHERE workos_organization_id = $2`,
- [TARGET_USER_ID, TARGET_ORG_ID]
+ const membershipsBefore = await pool.query(
+ `SELECT workos_user_id, workos_organization_id, role
+ FROM organization_memberships
+ WHERE workos_user_id IN ($1, $2)
+ ORDER BY workos_user_id`,
+ [HOST_USER_ID, TARGET_USER_ID],
);
- }
-
- it('promotes the target credential and moves the old primary\'s app-state forward', async () => {
- await setupBoundPair();
const response = await request(app)
.post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
- .expect(200);
-
- expect(response.body).toMatchObject({
- promoted: true,
- previous_primary_id: HOST_USER_ID,
- new_primary_id: TARGET_USER_ID,
- });
+ .expect(409);
- // is_primary swapped
- const bindings = await pool.query<{ workos_user_id: string; is_primary: boolean }>(
- `SELECT workos_user_id, is_primary FROM identity_workos_users
+ expect(response.body.error).toBe('credential_promotion_disabled');
+ const bindingsAfter = await pool.query(
+ `SELECT workos_user_id, identity_id, is_primary
+ FROM identity_workos_users
WHERE workos_user_id IN ($1, $2)
- ORDER BY is_primary DESC`,
- [HOST_USER_ID, TARGET_USER_ID]
- );
- expect(bindings.rows).toHaveLength(2);
- expect(bindings.rows.find(r => r.workos_user_id === TARGET_USER_ID)?.is_primary).toBe(true);
- expect(bindings.rows.find(r => r.workos_user_id === HOST_USER_ID)?.is_primary).toBe(false);
-
- // Both orgs now on TARGET_USER_ID (host's app-state moved forward;
- // target's stayed since it was already there)
- const memberships = await pool.query<{ workos_user_id: string }>(
- `SELECT workos_user_id FROM organization_memberships
- WHERE workos_organization_id IN ($1, $2)
- ORDER BY workos_organization_id`,
- [HOST_ORG_ID, TARGET_ORG_ID]
- );
- expect(memberships.rows).toHaveLength(2);
- expect(memberships.rows.every(r => r.workos_user_id === TARGET_USER_ID)).toBe(true);
- });
-
- it('writes a promote_credential_to_primary audit row', async () => {
- await setupBoundPair();
- await request(app)
- .post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
- .expect(200);
-
- const audit = await pool.query<{ details: any }>(
- `SELECT details FROM registry_audit_log
- WHERE action = 'promote_credential_to_primary' AND resource_id = $1
- ORDER BY created_at DESC LIMIT 1`,
- [TARGET_USER_ID]
+ ORDER BY workos_user_id`,
+ [HOST_USER_ID, TARGET_USER_ID],
);
- expect(audit.rows).toHaveLength(1);
- expect(audit.rows[0].details).toMatchObject({
- previous_primary_id: HOST_USER_ID,
- new_primary_id: TARGET_USER_ID,
- });
- });
-
- it('is idempotent: promoting an already-primary credential returns 200 with no change', async () => {
- // Promote target so it's the primary
- await setupBoundPair();
- await request(app)
- .post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
- .expect(200);
-
- // Calling again on the same credential should be a no-op
- const response = await request(app)
- .post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
- .expect(200);
- expect(response.body.promoted).toBe(true);
- expect(response.body.message).toMatch(/already primary/i);
-
- // Bindings unchanged
- const bindings = await pool.query<{ workos_user_id: string; is_primary: boolean }>(
- `SELECT workos_user_id, is_primary FROM identity_workos_users
- WHERE workos_user_id IN ($1, $2)`,
- [HOST_USER_ID, TARGET_USER_ID]
+ const membershipsAfter = await pool.query(
+ `SELECT workos_user_id, workos_organization_id, role
+ FROM organization_memberships
+ WHERE workos_user_id IN ($1, $2)
+ ORDER BY workos_user_id`,
+ [HOST_USER_ID, TARGET_USER_ID],
);
- expect(bindings.rows.find(r => r.workos_user_id === TARGET_USER_ID)?.is_primary).toBe(true);
+ expect(bindingsAfter.rows).toEqual(bindingsBefore.rows);
+ expect(membershipsAfter.rows).toEqual(membershipsBefore.rows);
});
- it('400s when the credentialId in the URL matches the host id', async () => {
+ it('400s when the credential id equals the host id', async () => {
const response = await request(app)
.post(`/api/admin/users/${HOST_USER_ID}/credentials/${HOST_USER_ID}/promote`)
.expect(400);
expect(response.body.error).toMatch(/must differ/i);
});
- it('404s when the credential is not bound to the host\'s identity', async () => {
- // Target is its own singleton identity, not bound to host
- const response = await request(app)
- .post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
- .expect(404);
- expect(response.body.error).toMatch(/not bound/i);
- });
-
- it('refuses when the admin is signed in as a member of the target identity', async () => {
- await setupBoundPair();
-
- // Find the host's identity id
- const identityRow = await pool.query<{ identity_id: string }>(
- `SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1`,
- [HOST_USER_ID]
- );
- const hostIdentityId = identityRow.rows[0].identity_id;
-
- // Simulate an admin whose identityId == host's identityId via the test
- // header the auth mock reads.
- const response = await request(app)
- .post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
- .set('X-Test-Admin-Identity', hostIdentityId)
- .expect(409);
- expect(response.body.error).toMatch(/own credential/i);
- });
-
- it('repairs an orphan-no-primary state by setting the target as primary directly', async () => {
- await setupBoundPair();
- // Manually break the primary so the identity has no current primary
+ it('404s when the credential is not bound to the host identity', async () => {
+ await pool.query(`DELETE FROM identity_workos_users WHERE workos_user_id = $1`, [TARGET_USER_ID]);
+ const newIdentity = await pool.query<{ id: string }>(`INSERT INTO identities DEFAULT VALUES RETURNING id`);
await pool.query(
- `UPDATE identity_workos_users SET is_primary = FALSE WHERE workos_user_id = $1`,
- [HOST_USER_ID]
+ `INSERT INTO identity_workos_users (workos_user_id, identity_id, is_primary)
+ VALUES ($1, $2, TRUE)`,
+ [TARGET_USER_ID, newIdentity.rows[0].id],
);
- const response = await request(app)
+ await request(app)
.post(`/api/admin/users/${HOST_USER_ID}/credentials/${TARGET_USER_ID}/promote`)
- .expect(200);
- expect(response.body.message).toMatch(/no current primary|invariant repaired/i);
-
- const bindings = await pool.query<{ workos_user_id: string; is_primary: boolean }>(
- `SELECT workos_user_id, is_primary FROM identity_workos_users
- WHERE workos_user_id IN ($1, $2)`,
- [HOST_USER_ID, TARGET_USER_ID]
- );
- expect(bindings.rows.find(r => r.workos_user_id === TARGET_USER_ID)?.is_primary).toBe(true);
+ .expect(404);
});
});
diff --git a/server/tests/integration/agent-visibility-e2e.test.ts b/server/tests/integration/agent-visibility-e2e.test.ts
index c24662953d..ee6f4bb5e8 100644
--- a/server/tests/integration/agent-visibility-e2e.test.ts
+++ b/server/tests/integration/agent-visibility-e2e.test.ts
@@ -80,9 +80,9 @@ describe('Agent visibility E2E', () => {
app = express();
app.use(express.json());
- // Route stubs a user onto the request so requireAuth-backed routes
- // resolve the test user's primary organization. We swap the user +
- // its declared org per test via middleware state.
+ // Route stubs a user onto the request so requireAuth-backed routes can
+ // authenticate the caller. Organization scope is selected explicitly on
+ // each request below.
let currentUserId = 'user_e2e';
(app as any).setCurrentUser = (id: string, orgId?: string | null) => {
currentUserId = id;
@@ -289,16 +289,22 @@ describe('Agent visibility E2E', () => {
await request(app)
.put('/api/me/member-profile/visibility')
.send({ is_public: false })
- .expect(403);
+ .expect(400, {
+ error: 'The org query parameter is required',
+ });
await request(app)
.put('/api/me/member-profile')
.send({ show_in_carousel: false })
- .expect(403);
+ .expect(400, {
+ error: 'The org query parameter is required',
+ });
await request(app)
.delete('/api/me/member-profile')
- .expect(403);
+ .expect(400, {
+ error: 'The org query parameter is required',
+ });
const profile = await memberDb.getProfileByOrgId(orgId);
expect(profile).not.toBeNull();
@@ -314,7 +320,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId);
const response = await request(app)
- .put('/api/me/member-profile')
+ .put(`/api/me/member-profile?org=${orgId}`)
.send({ linkedin_url: 'javascript:alert(document.domain)' })
.expect(400);
@@ -335,7 +341,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId);
const res = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'public' });
expect(res.status).toBe(403);
@@ -351,7 +357,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId);
const res = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'members_only' });
expect(res.status).toBe(200);
@@ -439,7 +445,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId);
const res = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'public' });
expect(res.status).toBe(200);
@@ -457,12 +463,12 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId);
const pubRes = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'public' });
expect(pubRes.status).toBe(403);
const memRes = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'members_only' });
expect(memRes.status).toBe(200);
});
@@ -562,7 +568,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId, orgId);
const res = await request(app)
- .put('/api/me/member-profile')
+ .put(`/api/me/member-profile?org=${orgId}`)
.send({
agents: [
{ url: 'https://smuggled.putbypass.example', visibility: 'public' },
@@ -595,7 +601,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId, orgId);
const res = await request(app)
- .put('/api/me/member-profile')
+ .put(`/api/me/member-profile?org=${orgId}`)
.send({
agents: [
{ url: 'https://pro-pub.putpro.example', visibility: 'public' },
@@ -735,7 +741,9 @@ describe('Agent visibility E2E', () => {
try {
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).post('/api/me/member-profile/agents/0/publish');
+ const res = await request(app).post(
+ `/api/me/member-profile/agents/0/publish?org=${orgId}`,
+ );
// Response should still be 200 — the profile update is
// authoritative; manifest drift logs but doesn't fail the request.
@@ -787,7 +795,9 @@ describe('Agent visibility E2E', () => {
const updateSpy = vi.spyOn(brandDb, 'updateManifestAgents');
try {
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).post('/api/me/member-profile/agents/0/publish');
+ const res = await request(app).post(
+ `/api/me/member-profile/agents/0/publish?org=${orgId}`,
+ );
expect(res.status).toBe(200);
expect(res.body.visibility).toBe('public');
@@ -814,7 +824,7 @@ describe('Agent visibility E2E', () => {
await createProfile(orgId, 'gateok');
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).get('/api/me/member-profile');
+ const res = await request(app).get(`/api/me/member-profile?org=${orgId}`);
expect(res.status).toBe(200);
expect(res.body.has_api_access).toBe(true);
@@ -835,7 +845,7 @@ describe('Agent visibility E2E', () => {
await createProfile(orgId, 'gatetier');
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).get('/api/me/member-profile');
+ const res = await request(app).get(`/api/me/member-profile?org=${orgId}`);
expect(res.status).toBe(200);
expect(res.body.agent_visibility_gate.can_publish_publicly).toBe(false);
@@ -857,7 +867,7 @@ describe('Agent visibility E2E', () => {
// Deliberately no seedBrandPrimary — the org has no is_primary row.
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).get('/api/me/member-profile');
+ const res = await request(app).get(`/api/me/member-profile?org=${orgId}`);
expect(res.status).toBe(200);
expect(res.body.agent_visibility_gate.can_publish_publicly).toBe(false);
@@ -879,7 +889,7 @@ describe('Agent visibility E2E', () => {
});
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).get('/api/me/member-profile');
+ const res = await request(app).get(`/api/me/member-profile?org=${orgId}`);
expect(res.status).toBe(200);
expect(res.body.agent_visibility_gate.can_publish_publicly).toBe(false);
@@ -906,7 +916,7 @@ describe('Agent visibility E2E', () => {
await seedBrandPrimaryUnverified(orgId, 'gateunverified.example');
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).get('/api/me/member-profile');
+ const res = await request(app).get(`/api/me/member-profile?org=${orgId}`);
expect(res.status).toBe(200);
expect(res.body.agent_visibility_gate.can_publish_publicly).toBe(false);
@@ -939,7 +949,9 @@ describe('Agent visibility E2E', () => {
it('POST /publish returns 400 brand_domain_unverified when primary domain is not DNS-verified', async () => {
const { userId, orgId } = await setupUnverifiedOrg('pub');
(app as any).setCurrentUser(userId, orgId);
- const res = await request(app).post('/api/me/member-profile/agents/0/publish');
+ const res = await request(app).post(
+ `/api/me/member-profile/agents/0/publish?org=${orgId}`,
+ );
expect(res.status).toBe(400);
expect(res.body.error).toBe('brand_domain_unverified');
});
@@ -948,7 +960,7 @@ describe('Agent visibility E2E', () => {
const { userId, orgId } = await setupUnverifiedOrg('patch');
(app as any).setCurrentUser(userId, orgId);
const res = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'public' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('brand_domain_unverified');
@@ -958,7 +970,7 @@ describe('Agent visibility E2E', () => {
const { userId, orgId, domain } = await setupUnverifiedOrg('put');
(app as any).setCurrentUser(userId, orgId);
const res = await request(app)
- .put('/api/me/member-profile')
+ .put(`/api/me/member-profile?org=${orgId}`)
.send({ agents: [{ url: `https://agent.${domain}`, visibility: 'public' }] });
expect(res.status).toBe(400);
expect(res.body.error).toBe('brand_domain_unverified');
@@ -981,7 +993,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId, orgId);
const res = await request(app)
- .put('/api/me/member-profile')
+ .put(`/api/me/member-profile?org=${orgId}`)
.send({
agents: [
{ url: 'https://existing.putrogue.example', visibility: 'private' },
@@ -1015,7 +1027,7 @@ describe('Agent visibility E2E', () => {
(app as any).setCurrentUser(userId, orgId);
// Same legacy URL — should pass the gate as a grandfather.
const res = await request(app)
- .put('/api/me/member-profile')
+ .put(`/api/me/member-profile?org=${orgId}`)
.send({
agents: [{ url: 'https://legacy.unrelated.example', visibility: 'private', name: 'updated' }],
});
@@ -1055,7 +1067,7 @@ describe('Agent visibility E2E', () => {
// trailing slash. The grandfather check has to compare canonicalized
// values on both sides.
const res = await request(app)
- .put('/api/me/member-profile')
+ .put(`/api/me/member-profile?org=${orgId}`)
.send({
agents: [
{ url: 'https://Legacy.Unrelated.Example/', visibility: 'private', name: 'still here' },
@@ -1084,21 +1096,21 @@ describe('Agent visibility E2E', () => {
// members_only flip — should reject.
const membersRes = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'members_only' });
expect(membersRes.status).toBe(400);
expect(membersRes.body.error).toBe('unverified_hostname');
// public flip — should also reject.
const publicRes = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'public' });
expect(publicRes.status).toBe(400);
expect(publicRes.body.error).toBe('unverified_hostname');
// private (demotion) — always allowed.
const privateRes = await request(app)
- .patch('/api/me/member-profile/agents/0/visibility')
+ .patch(`/api/me/member-profile/agents/0/visibility?org=${orgId}`)
.send({ visibility: 'private' });
expect(privateRes.status).toBe(200);
});
diff --git a/server/tests/integration/billing-stale-customer-auto-heal.test.ts b/server/tests/integration/billing-stale-customer-auto-heal.test.ts
index 8e6f500b5e..285b627261 100644
--- a/server/tests/integration/billing-stale-customer-auto-heal.test.ts
+++ b/server/tests/integration/billing-stale-customer-auto-heal.test.ts
@@ -28,15 +28,31 @@ const {
process.env.WORKOS_API_KEY ||= 'sk_test_dummy_for_unit_tests';
process.env.WORKOS_CLIENT_ID ||= 'client_test_dummy_for_unit_tests';
process.env.WORKOS_COOKIE_PASSWORD ||= 'test-cookie-password-32chars-min-len-1234';
+ const testUserId = 'user_stale_test';
+ const testOrg = 'org_stale_customer_test';
return {
- TEST_USER_ID: 'user_stale_test',
- TEST_ORG: 'org_stale_customer_test',
+ TEST_USER_ID: testUserId,
+ TEST_ORG: testOrg,
STALE_CUSTOMER_ID: 'cus_stale_does_not_exist',
FRESH_CUSTOMER_ID: 'cus_fresh_after_unlink',
RECOVERY_SECRET: 'cs_test_recovery_secret',
- mockListMemberships: vi.fn().mockResolvedValue({
- data: [{ id: 'om_test', role: { slug: 'owner' }, status: 'active' }],
- }),
+ mockListMemberships: vi.fn().mockImplementation(async ({
+ userId,
+ organizationId,
+ }: {
+ userId: string;
+ organizationId?: string;
+ }) => ({
+ data: userId === testUserId && organizationId === testOrg
+ ? [{
+ id: 'om_test',
+ userId: testUserId,
+ organizationId: testOrg,
+ role: { slug: 'owner' },
+ status: 'active',
+ }]
+ : [],
+ })),
mockCreateStripeCustomer: vi.fn(),
mockCreateCustomerSession: vi.fn(),
mockGetPendingInvoices: vi.fn().mockResolvedValue([]),
diff --git a/server/tests/integration/brand-ownership-route.test.ts b/server/tests/integration/brand-ownership-route.test.ts
index a427fa9c1e..aa24d9b342 100644
--- a/server/tests/integration/brand-ownership-route.test.ts
+++ b/server/tests/integration/brand-ownership-route.test.ts
@@ -15,21 +15,48 @@ import express from 'express';
import request from 'supertest';
import type { Pool } from 'pg';
-let currentUserId: string | null = null;
+const authState = vi.hoisted(() => ({
+ currentUserId: null as string | null,
+ currentOrganizationId: null as string | null,
+}));
+
+vi.mock('../../src/utils/resolve-user-org-membership.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveUserOrgMembership: vi.fn(async (
+ _workos: unknown,
+ principal: { id?: string; authWorkosUserId?: string },
+ organizationId: string,
+ ) => {
+ const authorizationUserId = principal.authWorkosUserId ?? principal.id;
+ if (
+ authorizationUserId !== authState.currentUserId
+ || organizationId !== authState.currentOrganizationId
+ ) {
+ return null;
+ }
+ return {
+ organizationId,
+ role: 'admin',
+ status: 'active',
+ via_credential_grant: false,
+ via_dev_bypass: false,
+ };
+ }),
+}));
vi.mock('../../src/middleware/auth.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => {
- if (currentUserId !== null) {
- req.user = { id: currentUserId, email: `${currentUserId}@test.com` };
+ if (authState.currentUserId !== null) {
+ req.user = { id: authState.currentUserId, email: `${authState.currentUserId}@test.com` };
}
next();
},
requireAuth: (req: { user?: unknown }, res: { status: (n: number) => { json: (b: unknown) => void } }, next: () => void) => {
- if (currentUserId === null) return res.status(401).json({ error: 'auth required' });
- req.user = { id: currentUserId, email: `${currentUserId}@test.com` };
+ if (authState.currentUserId === null) return res.status(401).json({ error: 'auth required' });
+ req.user = { id: authState.currentUserId, email: `${authState.currentUserId}@test.com` };
next();
},
};
@@ -51,6 +78,13 @@ const COMMUNITY_DOMAIN = `community-${RUN_SUFFIX}.example.com`;
const ORPHANED_DOMAIN = `orphaned-${RUN_SUFFIX}.example.com`;
const MISSING_DOMAIN = `missing-${RUN_SUFFIX}.example.com`;
+function ownershipPath(domain: string) {
+ const organizationQuery = authState.currentOrganizationId
+ ? `?org=${encodeURIComponent(authState.currentOrganizationId)}`
+ : '';
+ return `/api/brands/${domain}/ownership${organizationQuery}`;
+}
+
describe('GET /api/brands/:domain/ownership', () => {
let pool: Pool;
let app: express.Express;
@@ -113,7 +147,8 @@ describe('GET /api/brands/:domain/ownership', () => {
});
beforeEach(async () => {
- currentUserId = null;
+ authState.currentUserId = null;
+ authState.currentOrganizationId = null;
await pool.query(`DELETE FROM brands WHERE domain IN ($1, $2, $3, $4)`,
[VERIFIED_DOMAIN, COMMUNITY_DOMAIN, ORPHANED_DOMAIN, MISSING_DOMAIN]);
});
@@ -167,8 +202,9 @@ describe('GET /api/brands/:domain/ownership', () => {
workos_organization_id: OWNER_ORG_ID,
domain_verified: true,
});
- currentUserId = OWNER_USER_ID;
- const res = await request(app).get(`/api/brands/${VERIFIED_DOMAIN}/ownership`);
+ authState.currentUserId = OWNER_USER_ID;
+ authState.currentOrganizationId = OWNER_ORG_ID;
+ const res = await request(app).get(ownershipPath(VERIFIED_DOMAIN));
expect(res.status).toBe(200);
expect(res.body.status).toBe('verified');
expect(res.body.can_manage).toBe(true);
@@ -182,8 +218,9 @@ describe('GET /api/brands/:domain/ownership', () => {
workos_organization_id: OWNER_ORG_ID,
domain_verified: true,
});
- currentUserId = OTHER_USER_ID;
- const res = await request(app).get(`/api/brands/${VERIFIED_DOMAIN}/ownership`);
+ authState.currentUserId = OTHER_USER_ID;
+ authState.currentOrganizationId = OTHER_ORG_ID;
+ const res = await request(app).get(ownershipPath(VERIFIED_DOMAIN));
expect(res.status).toBe(200);
expect(res.body.can_manage).toBe(false);
// A verified brand is not claimable by another org through this UX path.
@@ -192,8 +229,9 @@ describe('GET /api/brands/:domain/ownership', () => {
it('lets any authenticated user claim a community brand', async () => {
await seedBrand(COMMUNITY_DOMAIN, {});
- currentUserId = OTHER_USER_ID;
- const res = await request(app).get(`/api/brands/${COMMUNITY_DOMAIN}/ownership`);
+ authState.currentUserId = OTHER_USER_ID;
+ authState.currentOrganizationId = OTHER_ORG_ID;
+ const res = await request(app).get(ownershipPath(COMMUNITY_DOMAIN));
expect(res.status).toBe(200);
expect(res.body.status).toBe('community');
expect(res.body.can_claim).toBe(true);
@@ -205,8 +243,9 @@ describe('GET /api/brands/:domain/ownership', () => {
manifest_orphaned: true,
prior_owner_org_id: OWNER_ORG_ID,
});
- currentUserId = OTHER_USER_ID;
- const res = await request(app).get(`/api/brands/${ORPHANED_DOMAIN}/ownership`);
+ authState.currentUserId = OTHER_USER_ID;
+ authState.currentOrganizationId = OTHER_ORG_ID;
+ const res = await request(app).get(ownershipPath(ORPHANED_DOMAIN));
expect(res.status).toBe(200);
expect(res.body.status).toBe('orphaned');
// Owner is null for orphaned brands — prior_owner_org_id is internal state.
diff --git a/server/tests/integration/brand-properties-parse.test.ts b/server/tests/integration/brand-properties-parse.test.ts
index 153643decd..0810b54e20 100644
--- a/server/tests/integration/brand-properties-parse.test.ts
+++ b/server/tests/integration/brand-properties-parse.test.ts
@@ -24,11 +24,36 @@ import type { Pool } from 'pg';
// Shared mocks accessed by both vi.mock factories and assertion blocks.
const mocks = vi.hoisted(() => ({
currentUserId: 'user_parse_owner',
+ currentOrganizationId: 'org_parse_owner',
anthropicCreate: vi.fn(),
validateFetchUrl: vi.fn(),
safeFetch: vi.fn(),
}));
+vi.mock('../../src/utils/resolve-user-org-membership.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveUserOrgMembership: vi.fn(async (
+ _workos: unknown,
+ principal: { id?: string; authWorkosUserId?: string },
+ organizationId: string,
+ ) => {
+ const authorizationUserId = principal.authWorkosUserId ?? principal.id;
+ if (
+ authorizationUserId !== mocks.currentUserId
+ || organizationId !== mocks.currentOrganizationId
+ ) {
+ return null;
+ }
+ return {
+ organizationId,
+ role: 'admin',
+ status: 'active',
+ via_credential_grant: false,
+ via_dev_bypass: false,
+ };
+ }),
+}));
+
vi.mock('../../src/middleware/auth.js', async (importOriginal) => ({
...(await importOriginal()),
requireAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => {
@@ -74,6 +99,10 @@ const OUTSIDER_ORG = `org_parse_outsider_${SUFFIX}`;
const OWNER_USER = `user_parse_owner_${SUFFIX}`;
const OUTSIDER_USER = `user_parse_outsider_${SUFFIX}`;
+function parsePropertiesPath(domain = TEST_DOMAIN) {
+ return `/api/brands/${domain}/properties/parse?org=${encodeURIComponent(mocks.currentOrganizationId)}`;
+}
+
// Build a Messages.create response that looks like the model invoked
// `extract_properties` with the supplied args. The route reads
// `tool_use.input` directly, so the input shape is what's exercised.
@@ -149,6 +178,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
mocks.currentUserId = OWNER_USER;
+ mocks.currentOrganizationId = OWNER_ORG;
mocks.anthropicCreate.mockReset();
mocks.validateFetchUrl.mockReset();
mocks.safeFetch.mockReset();
@@ -165,7 +195,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('400s on missing input', async () => {
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input_type: 'text' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('input required');
@@ -175,7 +205,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('400s on whitespace-only input', async () => {
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: ' \n\t ', input_type: 'text' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('input required');
@@ -184,7 +214,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('400s on bad input_type', async () => {
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'example.com', input_type: 'binary' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/input_type/);
@@ -192,7 +222,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('400s on bad relationship value', async () => {
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'example.com', input_type: 'text', relationship: 'spousal' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/relationship/);
@@ -202,9 +232,10 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('403s an outsider trying URL parse — never invokes safeFetch or Anthropic', async () => {
mocks.currentUserId = OUTSIDER_USER;
+ mocks.currentOrganizationId = OUTSIDER_ORG;
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://attacker.example/list.csv', input_type: 'url' });
expect(res.status).toBe(403);
@@ -216,9 +247,10 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('403s an outsider trying text parse — never invokes Anthropic', async () => {
mocks.currentUserId = OUTSIDER_USER;
+ mocks.currentOrganizationId = OUTSIDER_ORG;
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'example.com\nexample.org', input_type: 'text' });
expect(res.status).toBe(403);
@@ -227,7 +259,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('404s a missing brand without invoking the LLM', async () => {
const res = await request(app)
- .post(`/api/brands/does-not-exist.example/properties/parse`)
+ .post(parsePropertiesPath('does-not-exist.example'))
.send({ input: 'example.com', input_type: 'text' });
expect(res.status).toBe(404);
@@ -242,7 +274,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://internal.example/x', input_type: 'url' });
expect(res.status).toBe(400);
@@ -255,7 +287,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
it('400s an invalid URL string before any DNS work', async () => {
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'not a url', input_type: 'url' });
expect(res.status).toBe(400);
@@ -270,7 +302,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
mocks.anthropicCreate.mockResolvedValueOnce(toolUseResponse({ properties: [] }));
await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'example.com', input_type: 'text' });
expect(mocks.anthropicCreate).toHaveBeenCalledOnce();
@@ -292,7 +324,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
mocks.anthropicCreate.mockResolvedValueOnce(toolUseResponse({ properties: [] }));
await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'example.com', input_type: 'text' });
const callArgs = mocks.anthropicCreate.mock.calls[0][0];
@@ -306,7 +338,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
});
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'example.com', input_type: 'text' });
expect(res.status).toBe(200);
@@ -327,7 +359,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'Example.com\ncom.example.app', input_type: 'text' });
expect(res.status).toBe(200);
@@ -346,7 +378,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'x.example', input_type: 'text', relationship: 'owned' });
expect(res.status).toBe(200);
@@ -367,7 +399,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'list', input_type: 'text' });
expect(res.status).toBe(200);
@@ -389,7 +421,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'list', input_type: 'text' });
expect(res.status).toBe(200);
@@ -405,7 +437,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
mocks.anthropicCreate.mockResolvedValueOnce(toolUseResponse({ properties: props }));
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'list', input_type: 'text' });
expect(res.status).toBe(200);
@@ -422,7 +454,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
const huge = 'a.example\n'.repeat(6_000); // 60_000 chars
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: huge, input_type: 'text' });
expect(res.status).toBe(200);
@@ -447,7 +479,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'a.example', input_type: 'text', relationship: rel });
expect(res.status).toBe(200);
@@ -490,7 +522,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(res.status).toBe(200);
@@ -504,7 +536,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(res.status).toBe(400);
@@ -519,7 +551,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(res.status).toBe(400);
@@ -532,7 +564,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
mocks.safeFetch.mockRejectedValueOnce(new Error('ECONNREFUSED 10.0.0.1:443'));
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(res.status).toBe(400);
@@ -551,7 +583,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(res.status).toBe(400);
@@ -568,7 +600,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
mocks.anthropicCreate.mockResolvedValueOnce(toolUseResponse({ properties: [] }));
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(res.status).toBe(200);
@@ -580,7 +612,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
mocks.anthropicCreate.mockResolvedValueOnce(toolUseResponse({ properties: [] }));
await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(mocks.safeFetch).toHaveBeenCalledWith(
@@ -607,7 +639,7 @@ describe('POST /api/brands/:domain/properties/parse', () => {
);
const res = await request(app)
- .post(`/api/brands/${TEST_DOMAIN}/properties/parse`)
+ .post(parsePropertiesPath())
.send({ input: 'https://example.org/list.csv', input_type: 'url' });
expect(res.status).toBe(200);
diff --git a/server/tests/integration/content-my-content.test.ts b/server/tests/integration/content-my-content.test.ts
index 857e8de307..6e5095a10a 100644
--- a/server/tests/integration/content-my-content.test.ts
+++ b/server/tests/integration/content-my-content.test.ts
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
// Mock WorkOS client before any imports that depend on it
-vi.mock('../../src/auth/workos-client.js', () => ({
- workos: {
+vi.mock('../../src/auth/workos-client.js', () => {
+ const workos = {
userManagement: {
getUser: vi.fn().mockResolvedValue({ id: 'user_my_content', email: 'mc@example.com', firstName: 'Mary', lastName: 'Content' }),
listUsers: vi.fn().mockResolvedValue({ data: [], listMetadata: {} }),
@@ -10,7 +10,16 @@ vi.mock('../../src/auth/workos-client.js', () => ({
organizations: {
getOrganization: vi.fn().mockResolvedValue({ id: 'org_test', name: 'Test Org' }),
},
- },
+ };
+ return { workos, getWorkos: () => workos };
+});
+
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: vi.fn(async (_workos, user, organizationId) =>
+ organizationId === 'org_my_content_professional'
+ && user.id !== 'user_my_content_ineligible'
+ ? { organizationId, role: 'member', source: 'workos' }
+ : null),
}));
// Dynamic admin flag so each test can flip the current user between admin and
@@ -309,6 +318,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-unsafe-link',
content_type: 'link',
external_url: 'javascript:alert(document.domain)',
@@ -349,6 +359,7 @@ describe('My Content — body, admin scope, status, delete', () => {
await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-active-wg-proposal',
content: 'body',
content_type: 'article',
@@ -365,6 +376,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const archivedProposal = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-archived-wg-proposal',
content: 'body',
content_type: 'article',
@@ -378,6 +390,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-review-draft',
content: 'draft body',
content_type: 'article',
@@ -400,6 +413,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-lead-draft',
content: 'draft body',
content_type: 'article',
@@ -415,6 +429,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-lead-default',
content: 'body',
content_type: 'article',
@@ -436,6 +451,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-lead-publish',
content: 'body',
content_type: 'article',
@@ -455,6 +471,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-escalate',
content: 'body',
content_type: 'article',
@@ -483,6 +500,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'mc-test-ineligible',
content: 'body',
content_type: 'article',
@@ -498,6 +516,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'A'.repeat(501),
content: 'body',
content_type: 'article',
@@ -513,6 +532,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'B'.repeat(500),
content: 'body',
content_type: 'article',
@@ -527,6 +547,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const response = await request(app)
.post('/api/content/propose')
.send({
+ organization_id: ELIGIBLE_ORG_ID,
title: 'short title',
subtitle: 'C'.repeat(1001),
content: 'body',
@@ -549,6 +570,7 @@ describe('My Content — body, admin scope, status, delete', () => {
const results: Array<{ success: boolean; error?: string }> = [];
for (let i = 0; i < 21; i++) {
const r = await proposeContentForUser(testUser, {
+ organization_id: ELIGIBLE_ORG_ID,
title: `mc-test-ratelimit-${i}`,
content: 'body',
content_type: 'article',
diff --git a/server/tests/integration/credential-authorization-isolation.test.ts b/server/tests/integration/credential-authorization-isolation.test.ts
new file mode 100644
index 0000000000..6fff05f491
--- /dev/null
+++ b/server/tests/integration/credential-authorization-isolation.test.ts
@@ -0,0 +1,366 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
+import type { Pool } from 'pg';
+import type { WorkOS } from '@workos-inc/node';
+import { initializeDatabase, closeDatabase } from '../../src/db/client.js';
+import { runMigrations } from '../../src/db/migrate.js';
+import {
+ attachStateEmptyCredential,
+ CredentialAlreadyLinkedError,
+ CredentialHasStateError,
+ USER_STATE_REFERENCES,
+ USER_STATE_REFERENCE_EXCEPTIONS,
+} from '../../src/db/user-merge-db.js';
+import { resolveUserOrgMembership } from '../../src/utils/resolve-user-org-membership.js';
+
+const HOST_A = 'user_auth_isolation_host_a';
+const HOST_B = 'user_auth_isolation_host_b';
+const CREDENTIAL = 'user_auth_isolation_credential';
+const ORG_ID = 'org_auth_isolation';
+const USER_IDS = [HOST_A, HOST_B, CREDENTIAL];
+
+describe('credential authorization isolation', () => {
+ let pool: Pool;
+
+ beforeAll(async () => {
+ pool = initializeDatabase({
+ connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test',
+ });
+ await runMigrations();
+ }, 60000);
+
+ afterAll(async () => {
+ await cleanup();
+ await closeDatabase();
+ });
+
+ beforeEach(async () => {
+ await cleanup();
+ await pool.query(
+ `INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified,
+ workos_created_at, workos_updated_at, created_at, updated_at)
+ VALUES ($1, 'host-a@test.example', 'Host', 'A', true, NOW(), NOW(), NOW(), NOW()),
+ ($2, 'host-b@test.example', 'Host', 'B', true, NOW(), NOW(), NOW(), NOW()),
+ ($3, 'credential@test.example', 'Credential', 'User', true, NOW(), NOW(), NOW(), NOW())`,
+ USER_IDS,
+ );
+ await pool.query(
+ `INSERT INTO organizations (workos_organization_id, name, created_at, updated_at)
+ VALUES ($1, 'Authorization Isolation Org', NOW(), NOW())
+ ON CONFLICT (workos_organization_id) DO NOTHING`,
+ [ORG_ID],
+ );
+ });
+
+ async function cleanup() {
+ await pool.query(`DELETE FROM working_group_leaders WHERE user_id = ANY($1)`, [USER_IDS]);
+ await pool.query(`DELETE FROM working_groups WHERE slug = 'auth-isolation-leadership'`);
+ await pool.query(`DELETE FROM organization_credential_grants WHERE workos_organization_id = $1`, [ORG_ID]);
+ await pool.query(`DELETE FROM organization_memberships WHERE workos_organization_id = $1`, [ORG_ID]);
+ await pool.query(
+ `DELETE FROM registry_audit_log
+ WHERE action = 'attach_state_empty_credential'
+ AND resource_id = ANY($1)`,
+ [USER_IDS],
+ );
+ await pool.query(`DELETE FROM users WHERE workos_user_id = ANY($1)`, [USER_IDS]);
+ await pool.query(`DELETE FROM organizations WHERE workos_organization_id = $1`, [ORG_ID]);
+ }
+
+ async function epochFor(userId: string): Promise {
+ const result = await pool.query<{ authorization_epoch: string }>(
+ `SELECT i.authorization_epoch
+ FROM identities i
+ JOIN identity_workos_users iwu ON iwu.identity_id = i.id
+ WHERE iwu.workos_user_id = $1`,
+ [userId],
+ );
+ return Number(result.rows[0].authorization_epoch);
+ }
+
+ async function authorizationVersionFor(userId: string): Promise<{
+ identityId: string;
+ identityEpoch: number;
+ credentialEpoch: number;
+ }> {
+ const result = await pool.query<{
+ identity_id: string;
+ identity_authorization_epoch: string;
+ credential_authorization_epoch: string;
+ }>(
+ `SELECT iwu.identity_id,
+ i.authorization_epoch AS identity_authorization_epoch,
+ iwu.authorization_epoch AS credential_authorization_epoch
+ FROM identity_workos_users iwu
+ JOIN identities i ON i.id = iwu.identity_id
+ WHERE iwu.workos_user_id = $1`,
+ [userId],
+ );
+ return {
+ identityId: result.rows[0].identity_id,
+ identityEpoch: Number(result.rows[0].identity_authorization_epoch),
+ credentialEpoch: Number(result.rows[0].credential_authorization_epoch),
+ };
+ }
+
+ it('bumps the persisted epoch on binding and membership changes', async () => {
+ const beforeAttach = await epochFor(HOST_A);
+ await attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A);
+ const afterAttach = await epochFor(HOST_A);
+ expect(afterAttach).toBeGreaterThan(beforeAttach);
+
+ await pool.query(
+ `INSERT INTO organization_memberships
+ (workos_user_id, workos_organization_id, workos_membership_id, email, role,
+ seat_type, provisioning_source, created_at, updated_at)
+ VALUES ($1, $2, 'om_auth_isolation', 'credential@test.example', 'member',
+ 'community_only', 'webhook', NOW(), NOW())`,
+ [CREDENTIAL, ORG_ID],
+ );
+ const afterInsert = await epochFor(CREDENTIAL);
+ expect(afterInsert).toBeGreaterThan(afterAttach);
+
+ await pool.query(
+ `UPDATE organization_memberships SET role = 'admin'
+ WHERE workos_user_id = $1 AND workos_organization_id = $2`,
+ [CREDENTIAL, ORG_ID],
+ );
+ const afterUpdate = await epochFor(CREDENTIAL);
+ expect(afterUpdate).toBeGreaterThan(afterInsert);
+
+ await pool.query(
+ `DELETE FROM organization_memberships
+ WHERE workos_user_id = $1 AND workos_organization_id = $2`,
+ [CREDENTIAL, ORG_ID],
+ );
+ expect(await epochFor(CREDENTIAL)).toBeGreaterThan(afterUpdate);
+ });
+
+ it('keeps the credential-local epoch across a binding move and bumps it on later revocation', async () => {
+ const initial = await authorizationVersionFor(CREDENTIAL);
+ await pool.query(
+ `INSERT INTO organization_credential_grants (
+ workos_organization_id, workos_user_id, role, granted_by_workos_user_id, reason
+ ) VALUES ($1, $2, 'member', $3, 'binding move epoch test')`,
+ [ORG_ID, CREDENTIAL, HOST_A],
+ );
+ const granted = await authorizationVersionFor(CREDENTIAL);
+ expect(granted.credentialEpoch).toBeGreaterThan(initial.credentialEpoch);
+
+ await attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A);
+ const moved = await authorizationVersionFor(CREDENTIAL);
+ expect(moved.identityId).not.toBe(initial.identityId);
+ expect(moved.credentialEpoch).toBe(granted.credentialEpoch);
+
+ await pool.query(
+ `UPDATE organization_credential_grants
+ SET revoked_at = NOW(), revoked_by_workos_user_id = $3, updated_at = NOW()
+ WHERE workos_organization_id = $1 AND workos_user_id = $2`,
+ [ORG_ID, CREDENTIAL, HOST_A],
+ );
+ const revoked = await authorizationVersionFor(CREDENTIAL);
+ expect(revoked.identityId).toBe(moved.identityId);
+ expect(revoked.credentialEpoch).toBeGreaterThan(moved.credentialEpoch);
+ expect(revoked.identityEpoch).toBeGreaterThan(moved.identityEpoch);
+ });
+
+ it('rejects attachment when membership provenance exists and leaves every field unchanged', async () => {
+ await pool.query(
+ `INSERT INTO organization_memberships
+ (workos_user_id, workos_organization_id, workos_membership_id, email, role,
+ seat_type, provisioning_source, created_at, updated_at)
+ VALUES ($1, $2, 'om_auth_isolation', 'credential@test.example', 'admin',
+ 'contributor', 'admin_added', NOW(), NOW())`,
+ [CREDENTIAL, ORG_ID],
+ );
+ const membershipBefore = await pool.query(
+ `SELECT * FROM organization_memberships
+ WHERE workos_user_id = $1 AND workos_organization_id = $2`,
+ [CREDENTIAL, ORG_ID],
+ );
+ const bindingBefore = await pool.query(
+ `SELECT identity_id, is_primary FROM identity_workos_users WHERE workos_user_id = $1`,
+ [CREDENTIAL],
+ );
+
+ await expect(
+ attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A),
+ ).rejects.toBeInstanceOf(CredentialHasStateError);
+
+ const membershipAfter = await pool.query(
+ `SELECT * FROM organization_memberships
+ WHERE workos_user_id = $1 AND workos_organization_id = $2`,
+ [CREDENTIAL, ORG_ID],
+ );
+ const bindingAfter = await pool.query(
+ `SELECT identity_id, is_primary FROM identity_workos_users WHERE workos_user_id = $1`,
+ [CREDENTIAL],
+ );
+ expect(membershipAfter.rows).toEqual(membershipBefore.rows);
+ expect(bindingAfter.rows).toEqual(bindingBefore.rows);
+ });
+
+ it('serializes attachment against a concurrent state insert', async () => {
+ const writer = await pool.connect();
+ try {
+ await writer.query('BEGIN');
+ await writer.query(
+ `INSERT INTO organization_memberships
+ (workos_user_id, workos_organization_id, workos_membership_id, email, role,
+ seat_type, provisioning_source, created_at, updated_at)
+ VALUES ($1, $2, 'om_auth_isolation_race', 'credential@test.example', 'member',
+ 'community_only', 'webhook', NOW(), NOW())`,
+ [CREDENTIAL, ORG_ID],
+ );
+
+ const attach = attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A);
+ await new Promise((resolve) => setImmediate(resolve));
+ await writer.query('COMMIT');
+
+ await expect(attach).rejects.toMatchObject({
+ references: expect.arrayContaining([
+ { table: 'organization_memberships', column: 'workos_user_id' },
+ ]),
+ });
+ } finally {
+ await writer.query('ROLLBACK').catch(() => undefined);
+ writer.release();
+ }
+ });
+
+ it('rejects attachment when the credential carries a working-group leadership grant', async () => {
+ const group = await pool.query<{ id: string }>(
+ `INSERT INTO working_groups (name, slug, status)
+ VALUES ('Authorization isolation', 'auth-isolation-leadership', 'active')
+ RETURNING id`,
+ );
+ await pool.query(
+ `INSERT INTO working_group_leaders (working_group_id, user_id)
+ VALUES ($1, $2)`,
+ [group.rows[0].id, CREDENTIAL],
+ );
+
+ await expect(
+ attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A),
+ ).rejects.toMatchObject({
+ references: expect.arrayContaining([
+ { table: 'working_group_leaders', column: 'user_id' },
+ ]),
+ });
+ });
+
+ it('classifies every schema column that can carry a WorkOS user id', async () => {
+ const candidates = await pool.query<{ table_name: string; column_name: string }>(
+ `SELECT c.table_name, c.column_name
+ FROM information_schema.columns c
+ JOIN information_schema.tables t
+ USING (table_schema, table_name)
+ WHERE c.table_schema = 'public'
+ AND t.table_type = 'BASE TABLE'
+ AND c.column_name NOT LIKE '%slack_user_id'
+ AND (
+ c.column_name ~ '(^|_)(workos_)?user_id$'
+ OR c.column_name ~ '_by$'
+ )
+ ORDER BY c.table_name, c.column_name`,
+ );
+ const classified = new Set([
+ ...USER_STATE_REFERENCES.map((ref) => `${ref.name}.${ref.col}`),
+ ...USER_STATE_REFERENCE_EXCEPTIONS.map((ref) => `${ref.name}.${ref.col}`),
+ ]);
+
+ expect(
+ candidates.rows
+ .map((row) => `${row.table_name}.${row.column_name}`)
+ .filter((reference) => !classified.has(reference)),
+ ).toEqual([]);
+ });
+
+ it('makes same-host replay idempotent and rejects cross-identity replay', async () => {
+ await attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A);
+ await expect(attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A)).resolves.toBeUndefined();
+ await expect(
+ attachStateEmptyCredential(HOST_B, CREDENTIAL, HOST_B),
+ ).rejects.toBeInstanceOf(CredentialAlreadyLinkedError);
+
+ const bindings = await pool.query<{ workos_user_id: string }>(
+ `SELECT workos_user_id
+ FROM identity_workos_users
+ WHERE identity_id = (
+ SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1
+ )
+ ORDER BY workos_user_id`,
+ [CREDENTIAL],
+ );
+ expect(bindings.rows.map((row) => row.workos_user_id)).toEqual([HOST_A, CREDENTIAL].sort());
+ });
+
+ it('authorizes only the exact credential through an explicit grant and revokes via epoch', async () => {
+ await attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A);
+ const mockWorkos = {
+ userManagement: {
+ listOrganizationMemberships: async () => ({ data: [] }),
+ },
+ } as unknown as WorkOS;
+
+ const beforeGrant = await epochFor(CREDENTIAL);
+ await pool.query(
+ `INSERT INTO organization_credential_grants (
+ workos_organization_id, workos_user_id, role, granted_by_workos_user_id, reason
+ ) VALUES ($1, $2, 'admin', $3, 'integration test')`,
+ [ORG_ID, CREDENTIAL, HOST_A],
+ );
+ expect(await epochFor(CREDENTIAL)).toBeGreaterThan(beforeGrant);
+
+ const granted = await resolveUserOrgMembership(
+ mockWorkos,
+ { id: HOST_A, authWorkosUserId: CREDENTIAL },
+ ORG_ID,
+ );
+ expect(granted).toMatchObject({
+ organizationId: ORG_ID,
+ role: 'admin',
+ via_credential_grant: true,
+ });
+ await expect(resolveUserOrgMembership(mockWorkos, { id: HOST_A }, ORG_ID)).resolves.toBeNull();
+
+ const beforeRevoke = await epochFor(CREDENTIAL);
+ await pool.query(
+ `UPDATE organization_credential_grants
+ SET revoked_at = NOW(), revoked_by_workos_user_id = $3, updated_at = NOW()
+ WHERE workos_organization_id = $1 AND workos_user_id = $2`,
+ [ORG_ID, CREDENTIAL, HOST_A],
+ );
+ expect(await epochFor(CREDENTIAL)).toBeGreaterThan(beforeRevoke);
+ await expect(
+ resolveUserOrgMembership(
+ mockWorkos,
+ { id: HOST_A, authWorkosUserId: CREDENTIAL },
+ ORG_ID,
+ ),
+ ).resolves.toBeNull();
+ });
+
+ it('allows at most one concurrent host to attach a credential', async () => {
+ const outcomes = await Promise.allSettled([
+ attachStateEmptyCredential(HOST_A, CREDENTIAL, HOST_A),
+ attachStateEmptyCredential(HOST_B, CREDENTIAL, HOST_B),
+ ]);
+ expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1);
+ const rejected = outcomes.filter(
+ (outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected',
+ );
+ expect(rejected).toHaveLength(1);
+ expect(rejected[0].reason).toBeInstanceOf(CredentialAlreadyLinkedError);
+
+ const credentialIdentity = await pool.query<{ workos_user_id: string }>(
+ `SELECT workos_user_id
+ FROM identity_workos_users
+ WHERE identity_id = (
+ SELECT identity_id FROM identity_workos_users WHERE workos_user_id = $1
+ )`,
+ [CREDENTIAL],
+ );
+ expect(credentialIdentity.rows).toHaveLength(2);
+ expect(credentialIdentity.rows.map((row) => row.workos_user_id)).toContain(CREDENTIAL);
+ });
+});
diff --git a/server/tests/integration/credential-grant-routes.test.ts b/server/tests/integration/credential-grant-routes.test.ts
new file mode 100644
index 0000000000..f131bc776b
--- /dev/null
+++ b/server/tests/integration/credential-grant-routes.test.ts
@@ -0,0 +1,197 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import express, { type NextFunction, type Request, type Response } from 'express';
+import request from 'supertest';
+import type { Pool } from 'pg';
+import type { WorkOS } from '@workos-inc/node';
+import { initializeDatabase, closeDatabase } from '../../src/db/client.js';
+import { runMigrations } from '../../src/db/migrate.js';
+
+vi.mock('../../src/middleware/auth.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ requireAuth: (req: Request, _res: Response, next: NextFunction) => {
+ const authenticatedCredentialId = req.header('x-test-authenticated-credential')!;
+ req.user = {
+ id: req.header('x-test-canonical-user') || authenticatedCredentialId,
+ authWorkosUserId: authenticatedCredentialId,
+ email: `${authenticatedCredentialId}@example.test`,
+ emailVerified: true,
+ createdAt: new Date(0).toISOString(),
+ updatedAt: new Date(0).toISOString(),
+ };
+ next();
+ },
+ };
+});
+
+const { createOrganizationsRouter } = await import('../../src/routes/organizations.js');
+const { attachStateEmptyCredential } = await import('../../src/db/user-merge-db.js');
+const { stopAuthTimers } = await import('../../src/middleware/auth.js');
+
+const ORG_ID = 'org_credential_grant_routes';
+const ACTOR = 'user_credential_grant_actor';
+const CANONICAL = 'user_credential_grant_canonical';
+const TARGET = 'user_credential_grant_target';
+const USERS = [ACTOR, CANONICAL, TARGET];
+
+describe('credential grant routes', () => {
+ let pool: Pool;
+ const roleSequences = new Map>();
+ const stableRoles = new Map();
+ const workos = {
+ userManagement: {
+ listOrganizationMemberships: vi.fn(async ({ userId, organizationId }: {
+ userId: string;
+ organizationId: string;
+ }) => {
+ const sequence = roleSequences.get(userId);
+ const role = sequence?.length ? sequence.shift() : stableRoles.get(userId) ?? null;
+ return {
+ data: role ? [{
+ id: `om_${userId}`,
+ userId,
+ organizationId,
+ status: 'active',
+ role: { slug: role },
+ }] : [],
+ };
+ }),
+ },
+ } as unknown as WorkOS;
+
+ const app = express();
+ app.use(express.json());
+ app.use('/api/organizations', createOrganizationsRouter(workos));
+
+ beforeAll(async () => {
+ pool = initializeDatabase({
+ connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test',
+ });
+ await runMigrations();
+ }, 60000);
+
+ beforeEach(async () => {
+ stableRoles.clear();
+ roleSequences.clear();
+ workos.userManagement.listOrganizationMemberships.mockClear();
+ await cleanup();
+ await pool.query(
+ `INSERT INTO users (workos_user_id, email, first_name, last_name, email_verified,
+ workos_created_at, workos_updated_at, created_at, updated_at)
+ VALUES ($1, 'grant-actor@example.test', 'Grant', 'Actor', true, NOW(), NOW(), NOW(), NOW()),
+ ($2, 'grant-canonical@example.test', 'Grant', 'Canonical', true, NOW(), NOW(), NOW(), NOW()),
+ ($3, 'grant-target@example.test', 'Grant', 'Target', true, NOW(), NOW(), NOW(), NOW())`,
+ USERS,
+ );
+ await pool.query(
+ `INSERT INTO organizations (workos_organization_id, name, created_at, updated_at)
+ VALUES ($1, 'Credential Grant Route Org', NOW(), NOW())`,
+ [ORG_ID],
+ );
+ });
+
+ afterAll(async () => {
+ await cleanup();
+ stopAuthTimers();
+ await closeDatabase();
+ });
+
+ async function cleanup(): Promise {
+ if (!pool) return;
+ await pool.query(`DELETE FROM organization_credential_grants WHERE workos_organization_id = $1`, [ORG_ID]);
+ await pool.query(`DELETE FROM registry_audit_log WHERE workos_organization_id = $1`, [ORG_ID]);
+ await pool.query(`DELETE FROM users WHERE workos_user_id = ANY($1)`, [USERS]);
+ await pool.query(`DELETE FROM organizations WHERE workos_organization_id = $1`, [ORG_ID]);
+ }
+
+ function asCredential(authenticated: string, canonical = authenticated) {
+ return {
+ 'x-test-authenticated-credential': authenticated,
+ 'x-test-canonical-user': canonical,
+ };
+ }
+
+ it('creates, deduplicates, revokes, and recreates an exact-credential grant', async () => {
+ stableRoles.set(ACTOR, 'admin');
+ const payload = { workos_user_id: TARGET, role: 'member', reason: 'route test' };
+
+ const created = await request(app)
+ .post(`/api/organizations/${ORG_ID}/credential-grants`)
+ .set(asCredential(ACTOR))
+ .send(payload);
+ expect(created.status).toBe(201);
+
+ const duplicate = await request(app)
+ .post(`/api/organizations/${ORG_ID}/credential-grants`)
+ .set(asCredential(ACTOR))
+ .send(payload);
+ expect(duplicate.status).toBe(409);
+
+ const revoked = await request(app)
+ .delete(`/api/organizations/${ORG_ID}/credential-grants/${created.body.grant_id}`)
+ .set(asCredential(ACTOR));
+ expect(revoked.status).toBe(200);
+
+ const stored = await pool.query(
+ `SELECT granted_by_workos_user_id, revoked_by_workos_user_id, revoked_at
+ FROM organization_credential_grants WHERE id = $1`,
+ [created.body.grant_id],
+ );
+ expect(stored.rows[0]).toMatchObject({
+ granted_by_workos_user_id: ACTOR,
+ revoked_by_workos_user_id: ACTOR,
+ });
+ expect(stored.rows[0].revoked_at).toBeTruthy();
+
+ const recreated = await request(app)
+ .post(`/api/organizations/${ORG_ID}/credential-grants`)
+ .set(asCredential(ACTOR))
+ .send(payload);
+ expect(recreated.status).toBe(201);
+ });
+
+ it('rejects a linked credential that only the canonical credential could authorize', async () => {
+ stableRoles.set(CANONICAL, 'admin');
+ await attachStateEmptyCredential(CANONICAL, ACTOR, CANONICAL);
+
+ const response = await request(app)
+ .post(`/api/organizations/${ORG_ID}/credential-grants`)
+ .set(asCredential(ACTOR, CANONICAL))
+ .send({ workos_user_id: TARGET, role: 'member' });
+
+ expect(response.status).toBe(403);
+ expect(workos.userManagement.listOrganizationMemberships).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: ACTOR, organizationId: ORG_ID }),
+ );
+ });
+
+ it('fails closed when the exact actor loses its role before commit', async () => {
+ roleSequences.set(ACTOR, ['admin', null]);
+ const response = await request(app)
+ .post(`/api/organizations/${ORG_ID}/credential-grants`)
+ .set(asCredential(ACTOR))
+ .send({ workos_user_id: TARGET, role: 'member' });
+
+ expect(response.status).toBe(403);
+ const grants = await pool.query(
+ `SELECT 1 FROM organization_credential_grants WHERE workos_organization_id = $1`,
+ [ORG_ID],
+ );
+ expect(grants.rowCount).toBe(0);
+ });
+
+ it('rejects expired grants before authorization or mutation', async () => {
+ stableRoles.set(ACTOR, 'admin');
+ const response = await request(app)
+ .post(`/api/organizations/${ORG_ID}/credential-grants`)
+ .set(asCredential(ACTOR))
+ .send({
+ workos_user_id: TARGET,
+ role: 'member',
+ effective_until: new Date(Date.now() - 60_000).toISOString(),
+ });
+ expect(response.status).toBe(400);
+ expect(workos.userManagement.listOrganizationMemberships).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/tests/integration/join-request-approval.test.ts b/server/tests/integration/join-request-approval.test.ts
index 0ec589d1e0..02a46b811f 100644
--- a/server/tests/integration/join-request-approval.test.ts
+++ b/server/tests/integration/join-request-approval.test.ts
@@ -5,6 +5,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi }
// and calls new WorkOS() — without them workos is null and every workos!. call throws.
const {
TEST_ADMIN_USER_ID,
+ TEST_AUTH_USER_ID,
TEST_REQUESTER_USER_ID,
TEST_ORG_ID,
mockCreateOrganizationMembership,
@@ -16,6 +17,7 @@ const {
process.env.WORKOS_COOKIE_PASSWORD ||= 'test-cookie-password-32chars-min-len-1234';
return {
TEST_ADMIN_USER_ID: 'user_join_req_admin',
+ TEST_AUTH_USER_ID: 'user_join_req_authenticated_credential',
TEST_REQUESTER_USER_ID: 'user_join_req_requester',
TEST_ORG_ID: 'org_join_req_test',
mockCreateOrganizationMembership: vi.fn().mockResolvedValue({ id: 'om_test_new' }),
@@ -71,6 +73,7 @@ vi.mock('../../src/middleware/auth.js', async (importOriginal) => ({
requireAuth: (req: any, _res: any, next: any) => {
req.user = {
id: TEST_ADMIN_USER_ID,
+ authWorkosUserId: TEST_AUTH_USER_ID,
email: 'admin@example.com',
firstName: 'Admin',
lastName: 'User',
@@ -129,9 +132,9 @@ describe('Join Request Approval', () => {
// Re-establish after clearAllMocks: handler calls workos!.userManagement.listOrganizationMemberships
// via the new WorkOS() instance; the mock must return admin membership for test user.
listOrganizationMemberships.mockImplementation(({ userId, organizationId }: { userId: string; organizationId: string }) => {
- if (userId === TEST_ADMIN_USER_ID && organizationId === TEST_ORG_ID) {
+ if (userId === TEST_AUTH_USER_ID && organizationId === TEST_ORG_ID) {
return Promise.resolve({
- data: [{ id: 'om_admin', userId: TEST_ADMIN_USER_ID, organizationId: TEST_ORG_ID, role: { slug: 'admin' }, status: 'active' }],
+ data: [{ id: 'om_admin', userId: TEST_AUTH_USER_ID, organizationId: TEST_ORG_ID, role: { slug: 'admin' }, status: 'active' }],
});
}
return Promise.resolve({ data: [] });
@@ -161,7 +164,7 @@ describe('Join Request Approval', () => {
listOrganizationMemberships.mockResolvedValueOnce({
data: [{
id: 'om_pending',
- userId: TEST_ADMIN_USER_ID,
+ userId: TEST_AUTH_USER_ID,
organizationId: TEST_ORG_ID,
role: { slug: 'member' },
status: 'pending',
@@ -199,11 +202,47 @@ describe('Join Request Approval', () => {
const result = await pool.query(
`SELECT id FROM organization_join_requests
WHERE workos_organization_id = $1 AND workos_user_id = $2`,
- [TEST_ORG_ID, TEST_ADMIN_USER_ID],
+ [TEST_ORG_ID, TEST_AUTH_USER_ID],
);
expect(result.rows).toHaveLength(0);
});
+ it('stores a join request against the credential that authenticated, not its canonical identity user', async () => {
+ listOrganizationMemberships
+ .mockResolvedValueOnce({ data: [] })
+ .mockResolvedValueOnce({
+ data: [{
+ id: 'om_existing_owner',
+ userId: 'user_existing_owner',
+ organizationId: TEST_ORG_ID,
+ role: { slug: 'owner' },
+ status: 'active',
+ }],
+ });
+
+ await request(app)
+ .post('/api/join-requests')
+ .send({ organization_id: TEST_ORG_ID })
+ .expect(201);
+
+ const exactCredentialRequest = await pool.query(
+ `SELECT workos_user_id FROM organization_join_requests
+ WHERE workos_organization_id = $1 AND workos_user_id = $2`,
+ [TEST_ORG_ID, TEST_AUTH_USER_ID],
+ );
+ const canonicalUserRequest = await pool.query(
+ `SELECT workos_user_id FROM organization_join_requests
+ WHERE workos_organization_id = $1 AND workos_user_id = $2`,
+ [TEST_ORG_ID, TEST_ADMIN_USER_ID],
+ );
+
+ expect(exactCredentialRequest.rows).toHaveLength(1);
+ expect(canonicalUserRequest.rows).toHaveLength(0);
+ expect(listOrganizationMemberships).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ userId: TEST_AUTH_USER_ID,
+ }));
+ });
+
it('approves a join request by creating direct org membership, not sending an invitation', async () => {
const response = await request(app)
.post(`/api/organizations/${TEST_ORG_ID}/join-requests/${joinRequestId}/approve`)
diff --git a/server/tests/integration/member-agents-api.test.ts b/server/tests/integration/member-agents-api.test.ts
index a207cb4b53..2ca838b62e 100644
--- a/server/tests/integration/member-agents-api.test.ts
+++ b/server/tests/integration/member-agents-api.test.ts
@@ -56,6 +56,9 @@ describe('Per-agent REST API (/api/me/agents)', () => {
let app: express.Application;
let memberDb: MemberDatabase;
let orgDb: OrganizationDatabase;
+ const selectedOrgByUser = new Map();
+ let revokeAfterFirstMembershipCheck = false;
+ let membershipLookupCount = 0;
beforeAll(async () => {
pool = initializeDatabase({
@@ -82,6 +85,10 @@ describe('Per-agent REST API (/api/me/agents)', () => {
firstName: 'Test',
lastName: 'User',
};
+ const selectedOrg = selectedOrgByUser.get(currentUserId);
+ if (selectedOrg && !req.url.includes('?org=')) {
+ req.url += `${req.url.includes('?') ? '&' : '?'}org=${encodeURIComponent(selectedOrg)}`;
+ }
next();
});
@@ -100,6 +107,10 @@ describe('Per-agent REST API (/api/me/agents)', () => {
userId: string;
organizationId?: string;
}) => {
+ membershipLookupCount += 1;
+ if (revokeAfterFirstMembershipCheck && membershipLookupCount > 1) {
+ return { data: [] };
+ }
const args: unknown[] = [userId];
let where = `workos_user_id = $1`;
if (organizationId) {
@@ -152,6 +163,7 @@ describe('Per-agent REST API (/api/me/agents)', () => {
});
async function provisionUser(userId: string, orgId: string) {
+ selectedOrgByUser.set(userId, orgId);
await pool.query(
`INSERT INTO users (workos_user_id, email, primary_organization_id, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
@@ -215,6 +227,9 @@ describe('Per-agent REST API (/api/me/agents)', () => {
}
beforeEach(async () => {
+ selectedOrgByUser.clear();
+ revokeAfterFirstMembershipCheck = false;
+ membershipLookupCount = 0;
await pool.query(
`DELETE FROM organization_domains WHERE workos_organization_id LIKE $1`,
[`${TEST_PREFIX}%`],
@@ -453,6 +468,25 @@ describe('Per-agent REST API (/api/me/agents)', () => {
expect(res.status).toBe(403);
});
+ it('rechecks membership after locking and aborts when access is revoked before write', async () => {
+ const orgId = `${TEST_PREFIX}_revoked_during_write`;
+ const userId = `${TEST_PREFIX}_revoked_during_write_user`;
+ await seedOrg(pool, orgId, 'individual_professional');
+ await provisionUser(userId, orgId);
+ await createProfile(orgId, 'revokedduringwrite');
+ (app as any).setCurrentUser(userId);
+ revokeAfterFirstMembershipCheck = true;
+
+ const response = await request(app)
+ .post('/api/me/agents')
+ .send({ url: 'https://revoked.example.test/mcp', type: 'sales', visibility: 'private' });
+
+ expect(response.status).toBe(403);
+ expect(response.body.error).toMatch(/revoked/i);
+ const profile = await memberDb.getProfileByOrgId(orgId);
+ expect(profile!.agents.some((agent) => agent.url === 'https://revoked.example.test/mcp')).toBe(false);
+ });
+
it('POST resolves type server-side from capability snapshot, ignoring smuggled client value', async () => {
const orgId = `${TEST_PREFIX}_smuggle`;
const userId = `${TEST_PREFIX}_smuggle_user`;
diff --git a/server/tests/integration/member-agents-auto-bootstrap.test.ts b/server/tests/integration/member-agents-auto-bootstrap.test.ts
index 92aedc8185..fa8f50aa11 100644
--- a/server/tests/integration/member-agents-auto-bootstrap.test.ts
+++ b/server/tests/integration/member-agents-auto-bootstrap.test.ts
@@ -1,22 +1,17 @@
/**
- * Integration tests for the auto-bootstrap chain on `POST /api/me/agents`.
+ * Integration tests for explicit-organization agent registration.
*
* The route now self-heals two prior 4xx cliffs that forced storefront-style
* integrations to chain extra round trips:
*
- * - **No org**: a fresh OAuth user with zero memberships used to get a
- * `400 No organization associated with this account`. The route now
- * auto-creates an org (corporate or personal workspace based on the
- * user's email domain) and surfaces `org_auto_created: true`.
+ * - **No org**: requests fail closed with `organization_selection_required`.
*
* - **No profile**: any POST against an org without a member profile used
* to get a `404 Create a member profile via POST /api/me/member-profile first`.
* The route now creates a private profile on first call and surfaces
* `profile_auto_created: true`.
*
- * Auto-bootstrap is gated on the caller having zero memberships; users with
- * existing memberships but no `users.primary_organization_id` set fall
- * through to a clear 400 telling them to pass `?org=`.
+ * Organization creation is a separate explicit workflow.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
@@ -62,6 +57,7 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
let app: express.Application;
let memberDb: MemberDatabase;
let orgDb: OrganizationDatabase;
+ let selectedOrgId: string | null = null;
beforeAll(async () => {
pool = initializeDatabase({
@@ -83,6 +79,9 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
firstName: userOverride.firstName ?? 'Test',
lastName: userOverride.lastName ?? 'User',
};
+ if (selectedOrgId && !req.url.includes('?org=')) {
+ req.url += `${req.url.includes('?') ? '&' : '?'}org=${encodeURIComponent(selectedOrgId)}`;
+ }
next();
});
@@ -159,6 +158,7 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
});
async function seedOrgWithoutProfile(orgId: string, name = 'Acme Bootstrap Co') {
+ selectedOrgId = orgId;
// The hostname verification gate (#4499 MVP) requires a row in
// `organization_domains` with `verified = true` — `email_domain`
// is NOT a trustworthy claim and is no longer trusted by the gate
@@ -193,6 +193,7 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
}
beforeEach(async () => {
+ selectedOrgId = null;
userOverride.email = undefined;
userOverride.firstName = undefined;
userOverride.lastName = undefined;
@@ -289,8 +290,8 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
expect(res.status).toBe(404);
});
- describe('org auto-bootstrap (caller has zero memberships)', () => {
- it('auto-creates a corporate org for a fresh user with a corporate email', async () => {
+ describe('organization selection', () => {
+ it('fails closed for a fresh corporate-email user when no org is selected', async () => {
userOverride.email = `fresh@boot-corp.test`;
userOverride.firstName = 'Fresh';
userOverride.lastName = 'User';
@@ -299,50 +300,16 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
.post('/api/me/agents')
.send({ url: 'https://agent.boot-corp.test/mcp', type: 'sales', visibility: 'private' });
- expect(res.status).toBe(201);
- expect(res.body.org_auto_created).toBe(true);
- expect(res.body.profile_auto_created).toBe(true);
- expect(res.body.agent.url).toBe('https://agent.boot-corp.test/mcp');
-
- // Org row should be corporate (is_personal = false), name derived from
- // domain root with leading-cap.
- const orgRow = await pool.query<{
- workos_organization_id: string;
- name: string;
- is_personal: boolean;
- membership_tier: string | null;
- }>(
- `SELECT o.workos_organization_id, o.name, o.is_personal, o.membership_tier
- FROM organizations o
- JOIN organization_memberships om ON om.workos_organization_id = o.workos_organization_id
- WHERE om.workos_user_id = $1`,
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('organization_selection_required');
+ const memberships = await pool.query(
+ `SELECT 1 FROM organization_memberships WHERE workos_user_id = $1`,
[USER_ID],
);
- expect(orgRow.rowCount).toBe(1);
- expect(orgRow.rows[0].is_personal).toBe(false);
- expect(orgRow.rows[0].name).toBe('Boot-corp');
- // Tier MUST be NULL — Stripe webhook is the only writer.
- expect(orgRow.rows[0].membership_tier).toBeNull();
-
- // Domain should be email-verified.
- const domainRow = await pool.query<{ domain: string; verified: boolean }>(
- `SELECT domain, verified FROM organization_domains WHERE workos_organization_id = $1`,
- [orgRow.rows[0].workos_organization_id],
- );
- expect(domainRow.rows.find((r) => r.domain === 'boot-corp.test')).toBeDefined();
- expect(domainRow.rows.find((r) => r.domain === 'boot-corp.test')!.verified).toBe(true);
+ expect(memberships.rowCount).toBe(0);
});
- it('auto-bootstraps a personal workspace for a fresh free-email user but rejects the agent registration (no hostname claim)', async () => {
- // Pre-#4499-MVP this auto-bootstrap succeeded silently with an
- // unverified agent registered on whatever hostname the caller
- // supplied. With the hardened gate (security review on PR #4648),
- // orgs with zero verified domains hard-reject — the org is
- // still auto-bootstrapped (resolveOrAutoBootstrapOrg fires before
- // the gate) but the agent registration returns 400
- // no_verified_domains. The user gets a personal workspace they
- // can use for everything else; they just can't register agents
- // until they verify a domain.
+ it('fails closed for a fresh free-email user without creating a workspace', async () => {
userOverride.email = `solo+${Date.now()}@gmail.com`;
userOverride.firstName = 'Solo';
userOverride.lastName = 'Founder';
@@ -352,38 +319,15 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
.send({ url: 'https://agent.solo.test/mcp', type: 'sales', visibility: 'private' });
expect(res.status).toBe(400);
- expect(res.body.error).toBe('unverified_hostname');
- expect(res.body.reason).toBe('no_verified_domains');
-
- // The org bootstrap still fired — failure was on the agent
- // registration step, not on org creation.
- const orgRow = await pool.query<{ workos_organization_id: string; name: string; is_personal: boolean }>(
- `SELECT o.workos_organization_id, o.name, o.is_personal
- FROM organizations o
- JOIN organization_memberships om ON om.workos_organization_id = o.workos_organization_id
- WHERE om.workos_user_id = $1`,
+ expect(res.body.error).toBe('organization_selection_required');
+ const memberships = await pool.query(
+ `SELECT 1 FROM organization_memberships WHERE workos_user_id = $1`,
[USER_ID],
);
- expect(orgRow.rowCount).toBe(1);
- expect(orgRow.rows[0].is_personal).toBe(true);
- expect(orgRow.rows[0].name).toBe("Solo Founder's Workspace");
-
- // No member profile should exist either — the gate fires after
- // profile auto-bootstrap, so a failed agent registration on a
- // brand-new org should NOT leave a half-built profile. Pin this
- // so a future regression doesn't silently strand orphan profiles.
- const profile = await memberDb.getProfileByOrgId(
- orgRow.rows[0].workos_organization_id,
- );
- expect(profile).toBeNull();
+ expect(memberships.rowCount).toBe(0);
});
- it('does NOT auto-bootstrap when caller already has a membership — registers against the derived primary org instead of forking', async () => {
- // resolvePrimaryOrganization derives from organization_memberships when
- // users.primary_organization_id is null, so a user with any membership
- // already resolves to that org. Auto-bootstrap is gated on a truly
- // empty membership set; a stale `users` row never causes a silent
- // fork.
+ it('registers against an explicitly selected existing membership without forking', async () => {
const existingOrgId = `${TEST_PREFIX}_existing`;
await pool.query(
`INSERT INTO organizations (workos_organization_id, name, is_personal, created_at, updated_at)
@@ -406,16 +350,14 @@ describe('POST /api/me/agents (auto-bootstrap)', () => {
ON CONFLICT (workos_user_id, workos_organization_id) DO NOTHING`,
[USER_ID, existingOrgId, `${USER_ID}@example.com`],
);
+ selectedOrgId = existingOrgId;
const res = await request(app)
.post('/api/me/agents')
.send({ url: 'https://agent.no-fork.test/mcp', type: 'sales', visibility: 'private' });
expect(res.status).toBe(201);
- // Profile auto-bootstrap fires (no profile yet on the existing org)
- // but org auto-bootstrap MUST NOT fire — the agent must land on the
- // already-owned org, not a fresh fork.
- expect(res.body.org_auto_created).toBeUndefined();
+ // Profile creation remains safe after explicit organization selection.
expect(res.body.profile_auto_created).toBe(true);
const orgRow = await pool.query<{ workos_organization_id: string }>(
diff --git a/server/tests/integration/member-profile-bootstrap.test.ts b/server/tests/integration/member-profile-bootstrap.test.ts
index 8a475ab4b5..acc1d11d89 100644
--- a/server/tests/integration/member-profile-bootstrap.test.ts
+++ b/server/tests/integration/member-profile-bootstrap.test.ts
@@ -184,7 +184,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme Media',
company_type: 'publisher',
@@ -230,7 +230,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const first = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme Idempotent',
company_type: 'publisher',
@@ -239,7 +239,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
expect(first.status).toBe(201);
const second = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme Idempotent',
company_type: 'publisher',
@@ -259,7 +259,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme',
company_type: 'publisher',
@@ -276,7 +276,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Other Co',
company_type: 'publisher',
@@ -292,7 +292,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme',
company_type: 'not_a_real_type',
@@ -303,7 +303,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
expect(res.body.error).toBe('Invalid company_type');
});
- it('returns 404 when caller has no organization', async () => {
+ it('fails closed when no organization is selected', async () => {
currentUserId = 'user_boot_orphan';
currentUserEmail = 'orphan@acme.example';
@@ -315,8 +315,8 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
corporate_domain: 'acme.example',
});
- expect(res.status).toBe(404);
- expect(res.body.error).toBe('No organization');
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('The org query parameter is required');
});
it('rejects paid membership_tier values with 400', async () => {
@@ -324,7 +324,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme',
company_type: 'publisher',
@@ -347,7 +347,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme',
company_type: 'publisher',
@@ -373,7 +373,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
});
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Programmatic Override',
company_type: 'publisher',
@@ -418,7 +418,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme Audit',
company_type: 'publisher',
@@ -465,7 +465,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme Conflict',
company_type: 'publisher',
@@ -521,7 +521,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.set('User-Agent', 'BootstrapTest/1.0')
.set('X-Forwarded-For', '203.0.113.42')
.send({
@@ -552,7 +552,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId, { role: 'member' });
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
organization_name: 'Acme Media',
company_type: 'publisher',
@@ -574,7 +574,7 @@ describe('POST /api/me/member-profile (REST bootstrap)', () => {
await seedOrg(orgId);
const res = await request(app)
- .post('/api/me/member-profile')
+ .post(`/api/me/member-profile?org=${orgId}`)
.send({
display_name: 'Legacy Profile',
slug: 'legacy-profile-boot',
diff --git a/server/tests/integration/member-profile-verify-brand-auth.test.ts b/server/tests/integration/member-profile-verify-brand-auth.test.ts
index 0683c6a89e..008c3e4305 100644
--- a/server/tests/integration/member-profile-verify-brand-auth.test.ts
+++ b/server/tests/integration/member-profile-verify-brand-auth.test.ts
@@ -227,11 +227,13 @@ describe('POST /api/me/member-profile/verify-brand authz', () => {
}
}
- it('rejects a plain member verifying their primary org brand domain', async () => {
+ it('rejects a plain member verifying their explicitly selected org brand domain', async () => {
await seedOrg(TEST_ORG);
await seedUser(MEMBER_USER, TEST_ORG, 'member');
- const res = await request(app).post('/api/me/member-profile/verify-brand');
+ const res = await request(app).post(
+ `/api/me/member-profile/verify-brand?org=${TEST_ORG}`,
+ );
expect(res.status).toBe(403);
expect(res.body.error).toBe('Not authorized');
diff --git a/server/tests/integration/registry-api-agent-refresh.test.ts b/server/tests/integration/registry-api-agent-refresh.test.ts
index bb89a20b34..bd610ce843 100644
--- a/server/tests/integration/registry-api-agent-refresh.test.ts
+++ b/server/tests/integration/registry-api-agent-refresh.test.ts
@@ -80,6 +80,30 @@ vi.mock('../../src/middleware/auth.js', async () => {
};
});
+vi.mock('../../src/utils/resolve-user-org-membership.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveUserOrgMembership: vi.fn(async (
+ _workos: unknown,
+ principal: { id?: string; authWorkosUserId?: string },
+ organizationId: string,
+ ) => {
+ const authorizationUserId = principal.authWorkosUserId ?? principal.id;
+ if (
+ authorizationUserId !== OWNER_USER_ID
+ || (organizationId !== TEST_ORG_ID && organizationId !== SECOND_ORG_ID)
+ ) {
+ return null;
+ }
+ return {
+ organizationId,
+ role: 'admin',
+ status: 'active',
+ via_credential_grant: false,
+ via_dev_bypass: false,
+ };
+ }),
+}));
+
vi.mock('../../src/middleware/csrf.js', async () => {
const actual = await vi.importActual>('../../src/middleware/csrf.js');
return {
@@ -304,10 +328,11 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
});
const url = (agentUrl: string) => `/api/registry/agents/${encodeURIComponent(agentUrl)}/refresh`;
+ const selectedOrganization = (organizationId = TEST_ORG_ID) => ({ organization_id: organizationId });
it('owner can refresh and gets the snapshot back', async () => {
const agentUrl = ownedAgentUrl('owner');
- const res = await request(app).post(url(agentUrl)).send();
+ const res = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
online: true,
@@ -374,7 +399,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
it('public compliance bounds notice output while retaining the raw private record', async () => {
const agentUrl = ownedAgentUrl('public-notices');
- const refresh = await request(app).post(url(agentUrl)).send();
+ const refresh = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(refresh.status).toBe(200);
const rawNotices = [
@@ -446,7 +471,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
it('admin can refresh an agent they do not own', async () => {
currentUserId = ADMIN_USER_ID;
const agentUrl = ownedAgentUrl('admin');
- const res = await request(app).post(url(agentUrl)).send();
+ const res = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(res.status).toBe(200);
expect(refreshSingleAgentMock).toHaveBeenCalledWith(agentUrl, expect.any(Object));
});
@@ -455,7 +480,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
currentUserId = STATIC_ADMIN_USER_ID;
const agentUrl = ownedAgentUrl('static-admin');
- const res = await request(app).post(url(agentUrl)).send();
+ const res = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(res.status).toBe(200);
expect(res.body.compliance).toMatchObject({
@@ -489,50 +514,50 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
it('non-owner non-admin gets 403', async () => {
currentUserId = OTHER_USER_ID;
- const res = await request(app).post(url(OTHER_AGENT_URL)).send();
+ const res = await request(app).post(url(OTHER_AGENT_URL)).send(selectedOrganization());
expect(res.status).toBe(403);
expect(refreshSingleAgentMock).not.toHaveBeenCalled();
});
it('unauthenticated request gets 401', async () => {
currentUserId = null;
- const res = await request(app).post(url(ownedAgentUrl('owner'))).send();
+ const res = await request(app).post(url(ownedAgentUrl('owner'))).send(selectedOrganization());
expect(res.status).toBe(401);
expect(refreshSingleAgentMock).not.toHaveBeenCalled();
});
it('returns 400 for a malformed agent URL', async () => {
- const res = await request(app).post(url('not-a-valid-url')).send();
+ const res = await request(app).post(url('not-a-valid-url')).send(selectedOrganization());
expect(res.status).toBe(400);
expect(refreshSingleAgentMock).not.toHaveBeenCalled();
});
it('returns 400 for a private-IP URL (SSRF guard)', async () => {
- const res = await request(app).post(url('http://169.254.169.254/mcp')).send();
+ const res = await request(app).post(url('http://169.254.169.254/mcp')).send(selectedOrganization());
expect(res.status).toBe(400);
expect(refreshSingleAgentMock).not.toHaveBeenCalled();
});
it('returns 502 when the probe throws', async () => {
refreshSingleAgentMock.mockRejectedValue(new Error('Probe timeout'));
- const res = await request(app).post(url(ownedAgentUrl('probe-fail'))).send();
+ const res = await request(app).post(url(ownedAgentUrl('probe-fail'))).send(selectedOrganization());
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/Probe timeout/);
});
it('returns 409 when monitoring is paused', async () => {
refreshSingleAgentMock.mockRejectedValue(new Error('Monitoring paused for this agent'));
- const res = await request(app).post(url(ownedAgentUrl('paused'))).send();
+ const res = await request(app).post(url(ownedAgentUrl('paused'))).send(selectedOrganization());
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/Monitoring paused/);
});
it('rate-limits a second refresh of the same agent within the window', async () => {
const agentUrl = ownedAgentUrl('rate-limit');
- const first = await request(app).post(url(agentUrl)).send();
+ const first = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(first.status).toBe(200);
- const second = await request(app).post(url(agentUrl)).send();
+ const second = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(second.status).toBe(429);
expect(second.body.retry_after).toBeGreaterThan(0);
});
@@ -555,7 +580,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
await db.saveAuthToken(context.id, FAKE_BEARER, 'bearer');
try {
- const res = await request(app).post(url(agentUrl)).send();
+ const res = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(res.status).toBe(200);
expect(refreshSingleAgentMock).toHaveBeenCalledWith(
agentUrl,
@@ -585,7 +610,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
await db.saveAuthToken(context.id, FAKE_BEARER, 'bearer');
try {
- const res = await request(app).post(url(requestedUrl)).send();
+ const res = await request(app).post(url(requestedUrl)).send(selectedOrganization());
expect(res.status).toBe(200);
expect(refreshSingleAgentMock).toHaveBeenCalledWith(
agentUrl,
@@ -617,7 +642,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
try {
const res = await request(app)
.get(`/api/registry/agents/${encodeURIComponent(agentUrl)}/applicable-storyboards`)
- .send();
+ .query({ org: TEST_ORG_ID });
expect(res.status).toBe(200);
expect(testCapabilityDiscoveryMock).toHaveBeenCalledWith(
@@ -649,7 +674,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
try {
const res = await request(app)
.post(url(agentUrl))
- .send({ organization_id: SECOND_ORG_ID });
+ .send(selectedOrganization(SECOND_ORG_ID));
expect(res.status).toBe(200);
expect(refreshSingleAgentMock).toHaveBeenCalledWith(
@@ -701,7 +726,7 @@ describe('POST /api/registry/agents/:encodedUrl/refresh (integration)', () => {
storyboardId: 'sales_broadcast_tv',
}));
- const res = await request(app).post(url(agentUrl)).send();
+ const res = await request(app).post(url(agentUrl)).send(selectedOrganization());
expect(res.status).toBe(200);
expect(res.body.compliance).toMatchObject({
diff --git a/server/tests/integration/registry-api-compliance-verdict-source.test.ts b/server/tests/integration/registry-api-compliance-verdict-source.test.ts
index e23b3dbeab..4a2ff089f3 100644
--- a/server/tests/integration/registry-api-compliance-verdict-source.test.ts
+++ b/server/tests/integration/registry-api-compliance-verdict-source.test.ts
@@ -74,6 +74,28 @@ vi.mock('../../src/middleware/auth.js', async () => {
};
});
+vi.mock('../../src/utils/resolve-user-org-membership.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveUserOrgMembership: vi.fn(async (
+ _workos: unknown,
+ principal: { id?: string; authWorkosUserId?: string },
+ organizationId: string,
+ ) => {
+ const authorizationUserId = principal.authWorkosUserId ?? principal.id;
+ const hasExactMembership =
+ (authorizationUserId === OWNER_USER_ID && organizationId === OWNER_ORG_ID)
+ || (authorizationUserId === CROSS_ORG_USER_ID && organizationId === CROSS_ORG_ID);
+ if (!hasExactMembership) return null;
+ return {
+ organizationId,
+ role: 'admin',
+ status: 'active',
+ via_credential_grant: false,
+ via_dev_bypass: false,
+ };
+ }),
+}));
+
vi.mock('../../src/middleware/csrf.js', async () => {
const actual = await vi.importActual>('../../src/middleware/csrf.js');
return {
@@ -303,7 +325,7 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
it('cross-org caller: shape is intact, owner-only fields are null/false', async () => {
currentUserId = CROSS_ORG_USER_ID;
- const res = await request(app).get(endpoint);
+ const res = await request(app).get(endpoint).query({ org: CROSS_ORG_ID });
expect(res.status).toBe(200);
for (const key of OWNER_ONLY_KEYS) {
expect(res.body).toHaveProperty(key);
@@ -318,7 +340,7 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
it('owner caller: verdict_source + membership tier populated', async () => {
currentUserId = OWNER_USER_ID;
- const res = await request(app).get(endpoint);
+ const res = await request(app).get(endpoint).query({ org: OWNER_ORG_ID });
expect(res.status).toBe(200);
expect(res.body.verdict_source).toBe('owner_test');
expect(res.body.membership_tier).toBe('company_standard');
@@ -338,7 +360,7 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
);
try {
currentUserId = OWNER_USER_ID;
- const res = await request(app).get(endpoint);
+ const res = await request(app).get(endpoint).query({ org: OWNER_ORG_ID });
expect(res.status).toBe(200);
expect(res.body.verdict_source).toBe('owner_test');
expect(res.body.is_api_access_tier).toBe(false);
@@ -374,7 +396,9 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
it('owner caller can read storyboard status diagnostics', async () => {
currentUserId = OWNER_USER_ID;
- const res = await request(app).get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/storyboard-status`);
+ const res = await request(app)
+ .get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/storyboard-status`)
+ .query({ org: OWNER_ORG_ID });
expect(res.status).toBe(200);
expect(res.body.storyboards).toEqual(expect.arrayContaining([
expect.objectContaining({
@@ -391,7 +415,9 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
it('cross-org member sees storyboard status counts but not diagnostics', async () => {
currentUserId = CROSS_ORG_USER_ID;
- const res = await request(app).get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/storyboard-status`);
+ const res = await request(app)
+ .get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/storyboard-status`)
+ .query({ org: CROSS_ORG_ID });
expect(res.status).toBe(200);
expect(res.body.storyboards).toEqual(expect.arrayContaining([
expect.objectContaining({
@@ -409,6 +435,25 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
]));
});
+ it('an unauthorized explicit organization cannot unlock owner fields or storyboard detail', async () => {
+ currentUserId = OWNER_USER_ID;
+ const [complianceRes, storyboardRes] = await Promise.all([
+ request(app).get(endpoint).query({ org: CROSS_ORG_ID }),
+ request(app)
+ .get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/storyboard-status`)
+ .query({ org: CROSS_ORG_ID }),
+ ]);
+
+ // Compliance is public, so an unauthorized selector degrades to the
+ // public projection instead of revealing whether the org exists.
+ expect(complianceRes.status).toBe(200);
+ expect(complianceRes.body.verdict_source).toBeNull();
+ expectPublicStoryboardStatus(complianceRes.body);
+
+ // The member-only storyboard endpoint fails closed.
+ expect(storyboardRes.status).toBe(403);
+ });
+
it('static admin API key can read bulk storyboard status through real Postgres SQL', async () => {
currentUserId = STATIC_ADMIN_USER_ID;
const res = await request(app)
@@ -434,7 +479,7 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
currentUserId = CROSS_ORG_USER_ID;
const res = await request(app)
.post('/api/registry/agents/storyboard-status')
- .send({ agent_urls: [AGENT_URL] });
+ .send({ agent_urls: [AGENT_URL], organization_id: CROSS_ORG_ID });
expect(res.status).toBe(200);
expect(res.body.agents[AGENT_URL]).toEqual(expect.arrayContaining([
@@ -495,8 +540,12 @@ describe('GET /api/registry/agents/:encodedUrl/compliance — owner-scope gate (
it('cross-org caller still cannot read owner diagnostics or monitoring requests', async () => {
currentUserId = CROSS_ORG_USER_ID;
const [diagnosticsRes, monitoringRes] = await Promise.all([
- request(app).get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/compliance/diagnostics`),
- request(app).get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/monitoring/requests`),
+ request(app)
+ .get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/compliance/diagnostics`)
+ .query({ org: CROSS_ORG_ID }),
+ request(app)
+ .get(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/monitoring/requests`)
+ .query({ org: CROSS_ORG_ID }),
]);
expect(diagnosticsRes.status).toBe(403);
expect(monitoringRes.status).toBe(403);
diff --git a/server/tests/integration/registry-api-oauth.test.ts b/server/tests/integration/registry-api-oauth.test.ts
index c203b02384..cec73662a2 100644
--- a/server/tests/integration/registry-api-oauth.test.ts
+++ b/server/tests/integration/registry-api-oauth.test.ts
@@ -50,6 +50,30 @@ vi.mock('../../src/middleware/auth.js', async () => {
};
});
+vi.mock('../../src/utils/resolve-user-org-membership.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveUserOrgMembership: vi.fn(async (
+ _workos: unknown,
+ principal: { id?: string; authWorkosUserId?: string },
+ organizationId: string,
+ ) => {
+ const authorizationUserId = principal.authWorkosUserId ?? principal.id;
+ if (
+ authorizationUserId !== TEST_USER_ID
+ || (organizationId !== TEST_ORG_ID && organizationId !== SECOND_ORG_ID)
+ ) {
+ return null;
+ }
+ return {
+ organizationId,
+ role: 'admin',
+ status: 'active',
+ via_credential_grant: false,
+ via_dev_bypass: false,
+ };
+ }),
+}));
+
// CSRF middleware looks for a cookie + matching header on writes. In
// production the frontend's `/csrf.js` monkey-patches fetch to attach the
// header; supertest doesn't run that. Short-circuit the middleware so
@@ -92,6 +116,13 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
let app: unknown;
let pool: Pool;
+ function forOrganization>(
+ body: T,
+ organizationId = TEST_ORG_ID,
+ ): T & { organization_id: string } {
+ return { ...body, organization_id: organizationId };
+ }
+
beforeAll(async () => {
pool = initializeDatabase({
connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:53198/adcp_test',
@@ -186,7 +217,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
const url = `/api/registry/agents/${encodeURIComponent(TEST_AGENT_URL)}/connect`;
it('saves a bearer token and returns agent_context_id', async () => {
- const res = await request(app).put(url).send({ auth_token: 'test-bearer-123', auth_type: 'bearer' });
+ const res = await request(app).put(url).send(forOrganization({ auth_token: 'test-bearer-123', auth_type: 'bearer' }));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ connected: true, has_auth: true });
expect(res.body.agent_context_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
@@ -196,7 +227,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
const requestedUrl = 'HTTPS://AGENT.EXAMPLE.COM/';
const res = await request(app)
.put(`/api/registry/agents/${encodeURIComponent(requestedUrl)}/connect`)
- .send({ auth_token: 'test-bearer-123', auth_type: 'bearer' });
+ .send(forOrganization({ auth_token: 'test-bearer-123', auth_type: 'bearer' }));
expect(res.status).toBe(200);
const stored = await pool.query(
@@ -213,7 +244,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
});
it('normalizes raw Basic credentials before storing', async () => {
- const res = await request(app).put(url).send({ auth_token: 'test-user:test-password', auth_type: 'basic' });
+ const res = await request(app).put(url).send(forOrganization({ auth_token: 'test-user:test-password', auth_type: 'basic' }));
expect(res.status).toBe(200);
const stored = await pool.query(
@@ -230,7 +261,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
});
it('normalizes raw Basic credentials with a blank password before storing', async () => {
- const res = await request(app).put(url).send({ auth_token: 'test-user:', auth_type: 'basic' });
+ const res = await request(app).put(url).send(forOrganization({ auth_token: 'test-user:', auth_type: 'basic' }));
expect(res.status).toBe(200);
const stored = await pool.query(
@@ -250,7 +281,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
['raw', ':test-password'],
['base64', Buffer.from(':test-password', 'utf8').toString('base64')],
])('rejects %s Basic credentials with a blank username', async (_label, authToken) => {
- const res = await request(app).put(url).send({ auth_token: authToken, auth_type: 'basic' });
+ const res = await request(app).put(url).send(forOrganization({ auth_token: authToken, auth_type: 'basic' }));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/non-empty username/);
@@ -262,7 +293,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
});
it('creates a context without an auth_token (for OAuth-flow prep)', async () => {
- const res = await request(app).put(url).send({});
+ const res = await request(app).put(url).send(forOrganization({}));
expect(res.status).toBe(200);
expect(res.body.has_auth).toBe(false);
expect(res.body.agent_context_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
@@ -295,11 +326,12 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
expect(stored.rows).toHaveLength(0);
});
- it('fails closed when organization is omitted and multiple orgs own the URL', async () => {
+ it('fails closed with 400 when organization is omitted and multiple orgs own the URL', async () => {
await registerAgentInSecondOrg();
const res = await request(app).put(url).send({});
- expect(res.status).toBe(403);
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/organization_id/);
const stored = await pool.query(
'SELECT id FROM agent_contexts WHERE organization_id = ANY($1)',
@@ -319,12 +351,12 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('returns 403 for an agent the user does not own', async () => {
const res = await request(app)
.put(`/api/registry/agents/${encodeURIComponent(OTHER_AGENT_URL)}/connect`)
- .send({ auth_token: 'test-bearer-123', auth_type: 'bearer' });
+ .send(forOrganization({ auth_token: 'test-bearer-123', auth_type: 'bearer' }));
expect(res.status).toBe(403);
});
it('returns 400 when auth_type is outside the enum', async () => {
- const res = await request(app).put(url).send({ auth_token: 'x', auth_type: 'bogus' });
+ const res = await request(app).put(url).send(forOrganization({ auth_token: 'x', auth_type: 'bogus' }));
expect(res.status).toBe(400);
});
});
@@ -340,7 +372,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
};
it('saves a valid minimal config and returns 200', async () => {
- const res = await request(app).put(url).send(validBody);
+ const res = await request(app).put(url).send(forOrganization(validBody));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
connected: true,
@@ -353,7 +385,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
const requestedUrl = 'HTTPS://AGENT.EXAMPLE.COM/';
const res = await request(app)
.put(`/api/registry/agents/${encodeURIComponent(requestedUrl)}/oauth-client-credentials`)
- .send(validBody);
+ .send(forOrganization(validBody));
expect(res.status).toBe(200);
const stored = await pool.query(
@@ -372,7 +404,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('persists the full config including optional fields', async () => {
await request(app)
.put(url)
- .send({ ...validBody, scope: 'adcp', resource: TEST_AGENT_URL, auth_method: 'body' })
+ .send(forOrganization({ ...validBody, scope: 'adcp', resource: TEST_AGENT_URL, auth_method: 'body' }))
.expect(200);
const r = await pool.query(
@@ -391,7 +423,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('returns 400 when token_endpoint is a cloud-metadata host (SSRF guard)', async () => {
const res = await request(app)
.put(url)
- .send({ ...validBody, token_endpoint: 'http://169.254.169.254/latest/meta-data/' });
+ .send(forOrganization({ ...validBody, token_endpoint: 'http://169.254.169.254/latest/meta-data/' }));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/token_endpoint/i);
});
@@ -399,14 +431,14 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('returns 400 when client_secret uses an unauthorized $ENV: reference', async () => {
const res = await request(app)
.put(url)
- .send({ ...validBody, client_secret: '$ENV:DATABASE_URL' });
+ .send(forOrganization({ ...validBody, client_secret: '$ENV:DATABASE_URL' }));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/\$ENV/);
});
it('returns 400 when a required field is missing', async () => {
const { client_id: _, ...missing } = validBody;
- const res = await request(app).put(url).send(missing);
+ const res = await request(app).put(url).send(forOrganization(missing));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/client_id/);
});
@@ -415,7 +447,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
const resources = ['https://api1.example.com', 'https://api2.example.com'];
await request(app)
.put(url)
- .send({ ...validBody, resource: resources })
+ .send(forOrganization({ ...validBody, resource: resources }))
.expect(200);
const r = await pool.query(
@@ -432,7 +464,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
const collisionValue = 'v1a:["https://a"]';
await request(app)
.put(url)
- .send({ ...validBody, resource: collisionValue })
+ .send(forOrganization({ ...validBody, resource: collisionValue }))
.expect(200);
const r = await pool.query(
@@ -444,7 +476,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
// Round-trip via the test endpoint: the SDK should receive the original scalar
exchangeMock.mockResolvedValueOnce({ access_token: 'tok', token_type: 'Bearer' });
- const testRes = await request(app).post(`${url}/test`).send({});
+ const testRes = await request(app).post(`${url}/test`).send(forOrganization({}));
expect(testRes.status).toBe(200);
expect(exchangeMock).toHaveBeenCalledWith(
expect.objectContaining({ resource: collisionValue }),
@@ -455,7 +487,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('returns 403 for an agent the user does not own', async () => {
const res = await request(app)
.put(`/api/registry/agents/${encodeURIComponent(OTHER_AGENT_URL)}/oauth-client-credentials`)
- .send(validBody);
+ .send(forOrganization(validBody));
expect(res.status).toBe(403);
});
});
@@ -472,34 +504,34 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
};
it('returns 404 when no credentials are saved for this agent', async () => {
- const res = await request(app).post(testUrl).send({});
+ const res = await request(app).post(testUrl).send(forOrganization({}));
expect(res.status).toBe(404);
});
it('returns { ok: true, latency_ms } on a successful exchange', async () => {
- await request(app).put(saveUrl).send(validBody).expect(200);
+ await request(app).put(saveUrl).send(forOrganization(validBody)).expect(200);
exchangeMock.mockResolvedValueOnce({ access_token: 'new-access', token_type: 'Bearer' });
- const res = await request(app).post(testUrl).send({});
+ const res = await request(app).post(testUrl).send(forOrganization({}));
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(typeof res.body.latency_ms).toBe('number');
});
it('canonicalizes the requested URL before testing saved client credentials', async () => {
- await request(app).put(saveUrl).send(validBody).expect(200);
+ await request(app).put(saveUrl).send(forOrganization(validBody)).expect(200);
exchangeMock.mockResolvedValueOnce({ access_token: 'new-access', token_type: 'Bearer' });
const requestedUrl = 'HTTPS://AGENT.EXAMPLE.COM/';
const res = await request(app)
.post(`/api/registry/agents/${encodeURIComponent(requestedUrl)}/oauth-client-credentials/test`)
- .send({});
+ .send(forOrganization({}));
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('returns { ok: false, error: { kind: "oauth", ... } } when the AS rejects the client', async () => {
- await request(app).put(saveUrl).send(validBody).expect(200);
+ await request(app).put(saveUrl).send(forOrganization(validBody)).expect(200);
const { ClientCredentialsExchangeError } = await vi.importActual<{
ClientCredentialsExchangeError: new (
@@ -514,7 +546,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
),
);
- const res = await request(app).post(testUrl).send({});
+ const res = await request(app).post(testUrl).send(forOrganization({}));
expect(res.status).toBe(200);
expect(res.body.ok).toBe(false);
expect(res.body.error).toMatchObject({
@@ -526,10 +558,10 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('passes a decoded resource array to the exchange mock after save/load round-trip', async () => {
const resources = ['https://api1.example.com', 'https://api2.example.com'];
- await request(app).put(saveUrl).send({ ...validBody, resource: resources }).expect(200);
+ await request(app).put(saveUrl).send(forOrganization({ ...validBody, resource: resources })).expect(200);
exchangeMock.mockResolvedValueOnce({ access_token: 'tok', token_type: 'Bearer' });
- const res = await request(app).post(testUrl).send({});
+ const res = await request(app).post(testUrl).send(forOrganization({}));
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
@@ -543,14 +575,14 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('returns 403 when the user does not own the agent', async () => {
const res = await request(app)
.post(`/api/registry/agents/${encodeURIComponent(OTHER_AGENT_URL)}/oauth-client-credentials/test`)
- .send({});
+ .send(forOrganization({}));
expect(res.status).toBe(403);
});
it('returns 400 when PUT receives an empty resource array', async () => {
const res = await request(app)
.put(saveUrl)
- .send({ ...validBody, resource: [] });
+ .send(forOrganization({ ...validBody, resource: [] }));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/array/i);
});
@@ -563,7 +595,7 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
const statusUrl = `/api/registry/agents/${encodeURIComponent(TEST_AGENT_URL)}/auth-status`;
it('reports has_auth: false when nothing is saved', async () => {
- const res = await request(app).get(statusUrl);
+ const res = await request(app).get(statusUrl).query({ org: TEST_ORG_ID });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ has_auth: false, has_oauth_client_credentials: false });
});
@@ -571,10 +603,10 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('reports the static auth type after saving a bearer', async () => {
await request(app)
.put(`/api/registry/agents/${encodeURIComponent(TEST_AGENT_URL)}/connect`)
- .send({ auth_token: 'test-bearer', auth_type: 'bearer' })
+ .send(forOrganization({ auth_token: 'test-bearer', auth_type: 'bearer' }))
.expect(200);
- const res = await request(app).get(statusUrl).expect(200);
+ const res = await request(app).get(statusUrl).query({ org: TEST_ORG_ID }).expect(200);
expect(res.body).toMatchObject({ has_auth: true, auth_type: 'bearer' });
});
@@ -614,12 +646,13 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
it('canonicalizes the requested URL before ownership and auth-context lookup', async () => {
await request(app)
.put(`/api/registry/agents/${encodeURIComponent(TEST_AGENT_URL)}/connect`)
- .send({ auth_token: 'test-bearer', auth_type: 'bearer' })
+ .send(forOrganization({ auth_token: 'test-bearer', auth_type: 'bearer' }))
.expect(200);
const requestedUrl = 'HTTPS://AGENT.EXAMPLE.COM/';
const res = await request(app)
.get(`/api/registry/agents/${encodeURIComponent(requestedUrl)}/auth-status`)
+ .query({ org: TEST_ORG_ID })
.expect(200);
expect(res.body).toMatchObject({ has_auth: true, auth_type: 'bearer' });
@@ -632,10 +665,11 @@ describe('registry-api OAuth credential endpoints (integration)', () => {
token_endpoint: 'https://auth.example.com/oauth/token',
client_id: 'c',
client_secret: 's',
+ organization_id: TEST_ORG_ID,
})
.expect(200);
- const res = await request(app).get(statusUrl).expect(200);
+ const res = await request(app).get(statusUrl).query({ org: TEST_ORG_ID }).expect(200);
expect(res.body).toMatchObject({
has_auth: true,
has_oauth_client_credentials: true,
diff --git a/server/tests/integration/registry-property-save-identity.test.ts b/server/tests/integration/registry-property-save-identity.test.ts
index 6fe3671e3e..f61cdfe2fb 100644
--- a/server/tests/integration/registry-property-save-identity.test.ts
+++ b/server/tests/integration/registry-property-save-identity.test.ts
@@ -20,6 +20,45 @@ vi.hoisted(() => {
process.env.DATABASE_URL = process.env.DATABASE_URL ?? 'postgresql://adcp:localdev@localhost:5432/adcp_test';
});
+const { MEMBER_ORG, mockListOrganizationMemberships } = vi.hoisted(() => {
+ const memberOrg = 'org_save_identity_member';
+ return {
+ MEMBER_ORG: memberOrg,
+ mockListOrganizationMemberships: vi.fn().mockImplementation(async ({
+ userId,
+ organizationId,
+ }: {
+ userId: string;
+ organizationId?: string;
+ }) => ({
+ data: userId === 'user_save_test' && organizationId === memberOrg
+ ? [{
+ id: 'om_save_identity',
+ userId: 'user_save_test',
+ organizationId: memberOrg,
+ role: { slug: 'member' },
+ status: 'active',
+ }]
+ : [],
+ })),
+ };
+});
+
+vi.mock('../../src/auth/workos-client.js', async () => {
+ const actual = await vi.importActual(
+ '../../src/auth/workos-client.js',
+ );
+ const mockWorkos = {
+ userManagement: {
+ listOrganizationMemberships: mockListOrganizationMemberships,
+ },
+ };
+ return {
+ ...actual,
+ getWorkos: () => mockWorkos,
+ };
+});
+
vi.mock('../../src/middleware/auth.js', async () => {
const actual = await vi.importActual>('../../src/middleware/auth.js');
const pass = (req: { user: unknown }, _res: unknown, next: () => void) => {
@@ -102,6 +141,17 @@ describe('POST /api/properties/save — identity, not authorization', () => {
connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test',
});
await runMigrations();
+ await pool.query(
+ `INSERT INTO organizations (
+ workos_organization_id, name, is_personal, membership_tier,
+ subscription_status, created_at, updated_at
+ ) VALUES ($1, 'Save Identity Member Org', false, 'individual_professional', 'active', NOW(), NOW())
+ ON CONFLICT (workos_organization_id) DO UPDATE SET
+ membership_tier = EXCLUDED.membership_tier,
+ subscription_status = EXCLUDED.subscription_status,
+ subscription_canceled_at = NULL`,
+ [MEMBER_ORG],
+ );
propertyDb = new PropertyDatabase();
server = new HTTPServer();
await server.start(0);
@@ -110,6 +160,7 @@ describe('POST /api/properties/save — identity, not authorization', () => {
afterAll(async () => {
await clearFixtures();
+ await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [MEMBER_ORG]);
await server?.stop();
await closeDatabase();
}, 30000);
@@ -209,7 +260,7 @@ describe('POST /api/properties/save — identity, not authorization', () => {
it('scrubs caller authorization on the member community-property create route', async () => {
const res = await request(app)
- .post('/api/properties/hosted/community')
+ .post(`/api/properties/hosted/community?org=${MEMBER_ORG}`)
.send({
publisher_domain: HTTP_COMMUNITY_DOMAIN,
adagents_json: {
@@ -233,7 +284,7 @@ describe('POST /api/properties/save — identity, not authorization', () => {
});
const res = await request(app)
- .put(`/api/properties/hosted/${encodeURIComponent(HTTP_EDIT_DOMAIN)}`)
+ .put(`/api/properties/hosted/${encodeURIComponent(HTTP_EDIT_DOMAIN)}?org=${MEMBER_ORG}`)
.send({
edit_summary: 'Identity-only edit',
adagents_json: {
diff --git a/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts b/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts
index b1472cbbc6..2a68357c8a 100644
--- a/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts
+++ b/server/tests/integration/registry-reader-baseline-public-endpoints.test.ts
@@ -47,11 +47,33 @@ vi.hoisted(() => {
const DEFAULT_TEST_USER_ID = 'user_test_registry_baseline_endpoints';
const authState = vi.hoisted(() => ({
optAuthUser: null as { id: string; email: string } | null,
+ organizationByUser: {} as Record,
requireAuthUser: {
id: 'user_test_registry_baseline_endpoints',
email: 'registry-baseline@test.com',
} as { id: string; email: string; isAdmin?: boolean } | null,
}));
+
+vi.mock('../../src/utils/resolve-user-org-membership.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveUserOrgMembership: vi.fn(async (
+ _workos: unknown,
+ principal: { id?: string; authWorkosUserId?: string },
+ organizationId: string,
+ ) => {
+ const authorizationUserId = principal.authWorkosUserId ?? principal.id;
+ if (!authorizationUserId || authState.organizationByUser[authorizationUserId] !== organizationId) {
+ return null;
+ }
+ return {
+ organizationId,
+ role: 'admin',
+ status: 'active',
+ via_credential_grant: false,
+ via_dev_bypass: false,
+ };
+ }),
+}));
vi.mock('../../src/middleware/auth.js', async () => {
const actual = await vi.importActual>(
'../../src/middleware/auth.js'
@@ -209,6 +231,8 @@ describe('Registry reader baseline — public endpoints', () => {
});
beforeEach(async () => {
+ authState.optAuthUser = null;
+ authState.organizationByUser = {};
await clearFixtures();
});
@@ -1222,6 +1246,7 @@ describe('Registry reader baseline — public endpoints', () => {
ON CONFLICT (workos_user_id, workos_organization_id) DO NOTHING`,
[userId, orgId, `${userId}@example.com`],
);
+ authState.organizationByUser[userId] = orgId;
}
async function clearScopeFixtures() {
@@ -1300,12 +1325,24 @@ describe('Registry reader baseline — public endpoints', () => {
setOptAuthUser({ id, email: `${id}@example.com` });
}
+ function callerOrganization(kind: CallerKind): string | null {
+ if (kind === 'owner') return SCOPE_OWNER_ORG;
+ if (kind === 'other_api') return SCOPE_OTHER_API_ORG;
+ if (kind === 'explorer') return SCOPE_EXPLORER_ORG;
+ return null;
+ }
+
async function fetchAgents(scope: string | null, kind: CallerKind): Promise {
setCaller(kind);
const qs = scope === null ? '' : `&scope=${encodeURIComponent(scope)}`;
- const res = await request(app).get(
+ let testRequest = request(app).get(
`/api/registry/operator?domain=${encodeURIComponent(SCOPE_DOMAIN)}${qs}`,
);
+ const organizationId = callerOrganization(kind);
+ if (organizationId) {
+ testRequest = testRequest.set('x-organization-id', organizationId);
+ }
+ const res = await testRequest;
expect(res.status).toBe(200);
return (res.body.agents as Array<{ url: string }>).map(a => a.url).sort();
}
diff --git a/server/tests/integration/resolve-user-org-membership.test.ts b/server/tests/integration/resolve-user-org-membership.test.ts
index 77b409a9ed..2c6409e312 100644
--- a/server/tests/integration/resolve-user-org-membership.test.ts
+++ b/server/tests/integration/resolve-user-org-membership.test.ts
@@ -24,7 +24,11 @@ vi.mock('../../src/middleware/auth.js', async (importOriginal) => {
import { initializeDatabase, closeDatabase } from '../../src/db/client.js';
import { runMigrations } from '../../src/db/migrate.js';
-import { resolveUserOrgMembership } from '../../src/utils/resolve-user-org-membership.js';
+import {
+ evaluateUserOrgRoleAuthorization,
+ resolveUserOrgAuthorization,
+ resolveUserOrgMembership,
+} from '../../src/utils/resolve-user-org-membership.js';
import type { Pool } from 'pg';
import type { WorkOS } from '@workos-inc/node';
@@ -60,13 +64,14 @@ describe('resolveUserOrgMembership', () => {
await seedMembership(pool, DEV_ADMIN_USER, DEV_ORG, 'owner');
// workos arg is a no-op in dev path — pass null to prove it.
- const result = await resolveUserOrgMembership(null, DEV_ADMIN_USER, DEV_ORG);
+ const result = await resolveUserOrgMembership(null, { id: DEV_ADMIN_USER }, DEV_ORG);
expect(result).toEqual({
organizationId: DEV_ORG,
role: 'owner',
status: 'active',
via_dev_bypass: true,
+ via_credential_grant: false,
});
});
@@ -74,7 +79,7 @@ describe('resolveUserOrgMembership', () => {
await seedOrg(pool, DEV_ORG);
// Don't seed membership.
- const result = await resolveUserOrgMembership(null, DEV_ADMIN_USER, DEV_ORG);
+ const result = await resolveUserOrgMembership(null, { id: DEV_ADMIN_USER }, DEV_ORG);
expect(result).toBeNull();
});
@@ -83,13 +88,14 @@ describe('resolveUserOrgMembership', () => {
await seedOrg(pool, DEV_ORG);
await seedMembership(pool, DEV_MEMBER_USER, DEV_ORG, 'weirdRole');
- const result = await resolveUserOrgMembership(null, DEV_MEMBER_USER, DEV_ORG);
+ const result = await resolveUserOrgMembership(null, { id: DEV_MEMBER_USER }, DEV_ORG);
expect(result).toEqual({
organizationId: DEV_ORG,
role: 'member',
status: 'active',
via_dev_bypass: true,
+ via_credential_grant: false,
});
});
@@ -103,13 +109,14 @@ describe('resolveUserOrgMembership', () => {
} as unknown as WorkOS;
// NON_DEV_USER isn't in DEV_USERS, so we hit WorkOS even in dev mode.
- const result = await resolveUserOrgMembership(mockWorkos, NON_DEV_USER, NON_DEV_ORG);
+ const result = await resolveUserOrgMembership(mockWorkos, { id: NON_DEV_USER }, NON_DEV_ORG);
expect(result).toEqual({
organizationId: NON_DEV_ORG,
role: 'admin',
status: 'active',
via_dev_bypass: false,
+ via_credential_grant: false,
});
expect(mockWorkos.userManagement.listOrganizationMemberships).toHaveBeenCalledWith({
userId: NON_DEV_USER,
@@ -119,6 +126,26 @@ describe('resolveUserOrgMembership', () => {
});
describe('WorkOS path', () => {
+ it('authorizes the authenticated credential instead of the canonical person-state user', async () => {
+ const listOrganizationMemberships = vi.fn().mockResolvedValue({
+ data: [{ organizationId: NON_DEV_ORG, status: 'active', role: { slug: 'member' } }],
+ });
+ const mockWorkos = {
+ userManagement: { listOrganizationMemberships },
+ } as unknown as WorkOS;
+
+ await resolveUserOrgMembership(
+ mockWorkos,
+ { id: 'user_canonical', authWorkosUserId: 'user_authenticated' },
+ NON_DEV_ORG,
+ );
+
+ expect(listOrganizationMemberships).toHaveBeenCalledWith({
+ userId: 'user_authenticated',
+ organizationId: NON_DEV_ORG,
+ });
+ });
+
it('returns the highest-privilege active role from WorkOS memberships', async () => {
const mockWorkos = {
userManagement: {
@@ -132,7 +159,7 @@ describe('resolveUserOrgMembership', () => {
},
} as unknown as WorkOS;
- const result = await resolveUserOrgMembership(mockWorkos, NON_DEV_USER, NON_DEV_ORG);
+ const result = await resolveUserOrgMembership(mockWorkos, { id: NON_DEV_USER }, NON_DEV_ORG);
// 'admin' is the highest active role; pending 'owner' is filtered out.
expect(result?.role).toBe('admin');
@@ -146,7 +173,7 @@ describe('resolveUserOrgMembership', () => {
},
} as unknown as WorkOS;
- const result = await resolveUserOrgMembership(mockWorkos, NON_DEV_USER, NON_DEV_ORG);
+ const result = await resolveUserOrgMembership(mockWorkos, { id: NON_DEV_USER }, NON_DEV_ORG);
expect(result).toBeNull();
});
@@ -163,7 +190,7 @@ describe('resolveUserOrgMembership', () => {
},
} as unknown as WorkOS;
- const result = await resolveUserOrgMembership(mockWorkos, NON_DEV_USER, NON_DEV_ORG);
+ const result = await resolveUserOrgMembership(mockWorkos, { id: NON_DEV_USER }, NON_DEV_ORG);
expect(result).toBeNull();
});
@@ -180,17 +207,107 @@ describe('resolveUserOrgMembership', () => {
},
} as unknown as WorkOS;
- const result = await resolveUserOrgMembership(mockWorkos, NON_DEV_USER, NON_DEV_ORG);
+ const result = await resolveUserOrgMembership(mockWorkos, { id: NON_DEV_USER }, NON_DEV_ORG);
expect(result).toBeNull();
});
it('returns null when the WorkOS client is missing', async () => {
// Use a non-DEV user so the dev-mode path doesn't bypass.
- const result = await resolveUserOrgMembership(null, NON_DEV_USER, NON_DEV_ORG);
+ const result = await resolveUserOrgMembership(null, { id: NON_DEV_USER }, NON_DEV_ORG);
expect(result).toBeNull();
});
+
+ it('distinguishes a missing WorkOS client from a definitive denial', async () => {
+ const result = await resolveUserOrgAuthorization(
+ null,
+ { id: NON_DEV_USER },
+ NON_DEV_ORG,
+ );
+
+ expect(result).toEqual({
+ status: 'unavailable',
+ complete: false,
+ unavailableSources: ['workos'],
+ });
+ expect(evaluateUserOrgRoleAuthorization(result)).toEqual({
+ status: 'unavailable',
+ unavailableSources: ['workos'],
+ });
+ });
+
+ it('returns a definitive denial when all authority sources are available', async () => {
+ const mockWorkos = {
+ userManagement: {
+ listOrganizationMemberships: vi.fn().mockResolvedValue({ data: [] }),
+ },
+ } as unknown as WorkOS;
+
+ const result = await resolveUserOrgAuthorization(
+ mockWorkos,
+ { id: NON_DEV_USER },
+ NON_DEV_ORG,
+ );
+
+ expect(result).toEqual({ status: 'forbidden', complete: true, unavailableSources: [] });
+ expect(evaluateUserOrgRoleAuthorization(result)).toEqual({ status: 'forbidden' });
+ });
+
+ it('accepts a sufficient grant during WorkOS failure but does not understate role uncertainty', async () => {
+ await seedOrg(pool, NON_DEV_ORG);
+ await seedUser(pool, NON_DEV_USER);
+ await pool.query(
+ `INSERT INTO organization_credential_grants (
+ workos_organization_id, workos_user_id, role, granted_by_workos_user_id
+ ) VALUES ($1, $2, 'member', 'user_grant_admin')`,
+ [NON_DEV_ORG, NON_DEV_USER],
+ );
+ const mockWorkos = {
+ userManagement: {
+ listOrganizationMemberships: vi.fn().mockRejectedValue(new Error('WorkOS unavailable')),
+ },
+ } as unknown as WorkOS;
+
+ const result = await resolveUserOrgAuthorization(
+ mockWorkos,
+ { id: NON_DEV_USER },
+ NON_DEV_ORG,
+ );
+
+ expect(result).toMatchObject({
+ status: 'authorized',
+ complete: false,
+ unavailableSources: ['workos'],
+ membership: { role: 'member', via_credential_grant: true },
+ });
+ expect(evaluateUserOrgRoleAuthorization(result, 'member')).toMatchObject({
+ status: 'authorized',
+ membership: { role: 'member' },
+ });
+ expect(evaluateUserOrgRoleAuthorization(result, 'admin')).toEqual({
+ status: 'unavailable',
+ unavailableSources: ['workos'],
+ });
+ });
+
+ it('returns forbidden for an insufficient role when every source is complete', async () => {
+ const mockWorkos = {
+ userManagement: {
+ listOrganizationMemberships: vi.fn().mockResolvedValue({
+ data: [{ organizationId: NON_DEV_ORG, status: 'active', role: { slug: 'member' } }],
+ }),
+ },
+ } as unknown as WorkOS;
+
+ const result = await resolveUserOrgAuthorization(
+ mockWorkos,
+ { id: NON_DEV_USER },
+ NON_DEV_ORG,
+ );
+
+ expect(evaluateUserOrgRoleAuthorization(result, 'admin')).toEqual({ status: 'forbidden' });
+ });
});
});
@@ -203,6 +320,7 @@ async function cleanup(pool: Pool) {
'DELETE FROM organizations WHERE workos_organization_id = ANY($1)',
[[DEV_ORG, NON_DEV_ORG]],
);
+ await pool.query('DELETE FROM users WHERE workos_user_id = $1', [NON_DEV_USER]);
}
async function seedOrg(pool: Pool, orgId: string) {
@@ -214,6 +332,15 @@ async function seedOrg(pool: Pool, orgId: string) {
);
}
+async function seedUser(pool: Pool, userId: string) {
+ await pool.query(
+ `INSERT INTO users (workos_user_id, email, first_name, last_name, created_at, updated_at)
+ VALUES ($1, $2, 'Test', 'User', NOW(), NOW())
+ ON CONFLICT (workos_user_id) DO NOTHING`,
+ [userId, `${userId}@test.com`],
+ );
+}
+
async function seedMembership(pool: Pool, userId: string, orgId: string, role: string) {
await pool.query(
`INSERT INTO organization_memberships (
diff --git a/server/tests/integration/user-context.test.ts b/server/tests/integration/user-context.test.ts
index eeee0fe6c1..fd31e4d5d3 100644
--- a/server/tests/integration/user-context.test.ts
+++ b/server/tests/integration/user-context.test.ts
@@ -360,14 +360,21 @@ describe('User Context API Tests', () => {
expect(response.body.org_membership).toBeUndefined();
});
- it('should default org context for a user with exactly one active WorkOS organization', async () => {
+ it('should require explicit org context even for a user with exactly one active WorkOS organization', async () => {
const response = await request(app)
.get(`/api/admin/users/${TEST_SINGLE_ORG_WORKOS_USER_ID}/context?type=workos`)
.expect(200);
expect(response.body.workos_user.workos_user_id).toBe(TEST_SINGLE_ORG_WORKOS_USER_ID);
- expect(response.body.organization.workos_organization_id).toBe(TEST_ORG_ID);
- expect(response.body.org_membership.role).toBe('member');
+ expect(response.body.organization).toBeUndefined();
+ expect(response.body.org_membership).toBeUndefined();
+
+ const selectedResponse = await request(app)
+ .get(`/api/admin/users/${TEST_SINGLE_ORG_WORKOS_USER_ID}/context?type=workos&org=${TEST_ORG_ID}`)
+ .expect(200);
+
+ expect(selectedResponse.body.organization.workos_organization_id).toBe(TEST_ORG_ID);
+ expect(selectedResponse.body.org_membership.role).toBe('member');
});
it('should preserve member engagement when relationship engagement is present', async () => {
diff --git a/server/tests/unit/addie-admin-global-auth.test.ts b/server/tests/unit/addie-admin-global-auth.test.ts
index 0a9c766783..328b344899 100644
--- a/server/tests/unit/addie-admin-global-auth.test.ts
+++ b/server/tests/unit/addie-admin-global-auth.test.ts
@@ -125,6 +125,17 @@ describe('Addie real global-admin boundary', () => {
rowCount: 1,
});
}
+ if (sql.includes('FROM identity_workos_users')) {
+ return Promise.resolve({
+ rows: [{
+ identity_id: 'identity_sso_admin',
+ primary_workos_user_id: null,
+ identity_authorization_epoch: '1',
+ credential_authorization_epoch: '1',
+ }],
+ rowCount: 1,
+ });
+ }
return Promise.resolve({ rows: [], rowCount: 0 });
});
mocks.loadSealedSession.mockReturnValue({
diff --git a/server/tests/unit/addie-chat-object-authorization.test.ts b/server/tests/unit/addie-chat-object-authorization.test.ts
index 75c2b57ad3..56de04ad81 100644
--- a/server/tests/unit/addie-chat-object-authorization.test.ts
+++ b/server/tests/unit/addie-chat-object-authorization.test.ts
@@ -266,6 +266,12 @@ function mountChatRouter() {
const app = express();
app.use(express.json());
+ app.use((req, _res, next) => {
+ if (mocks.authenticated && req.body && typeof req.body === 'object' && !req.body.organization_id) {
+ req.body.organization_id = 'org_object_authorization';
+ }
+ next();
+ });
app.use((req, _res, next) => {
const ownerCookie = req.get('cookie')
?.split(';')
diff --git a/server/tests/unit/addie-chat-org-selection.test.ts b/server/tests/unit/addie-chat-org-selection.test.ts
index 8696d9a17d..bf4bdd4449 100644
--- a/server/tests/unit/addie-chat-org-selection.test.ts
+++ b/server/tests/unit/addie-chat-org-selection.test.ts
@@ -211,6 +211,7 @@ describe('mounted Addie web-thread ownership', () => {
.set('x-test-user-id', 'attacker')
.send({
message: 'hello',
+ organization_id: 'org_attacker',
conversation_id: '11111111-1111-4111-8111-111111111111',
})
.expect(404);
diff --git a/server/tests/unit/addie/brand-property-tools.test.ts b/server/tests/unit/addie/brand-property-tools.test.ts
index b47c280ac0..cbec55aed0 100644
--- a/server/tests/unit/addie/brand-property-tools.test.ts
+++ b/server/tests/unit/addie/brand-property-tools.test.ts
@@ -22,6 +22,7 @@ process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID ?? 'client_test';
const mocks = vi.hoisted(() => ({
parsePropertyInputForBrand: vi.fn(),
mergeBrandProperties: vi.fn(),
+ resolveUserOrgMembership: vi.fn(),
}));
// Mock the shared service so the tests don't need a DB / Anthropic. We're
@@ -36,6 +37,10 @@ vi.mock('../../../src/services/brand-property-parse.js', async (importOriginal)
};
});
+vi.mock('../../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: mocks.resolveUserOrgMembership,
+}));
+
const {
BRAND_PROPERTY_TOOLS,
createBrandPropertyToolHandlers,
@@ -54,11 +59,19 @@ const SIGNED_IN_CTX = {
is_member: true,
slack_linked: false,
workos_user: { workos_user_id: 'user_owner_123', email: 'owner@example.com' },
+ organization: { workos_organization_id: 'org_owner_123' },
} as unknown as MemberContext;
+const WITH_ORG = { organization_id: 'org_owner_123' };
+
beforeEach(() => {
mocks.parsePropertyInputForBrand.mockReset();
mocks.mergeBrandProperties.mockReset();
+ mocks.resolveUserOrgMembership.mockReset();
+ mocks.resolveUserOrgMembership.mockResolvedValue({
+ organizationId: 'org_owner_123',
+ role: 'member',
+ });
});
describe('parse_brand_properties tool schema', () => {
@@ -91,6 +104,7 @@ describe('parse_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('parse_brand_properties')!({
+ ...WITH_ORG,
domain: '',
input: 'cnn.com',
}),
@@ -103,6 +117,7 @@ describe('parse_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('parse_brand_properties')!({
+ ...WITH_ORG,
domain: 'paste-demo.example',
input: ' ',
}),
@@ -120,13 +135,14 @@ describe('parse_brand_properties handler', () => {
});
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
await handlers.get('parse_brand_properties')!({
+ ...WITH_ORG,
domain: 'HTTPS://Paste-Demo.Example/path?q=1',
input: 'cnn.com',
});
expect(mocks.parsePropertyInputForBrand).toHaveBeenCalledOnce();
const callArgs = mocks.parsePropertyInputForBrand.mock.calls[0][0];
expect(callArgs.domain).toBe('paste-demo.example');
- expect(callArgs.userId).toBe('user_owner_123');
+ expect(callArgs.organizationId).toBe('org_owner_123');
expect(callArgs.inputType).toBe('text');
});
@@ -143,6 +159,7 @@ describe('parse_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('parse_brand_properties')!({
+ ...WITH_ORG,
domain: 'paste-demo.example',
input: 'cnn.com\nbbc.co.uk',
input_type: 'text',
@@ -163,6 +180,7 @@ describe('parse_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('parse_brand_properties')!({
+ ...WITH_ORG,
domain: 'someone-elses.example',
input: 'cnn.com',
input_type: 'text',
@@ -181,6 +199,7 @@ describe('parse_brand_properties handler', () => {
});
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
await handlers.get('parse_brand_properties')!({
+ ...WITH_ORG,
domain: 'paste-demo.example',
input: 'cnn.com',
input_type: 'text',
@@ -200,6 +219,7 @@ describe('parse_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('parse_brand_properties')!({
+ ...WITH_ORG,
domain: 'paste-demo.example',
input: 'no-real-domains-here',
input_type: 'text',
@@ -216,6 +236,7 @@ describe('import_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(null);
const result = JSON.parse(
await handlers.get('import_brand_properties')!({
+ ...WITH_ORG,
domain: 'paste-demo.example',
properties: [{ identifier: 'cnn.com', type: 'website' }],
}),
@@ -228,6 +249,7 @@ describe('import_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('import_brand_properties')!({
+ ...WITH_ORG,
domain: 'paste-demo.example',
properties: 'not an array',
}),
@@ -244,6 +266,7 @@ describe('import_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('import_brand_properties')!({
+ ...WITH_ORG,
domain: 'paste-demo.example',
properties: [
{ identifier: 'cnn.com', type: 'website', relationship: 'delegated' },
@@ -254,7 +277,7 @@ describe('import_brand_properties handler', () => {
expect(result.added).toBe(2);
expect(mocks.mergeBrandProperties).toHaveBeenCalledOnce();
const callArgs = mocks.mergeBrandProperties.mock.calls[0][0];
- expect(callArgs.userId).toBe('user_owner_123');
+ expect(callArgs.organizationId).toBe('org_owner_123');
expect(callArgs.domain).toBe('paste-demo.example');
expect(callArgs.properties).toHaveLength(2);
});
@@ -268,6 +291,7 @@ describe('import_brand_properties handler', () => {
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
const result = JSON.parse(
await handlers.get('import_brand_properties')!({
+ ...WITH_ORG,
domain: 'unknown.example',
properties: [{ identifier: 'cnn.com', type: 'website' }],
}),
@@ -283,6 +307,7 @@ describe('import_brand_properties handler', () => {
});
const handlers = createBrandPropertyToolHandlers(SIGNED_IN_CTX);
await handlers.get('import_brand_properties')!({
+ ...WITH_ORG,
domain: 'HTTPS://Paste-Demo.Example/foo',
properties: [{ identifier: 'cnn.com', type: 'website' }],
});
diff --git a/server/tests/unit/agent-ownership.test.ts b/server/tests/unit/agent-ownership.test.ts
index bd27b7810a..517abf4cb4 100644
--- a/server/tests/unit/agent-ownership.test.ts
+++ b/server/tests/unit/agent-ownership.test.ts
@@ -1,215 +1,92 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
-vi.mock('../../src/db/client.js', () => ({
- query: vi.fn(),
+vi.mock('../../src/db/client.js', () => ({ query: vi.fn() }));
+vi.mock('../../src/auth/workos-client.js', () => ({ getWorkos: vi.fn(() => ({ marker: 'workos' })) }));
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: vi.fn(),
}));
import { query } from '../../src/db/client.js';
+import { resolveUserOrgMembership } from '../../src/utils/resolve-user-org-membership.js';
import {
- findOwnerOrgForUser,
+ findOwnedAgentVisibility,
isOrgOwnerOfAgent,
resolveOwnerOrgForUser,
} from '../../src/services/agent-ownership.js';
const queryMock = vi.mocked(query);
+const membershipMock = vi.mocked(resolveUserOrgMembership);
+const principal = { id: 'user_primary_b', authWorkosUserId: 'user_credential_a' };
-describe('agent-ownership', () => {
+describe('agent ownership authorization', () => {
beforeEach(() => {
queryMock.mockReset();
+ membershipMock.mockReset();
});
- describe('findOwnerOrgForUser', () => {
- it('returns the org_id when the user owns the agent through some org', async () => {
- queryMock.mockResolvedValueOnce({
- rows: [{ workos_organization_id: 'org_abc' }],
- rowCount: 1,
- command: 'SELECT',
- oid: 0,
- fields: [],
- } as never);
-
- const result = await findOwnerOrgForUser('user_123', 'https://agent.example.com/mcp');
- expect(result).toBe('org_abc');
- expect(queryMock).toHaveBeenCalledOnce();
- const [sql, params] = queryMock.mock.calls[0];
- expect(sql).toContain('member_profiles mp');
- expect(sql).toContain('organization_memberships om');
- expect(params).toEqual([
- JSON.stringify([{ url: 'https://agent.example.com/mcp' }]),
- 'user_123',
- ]);
- });
-
- it('returns null when the user is not a member of any owning org', async () => {
- queryMock.mockResolvedValueOnce({
- rows: [],
- rowCount: 0,
- command: 'SELECT',
- oid: 0,
- fields: [],
- } as never);
- const result = await findOwnerOrgForUser('user_123', 'https://agent.example.com/mcp');
- expect(result).toBeNull();
- });
-
- it('returns null when the query throws', async () => {
- queryMock.mockRejectedValueOnce(new Error('connection refused'));
- const result = await findOwnerOrgForUser('user_123', 'https://agent.example.com/mcp');
- expect(result).toBeNull();
- });
-
- it('canonicalizes the lookup URL before matching stored member profile agents', async () => {
- queryMock.mockResolvedValueOnce({
- rows: [{ workos_organization_id: 'org_abc' }],
- rowCount: 1,
- command: 'SELECT',
- oid: 0,
- fields: [],
- } as never);
-
- const result = await findOwnerOrgForUser('user_123', 'HTTPS://Agent.Example.com/MCP///');
-
- expect(result).toBe('org_abc');
- const [, params] = queryMock.mock.calls[0];
- expect(params).toEqual([
- JSON.stringify([{ url: 'https://agent.example.com/mcp' }]),
- 'user_123',
- ]);
- });
+ it('requires an explicit organization', async () => {
+ await expect(resolveOwnerOrgForUser(principal, 'https://agent.example/mcp', undefined))
+ .resolves.toBeNull();
+ expect(membershipMock).not.toHaveBeenCalled();
+ expect(queryMock).not.toHaveBeenCalled();
});
- describe('isOrgOwnerOfAgent', () => {
- it('returns true when the specific org owns the agent for the user', async () => {
- queryMock.mockResolvedValueOnce({
- rows: [{ '?column?': 1 }],
- rowCount: 1,
- command: 'SELECT',
- oid: 0,
- fields: [],
- } as never);
- const result = await isOrgOwnerOfAgent('org_abc', 'user_123', 'https://agent.example.com/mcp');
- expect(result).toBe(true);
- const [sql, params] = queryMock.mock.calls[0];
- expect(sql).toContain('mp.workos_organization_id = $1');
- expect(params).toEqual([
- 'org_abc',
- JSON.stringify([{ url: 'https://agent.example.com/mcp' }]),
- 'user_123',
- ]);
- });
-
- it('returns false when the resolved org is not the owning org', async () => {
- queryMock.mockResolvedValueOnce({
- rows: [],
- rowCount: 0,
- command: 'SELECT',
- oid: 0,
- fields: [],
- } as never);
- const result = await isOrgOwnerOfAgent('org_wrong', 'user_123', 'https://agent.example.com/mcp');
- expect(result).toBe(false);
- });
-
- it('returns false when the query throws', async () => {
- queryMock.mockRejectedValueOnce(new Error('query failed'));
- const result = await isOrgOwnerOfAgent('org_abc', 'user_123', 'https://agent.example.com/mcp');
- expect(result).toBe(false);
- });
-
- it('canonicalizes the lookup URL before confirming a specific owning org', async () => {
- queryMock.mockResolvedValueOnce({
- rows: [{ '?column?': 1 }],
- rowCount: 1,
- command: 'SELECT',
- oid: 0,
- fields: [],
- } as never);
-
- const result = await isOrgOwnerOfAgent('org_abc', 'user_123', 'HTTPS://Agent.Example.com/MCP///');
-
- expect(result).toBe(true);
- const [, params] = queryMock.mock.calls[0];
- expect(params).toEqual([
- 'org_abc',
- JSON.stringify([{ url: 'https://agent.example.com/mcp' }]),
- 'user_123',
- ]);
- });
+ it('fails before ownership lookup when the exact credential lacks live access', async () => {
+ membershipMock.mockResolvedValue(null);
+ await expect(resolveOwnerOrgForUser(principal, 'https://agent.example/mcp', 'org_b'))
+ .resolves.toBeNull();
+ expect(membershipMock).toHaveBeenCalledWith(
+ expect.anything(),
+ principal,
+ 'org_b',
+ );
+ expect(queryMock).not.toHaveBeenCalled();
});
- describe('resolveOwnerOrgForUser', () => {
- it('uses the explicitly requested owning organization', async () => {
- queryMock.mockResolvedValueOnce({ rows: [{ '?column?': 1 }] } as never);
-
- await expect(resolveOwnerOrgForUser(
- 'user_123',
- 'https://agent.example.com/mcp',
- 'org_selected',
- )).resolves.toBe('org_selected');
-
- expect(queryMock.mock.calls[0]?.[1]).toEqual([
- 'org_selected',
- JSON.stringify([{ url: 'https://agent.example.com/mcp' }]),
- 'user_123',
- ]);
- });
-
- it('fails closed when the requested organization does not own the agent', async () => {
- queryMock.mockResolvedValueOnce({ rows: [] } as never);
-
- await expect(resolveOwnerOrgForUser(
- 'user_123',
- 'https://agent.example.com/mcp',
- 'org_wrong',
- )).resolves.toBeNull();
-
- expect(queryMock).toHaveBeenCalledTimes(1);
- });
-
- it('resolves a sole owner when no organization is requested', async () => {
- queryMock.mockResolvedValueOnce({ rows: [{ workos_organization_id: 'org_discovered' }] } as never);
-
- await expect(resolveOwnerOrgForUser(
- 'user_123',
- 'https://agent.example.com/mcp',
- )).resolves.toBe('org_discovered');
+ it('does not substitute the canonical primary credential for linked credential A', async () => {
+ membershipMock.mockImplementation(async (_workos, receivedPrincipal) => {
+ return receivedPrincipal.authWorkosUserId === 'user_credential_a' ? null : {
+ organizationId: 'org_b',
+ role: 'owner',
+ } as never;
});
+ await expect(resolveOwnerOrgForUser(principal, 'https://agent.example/mcp', 'org_b'))
+ .resolves.toBeNull();
+ expect(queryMock).not.toHaveBeenCalled();
+ });
- it('fails closed when an omitted organization has multiple owners', async () => {
- queryMock.mockResolvedValueOnce({
- rows: [
- { workos_organization_id: 'org_a' },
- { workos_organization_id: 'org_b' },
- ],
- } as never);
+ it('returns the explicit org only when live access and ownership both hold', async () => {
+ membershipMock.mockResolvedValue({ organizationId: 'org_a', role: 'member' } as never);
+ queryMock.mockResolvedValue({ rows: [{ '?column?': 1 }] } as never);
- await expect(resolveOwnerOrgForUser(
- 'user_123',
- 'https://agent.example.com/mcp',
- )).resolves.toBeNull();
+ await expect(resolveOwnerOrgForUser(principal, 'HTTPS://Agent.Example/MCP///', 'org_a'))
+ .resolves.toBe('org_a');
+ expect(queryMock.mock.calls[0]?.[1]).toEqual([
+ 'org_a',
+ JSON.stringify([{ url: 'https://agent.example/mcp' }]),
+ ]);
+ });
- expect(queryMock.mock.calls[0]?.[0]).toContain('SELECT DISTINCT');
- expect(queryMock.mock.calls[0]?.[0]).toContain('LIMIT 2');
- });
+ it('fails when the selected org does not own the agent', async () => {
+ membershipMock.mockResolvedValue({ organizationId: 'org_wrong', role: 'owner' } as never);
+ queryMock.mockResolvedValue({ rows: [] } as never);
+ await expect(resolveOwnerOrgForUser(principal, 'https://agent.example/mcp', 'org_wrong'))
+ .resolves.toBeNull();
});
- describe('semantic distinction between the two helpers', () => {
- it('findOwnerOrgForUser discovers ownership; isOrgOwnerOfAgent confirms a specific org', async () => {
- // Two distinct queries: findOwnerOrgForUser returns ANY owning org for
- // the user; isOrgOwnerOfAgent requires the named org to BE the owner.
- // The SQL shapes differ — registry-api.ts and member-tools.ts had
- // distinct inline copies of these (drift surface) before extraction.
- queryMock.mockResolvedValueOnce({ rows: [{ workos_organization_id: 'org_a' }], rowCount: 1, command: 'SELECT', oid: 0, fields: [] } as never);
- await findOwnerOrgForUser('user_1', 'https://x/mcp');
- const [findSql, findParams] = queryMock.mock.calls[0];
- expect(findParams).toHaveLength(2);
- expect(findSql).not.toContain('mp.workos_organization_id = $1');
+ it('checks ownership without joining the stale local membership mirror', async () => {
+ queryMock.mockResolvedValue({ rows: [{ '?column?': 1 }] } as never);
+ await expect(isOrgOwnerOfAgent('org_a', 'user_credential_a', 'https://agent.example/mcp'))
+ .resolves.toBe(true);
+ const [sql, params] = queryMock.mock.calls[0];
+ expect(sql).not.toContain('organization_memberships');
+ expect(params).toEqual(['org_a', JSON.stringify([{ url: 'https://agent.example/mcp' }])]);
+ });
- queryMock.mockResolvedValueOnce({ rows: [{ '?column?': 1 }], rowCount: 1, command: 'SELECT', oid: 0, fields: [] } as never);
- await isOrgOwnerOfAgent('org_a', 'user_1', 'https://x/mcp');
- const [isSql, isParams] = queryMock.mock.calls[1];
- expect(isParams).toHaveLength(3);
- expect(isSql).toContain('mp.workos_organization_id = $1');
- });
+ it('reads visibility only inside an already-authorized organization', async () => {
+ queryMock.mockResolvedValue({ rows: [{ visibility: 'members_only' }] } as never);
+ await expect(findOwnedAgentVisibility('org_a', 'https://agent.example/mcp'))
+ .resolves.toBe('members_only');
+ expect(queryMock.mock.calls[0]?.[1]).toEqual(['org_a', 'https://agent.example/mcp']);
});
});
diff --git a/server/tests/unit/auth-admin-credential-isolation.test.ts b/server/tests/unit/auth-admin-credential-isolation.test.ts
new file mode 100644
index 0000000000..1bc70de008
--- /dev/null
+++ b/server/tests/unit/auth-admin-credential-isolation.test.ts
@@ -0,0 +1,47 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { isWebUserAAOAdmin } = vi.hoisted(() => ({
+ isWebUserAAOAdmin: vi.fn(),
+}));
+
+vi.mock('../../src/addie/mcp/admin-tools.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ isWebUserAAOAdmin,
+}));
+
+import { requireAdmin } from '../../src/middleware/auth.js';
+
+describe('requireAdmin credential isolation', () => {
+ beforeEach(() => {
+ isWebUserAAOAdmin.mockReset();
+ delete process.env.ADMIN_EMAILS;
+ });
+
+ it('does not inherit platform admin from the linked primary credential', async () => {
+ isWebUserAAOAdmin.mockImplementation(async (userId: string) => userId === 'user_primary_admin');
+ const req = {
+ user: {
+ id: 'user_primary_admin',
+ authWorkosUserId: 'user_authenticated_nonadmin',
+ email: 'not-admin@test.example',
+ },
+ originalUrl: '/api/admin/users',
+ headers: { accept: 'application/json' },
+ accepts: () => false,
+ } as any;
+ const json = vi.fn();
+ const res = {
+ status: vi.fn().mockReturnThis(),
+ json,
+ redirect: vi.fn(),
+ send: vi.fn(),
+ } as any;
+ const next = vi.fn();
+
+ await requireAdmin(req, res, next);
+
+ expect(isWebUserAAOAdmin).toHaveBeenCalledWith('user_authenticated_nonadmin');
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(next).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/tests/unit/auth-authorization-epoch.test.ts b/server/tests/unit/auth-authorization-epoch.test.ts
new file mode 100644
index 0000000000..c34778cd10
--- /dev/null
+++ b/server/tests/unit/auth-authorization-epoch.test.ts
@@ -0,0 +1,161 @@
+import type { NextFunction, Request, Response } from 'express';
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ query: vi.fn(),
+ epoch: 1,
+ identityId: 'identity_epoch_test',
+ checkPlatformBan: vi.fn(),
+}));
+
+vi.hoisted(() => {
+ process.env.WORKOS_API_KEY ??= 'sk_test_authorization_epoch';
+ process.env.WORKOS_CLIENT_ID ??= 'client_test_authorization_epoch';
+ process.env.WORKOS_COOKIE_PASSWORD ??= 'test-cookie-password-at-least-32-chars';
+});
+
+vi.mock('@workos-inc/node', () => ({
+ WorkOS: vi.fn(function WorkOS() {
+ return { apiKeys: { createValidation: vi.fn().mockResolvedValue({ apiKey: null }) } };
+ }),
+}));
+
+vi.mock('../../src/auth/workos-jwt.js', () => ({
+ looksLikeJWT: () => true,
+ verifyWorkOSJWT: vi.fn().mockResolvedValue({
+ sub: 'user_epoch_test',
+ email: 'epoch@test.example',
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ isM2M: false,
+ }),
+}));
+
+vi.mock('../../src/db/client.js', () => ({
+ getPool: () => ({ query: mocks.query }),
+}));
+
+vi.mock('../../src/db/bans-db.js', () => ({
+ bansDb: {
+ checkPlatformBan: mocks.checkPlatformBan,
+ checkPlatformBanForApiKey: vi.fn(),
+ },
+}));
+
+vi.mock('../../src/db/org-filters.js', () => ({
+ resolveEffectiveMembership: vi.fn(),
+}));
+
+import { requireAuth, stopAuthTimers } from '../../src/middleware/auth.js';
+
+function requestForToken(): Request {
+ return {
+ headers: { authorization: 'Bearer header.payload.signature' },
+ path: '/api/organizations/org_selected',
+ originalUrl: '/api/organizations/org_selected',
+ accepts: () => false,
+ } as unknown as Request;
+}
+
+function responseRecorder(): Response & { statusCode?: number; body?: unknown } {
+ const res = {
+ status(code: number) {
+ this.statusCode = code;
+ return this;
+ },
+ json(body: unknown) {
+ this.body = body;
+ return this;
+ },
+ } as Response & { statusCode?: number; body?: unknown };
+ return res;
+}
+
+describe('persisted identity authorization epoch', () => {
+ afterAll(() => stopAuthTimers());
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.epoch = 1;
+ mocks.identityId = 'identity_epoch_test';
+ mocks.checkPlatformBan.mockResolvedValue({ banned: false, ban: null });
+ mocks.query.mockImplementation(async (sql: string) => {
+ if (sql.includes('FROM users WHERE workos_user_id')) {
+ return {
+ rowCount: 1,
+ rows: [{ first_name: 'Epoch', last_name: 'Test', email: 'epoch@test.example' }],
+ };
+ }
+ if (sql.includes('FROM identity_workos_users iwu')) {
+ return {
+ rowCount: 1,
+ rows: [{
+ identity_id: mocks.identityId,
+ primary_workos_user_id: 'user_epoch_test',
+ identity_authorization_epoch: mocks.epoch,
+ credential_authorization_epoch: 1,
+ }],
+ };
+ }
+ throw new Error(`Unexpected query: ${sql}`);
+ });
+ });
+
+ it('rejects one cached replay after the persisted epoch changes', async () => {
+ const firstReq = requestForToken();
+ const firstRes = responseRecorder();
+ const firstNext = vi.fn() as NextFunction;
+ await requireAuth(firstReq, firstRes, firstNext);
+ expect(firstNext).toHaveBeenCalledOnce();
+ expect(firstReq.user?.authorizationEpoch).toBe('1:1');
+ firstReq.user!.isMember = true;
+
+ mocks.epoch = 2;
+ const staleReq = requestForToken();
+ const staleRes = responseRecorder();
+ const staleNext = vi.fn() as NextFunction;
+ await requireAuth(staleReq, staleRes, staleNext);
+ expect(staleNext).not.toHaveBeenCalled();
+ expect(staleRes.statusCode).toBe(401);
+ expect(staleRes.body).toMatchObject({ error: 'Authorization state changed' });
+ expect(staleReq.user?.isMember).toBeUndefined();
+
+ // The cached principal now carries the current epoch. A retry proceeds,
+ // but it cannot reuse authority from the pre-change request.
+ const retryReq = requestForToken();
+ const retryRes = responseRecorder();
+ const retryNext = vi.fn() as NextFunction;
+ await requireAuth(retryReq, retryRes, retryNext);
+ expect(retryNext).toHaveBeenCalledOnce();
+ expect(retryReq.user?.authorizationEpoch).toBe('2:1');
+ });
+
+ it('rejects a cached replay when the binding moves to an identity with the same epoch', async () => {
+ const firstReq = requestForToken();
+ await requireAuth(firstReq, responseRecorder(), vi.fn() as NextFunction);
+ expect(firstReq.user).toMatchObject({
+ identityId: 'identity_epoch_test',
+ authorizationEpoch: '1:1',
+ });
+ firstReq.user!.isMember = true;
+
+ mocks.identityId = 'identity_epoch_collision';
+ const staleReq = requestForToken();
+ const staleRes = responseRecorder();
+ const staleNext = vi.fn() as NextFunction;
+ await requireAuth(staleReq, staleRes, staleNext);
+
+ expect(staleNext).not.toHaveBeenCalled();
+ expect(staleRes.statusCode).toBe(401);
+ expect(staleRes.body).toMatchObject({ error: 'Authorization state changed' });
+ expect(staleReq.user?.isMember).toBeUndefined();
+
+ const retryReq = requestForToken();
+ const retryNext = vi.fn() as NextFunction;
+ await requireAuth(retryReq, responseRecorder(), retryNext);
+ expect(retryNext).toHaveBeenCalledOnce();
+ expect(retryReq.user).toMatchObject({
+ identityId: 'identity_epoch_collision',
+ authorizationEpoch: '1:1',
+ });
+ });
+});
diff --git a/server/tests/unit/billing-public-portal-authorization.test.ts b/server/tests/unit/billing-public-portal-authorization.test.ts
index bee9f030e0..1dc54621a0 100644
--- a/server/tests/unit/billing-public-portal-authorization.test.ts
+++ b/server/tests/unit/billing-public-portal-authorization.test.ts
@@ -57,7 +57,8 @@ vi.mock('../../src/middleware/auth.js', () => ({
isDevModeEnabled: () => false,
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
req.user = {
- id: 'user_billing',
+ id: 'user_billing_canonical',
+ authWorkosUserId: 'user_billing_authenticated',
email: 'billing@example.test',
firstName: 'Billing',
lastName: 'Tester',
@@ -132,7 +133,7 @@ function membership(
) {
return {
id: `om_${role}`,
- userId: 'user_billing',
+ userId: 'user_billing_authenticated',
organizationId: options.organizationId ?? ORG_ID,
role: { slug: role },
status: options.status ?? 'active',
@@ -266,6 +267,7 @@ describe('invoice request in-lock authorization', () => {
expect(response.status).toBe(403);
expect(mockListMemberships).toHaveBeenCalledTimes(2);
+ expect(mockUpdateOrganization).not.toHaveBeenCalled();
expect(mockCreateInvoice).not.toHaveBeenCalled();
expect(mockCreatePortal).not.toHaveBeenCalled();
});
@@ -294,7 +296,12 @@ describe('invoice request in-lock authorization', () => {
expect(response.status).toBe(200);
expect(response.body.invoiceId).toBe('in_test');
- expect(mockCreateInvoice).toHaveBeenCalledTimes(1);
+ expect(mockUpdateOrganization).toHaveBeenCalledWith(ORG_ID, expect.objectContaining({
+ pending_agreement_user_id: 'user_billing_authenticated',
+ }));
+ expect(mockCreateInvoice).toHaveBeenCalledWith(expect.objectContaining({
+ workosUserId: 'user_billing_authenticated',
+ }));
expect(mockCreatePortal).not.toHaveBeenCalled();
});
});
@@ -342,6 +349,11 @@ describe('checkout member intake', () => {
expect(mockCreateCheckout).toHaveBeenCalledTimes(1);
expect(mockCreateCheckout).toHaveBeenCalledWith(expect.objectContaining({
idempotencyKey: 'attempt_key',
+ workosUserId: 'user_billing_authenticated',
+ }));
+ expect(mockClaimCheckoutAttempt).toHaveBeenCalledWith(expect.objectContaining({
+ organizationId: ORG_ID,
+ userId: 'user_billing_authenticated',
}));
expect(mockCompleteCheckoutAttempt).toHaveBeenCalledWith(expect.objectContaining({
organizationId: ORG_ID,
diff --git a/server/tests/unit/billing-tool-lockdown.test.ts b/server/tests/unit/billing-tool-lockdown.test.ts
index 6606cc589f..bf77f4d469 100644
--- a/server/tests/unit/billing-tool-lockdown.test.ts
+++ b/server/tests/unit/billing-tool-lockdown.test.ts
@@ -144,7 +144,7 @@ describe('get_billing_portal tool', () => {
...memberContext('owner'), // stale cached owner must not authorize
});
- const result = JSON.parse(await handlers.get('get_billing_portal')!({}));
+ const result = JSON.parse(await handlers.get('get_billing_portal')!({ organization_id: 'org_123' }));
expect(result.success).toBe(false);
expect(result.error).toMatch(/owners and admins/i);
@@ -158,7 +158,7 @@ describe('get_billing_portal tool', () => {
mockListMemberships.mockResolvedValue({ data: [membership] });
const handlers = createBillingToolHandlers(memberContext('owner'));
- const result = JSON.parse(await handlers.get('get_billing_portal')!({}));
+ const result = JSON.parse(await handlers.get('get_billing_portal')!({ organization_id: 'org_123' }));
expect(result.success).toBe(false);
expect(mockCreatePortalSession).not.toHaveBeenCalled();
@@ -168,7 +168,7 @@ describe('get_billing_portal tool', () => {
mockListMemberships.mockRejectedValue(new Error('WorkOS unavailable'));
const handlers = createBillingToolHandlers(memberContext('owner'));
- const result = JSON.parse(await handlers.get('get_billing_portal')!({}));
+ const result = JSON.parse(await handlers.get('get_billing_portal')!({ organization_id: 'org_123' }));
expect(result.success).toBe(false);
expect(mockCreatePortalSession).not.toHaveBeenCalled();
@@ -185,7 +185,7 @@ describe('get_billing_portal tool', () => {
mockCreatePortalSession.mockResolvedValueOnce('https://billing.stripe.test/session');
const handlers = createBillingToolHandlers(memberContext('member'));
- const result = JSON.parse(await handlers.get('get_billing_portal')!({}));
+ const result = JSON.parse(await handlers.get('get_billing_portal')!({ organization_id: 'org_123' }));
expect(result.success).toBe(true);
expect(mockCreatePortalSession).toHaveBeenCalledWith(
diff --git a/server/tests/unit/brand-logos-abuse-signals.test.ts b/server/tests/unit/brand-logos-abuse-signals.test.ts
index c9af9356ad..83d97e3c36 100644
--- a/server/tests/unit/brand-logos-abuse-signals.test.ts
+++ b/server/tests/unit/brand-logos-abuse-signals.test.ts
@@ -33,6 +33,11 @@ const mocks = vi.hoisted(() => ({
rebuildManifestLogos: vi.fn().mockResolvedValue(undefined),
notifyPendingBrandLogo: vi.fn().mockResolvedValue(null),
notifyBrandLogoReviewed: vi.fn().mockResolvedValue(null),
+ resolveUserOrgMembership: vi.fn(),
+}));
+
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: (...args: unknown[]) => mocks.resolveUserOrgMembership(...args),
}));
vi.mock('../../src/notifications/registry.js', () => ({
@@ -107,6 +112,11 @@ function makeApp(opts: {
}) {
mocks.isVerifiedOwner.mockReset();
mocks.isVerifiedOwner.mockResolvedValue(opts.isOwner);
+ mocks.resolveUserOrgMembership.mockResolvedValue({
+ organizationId: 'org_test',
+ role: 'member',
+ source: 'workos',
+ });
const brandDb = {
getHostedBrandByDomain: vi.fn().mockResolvedValue(opts.hostedBrand ?? null),
@@ -140,6 +150,7 @@ describe('Wedge A — per-user pending-queue threshold', () => {
const app = makeApp({ hostedBrand: null, isOwner: false });
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(429);
@@ -159,6 +170,7 @@ describe('Wedge A — per-user pending-queue threshold', () => {
});
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(201);
@@ -170,6 +182,7 @@ describe('Wedge A — per-user pending-queue threshold', () => {
const app = makeApp({ hostedBrand: null, isOwner: false });
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(201);
@@ -194,6 +207,7 @@ describe('Wedge B — per-brand reserved owner slots', () => {
const app = makeApp({ hostedBrand: null, isOwner: false });
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(400);
@@ -211,6 +225,7 @@ describe('Wedge B — per-brand reserved owner slots', () => {
});
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(201);
@@ -226,6 +241,7 @@ describe('Wedge B — per-brand reserved owner slots', () => {
});
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(400);
@@ -258,6 +274,7 @@ describe('Wedge C — threaded approve/reject Slack replies', () => {
const app = makeApp({ hostedBrand: null, isOwner: false });
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(201);
@@ -271,6 +288,7 @@ describe('Wedge C — threaded approve/reject Slack replies', () => {
const app = makeApp({ hostedBrand: null, isOwner: false });
await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
await new Promise((r) => setImmediate(r));
diff --git a/server/tests/unit/brand-logos-upload-auth.test.ts b/server/tests/unit/brand-logos-upload-auth.test.ts
index 9494dbf77f..a826a97afc 100644
--- a/server/tests/unit/brand-logos-upload-auth.test.ts
+++ b/server/tests/unit/brand-logos-upload-auth.test.ts
@@ -45,6 +45,16 @@ vi.mock('../../src/utils/html-config.js', () => ({
enrichUserWithMembership: (...args: unknown[]) => mocks.enrichUserWithMembership(...args),
}));
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: vi.fn().mockResolvedValue({
+ organizationId: 'org_test',
+ role: 'member',
+ status: 'active',
+ via_credential_grant: false,
+ via_dev_bypass: false,
+ }),
+}));
+
vi.mock('../../src/middleware/rate-limit.js', () => ({
logoUploadRateLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
}));
@@ -175,6 +185,7 @@ describe('POST /api/brands/:domain/logos write authority', () => {
});
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(403);
@@ -207,6 +218,7 @@ describe('POST /api/brands/:domain/logos write authority', () => {
});
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_owner')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(201);
@@ -335,6 +347,7 @@ describe('POST /api/brands/:domain/logos write authority', () => {
const { app } = makeApp({ hostedBrand: null, isOwner: false });
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(201);
@@ -364,6 +377,7 @@ describe('POST /api/brands/:domain/logos write authority', () => {
});
const res = await request(app)
.post('/api/brands/example.com/logos')
+ .field('organization_id', 'org_test')
.field('tags', 'primary')
.attach('file', MINIMAL_PNG, { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(201);
diff --git a/server/tests/unit/brand-ownership-route.test.ts b/server/tests/unit/brand-ownership-route.test.ts
index a204b100b5..0754b73732 100644
--- a/server/tests/unit/brand-ownership-route.test.ts
+++ b/server/tests/unit/brand-ownership-route.test.ts
@@ -20,9 +20,9 @@ vi.mock('../../src/middleware/auth.js', async () => ({
},
}));
-const resolvePrimaryOrganizationMock = vi.fn();
-vi.mock('../../src/db/users-db.js', () => ({
- resolvePrimaryOrganization: (userId: string) => resolvePrimaryOrganizationMock(userId),
+const resolveUserOrgMembershipMock = vi.fn();
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: (...args: unknown[]) => resolveUserOrgMembershipMock(...args),
}));
import { createBrandOwnershipRouter } from '../../src/routes/brand-ownership.js';
@@ -50,7 +50,8 @@ function makeApp(brandRow: Record | null, orgName: string | nul
describe('GET /api/brands/:domain/ownership', () => {
beforeEach(() => {
currentUserId = null;
- resolvePrimaryOrganizationMock.mockReset();
+ resolveUserOrgMembershipMock.mockReset();
+ resolveUserOrgMembershipMock.mockResolvedValue(null);
});
it('treats a missing brand row as community (not 404)', async () => {
@@ -94,8 +95,8 @@ describe('GET /api/brands/:domain/ownership', () => {
'Acme Corp',
);
currentUserId = OWNER_USER;
- resolvePrimaryOrganizationMock.mockResolvedValueOnce(OWNER_ORG);
- const res = await request(app).get('/api/brands/example.com/ownership');
+ resolveUserOrgMembershipMock.mockResolvedValueOnce({ organizationId: OWNER_ORG, role: 'member', source: 'workos' });
+ const res = await request(app).get(`/api/brands/example.com/ownership?org=${OWNER_ORG}`);
expect(res.status).toBe(200);
expect(res.body.can_manage).toBe(true);
expect(res.body.can_claim).toBe(false);
@@ -109,8 +110,8 @@ describe('GET /api/brands/:domain/ownership', () => {
'Acme Corp',
);
currentUserId = OTHER_USER;
- resolvePrimaryOrganizationMock.mockResolvedValueOnce('org_other');
- const res = await request(app).get('/api/brands/example.com/ownership');
+ resolveUserOrgMembershipMock.mockResolvedValueOnce({ organizationId: 'org_other', role: 'member', source: 'workos' });
+ const res = await request(app).get('/api/brands/example.com/ownership?org=org_other');
expect(res.status).toBe(200);
expect(res.body.can_manage).toBe(false);
expect(res.body.can_claim).toBe(false);
@@ -119,8 +120,8 @@ describe('GET /api/brands/:domain/ownership', () => {
it('lets any authenticated user claim a community brand', async () => {
const app = makeApp({ domain: 'example.com' }, null);
currentUserId = OTHER_USER;
- resolvePrimaryOrganizationMock.mockResolvedValueOnce('org_other');
- const res = await request(app).get('/api/brands/example.com/ownership');
+ resolveUserOrgMembershipMock.mockResolvedValueOnce({ organizationId: 'org_other', role: 'member', source: 'workos' });
+ const res = await request(app).get('/api/brands/example.com/ownership?org=org_other');
expect(res.status).toBe(200);
expect(res.body.status).toBe('community');
expect(res.body.can_claim).toBe(true);
@@ -133,8 +134,8 @@ describe('GET /api/brands/:domain/ownership', () => {
null,
);
currentUserId = OTHER_USER;
- resolvePrimaryOrganizationMock.mockResolvedValueOnce('org_other');
- const res = await request(app).get('/api/brands/example.com/ownership');
+ resolveUserOrgMembershipMock.mockResolvedValueOnce({ organizationId: 'org_other', role: 'member', source: 'workos' });
+ const res = await request(app).get('/api/brands/example.com/ownership?org=org_other');
expect(res.status).toBe(200);
expect(res.body.status).toBe('orphaned');
expect(res.body.owner).toBeNull();
diff --git a/server/tests/unit/claude-client-cost-gate.test.ts b/server/tests/unit/claude-client-cost-gate.test.ts
index c042dff4b0..6701f7d925 100644
--- a/server/tests/unit/claude-client-cost-gate.test.ts
+++ b/server/tests/unit/claude-client-cost-gate.test.ts
@@ -25,9 +25,9 @@ import { AddieClaudeClient } from '../../src/addie/claude-client.js';
// integration assertion — no SDK call means the gate fired at entry.
// claude-client uses `beta.messages.create` for non-stream and
// `messages.stream` for stream, so wire both to the same spy.
-const anthropicCall = vi.fn(() => {
+const anthropicCall = vi.hoisted(() => vi.fn(() => {
throw new Error('SDK should not be reached when cap is exhausted');
-});
+}));
vi.mock('@anthropic-ai/sdk', () => {
return {
default: class {
diff --git a/server/tests/unit/conformance-token-route.test.ts b/server/tests/unit/conformance-token-route.test.ts
index 54eb8883bf..1e826f6509 100644
--- a/server/tests/unit/conformance-token-route.test.ts
+++ b/server/tests/unit/conformance-token-route.test.ts
@@ -12,7 +12,9 @@ vi.mock('../../src/middleware/auth.js', () => ({
}));
vi.mock('../../src/routes/helpers/resolve-caller-org.js', () => ({
- resolveCallerOrgId: vi.fn(async (req: any) => req.headers['x-test-org'] ?? null),
+ resolveCallerOrganization: vi.fn(async (req: any) => req.headers['x-test-org']
+ ? { status: 'authorized', organizationId: req.headers['x-test-org'] }
+ : { status: 'missing' }),
}));
async function buildApp() {
diff --git a/server/tests/unit/current-user-organizations.test.ts b/server/tests/unit/current-user-organizations.test.ts
index 5b51504e71..1a8d519763 100644
--- a/server/tests/unit/current-user-organizations.test.ts
+++ b/server/tests/unit/current-user-organizations.test.ts
@@ -18,14 +18,16 @@ import {
describe('current user organization resolution', () => {
beforeEach(() => {
vi.clearAllMocks();
- mocks.query.mockResolvedValue({
- rows: [{
- workos_organization_id: 'org_cached',
- name: 'Cached Org',
- role: 'admin',
- is_personal: false,
- }],
- });
+ mocks.query.mockImplementation(async (sql: string) => ({
+ rows: sql.includes('FROM organization_credential_grants')
+ ? []
+ : [{
+ workos_organization_id: 'org_cached',
+ name: 'Cached Org',
+ role: 'admin',
+ is_personal: false,
+ }],
+ }));
});
it('does not trust cached local memberships when WorkOS membership lookup fails', async () => {
@@ -39,7 +41,7 @@ describe('current user organization resolution', () => {
} as any;
await expect(getCurrentUserOrganizations({
- userId: 'user_123',
+ principal: { id: 'user_123' },
email: 'user@example.com',
workos,
orgDb: { getOrganization: vi.fn() },
@@ -51,7 +53,7 @@ describe('current user organization resolution', () => {
it('uses cached local memberships when WorkOS is not configured', async () => {
const organizations = await getCurrentUserOrganizations({
- userId: 'user_123',
+ principal: { id: 'user_123' },
email: 'user@example.com',
workos: null,
orgDb: { getOrganization: vi.fn() },
@@ -68,6 +70,27 @@ describe('current user organization resolution', () => {
expect(mocks.query).toHaveBeenCalledWith(expect.stringContaining('FROM organization_memberships om'), ['user_123']);
});
+ it('lists organizations for the authenticated credential, not the canonical user', async () => {
+ const listOrganizationMemberships = vi.fn().mockResolvedValue({ data: [] });
+ const workos = {
+ userManagement: { listOrganizationMemberships },
+ organizations: { getOrganization: vi.fn() },
+ } as any;
+
+ await getCurrentUserOrganizations({
+ principal: { id: 'user_canonical', authWorkosUserId: 'user_authenticated' },
+ email: 'user@example.com',
+ workos,
+ orgDb: { getOrganization: vi.fn() },
+ autoLinkByVerifiedDomain: vi.fn(),
+ });
+
+ expect(listOrganizationMemberships).toHaveBeenCalledWith({
+ userId: 'user_authenticated',
+ statuses: ['active'],
+ });
+ });
+
it('uses local organization details when WorkOS org detail lookup fails', async () => {
const workos = {
organizations: {
@@ -114,7 +137,7 @@ describe('current user organization resolution', () => {
} as any;
const organizations = await getCurrentUserOrganizations({
- userId: 'user_123',
+ principal: { id: 'user_123' },
email: 'user@example.com',
workos,
orgDb: { getOrganization: vi.fn().mockResolvedValue({ is_personal: false }) },
@@ -131,6 +154,42 @@ describe('current user organization resolution', () => {
}]);
});
+ it('includes active exact-credential grants in the organization selector', async () => {
+ mocks.query.mockImplementation(async (sql: string, params: unknown[]) => ({
+ rows: sql.includes('FROM organization_credential_grants')
+ ? [{
+ workos_organization_id: 'org_granted',
+ name: 'Granted Org',
+ role: 'admin',
+ is_personal: false,
+ }]
+ : [],
+ params,
+ }));
+ const workos = {
+ userManagement: { listOrganizationMemberships: vi.fn().mockResolvedValue({ data: [] }) },
+ organizations: { getOrganization: vi.fn() },
+ } as any;
+
+ await expect(getCurrentUserOrganizations({
+ principal: { id: 'user_primary', authWorkosUserId: 'user_credential' },
+ email: 'user@example.com',
+ workos,
+ orgDb: { getOrganization: vi.fn() },
+ autoLinkByVerifiedDomain: vi.fn().mockResolvedValue(null),
+ })).resolves.toEqual([{
+ id: 'org_granted',
+ name: 'Granted Org',
+ role: 'admin',
+ status: 'active',
+ is_personal: false,
+ }]);
+ expect(mocks.query).toHaveBeenCalledWith(
+ expect.stringContaining('FROM organization_credential_grants'),
+ ['user_credential'],
+ );
+ });
+
it('normalizes missing or blank membership roles to member', () => {
expect(getMembershipRole(undefined)).toBe('member');
expect(getMembershipRole({ slug: '' })).toBe('member');
diff --git a/server/tests/unit/event-sponsorship-membership.test.ts b/server/tests/unit/event-sponsorship-membership.test.ts
index b2f5551f72..6020c059e4 100644
--- a/server/tests/unit/event-sponsorship-membership.test.ts
+++ b/server/tests/unit/event-sponsorship-membership.test.ts
@@ -56,7 +56,7 @@ describe('event sponsorship organization authorization', () => {
expect(response.body.error).toBe('Organization access denied');
expect(mocks.resolveUserOrgMembership).toHaveBeenCalledWith(
mocks.getWorkos.mock.results[0]?.value,
- 'user_123',
+ expect.objectContaining({ id: 'user_123' }),
'org_other',
);
expect(mocks.getEventBySlug).not.toHaveBeenCalled();
diff --git a/server/tests/unit/member-profile-membership-authorization.test.ts b/server/tests/unit/member-profile-membership-authorization.test.ts
index 3df70410ed..d27c7a0693 100644
--- a/server/tests/unit/member-profile-membership-authorization.test.ts
+++ b/server/tests/unit/member-profile-membership-authorization.test.ts
@@ -20,15 +20,25 @@ describe('member profile membership authorization', () => {
expect(selectedOrganizationMembership(memberships, 'org_pending_admin')).toBeNull();
});
- it('selects only the active membership by default or explicit organization', () => {
- expect(selectedOrganizationMembership(memberships)).toEqual(memberships[1]);
+ it('never selects an organization implicitly', () => {
+ expect(selectedOrganizationMembership(memberships)).toBeNull();
expect(selectedOrganizationMembership(memberships, 'org_active_member')).toEqual(memberships[1]);
});
- it('uses the active selector across every WorkOS-backed profile route', async () => {
+ it('uses the exact credential resolver across WorkOS-backed profile routes', async () => {
const source = await readFile(new URL('../../src/routes/member-profiles.ts', import.meta.url), 'utf8');
- expect(source.match(/selectedOrganizationMembership\(memberships\.data, requestedOrgId\)/g)?.length)
+ expect(source.match(/resolveUserOrgMembership\(workos!, user,/g)?.length)
.toBeGreaterThanOrEqual(7);
+ expect(source).not.toContain('listOrganizationMemberships({\n userId: user.id');
expect(source).toContain('Only organization admins or owners can update brand identity');
});
+
+ it('attributes organization-scoped profile mutations to the authenticated credential', async () => {
+ const source = await readFile(new URL('../../src/routes/member-profiles.ts', import.meta.url), 'utf8');
+
+ expect(source).not.toMatch(/set_by_user_id:\s*user\.id/);
+ expect(source).not.toMatch(/recordProfilePublishedIfNeeded\([\s\S]{0,180}?user\.id\s*\)/);
+ expect(source).toContain('workos_user_id: actorCredentialId');
+ expect(source).toContain('workos_user_id: getOrganizationAuthorizationUserId(user)');
+ });
});
diff --git a/server/tests/unit/member-profile-url-boundaries.test.ts b/server/tests/unit/member-profile-url-boundaries.test.ts
index ef3edf7777..2939a9b6fd 100644
--- a/server/tests/unit/member-profile-url-boundaries.test.ts
+++ b/server/tests/unit/member-profile-url-boundaries.test.ts
@@ -1,8 +1,9 @@
-import { describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
query: vi.fn(),
resolvePrimaryOrganization: vi.fn(),
+ resolveUserOrgMembership: vi.fn(),
}));
vi.mock('../../src/db/client.js', () => ({
@@ -14,6 +15,10 @@ vi.mock('../../src/db/users-db.js', () => ({
resolvePrimaryOrganization: mocks.resolvePrimaryOrganization,
}));
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: mocks.resolveUserOrgMembership,
+}));
+
vi.mock('../../src/middleware/auth.js', () => ({
requireAuth: (req: any, _res: unknown, next: () => void) => {
req.user = { id: 'user-profile-url', email: 'profile-url@example.test' };
@@ -57,6 +62,13 @@ function createConfig() {
}
describe('member profile URL persistence boundaries', () => {
+ beforeEach(() => {
+ mocks.resolveUserOrgMembership.mockResolvedValue({
+ organizationId: 'org-profile-url',
+ role: 'member',
+ });
+ });
+
it('rejects unsafe URLs at the shared database boundary', async () => {
const memberDb = new MemberDatabase();
@@ -106,7 +118,7 @@ describe('member profile URL persistence boundaries', () => {
app.use('/api/me/member-profile', createMemberProfileRouter(config));
const response = await request(app)
- .post('/api/me/member-profile')
+ .post('/api/me/member-profile?org=org-profile-url')
.send({ display_name: 'Acme Media', slug: 'acme-media', [field]: value });
expect(response.status).toBe(400);
@@ -125,7 +137,7 @@ describe('member profile URL persistence boundaries', () => {
app.use('/api/me/member-profile', createMemberProfileRouter(config));
const response = await request(app)
- .put('/api/me/member-profile')
+ .put('/api/me/member-profile?org=org-profile-url')
.send({ [field]: value });
expect(response.status).toBe(400);
@@ -165,7 +177,7 @@ describe('member profile URL persistence boundaries', () => {
expect(updateProfile).not.toHaveBeenCalled();
});
- it('syncs an explicit null website when a personal profile clears it', async () => {
+ it('does not implicitly sync a personal profile into an organization profile', async () => {
const communityProfile = {
workos_user_id: 'user-profile-url',
slug: 'person',
@@ -221,13 +233,10 @@ describe('member profile URL persistence boundaries', () => {
.send({ headline: 'Updated headline', contact_website: null })
.expect(200);
- expect(updateProfileByOrgId).toHaveBeenCalledWith(
- 'org-personal',
- expect.objectContaining({ contact_website: null }),
- );
+ expect(updateProfileByOrgId).not.toHaveBeenCalled();
});
- it('syncs non-URL fields without rewriting unsafe legacy member URLs', async () => {
+ it('does not implicitly rewrite an organization profile from personal-profile fields', async () => {
const communityProfile = {
workos_user_id: 'user-profile-url',
slug: 'legacy-person',
@@ -287,14 +296,7 @@ describe('member profile URL persistence boundaries', () => {
.send({ headline: 'Updated headline' });
expect(response.status).toBe(200);
- expect(updateProfileByOrgId).toHaveBeenCalledWith('org-personal', {
- display_name: 'Legacy Person',
- tagline: 'Updated headline',
- });
- const memberUpdates = updateProfileByOrgId.mock.calls[0][1];
- expect(memberUpdates).not.toHaveProperty('linkedin_url');
- expect(memberUpdates).not.toHaveProperty('twitter_url');
- expect(memberUpdates).not.toHaveProperty('contact_website');
- expect(invalidateMemberContextCache).toHaveBeenCalledOnce();
+ expect(updateProfileByOrgId).not.toHaveBeenCalled();
+ expect(invalidateMemberContextCache).not.toHaveBeenCalled();
});
});
diff --git a/server/tests/unit/network-health-security.test.ts b/server/tests/unit/network-health-security.test.ts
index 94f35fa58c..f234b4d50f 100644
--- a/server/tests/unit/network-health-security.test.ts
+++ b/server/tests/unit/network-health-security.test.ts
@@ -189,6 +189,17 @@ describe('network-health global authorization boundary', () => {
rowCount: 1,
});
}
+ if (sql.includes('FROM identity_workos_users')) {
+ return Promise.resolve({
+ rows: [{
+ identity_id: 'identity_sso_user',
+ primary_workos_user_id: null,
+ identity_authorization_epoch: '1',
+ credential_authorization_epoch: '1',
+ }],
+ rowCount: 1,
+ });
+ }
return Promise.resolve({ rows: [], rowCount: 0 });
});
mocks.getNetworkSummaries.mockResolvedValue([]);
diff --git a/server/tests/unit/org-authorization-isolation-routes.test.ts b/server/tests/unit/org-authorization-isolation-routes.test.ts
new file mode 100644
index 0000000000..0a1b6f1f38
--- /dev/null
+++ b/server/tests/unit/org-authorization-isolation-routes.test.ts
@@ -0,0 +1,102 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import express from 'express';
+import request from 'supertest';
+import { readFile } from 'node:fs/promises';
+
+const { resolveUserOrgMembership } = vi.hoisted(() => ({
+ resolveUserOrgMembership: vi.fn(),
+}));
+
+vi.mock('../../src/utils/resolve-user-org-membership.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveUserOrgMembership,
+}));
+
+vi.mock('../../src/auth/workos-client.js', () => ({
+ getWorkos: () => ({}),
+ workos: {},
+}));
+
+vi.mock('../../src/middleware/auth.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ const requireAuth = (req: any, _res: any, next: any) => {
+ req.user = {
+ id: 'user_primary_b',
+ authWorkosUserId: 'user_authenticated_a',
+ email: 'a@test.example',
+ };
+ next();
+ };
+ return { ...actual, requireAuth };
+});
+
+import { createEngagementRouter } from '../../src/routes/engagement.js';
+import { createCertificationRouters } from '../../src/routes/certification.js';
+import { createBrandFeedsRouter } from '../../src/routes/brand-feeds.js';
+
+function buildApp() {
+ const app = express();
+ app.use(express.json());
+ app.use('/api/me/engagement', createEngagementRouter({
+ orgDb: {} as any,
+ orgKnowledgeDb: {} as any,
+ workingGroupDb: {} as any,
+ }));
+ const certification = createCertificationRouters();
+ app.use('/api/me', certification.userRouter);
+ app.use('/api/organizations', certification.orgRouter);
+ app.use('/api', createBrandFeedsRouter({
+ brandDb: { getDiscoveredBrandByDomain: vi.fn() } as any,
+ }));
+ return app;
+}
+
+describe('organization authorization route isolation', () => {
+ beforeEach(() => resolveUserOrgMembership.mockReset());
+
+ it.each([
+ ['/api/me/engagement', 'GET'],
+ ['/api/me/certification/expectation', 'GET'],
+ ['/api/brands/example.test/feeds', 'GET'],
+ ])('fails closed when %s has no explicit organization', async (path, method) => {
+ const app = buildApp();
+ const response = method === 'GET'
+ ? await request(app).get(path)
+ : await request(app).post(path);
+ expect(response.status).toBe(400);
+ expect(resolveUserOrgMembership).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ '/api/me/engagement?org=org_b_only',
+ '/api/me/certification/expectation?org=org_b_only',
+ '/api/brands/example.test/feeds?org=org_b_only',
+ ])('denies linked credential A access to organization B at %s', async (path) => {
+ resolveUserOrgMembership.mockResolvedValue(null);
+ const response = await request(buildApp()).get(path);
+ expect(response.status).toBe(403);
+ expect(resolveUserOrgMembership).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ id: 'user_primary_b', authWorkosUserId: 'user_authenticated_a' }),
+ 'org_b_only',
+ );
+ });
+
+ it('uses the authenticated credential for organization self-state and mutation actors', async () => {
+ const source = await readFile(new URL('../../src/routes/organizations.ts', import.meta.url), 'utf8');
+
+ expect(source).not.toContain('getUserPendingRequests(user.id)');
+ expect(source).not.toContain('inviterUserId: adminUser.id');
+ expect(source).not.toContain('workos_user_id: adminUser.id');
+ expect(source).toContain('getUserPendingRequests(authorizationUserId)');
+ expect(source).toContain('inviterUserId: actorCredentialId');
+ });
+
+ it('rechecks live organization authority at the remaining mutation boundaries', async () => {
+ const source = await readFile(new URL('../../src/routes/organizations.ts', import.meta.url), 'utf8');
+
+ expect(source).toMatch(/if \(!slackUser\.workos_user_id\)[\s\S]+?currentCallerMembership[\s\S]+?sendInvitation/);
+ expect(source).toMatch(/Directly add user to organization[\s\S]+?currentCallerMembership[\s\S]+?createOrganizationMembership/);
+ expect(source).toMatch(/Generate portal link for domain verification[\s\S]+?currentMembership[\s\S]+?adminPortal\.generateLink/);
+ });
+});
diff --git a/server/tests/unit/registry-badge-routes.test.ts b/server/tests/unit/registry-badge-routes.test.ts
index 4dba84038f..9d9f4e879b 100644
--- a/server/tests/unit/registry-badge-routes.test.ts
+++ b/server/tests/unit/registry-badge-routes.test.ts
@@ -131,7 +131,7 @@ describe('registry badge routes', () => {
complianceMocks.revokeAllBadges.mockResolvedValue([]);
notificationMocks.notifyVerificationChange.mockReset();
notificationMocks.notifyVerificationChange.mockResolvedValue(undefined);
- ownershipMocks.findOwnerOrgForUser.mockResolvedValue('org_badge_owner');
+ ownershipMocks.resolveOwnerOrgForUser.mockResolvedValue('org_badge_owner');
ownershipMocks.findOwnedAgentVisibility.mockResolvedValue('public');
});
@@ -319,7 +319,7 @@ describe('registry badge routes', () => {
const response = await request(buildApp('private', true))
.put(`/api/registry/agents/${encodedUrl}/compliance/opt-out`)
- .send({ opt_out: true });
+ .send({ opt_out: true, organization_id: 'org_badge_owner' });
expect(response.status).toBe(200);
expect(complianceMocks.setComplianceOptOut).toHaveBeenCalledWith(
@@ -346,12 +346,13 @@ describe('registry badge routes', () => {
const response = await request(buildApp('private', true))
.put(`/api/registry/agents/${encodeURIComponent(rawAgentUrl)}/compliance/opt-out`)
- .send({ opt_out: true });
+ .send({ opt_out: true, organization_id: 'org_badge_owner' });
expect(response.status).toBe(200);
- expect(ownershipMocks.findOwnerOrgForUser).toHaveBeenCalledWith(
- 'user_badge_owner',
+ expect(ownershipMocks.resolveOwnerOrgForUser).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'user_badge_owner' }),
canonicalAgentUrl,
+ 'org_badge_owner',
);
expect(complianceMocks.setComplianceOptOut).toHaveBeenCalledWith(
canonicalAgentUrl,
@@ -370,7 +371,7 @@ describe('registry badge routes', () => {
const response = await request(buildApp('private', true))
.put(`/api/registry/agents/${encodeURIComponent(AGENT_URL)}/compliance/opt-out`)
- .send({ opt_out: true });
+ .send({ opt_out: true, organization_id: 'org_badge_owner' });
expect(response.status).toBe(200);
expect(complianceMocks.setComplianceOptOut).toHaveBeenCalledWith(
diff --git a/server/tests/unit/registry-brand-setup-route.test.ts b/server/tests/unit/registry-brand-setup-route.test.ts
index d755500839..753c8bc289 100644
--- a/server/tests/unit/registry-brand-setup-route.test.ts
+++ b/server/tests/unit/registry-brand-setup-route.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
-const resolvePrimaryOrganizationMock = vi.fn();
+const resolveUserOrgMembershipMock = vi.fn();
const queryMock = vi.fn();
const ORIGINAL_DEV_USER_EMAIL = process.env.DEV_USER_EMAIL;
const ORIGINAL_DEV_USER_ID = process.env.DEV_USER_ID;
@@ -12,8 +12,8 @@ vi.hoisted(() => {
process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID || 'client_test_registry_brand_setup';
});
-vi.mock('../../src/db/users-db.js', () => ({
- resolvePrimaryOrganization: (userId: string) => resolvePrimaryOrganizationMock(userId),
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: (...args: unknown[]) => resolveUserOrgMembershipMock(...args),
}));
vi.mock('../../src/db/client.js', () => ({
@@ -28,7 +28,11 @@ function buildApp(brandDb: Partial, brandManager:
app.use(express.json());
const requireAuth: import('express').RequestHandler = (req, _res, next) => {
- req.user = { id: 'user_test', email: 'user@test.example' } as typeof req.user;
+ req.user = {
+ id: 'user_canonical',
+ authWorkosUserId: 'user_test',
+ email: 'user@test.example',
+ } as typeof req.user;
next();
};
@@ -59,7 +63,12 @@ describe('POST /api/brands/setup-my-brand', () => {
vi.clearAllMocks();
process.env.DEV_USER_EMAIL = 'dev@test.example';
process.env.DEV_USER_ID = 'user_test';
- resolvePrimaryOrganizationMock.mockResolvedValue('org_test');
+ resolveUserOrgMembershipMock.mockResolvedValue({
+ organizationId: 'org_test',
+ role: 'member',
+ status: 'active',
+ via_dev_bypass: true,
+ });
queryMock.mockResolvedValue({ rows: [] });
});
@@ -92,6 +101,7 @@ describe('POST /api/brands/setup-my-brand', () => {
.send({
domain: 'example.com',
brand_name: 'Example',
+ organization_id: 'org_test',
brand_json: brandJson,
});
@@ -104,7 +114,7 @@ describe('POST /api/brands/setup-my-brand', () => {
});
expect(brandDb.createHostedBrand).toHaveBeenCalledWith(expect.objectContaining({
workos_organization_id: 'org_test',
- created_by_user_id: 'user_test',
+ created_by_user_id: 'user_canonical',
created_by_email: 'user@test.example',
brand_domain: 'example.com',
brand_json: brandJson,
@@ -296,7 +306,7 @@ describe('POST /api/brands/setup-my-brand', () => {
const res = await request(buildApp(brandDb))
.post('/api/brands/setup-my-brand')
- .send({ domain: 'nova.example', brand_name: 'Nova' });
+ .send({ domain: 'nova.example', brand_name: 'Nova', organization_id: 'org_test' });
expect(res.status).toBe(200);
const savedBrandJson = brandDb.createHostedBrand.mock.calls[0][0].brand_json;
@@ -318,6 +328,7 @@ describe('POST /api/brands/setup-my-brand', () => {
.send({
domain: 'nova.example',
brand_name: hostileName,
+ organization_id: 'org_test',
logo_url: 'https://cdn.example.test/brand/logo.svg?theme=dark',
brand_color: '#12Ab9F',
});
@@ -335,10 +346,23 @@ describe('POST /api/brands/setup-my-brand', () => {
}));
});
- it('denies non-dev callers without a resolvable organization', async () => {
+ it('requires an explicitly selected organization', async () => {
+ const brandDb = { createHostedBrand: vi.fn() };
+
+ const res = await request(buildApp(brandDb))
+ .post('/api/brands/setup-my-brand')
+ .send({ domain: 'victim.example', brand_name: 'Victim' });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toBe('organization_id must be a non-empty organization ID');
+ expect(resolveUserOrgMembershipMock).not.toHaveBeenCalled();
+ expect(brandDb.createHostedBrand).not.toHaveBeenCalled();
+ });
+
+ it('denies callers whose authenticated credential lacks the selected organization', async () => {
delete process.env.DEV_USER_EMAIL;
delete process.env.DEV_USER_ID;
- resolvePrimaryOrganizationMock.mockResolvedValue(null);
+ resolveUserOrgMembershipMock.mockResolvedValue(null);
const brandDb = {
getDiscoveredBrandByDomain: vi.fn(),
getHostedBrandByDomain: vi.fn(),
@@ -350,6 +374,7 @@ describe('POST /api/brands/setup-my-brand', () => {
.send({
domain: 'victim.example',
brand_name: 'Victim',
+ organization_id: 'org_victim',
brand_json: {
house: { domain: 'victim.example', name: 'Victim' },
brands: [{ id: 'victim', names: [{ en: 'Victim' }], keller_type: 'master' }],
@@ -357,7 +382,12 @@ describe('POST /api/brands/setup-my-brand', () => {
});
expect(res.status).toBe(403);
- expect(res.body.error).toBe('A verified organization is required to set up a brand');
+ expect(res.body.error).toBe('The authenticated credential is not an active member of the selected organization');
+ expect(resolveUserOrgMembershipMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ id: 'user_canonical', authWorkosUserId: 'user_test' }),
+ 'org_victim',
+ );
expect(queryMock).not.toHaveBeenCalled();
expect(brandDb.createHostedBrand).not.toHaveBeenCalled();
});
@@ -365,7 +395,12 @@ describe('POST /api/brands/setup-my-brand', () => {
it('denies non-dev callers whose organization does not own the requested domain', async () => {
delete process.env.DEV_USER_EMAIL;
delete process.env.DEV_USER_ID;
- resolvePrimaryOrganizationMock.mockResolvedValue('org_test');
+ resolveUserOrgMembershipMock.mockResolvedValue({
+ organizationId: 'org_test',
+ role: 'member',
+ status: 'active',
+ via_dev_bypass: false,
+ });
queryMock.mockResolvedValue({ rows: [{ domain: 'owned.example' }] });
const brandDb = {
getDiscoveredBrandByDomain: vi.fn(),
@@ -378,6 +413,7 @@ describe('POST /api/brands/setup-my-brand', () => {
.send({
domain: 'victim.example',
brand_name: 'Victim',
+ organization_id: 'org_test',
brand_json: {
house: { domain: 'victim.example', name: 'Victim' },
brands: [{ id: 'victim', names: [{ en: 'Victim' }], keller_type: 'master' }],
diff --git a/server/tests/unit/resolve-caller-org.test.ts b/server/tests/unit/resolve-caller-org.test.ts
index db9fd03893..6dc3c0eeb0 100644
--- a/server/tests/unit/resolve-caller-org.test.ts
+++ b/server/tests/unit/resolve-caller-org.test.ts
@@ -18,7 +18,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const validateWorkOSApiKeyMock = vi.fn();
const jwtVerifyMock = vi.fn();
const decodeJwtMock = vi.fn();
-const dbQueryMock = vi.fn();
+const resolveUserOrgMembershipMock = vi.fn();
vi.mock('../../src/middleware/auth.js', () => ({
validateWorkOSApiKey: (...args: unknown[]) => validateWorkOSApiKeyMock(...args),
@@ -30,8 +30,12 @@ vi.mock('jose', () => ({
decodeJwt: (...args: unknown[]) => decodeJwtMock(...args),
}));
-vi.mock('../../src/db/client.js', () => ({
- query: (...args: unknown[]) => dbQueryMock(...args),
+vi.mock('../../src/auth/workos-client.js', () => ({
+ getWorkos: () => 'fake-workos',
+}));
+
+vi.mock('../../src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: (...args: unknown[]) => resolveUserOrgMembershipMock(...args),
}));
// Import under test *after* the mocks are registered.
@@ -39,9 +43,16 @@ const { resolveCallerOrgId, orgIdFromBearerJwt, __resetJwksForTests } = await im
'../../src/routes/helpers/resolve-caller-org.js'
);
-function reqWith(authHeader?: string, user?: { id?: string }) {
+function reqWith(
+ authHeader?: string,
+ user?: { id?: string; authWorkosUserId?: string },
+ selectedOrganizationId?: string,
+) {
return {
- headers: authHeader ? { authorization: authHeader } : {},
+ headers: {
+ ...(authHeader ? { authorization: authHeader } : {}),
+ ...(selectedOrganizationId ? { 'x-organization-id': selectedOrganizationId } : {}),
+ },
user,
};
}
@@ -53,7 +64,7 @@ describe('resolveCallerOrgId', () => {
validateWorkOSApiKeyMock.mockReset();
jwtVerifyMock.mockReset();
decodeJwtMock.mockReset();
- dbQueryMock.mockReset();
+ resolveUserOrgMembershipMock.mockReset();
__resetJwksForTests();
});
@@ -62,6 +73,9 @@ describe('resolveCallerOrgId', () => {
it('returns org_id from a verified OIDC JWT', async () => {
decodeJwtMock.mockReturnValueOnce({ iss: ISS });
jwtVerifyMock.mockResolvedValueOnce({ payload: { org_id: 'org_from_jwt', sub: 'user_123' } });
+ resolveUserOrgMembershipMock.mockResolvedValueOnce({
+ organizationId: 'org_from_jwt', role: 'member', status: 'active', via_dev_bypass: false,
+ });
const orgId = await resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi'));
@@ -70,7 +84,15 @@ describe('resolveCallerOrgId', () => {
// jwtVerify must pin the issuer it resolved from unverified decode.
expect(jwtVerifyMock.mock.calls[0][2]).toMatchObject({ issuer: ISS });
expect(validateWorkOSApiKeyMock).not.toHaveBeenCalled();
- expect(dbQueryMock).not.toHaveBeenCalled();
+ expect(resolveUserOrgMembershipMock).toHaveBeenCalledWith('fake-workos', { id: 'user_123' }, 'org_from_jwt');
+ });
+
+ it('rejects a verified JWT whose current membership was revoked', async () => {
+ decodeJwtMock.mockReturnValueOnce({ iss: ISS });
+ jwtVerifyMock.mockResolvedValueOnce({ payload: { org_id: 'org_revoked', sub: 'user_123' } });
+ resolveUserOrgMembershipMock.mockResolvedValueOnce(null);
+
+ expect(await resolveCallerOrgId(reqWith('Bearer eyJabc.def.ghi'))).toBeNull();
});
it('falls through to API key / session when JWT verification fails', async () => {
@@ -128,7 +150,7 @@ describe('resolveCallerOrgId', () => {
expect(decodeJwtMock).not.toHaveBeenCalled();
expect(jwtVerifyMock).not.toHaveBeenCalled();
expect(validateWorkOSApiKeyMock).toHaveBeenCalledTimes(1);
- expect(dbQueryMock).not.toHaveBeenCalled();
+ expect(resolveUserOrgMembershipMock).not.toHaveBeenCalled();
});
it('returns org from a legacy wos_api_key_ prefix key', async () => {
@@ -143,38 +165,57 @@ describe('resolveCallerOrgId', () => {
// ── Sealed-session path (existing behavior) ─────────────────────
- it('falls back to users.primary_organization_id when only req.user is set', async () => {
+ it('resolves an explicitly selected organization for the authenticated credential', async () => {
validateWorkOSApiKeyMock.mockResolvedValueOnce(null);
- // resolvePrimaryOrganization: fast-path read returns the cached column
- // alongside joins_valid, so a dangling pointer can fall through.
- dbQueryMock.mockResolvedValueOnce({ rows: [{ primary_organization_id: 'org_from_session', joins_valid: true }] });
+ resolveUserOrgMembershipMock.mockResolvedValueOnce({
+ organizationId: 'org_from_session', role: 'member', status: 'active', via_dev_bypass: false,
+ });
- const orgId = await resolveCallerOrgId(reqWith(undefined, { id: 'user_session' }));
+ const orgId = await resolveCallerOrgId(reqWith(
+ undefined,
+ { id: 'user_canonical', authWorkosUserId: 'user_authenticated' },
+ 'org_from_session',
+ ));
expect(orgId).toBe('org_from_session');
- // Assert the fast-path SQL — joins_valid checks both organizations and
- // organization_memberships so a dangling pointer falls through.
- expect(dbQueryMock.mock.calls[0][0]).toMatch(/SELECT[\s\S]*primary_organization_id[\s\S]*EXISTS[\s\S]*organizations[\s\S]*EXISTS[\s\S]*organization_memberships[\s\S]*joins_valid[\s\S]*FROM users[\s\S]*workos_user_id\s*=\s*\$1/);
- expect(dbQueryMock.mock.calls[0][1]).toEqual(['user_session']);
+ expect(resolveUserOrgMembershipMock).toHaveBeenCalledWith(
+ 'fake-workos',
+ { id: 'user_canonical', authWorkosUserId: 'user_authenticated' },
+ 'org_from_session',
+ );
});
- it('returns null when session user has no primary org and no memberships', async () => {
+ it('does not choose an implicit organization for a sealed session', async () => {
validateWorkOSApiKeyMock.mockResolvedValueOnce(null);
- // Fast-path: no row (column was NULL or user row missing).
- dbQueryMock.mockResolvedValueOnce({ rows: [] });
- // Fallback: resolvePreferredOrganization finds no memberships.
- dbQueryMock.mockResolvedValueOnce({ rows: [] });
const orgId = await resolveCallerOrgId(reqWith(undefined, { id: 'user_no_org' }));
+ expect(orgId).toBeNull();
+ expect(resolveUserOrgMembershipMock).not.toHaveBeenCalled();
+ });
+
+ it('returns null when the authenticated credential lacks the selected organization', async () => {
+ validateWorkOSApiKeyMock.mockResolvedValueOnce(null);
+ resolveUserOrgMembershipMock.mockResolvedValueOnce(null);
+
+ const orgId = await resolveCallerOrgId(reqWith(
+ undefined,
+ { id: 'user_session' },
+ 'org_unavailable',
+ ));
+
expect(orgId).toBeNull();
});
- it('swallows DB errors and returns null rather than throwing', async () => {
+ it('swallows authoritative membership lookup errors and returns null rather than throwing', async () => {
validateWorkOSApiKeyMock.mockResolvedValueOnce(null);
- dbQueryMock.mockRejectedValueOnce(new Error('connection reset'));
+ resolveUserOrgMembershipMock.mockRejectedValueOnce(new Error('connection reset'));
- const orgId = await resolveCallerOrgId(reqWith(undefined, { id: 'user_db_err' }));
+ const orgId = await resolveCallerOrgId(reqWith(
+ undefined,
+ { id: 'user_db_err' },
+ 'org_selected',
+ ));
expect(orgId).toBeNull();
});
@@ -188,7 +229,7 @@ describe('resolveCallerOrgId', () => {
expect(orgId).toBeNull();
expect(decodeJwtMock).not.toHaveBeenCalled();
- expect(dbQueryMock).not.toHaveBeenCalled();
+ expect(resolveUserOrgMembershipMock).not.toHaveBeenCalled();
});
it('returns null for a non-Bearer Authorization header', async () => {
diff --git a/server/tests/unit/tavus-session-guidance-route.test.ts b/server/tests/unit/tavus-session-guidance-route.test.ts
index 92d3726ad3..55c82e967d 100644
--- a/server/tests/unit/tavus-session-guidance-route.test.ts
+++ b/server/tests/unit/tavus-session-guidance-route.test.ts
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
getWebMemberContext: vi.fn(),
isWebUserAdmin: vi.fn(),
getCommitteesLedByUser: vi.fn(),
+ resolveUserOrgMembership: vi.fn(),
}));
vi.mock("express-rate-limit", () => ({
@@ -62,6 +63,10 @@ vi.mock("../../src/addie/member-context.js", () => ({
formatMemberContextForPrompt: () => "",
}));
+vi.mock("../../src/utils/resolve-user-org-membership.js", () => ({
+ resolveUserOrgMembership: mocks.resolveUserOrgMembership,
+}));
+
vi.mock("../../src/addie/mcp/knowledge-search.js", () => ({
initializeKnowledgeSearch: vi.fn().mockResolvedValue(undefined),
KNOWLEDGE_TOOLS: [],
@@ -149,6 +154,11 @@ describe("Tavus session guidance route boundary", () => {
);
mocks.addMessage.mockResolvedValue(undefined);
mocks.getWebMemberContext.mockResolvedValue(null);
+ mocks.resolveUserOrgMembership.mockResolvedValue({
+ organizationId: "org-video",
+ role: "member",
+ source: "workos",
+ });
mocks.isWebUserAdmin.mockResolvedValue(false);
mocks.getCommitteesLedByUser.mockResolvedValue([]);
mocks.processMessageStream.mockImplementation(async function* () {
@@ -178,6 +188,7 @@ describe("Tavus session guidance route boundary", () => {
const response = await request(mountApp())
.post("/api/addie/video/session")
.send({
+ organization_id: "org-video",
extraContext: GUIDANCE,
disableFillers: true,
});
@@ -189,6 +200,8 @@ describe("Tavus session guidance route boundary", () => {
user_id: "authenticated-session-user",
context: {
persona_id: "test-persona",
+ authorization_workos_user_id: "authenticated-session-user",
+ selected_organization_id: "org-video",
disable_fillers: true,
video_session_guidance: { version: 1, text: GUIDANCE },
},
@@ -205,6 +218,8 @@ describe("Tavus session guidance route boundary", () => {
});
expect(storedContext).toEqual({
persona_id: "test-persona",
+ authorization_workos_user_id: "authenticated-session-user",
+ selected_organization_id: "org-video",
disable_fillers: true,
video_session_guidance: { version: 1, text: GUIDANCE },
tavus_conversation_id: "tavus-conversation-id",
@@ -213,6 +228,7 @@ describe("Tavus session guidance route boundary", () => {
it("applies escaped guidance only to the resolved thread user's current turn", async () => {
await request(mountApp()).post("/api/addie/video/session").send({
+ organization_id: "org-video",
extraContext: GUIDANCE,
disableFillers: true,
});
@@ -234,7 +250,8 @@ describe("Tavus session guidance route boundary", () => {
expect(mocks.getThread).toHaveBeenCalledWith(THREAD_ID);
expect(mocks.getThread).not.toHaveBeenCalledWith(FAKE_THREAD_ID);
expect(mocks.getWebMemberContext).toHaveBeenCalledWith(
- "authenticated-session-user"
+ "authenticated-session-user",
+ "org-video"
);
expect(mocks.isWebUserAdmin).toHaveBeenCalledWith(
"authenticated-session-user"
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 6ba0feb819..57ceb5c3d9 100644
--- a/server/tests/unit/working-group-admin-global-auth.test.ts
+++ b/server/tests/unit/working-group-admin-global-auth.test.ts
@@ -129,6 +129,17 @@ describe('working-group real global-admin boundary', () => {
rowCount: 1,
});
}
+ if (sql.includes('FROM identity_workos_users')) {
+ return Promise.resolve({
+ rows: [{
+ identity_id: 'identity_sso_admin',
+ primary_workos_user_id: null,
+ identity_authorization_epoch: '1',
+ credential_authorization_epoch: '1',
+ }],
+ rowCount: 1,
+ });
+ }
return Promise.resolve({ rows: [], rowCount: 0 });
});
mocks.loadSealedSession.mockReturnValue({
diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml
index 0f6eeb625f..a63858d049 100644
--- a/static/openapi/registry.yaml
+++ b/static/openapi/registry.yaml
@@ -4629,9 +4629,6 @@ components:
type: array
items:
$ref: "#/components/schemas/MemberAgentVisibilityWarning"
- org_auto_created:
- type: boolean
- description: "Set to `true` when this `POST` was the caller's first interaction with the registry and the server auto-created the organization (display name derived from the user's email domain for corporate emails, or `'s Workspace` for free-email providers). Combined with `profile_auto_created`, this is the one-call storefront experience: a third-party app holding only an OAuth token gets the org, profile, and registered agent in a single request."
profile_auto_created:
type: boolean
description: "Set to `true` when this `POST` was the first agent registration on the caller's organization and the server auto-created a private member profile (display name = organization name, `is_public: false`). Absent on subsequent calls and on update-in-place. Surfaced so storefront-style integrations can show a \"we set up your profile\" hint without needing to detect the prior 404 → bootstrap → retry shape."
@@ -10283,6 +10280,13 @@ paths:
description: URL-encoded agent URL
name: encodedUrl
in: path
+ - schema:
+ type: string
+ description: Explicit organization used for member access.
+ required: true
+ description: Explicit organization used for member access.
+ name: org
+ in: query
responses:
"200":
description: Compliance detail
@@ -10796,8 +10800,12 @@ paths:
type: string
maxItems: 100
description: Agent URLs to fetch storyboard status for
+ organization_id:
+ type: string
+ description: Explicit organization used for member access.
required:
- agent_urls
+ - organization_id
responses:
"200":
description: Storyboard status keyed by agent URL
@@ -10946,8 +10954,12 @@ paths:
- testing
- production
- deprecated
+ organization_id:
+ type: string
+ description: Explicit organization that owns the agent.
required:
- lifecycle_stage
+ - organization_id
responses:
"200":
description: Updated metadata
@@ -11005,8 +11017,12 @@ paths:
properties:
opt_out:
type: boolean
+ organization_id:
+ type: string
+ description: Explicit organization that owns the agent.
required:
- opt_out
+ - organization_id
responses:
"200":
description: Updated metadata
@@ -11056,6 +11072,13 @@ paths:
description: URL-encoded agent URL
name: encodedUrl
in: path
+ - schema:
+ type: string
+ description: Explicit organization that owns the agent.
+ required: true
+ description: Explicit organization that owns the agent.
+ name: org
+ in: query
responses:
"200":
description: Monitoring settings
@@ -11113,8 +11136,12 @@ paths:
properties:
paused:
type: boolean
+ organization_id:
+ type: string
+ description: Explicit organization that owns the agent.
required:
- paused
+ - organization_id
responses:
"200":
description: Updated monitoring settings
@@ -11174,8 +11201,12 @@ paths:
type: integer
minimum: 6
maximum: 168
+ organization_id:
+ type: string
+ description: Explicit organization that owns the agent.
required:
- interval_hours
+ - organization_id
responses:
"200":
description: Updated monitoring settings
@@ -11225,6 +11256,17 @@ paths:
description: URL-encoded agent URL
name: encodedUrl
in: path
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ organization_id:
+ type: string
+ description: Explicit organization that owns the agent.
+ required:
+ - organization_id
responses:
"200":
description: Agent requeued
@@ -11288,6 +11330,13 @@ paths:
description: URL-encoded agent URL
name: encodedUrl
in: path
+ - schema:
+ type: string
+ description: Explicit organization that owns the agent.
+ required: true
+ description: Explicit organization that owns the agent.
+ name: org
+ in: query
- schema:
type: string
description: Specific compliance run UUID. Defaults to latest.
@@ -11369,6 +11418,13 @@ paths:
description: URL-encoded agent URL
name: encodedUrl
in: path
+ - schema:
+ type: string
+ description: Explicit organization that owns the agent.
+ required: true
+ description: Explicit organization that owns the agent.
+ name: org
+ in: query
- schema:
type: string
description: Max results (default 50, max 200)
@@ -11465,6 +11521,8 @@ paths:
organization_id:
type: string
description: Selected organization ID. The caller must own the agent in this organization before its credentials are used.
+ required:
+ - organization_id
responses:
"200":
description: Snapshot refreshed
@@ -11616,9 +11674,9 @@ paths:
in: path
- schema:
type: string
- description: Selected organization ID. Required to disambiguate an agent URL registered by multiple organizations.
- required: false
- description: Selected organization ID. Required to disambiguate an agent URL registered by multiple organizations.
+ description: Selected organization ID. Required to authorize the exact credential.
+ required: true
+ description: Selected organization ID. Required to authorize the exact credential.
name: org
in: query
responses:
@@ -11640,6 +11698,12 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Error"
+ "403":
+ description: Not authorized for the selected organization
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
"500":
description: Server error
content:
@@ -11683,6 +11747,8 @@ paths:
organization_id:
type: string
description: Selected organization ID. The caller must own the agent in this organization.
+ required:
+ - organization_id
responses:
"200":
description: Connection result
@@ -11795,6 +11861,7 @@ paths:
- token_endpoint
- client_id
- client_secret
+ - organization_id
responses:
"200":
description: Credentials saved
@@ -11873,6 +11940,8 @@ paths:
organization_id:
type: string
description: Selected organization ID. The caller must own the agent in this organization.
+ required:
+ - organization_id
responses:
"200":
description: "Result of the token exchange. `ok: true` on 2xx from the AS; `ok: false` with a typed error otherwise (HTTP response itself is still 200 — the error payload carries the rejection kind so UI can branch on it)."
@@ -11977,9 +12046,9 @@ paths:
in: path
- schema:
type: string
- description: Selected organization ID. Required to disambiguate an agent URL registered by multiple organizations.
- required: false
- description: Selected organization ID. Required to disambiguate an agent URL registered by multiple organizations.
+ description: Selected organization ID. Required to authorize the exact credential.
+ required: true
+ description: Selected organization ID. Required to authorize the exact credential.
name: org
in: query
responses:
@@ -12325,6 +12394,10 @@ paths:
example: acmecorp.com
brand_name:
type: string
+ organization_id:
+ type: string
+ minLength: 1
+ description: Organization explicitly selected for this mutation. The authenticated credential must have an active membership in this organization.
brand_json:
type: object
additionalProperties: {}
@@ -12341,6 +12414,7 @@ paths:
required:
- domain
- brand_name
+ - organization_id
responses:
"200":
description: Brand setup result
@@ -12636,6 +12710,8 @@ paths:
organization_id:
type: string
description: Selected organization ID. The caller must own the agent in this organization.
+ required:
+ - organization_id
responses:
"200":
description: Step execution result
@@ -12773,6 +12849,8 @@ paths:
organization_id:
type: string
description: Selected organization ID. The caller must own the agent in this organization.
+ required:
+ - organization_id
responses:
"200":
description: Storyboard run result with annotated phases
@@ -12983,6 +13061,8 @@ paths:
organization_id:
type: string
description: Selected organization ID. The caller must own the agent in this organization.
+ required:
+ - organization_id
responses:
"200":
description: Side-by-side comparison results
@@ -13090,10 +13170,10 @@ paths:
parameters:
- schema:
type: string
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
example: org_01HXZAB123
- required: false
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ required: true
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
name: org
in: query
responses:
@@ -13104,7 +13184,7 @@ paths:
schema:
$ref: "#/components/schemas/MemberAgentListResponse"
"400":
- description: No organization associated with this account
+ description: The required `org` query parameter is missing.
content:
application/json:
schema:
@@ -13116,7 +13196,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"403":
- description: "`?org=` was supplied but the caller is not a member of that organization."
+ description: The exact authenticated credential is not authorized for the selected organization.
content:
application/json:
schema:
@@ -13135,13 +13215,7 @@ paths:
Idempotent on `url`: re-posting the same `url` updates the entry in place rather than creating a duplicate. New entries return `201`; updates return `200`.
- **True one-call storefront experience.** A third-party app holding only a user's OAuth token can `POST /api/me/agents` once and have the entire bootstrap chain materialize:
-
- - If the caller has zero org memberships, the server auto-creates an organization (corporate or personal workspace based on the user's email domain) and the response includes `org_auto_created: true`.
-
- - If the caller's org has no member profile, the server auto-creates a private profile (display name = organization name, `is_public: false`) and the response includes `profile_auto_created: true`.
-
- Both auto-bootstraps are best-effort fallbacks. To customize org name / company_type / revenue_tier, or to control profile slug / brand identity / tagline, call `POST /api/organizations` and `POST /api/me/member-profile` explicitly before registering the agent. Tier transitions never happen via this path — go through the billing flow.
+ The `org` query parameter is required. If the selected organization has no member profile, the server creates a private profile (display name = organization name, `is_public: false`) and includes `profile_auto_created: true`.
`type` is required and declared by the caller — the server does not infer it. Server-side smuggle protection still cross-checks the declared type against the agent's capability snapshot when one exists; if the snapshot contradicts the declaration without classifying it, the stored value is `unknown` and the dashboard surfaces the conflict for the owner to resolve.
@@ -13154,10 +13228,10 @@ paths:
parameters:
- schema:
type: string
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
example: org_01HXZAB123
- required: false
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ required: true
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
name: org
in: query
requestBody:
@@ -13179,7 +13253,7 @@ paths:
schema:
$ref: "#/components/schemas/MemberAgentResponse"
"400":
- description: Missing or invalid `url`, missing/invalid `type`, or the caller has memberships in other orgs but no primary org set — pass `?org=` to target one explicitly. (Fresh users with no memberships at all hit the org auto-bootstrap path and do not see this error.)
+ description: Missing required `org`, missing or invalid `url`, or missing/invalid `type`.
content:
application/json:
schema:
@@ -13191,13 +13265,13 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"403":
- description: "`?org=` was supplied but the caller is not a member of that organization."
+ description: The exact authenticated credential is not authorized for the selected organization.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
- description: Auto-bootstrap could not run (e.g. the organization has no name yet). Call `POST /api/me/member-profile` to create a profile explicitly, then retry.
+ description: No member profile exists and a private profile could not be created.
content:
application/json:
schema:
@@ -13228,10 +13302,10 @@ paths:
in: path
- schema:
type: string
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
example: org_01HXZAB123
- required: false
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ required: true
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
name: org
in: query
requestBody:
@@ -13247,7 +13321,7 @@ paths:
schema:
$ref: "#/components/schemas/MemberAgentResponse"
"400":
- description: No organization associated with this account, or `body.url` differs from the path (`url_immutable`).
+ description: The required `org` query parameter is missing, or `body.url` differs from the path (`url_immutable`).
content:
application/json:
schema:
@@ -13259,7 +13333,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"403":
- description: "`?org=` was supplied but the caller is not a member of that organization."
+ description: The exact authenticated credential is not authorized for the selected organization.
content:
application/json:
schema:
@@ -13292,17 +13366,17 @@ paths:
in: path
- schema:
type: string
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
example: org_01HXZAB123
- required: false
- description: WorkOS organization id to act on. Defaults to the caller's primary organization. Use this from a multi-org session (or when shelling with a user JWT) to target a non-primary org. Verification goes through WorkOS membership lookup; non-members get `403`.
+ required: true
+ description: Explicit WorkOS organization id to act on. Required on every request. Verification uses the exact authenticated credential; identity linkage and primary organizations are not authorization inputs.
name: org
in: query
responses:
"204":
description: Agent removed.
"400":
- description: No organization associated with this account
+ description: The required `org` query parameter is missing.
content:
application/json:
schema:
@@ -13314,7 +13388,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"403":
- description: "`?org=` was supplied but the caller is not a member of that organization."
+ description: The exact authenticated credential is not authorized for the selected organization.
content:
application/json:
schema:
diff --git a/tests/addie/billing-tools.test.ts b/tests/addie/billing-tools.test.ts
index e4d9ec7e9e..93ecf1deaa 100644
--- a/tests/addie/billing-tools.test.ts
+++ b/tests/addie/billing-tools.test.ts
@@ -11,6 +11,7 @@ const {
mockGetRelationshipByWorkosId,
mockGetRelationshipBySlackId,
mockRecordEvent,
+ mockResolveUserOrgMembership,
} = vi.hoisted(() => {
const mockGetOrganization = vi.fn();
const mockSearchOrganizations = vi.fn();
@@ -20,6 +21,11 @@ const {
const mockGetRelationshipByWorkosId = vi.fn();
const mockGetRelationshipBySlackId = vi.fn();
const mockRecordEvent = vi.fn().mockResolvedValue(undefined);
+ const mockResolveUserOrgMembership = vi.fn().mockResolvedValue({
+ organizationId: 'org_test_123',
+ role: 'member',
+ source: 'workos',
+ });
return {
mockGetOrganization,
mockSearchOrganizations,
@@ -27,9 +33,17 @@ const {
mockGetRelationshipByWorkosId,
mockGetRelationshipBySlackId,
mockRecordEvent,
+ mockResolveUserOrgMembership,
};
});
+vi.mock('../../server/src/utils/resolve-user-org-membership.js', () => ({
+ resolveUserOrgMembership: mockResolveUserOrgMembership,
+}));
+vi.mock('../../server/src/auth/workos-client.js', () => ({
+ getWorkos: () => ({}),
+}));
+
// Mock the stripe-client module
vi.mock('../../server/src/billing/stripe-client.js', () => ({
getProductsForCustomer: vi.fn(),
@@ -225,7 +239,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers();
const createLink = handlers.get('create_payment_link')!;
- const result = await createLink({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await createLink({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -249,7 +263,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(contextWithUserNoOrg);
const createLink = handlers.get('create_payment_link')!;
- const result = await createLink({ lookup_key: 'aao_membership_individual' });
+ const result = await createLink({ organization_id: 'org_test_123', lookup_key: 'aao_membership_individual' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -272,6 +286,7 @@ describe('billing-tools', () => {
// an extra property in, the handler must ignore it and use only the
// memberContext email + user_id.
const result = await createLink({
+ organization_id: 'org_test_123',
lookup_key: 'aao_membership_corporate_5m',
customer_email: 'hallucinated@example.com',
} as unknown as Record);
@@ -336,7 +351,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(slackOnlyContext);
const createLink = handlers.get('create_payment_link')!;
- const result = await createLink({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await createLink({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -353,7 +368,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const createLink = handlers.get('create_payment_link')!;
- const result = await createLink({ lookup_key: 'invalid_key' });
+ const result = await createLink({ organization_id: 'org_test_123', lookup_key: 'invalid_key' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -371,7 +386,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const createLink = handlers.get('create_payment_link')!;
- const result = await createLink({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await createLink({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -400,7 +415,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const sendInvoice = handlers.get('send_invoice')!;
- const result = await sendInvoice({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await sendInvoice({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
@@ -423,7 +438,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers();
const sendInvoice = handlers.get('send_invoice')!;
- const result = await sendInvoice({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await sendInvoice({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -443,7 +458,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const sendInvoice = handlers.get('send_invoice')!;
- const result = await sendInvoice({ lookup_key: 'invalid_key' });
+ const result = await sendInvoice({ organization_id: 'org_test_123', lookup_key: 'invalid_key' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -463,7 +478,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const sendInvoice = handlers.get('send_invoice')!;
- const result = await sendInvoice({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await sendInvoice({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -501,7 +516,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const confirmSend = handlers.get('confirm_send_invoice')!;
- const result = await confirmSend({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await confirmSend({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(true);
@@ -528,7 +543,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const confirmSend = handlers.get('confirm_send_invoice')!;
- const result = await confirmSend({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await confirmSend({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -541,7 +556,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers();
const confirmSend = handlers.get('confirm_send_invoice')!;
- const result = await confirmSend({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await confirmSend({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -557,7 +572,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const confirmSend = handlers.get('confirm_send_invoice')!;
- const result = await confirmSend({ lookup_key: 'aao_membership_corporate_5m' });
+ const result = await confirmSend({ organization_id: 'org_test_123', lookup_key: 'aao_membership_corporate_5m' });
const parsed = JSON.parse(result);
expect(parsed.success).toBe(false);
@@ -640,6 +655,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const result = JSON.parse(await handlers.get('confirm_send_invoice')({
+ organization_id: 'org_test_123',
lookup_key: 'aao_membership_explorer_50',
}));
expect(result.success).toBe(false);
@@ -666,6 +682,7 @@ describe('billing-tools', () => {
const handlers = createBillingToolHandlers(mockMemberContext);
const result = JSON.parse(await handlers.get('create_payment_link')({
+ organization_id: 'org_test_123',
lookup_key: 'bogus_key_does_not_exist',
}));
expect(result.success).toBe(false);
@@ -734,7 +751,7 @@ describe('billing-tools', () => {
const { createBillingToolHandlers } = await import('../../server/src/addie/mcp/billing-tools.js');
const handlers = createBillingToolHandlers(mockMemberContext);
- await handlers.get('confirm_send_invoice')!({ lookup_key: 'aao_membership_explorer_50' });
+ await handlers.get('confirm_send_invoice')!({ organization_id: 'org_test_123', lookup_key: 'aao_membership_explorer_50' });
const call = mockRecordEvent.mock.calls.find(
([, type, opts]: any[]) => type === 'tool_error' && opts.data.reason === 'exception',
@@ -759,7 +776,7 @@ describe('billing-tools', () => {
const { createBillingToolHandlers } = await import('../../server/src/addie/mcp/billing-tools.js');
const handlers = createBillingToolHandlers(mockMemberContext);
- await handlers.get('confirm_send_invoice')!({ lookup_key: huge });
+ await handlers.get('confirm_send_invoice')!({ organization_id: 'org_test_123', lookup_key: huge });
const call = mockRecordEvent.mock.calls[0];
expect(call).toBeTruthy();
diff --git a/tests/addie/member-tools.test.ts b/tests/addie/member-tools.test.ts
index 841526ca53..e1c369069f 100644
--- a/tests/addie/member-tools.test.ts
+++ b/tests/addie/member-tools.test.ts
@@ -8,6 +8,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MemberContext } from '../../server/src/addie/member-context.js';
+vi.hoisted(() => {
+ process.env.WORKOS_API_KEY ||= 'sk_test_member_tools';
+ process.env.WORKOS_CLIENT_ID ||= 'client_test_member_tools';
+});
+
const memberToolMocks = vi.hoisted(() => ({
checkToolRateLimit: vi.fn(),
comply: vi.fn(),
@@ -156,7 +161,7 @@ describe('MEMBER_TOOLS definitions', () => {
expect(tool?.input_schema.properties).toHaveProperty('offerings');
expect(tool?.input_schema.properties).toHaveProperty('contact_website');
expect(tool?.input_schema.properties).toHaveProperty('headquarters');
- expect(tool?.input_schema.required).toEqual([]);
+ expect(tool?.input_schema.required).toEqual(['organization_id']);
});
it('has list_perspectives tool', () => {
diff --git a/tests/billing/invoice-signer-metadata.test.ts b/tests/billing/invoice-signer-metadata.test.ts
index 7e9c752789..3b1ea4e390 100644
--- a/tests/billing/invoice-signer-metadata.test.ts
+++ b/tests/billing/invoice-signer-metadata.test.ts
@@ -15,7 +15,7 @@ describe('invoice signer metadata invariants', () => {
const source = readRepoFile('server/src/routes/billing-public.ts');
expect(source).toMatch(
- /const invoiceData: InvoiceRequestData = \{[\s\S]*workosOrganizationId: orgId,\s*workosUserId: user\.id,[\s\S]*\};/
+ /const invoiceData: InvoiceRequestData = \{[\s\S]*workosOrganizationId: orgId,\s*workosUserId: authorizationUserId,[\s\S]*\};/
);
});