Skip to content

Commit f4513f5

Browse files
colinmegillclaude
andauthored
Owner checks on invite administration routes (#3066)
* Owner checks on invite administration routes Restrict the invite-administration routes to the conversation's owner or a moderator on the owner's site, the same ownership gate the data export and conversation UUID routes already use. Observable changes: - GET and POST /api/v3/treevite/waves, GET /api/v3/treevite/invites and GET /api/v3/treevite/invites/csv return 403 to callers who are neither the owner nor a site moderator (previously any valid token received 200/201). The admin UI, operated by the owner or a moderator, is unaffected. - POST /api/v3/users/invite returns 403 to the same non-owner callers. - GET /api/v3/conversations no longer treats an `xid` query parameter specially: listing conversations no longer creates single-use invite rows as a side effect, and each returned `url` is the ordinary conversation url. No in-repo client sends `xid` to this route. - POST /api/v3/treevite/acceptInvite records the invite against a participant in the requested conversation (creating one when needed) rather than a participant id carried by a token issued for a different conversation. Legitimate first-time accepts continue to succeed. The single-use invite insert is now a parameterised multi-row statement. Tests (server integration + unit): - For each gated route: a signed-in non-owner and an anonymous participant token from another conversation receive 403; the owner and a site moderator succeed. - acceptInvite: a token from another conversation is scoped to the requested conversation and is not rejected. - GET /api/v3/conversations with an xid writes no invite rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s * tests: plain describe name for the accept-invite scoping case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0b9c529 commit f4513f5

9 files changed

Lines changed: 577 additions & 51 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { beforeAll, describe, expect, test } from "@jest/globals";
2+
import {
3+
createConversation,
4+
getJwtAuthenticatedAgent,
5+
} from "../setup/api-test-helpers";
6+
import {
7+
getPooledTestUser,
8+
RESERVED_POOLED_USER_INDEXES,
9+
} from "../setup/test-user-helpers";
10+
import type { Agent, Response } from "supertest";
11+
12+
// GET /api/v3/conversations used to create a single-use suzinvite row for every
13+
// listed conversation whenever an `xid` query parameter was present, and point
14+
// each conversation's url at the resulting single-use /ot/ join link — a write
15+
// performed by a read route, with no owner check, recording invitations against
16+
// the conversation owner. That side effect has been removed: the xid parameter
17+
// no longer creates suzinvites rows, and the listed url is the ordinary
18+
// conversation url.
19+
describe("GET /api/v3/conversations?xid= has no suzinvite side effect", () => {
20+
let ownerAgent: Agent;
21+
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
let pool: any;
24+
25+
beforeAll(async () => {
26+
({ pool } = await import("../setup/db-test-helpers"));
27+
28+
const ownerUser = getPooledTestUser(
29+
RESERVED_POOLED_USER_INDEXES.conversationsXidSuzinvite
30+
);
31+
({ agent: ownerAgent } = await getJwtAuthenticatedAgent({
32+
email: ownerUser.email,
33+
hname: ownerUser.name,
34+
password: ownerUser.password,
35+
}));
36+
37+
// Ensure the owner has at least one conversation so the listing is non-empty
38+
// (before the fix, each listed conversation produced a suzinvites row).
39+
await createConversation(ownerAgent, {
40+
topic: "Conversations xid side-effect test",
41+
description: "The xid parameter must not create suzinvites",
42+
});
43+
});
44+
45+
test("passing xid writes no suzinvites row and returns an ordinary url", async () => {
46+
const xid = `xid-no-side-effect-${Date.now()}-${Math.floor(
47+
Math.random() * 1e6
48+
)}`;
49+
50+
const before = await pool.query(
51+
"SELECT COUNT(*)::int AS n FROM suzinvites WHERE xid = $1",
52+
[xid]
53+
);
54+
expect(before.rows[0].n).toBe(0);
55+
56+
// List the owner's conversations with an xid present. This is the path
57+
// (no conversation_id) that ran processConversationData, where the side
58+
// effect lived: before the fix it wrote one suzinvites row per listed
59+
// conversation. The owner has at least the conversation created above.
60+
const r: Response = await ownerAgent.get(
61+
`/api/v3/conversations?xid=${encodeURIComponent(xid)}`
62+
);
63+
expect(r.status).toBe(200);
64+
expect(Array.isArray(r.body)).toBe(true);
65+
expect(r.body.length).toBeGreaterThan(0);
66+
67+
// The returned url is an ordinary conversation url, not a single-use /ot/
68+
// join link.
69+
for (const conv of r.body) {
70+
expect(typeof conv.url).toBe("string");
71+
expect(conv.url).not.toContain("/ot/");
72+
}
73+
74+
const after = await pool.query(
75+
"SELECT COUNT(*)::int AS n FROM suzinvites WHERE xid = $1",
76+
[xid]
77+
);
78+
expect(after.rows[0].n).toBe(0);
79+
});
80+
});

0 commit comments

Comments
 (0)