Skip to content

Commit e85ac38

Browse files
icecrasher321claude
andcommitted
fix(invitations): guard the reverse disclosure direction
A will-join notice whose acceptance downgrades to no-join (stale escalation denial, concurrent other-org membership) now fails with disclosure-outdated instead of silently succeeding as an external grant — the disclosure token binds the outcome in both directions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4f2a3ff commit e85ac38

2 files changed

Lines changed: 76 additions & 0 deletions

File tree

apps/sim/lib/invitations/core.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,67 @@ describe('acceptInvitation', () => {
737737
expect(auditMock.recordAudit).not.toHaveBeenCalled()
738738
})
739739

740+
it('rejects a will-join disclosure when acceptance downgrades to external', async () => {
741+
mockGetWorkspaceWithOwner.mockResolvedValue({
742+
id: 'workspace-1',
743+
name: 'Workspace',
744+
ownerId: 'owner-1',
745+
organizationId: 'org-1',
746+
workspaceMode: 'organization',
747+
billedAccountUserId: 'owner-1',
748+
})
749+
// Invitee joined a different organization after the preview rendered.
750+
mockGetUserOrganization.mockImplementation(async (userId: string) =>
751+
userId === 'invitee-user'
752+
? { organizationId: 'org-2', role: 'member', memberId: 'member-2' }
753+
: null
754+
)
755+
756+
queueWhereResponses([
757+
[
758+
{
759+
id: 'inv-1',
760+
kind: 'workspace',
761+
email: 'invitee@example.com',
762+
organizationId: 'org-1',
763+
membershipIntent: 'internal',
764+
inviterId: 'owner-1',
765+
role: 'member',
766+
status: 'pending',
767+
token: 'tok-1',
768+
expiresAt: new Date(Date.now() + 60_000),
769+
createdAt: new Date(),
770+
updatedAt: new Date(),
771+
},
772+
],
773+
[
774+
{
775+
id: 'grant-1',
776+
workspaceId: 'workspace-1',
777+
permission: 'write',
778+
workspaceName: 'Workspace',
779+
},
780+
],
781+
[{ name: 'Acme' }],
782+
[{ name: 'Owner', email: 'owner@example.com' }],
783+
// Invitee-owned personal workspaces for the acceptance lock plan.
784+
[{ id: 'joiner-ws-1' }],
785+
])
786+
787+
const result = await acceptInvitation({
788+
userId: 'invitee-user',
789+
userEmail: 'invitee@example.com',
790+
invitationId: 'inv-1',
791+
token: 'tok-1',
792+
// The accept screen promised a membership migration of joiner-ws-1.
793+
disclosedWorkspaceIds: ['joiner-ws-1'],
794+
})
795+
796+
expect(result).toEqual({ success: false, kind: 'disclosure-outdated' })
797+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
798+
expect(auditMock.recordAudit).not.toHaveBeenCalled()
799+
})
800+
740801
it('rolls back acceptance when the sweep set differs from the disclosed set', async () => {
741802
mockGetWorkspaceWithOwner.mockResolvedValue({
742803
id: 'workspace-1',

apps/sim/lib/invitations/core.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,21 @@ async function acceptLockedInvitation(
881881
}
882882
}
883883

884+
/**
885+
* Reverse disclosure guard: a will-join notice (non-empty disclosed set)
886+
* whose acceptance resolved to no-join must not silently succeed as an
887+
* external grant — the user consented to membership plus a migration that
888+
* will not happen. Nothing has been written on the no-join path, so a
889+
* plain failure return suffices; retry renders the refreshed preview.
890+
*/
891+
if (
892+
!shouldJoinOrganization &&
893+
input.disclosedWorkspaceIds !== undefined &&
894+
input.disclosedWorkspaceIds.length > 0
895+
) {
896+
return { success: false, kind: 'disclosure-outdated' }
897+
}
898+
884899
const acceptedWorkspaceIds: string[] = []
885900

886901
try {

0 commit comments

Comments
 (0)