Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions scripts/test-checkmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Who wears a checkmark (model.ts isVerified): an account with a badge of its
// own, and an organisation's affiliate while affiliate_checkmarks is on.
import { afterEach, expect, test } from "bun:test";
import type { User } from "../src/auth";
import { isVerified, userResult, verifiedSince } from "../src/model";
import { settings } from "../src/settings";

const org = { screen_name: "acme", name: "Acme", avatar_url: null, since: "2026-01-02T00:00:00Z" };
const person = (extra: Partial<User> = {}) => ({ id: "1", screen_name: "jo", name: "Jo", verified_type: null, created_at: new Date("2024-01-01T00:00:00Z"), ...extra }) as User;

afterEach(() => {
settings().features.affiliate_checkmarks = true;
});

test("affiliates get a checkmark by default", () => {
const u = person({ org } as Partial<User>);
expect(isVerified(u)).toBe(true);
expect(verifiedSince(u)?.toISOString()).toBe("2026-01-02T00:00:00.000Z");
});

test("with affiliate checkmarks off, an affiliate has none", () => {
settings().features.affiliate_checkmarks = false;
const u = person({ org } as Partial<User>);
expect(isVerified(u)).toBe(false);
expect(verifiedSince(u)).toBe(null);
expect(userResult(u).is_blue_verified).toBe(false);
});

test("with affiliate checkmarks off, an affiliate's own badge still shows", () => {
settings().features.affiliate_checkmarks = false;
const u = person({ org, verified_type: "blue", verified_at: "2025-05-05T00:00:00Z" } as Partial<User>);
expect(isVerified(u)).toBe(true);
expect(verifiedSince(u)?.toISOString()).toBe("2025-05-05T00:00:00.000Z");
expect(JSON.stringify(userResult(u).verification_info)).not.toContain("affiliate of");
});
42 changes: 42 additions & 0 deletions scripts/test-remote-checkmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Admin > Federation can hide the checkmarks other instances gave their
// accounts (model.ts remoteCheckHidden); local accounts keep theirs.
import { afterEach, expect, test } from "bun:test";
import type { User } from "../src/auth";
import { isVerified, shownVerifiedType, userResult } from "../src/model";
import { settings } from "../src/settings";

const account = (extra: Partial<User>) => ({ id: "1", screen_name: "jo", name: "Jo", verified_type: null, created_at: new Date("2024-01-01T00:00:00Z"), ...extra }) as User;
const hide = (hidden: boolean) => {
(settings() as { remote_checkmarks: boolean }).remote_checkmarks = !hidden;
};

afterEach(() => hide(false));

test("remote checkmarks show by default", () => {
const u = account({ verified_type: "business", instance: "xit.example.org" });
expect(isVerified(u)).toBe(true);
expect(shownVerifiedType(u)).toBe("business");
});

test("hidden, a remote account shows no checkmark", () => {
hide(true);
const u = account({ verified_type: "government", instance: "xit.example.org" });
expect(isVerified(u)).toBe(false);
expect(shownVerifiedType(u)).toBe(null);
const r = userResult(u);
expect(r.is_blue_verified).toBe(false);
expect(JSON.stringify(r)).not.toContain('"verified_type":"Government"');
});

test("hidden, a remote affiliate shows no checkmark either", () => {
hide(true);
const org = { id: "9", screen_name: "acme@xit.example.org", name: "Acme", avatar_url: null };
expect(isVerified(account({ org, instance: "xit.example.org" } as Partial<User>))).toBe(false);
});

test("hidden, local accounts keep theirs", () => {
hide(true);
const u = account({ verified_type: "blue", instance: null } as Partial<User>);
expect(isVerified(u)).toBe(true);
expect(shownVerifiedType(u)).toBe("blue");
});
6 changes: 5 additions & 1 deletion src/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,9 @@
(i.key_change ? '<button class="btn" data-key="' + esc(i.id) + '">Accept the new key</button>' : "") + "</div>" : "") + "</article>").join("") + "</div>"
: '<div class="empty">No other instances yet.</div>') + "</div>" +
(f.instances.length ? '<div class="empty" id="instance-empty" hidden></div>' : "") +
'<div class="box" style="margin-top:16px"><h2>Checkmarks from other instances</h2><p class="dim small">' +
(f.remote_checkmarks ? "Accounts from other instances wear the checkmark their own instance gave them." : "Accounts from other instances show no checkmark here, whatever their own instance gave them.") + "</p>" +
(can("admin") ? '<button class="btn" id="remotechecks">' + (f.remote_checkmarks ? "Hide their checkmarks" : "Show their checkmarks") + "</button>" : "") + "</div>" +
'<div class="box" style="margin-top:16px"><h2>Media from other instances</h2><p class="dim small">' +
(f.media.stored ? "Saved on this server (FEDERATION_MEDIA=store)." : "Shown straight from their home instance and not saved here.") +
" Saved now: " + esc(String(f.media.files)) + " files, " + esc((f.media.bytes / 1048576).toFixed(1)) + " MB.</p>" +
Expand All @@ -1424,6 +1427,7 @@
if (!(await ask("Trust the new key of " + b.dataset.key + "? Only do this if its admins told you they changed it."))) return;
act(() => api("federationAcceptKey", { instance: b.dataset.key }), "Key accepted").then(route);
});
if ($("#remotechecks")) $("#remotechecks").onclick = () => act(() => api("setRemoteCheckmarks", { enabled: !f.remote_checkmarks }), f.remote_checkmarks ? "Their checkmarks are hidden" : "Their checkmarks show again").then(route);
if ($("#dropmedia")) $("#dropmedia").onclick = async () => {
if (!(await ask("Delete every picture and video saved from other instances? They are fetched from their home instance again when someone views them."))) return;
act(() => api("federationDropMedia"), "Deleted").then(route);
Expand Down Expand Up @@ -1775,7 +1779,7 @@
["Core social", ["communities", "lists"]],
["Composer", ["gifs", "polls", "location", "scheduling"]],
["AI and tools", ["grok", "image_gen", "premium", "calls", "history", "creator_studio", "spaces"]],
["Accounts and integrations", ["business", "notes_everyone", "link_previews", "archive_import", "money"]],
["Accounts and integrations", ["business", "notes_everyone", "link_previews", "archive_import", "money", "affiliate_checkmarks"]],
].map(([group, keys], index) => '<div class="box"><h2>' + group + '</h2><div class="feature-grid">' +
keys.map((k) => '<label class="feature-option"><input type="checkbox" data-feature="' + k + '"' + (s.features[k] ? " checked" : "") + dis + "><span>" + esc(features[k]) + "</span></label>").join("") + "</div>" +
(index === 3 && admin ? '<button class="btn primary" id="saveFeatures" style="margin-top:8px">Save features</button>' : "") + "</div>").join("") + "</div></section>" +
Expand Down
7 changes: 7 additions & 0 deletions src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const ACTIONS: Record<string, Action> = {
media: { stored: storesRemoteMedia(), ...(await savedRemoteMedia()) },
registry: { sequence: registrySequence(), checked_at: registryCheckedAt ? new Date(registryCheckedAt).toISOString() : null, error: registryError },
outbox_head: head,
remote_checkmarks: settings().remote_checkmarks,
superadmins: SUPERADMINS.map((x) => `${x.screen_name}@${x.instance}`),
superadmin: !!a.superadmin,
instances: await Promise.all(
Expand Down Expand Up @@ -201,6 +202,12 @@ const ACTIONS: Record<string, Action> = {
await audit(a, "federation_drop_media", INSTANCE_ID, dropped);
return dropped;
} },
setRemoteCheckmarks: { role: "admin", run: async (a, b) => {
if (typeof b.enabled !== "boolean") throw new AdminError("Provide a boolean setting.");
await setSetting("remote_checkmarks", b.enabled);
await audit(a, "set_remote_checkmarks", INSTANCE_ID, { enabled: b.enabled });
return {};
} },
federationPull: { role: "admin", run: async (a, b) => {
const id = instanceOf(b.instance);
await audit(a, "federation_pull", id);
Expand Down
2 changes: 1 addition & 1 deletion src/api-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function apiDocsPage(req: Request): Promise<Response> {

<h2>Shapes</h2>
<p>A post: <code>id</code>, <code>text</code>, <code>created_at</code>, <code>edited_at</code>, <code>url</code>, <code>author</code> (an account), <code>reply_to</code>, <code>quote_of</code>, <code>sensitive</code>, <code>counts</code> (likes, reposts, replies, quotes, bookmarks, views), <code>viewer</code> (whether you liked, reposted, bookmarked it), <code>media</code>; in timelines a repost adds <code>reposted_by</code> and <code>reposted_at</code>.</p>
<p>An account: <code>id</code>, <code>screen_name</code>, <code>name</code>, <code>bio</code>, <code>location</code>, <code>website</code>, <code>avatar_url</code>, <code>banner_url</code>, <code>verified_type</code> (<code>blue</code>, <code>business</code>, <code>government</code>, <code>government_individual</code> or null), <code>verified</code> (whether the app shows a checkmark: also true for an organisation's affiliates), <code>verified_since</code>, <code>parody_label</code> (<code>Parody</code>, <code>Commentary</code>, <code>Fan</code> or null), <code>label</code> (a label staff gave it: <code>text</code>, <code>url</code>, <code>badge_url</code>; or null), <code>automated_by</code> (a bot's owner, or null), <code>organization</code> (a business account: a business or government, or any account with affiliates) with <code>affiliates_count</code>, <code>affiliations</code> (organisations it's an affiliate of: <code>screen_name</code>, <code>name</code>, <code>badge_url</code>), <code>badges</code> (custom badges, pride flags and, for an account from another instance, a globe: <code>name</code>, <code>url</code>), <code>protected</code>, <code>created_at</code>, <code>url</code>, <code>counts</code> (posts, followers, following, likes) and <code>viewer</code> (following, followed_by).</p>
<p>An account: <code>id</code>, <code>screen_name</code>, <code>name</code>, <code>bio</code>, <code>location</code>, <code>website</code>, <code>avatar_url</code>, <code>banner_url</code>, <code>verified_type</code> (<code>blue</code>, <code>business</code>, <code>government</code>, <code>government_individual</code> or null), <code>verified</code> (whether the app shows a checkmark: also true for an organisation's affiliates, unless the instance turned affiliate checkmarks off), <code>verified_since</code>, <code>parody_label</code> (<code>Parody</code>, <code>Commentary</code>, <code>Fan</code> or null), <code>label</code> (a label staff gave it: <code>text</code>, <code>url</code>, <code>badge_url</code>; or null), <code>automated_by</code> (a bot's owner, or null), <code>organization</code> (a business account: a business or government, or any account with affiliates) with <code>affiliates_count</code>, <code>affiliations</code> (organisations it's an affiliate of: <code>screen_name</code>, <code>name</code>, <code>badge_url</code>), <code>badges</code> (custom badges, pride flags and, for an account from another instance, a globe: <code>name</code>, <code>url</code>), <code>protected</code>, <code>created_at</code>, <code>url</code>, <code>counts</code> (posts, followers, following, likes) and <code>viewer</code> (following, followed_by).</p>

<p class="dim">Posts made through the API show "Xitter API" as their app. What a token sees is what its account sees: blocks, protected accounts and suspensions apply.</p>
`;
Expand Down
6 changes: 3 additions & 3 deletions src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// realm (a separate service, see README) reached at /juicebox, with tokens this
// instance signs. Voice and video calls are not offered.

import { pictureShape } from "./model";
import { affiliateChecked, pictureShape, remoteCheckHidden, shownVerifiedType } from "./model";
import { createHmac, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import type { ServerWebSocket } from "bun";
Expand Down Expand Up @@ -105,8 +105,8 @@ async function hasKey(userIds: string[]): Promise<Set<string>> {
function verificationOf(u: User) {
return {
__typename: "UserVerification",
is_blue_verified: u.verified_type === "blue" || (!u.verified_type && !!u.org),
is_verified_organization: u.verified_type === "business",
is_blue_verified: !remoteCheckHidden(u) && (u.verified_type === "blue" || (!u.verified_type && affiliateChecked(u))),
is_verified_organization: shownVerifiedType(u) === "business",
is_verified_organization_affiliate: !!u.org,
verified: false,
};
Expand Down
33 changes: 21 additions & 12 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { type MediaRow, absolute, mediaEntity, mediaUrl, posterUrl } from "./med
import { placeById } from "./places";
import { shownSource } from "./platforms";
import { requestLanguage } from "./staff-access";
import { settings } from "./settings";
import { feature, settings } from "./settings";
import { SHORT_POST, previewOf, weightedLength } from "./textlen";
import { badgeUrl } from "./users";

Expand All @@ -43,17 +43,24 @@ export function apiDate(d: Date): string {

const globalId = (kind: string, id: string) => Buffer.from(`${kind}:${id}`).toString("base64");

// An account from another instance wears the badge that instance gave it,
// unless this instance stopped showing those (Admin > Federation).
export const remoteCheckHidden = (u: { instance?: string | null }) => !!u.instance && !settings().remote_checkmarks;
export const shownVerifiedType = (u: User) => (remoteCheckHidden(u) ? null : u.verified_type);

// Affiliates of an organisation wear X's checkmark next to the organisation's
// badge, as on X, even without a badge of their own.
export const isVerified = (u: User) => u.verified_type !== null || !!u.org;
// badge, as on X, even without a badge of their own, unless the instance
// switched that off (Settings: affiliate_checkmarks).
export const affiliateChecked = (u: User) => !!u.org && feature("affiliate_checkmarks");
export const isVerified = (u: User) => !remoteCheckHidden(u) && (u.verified_type !== null || affiliateChecked(u));
// a business or government badge: what makes an account an organisation on
// its own, and gives it a square picture
export const hasOrgBadge = (u: User) => u.verified_type === "business" || u.verified_type === "government";
// a business account to X: an organisation badge, or anyone with affiliates
// (isOrganizationSql in users.ts is the same rule in SQL)
export const isOrganization = (u: User) => hasOrgBadge(u) || !!u.affiliates;
// "Verified since": an affiliate's date of joining its organisation, else when the badge was given
export const verifiedSince = (u: User): Date | null => (u.org?.since ? new Date(u.org.since) : u.verified_at ? new Date(u.verified_at) : null);
export const verifiedSince = (u: User): Date | null => (affiliateChecked(u) && u.org?.since ? new Date(u.org.since) : u.verified_at ? new Date(u.verified_at) : null);

// X has two globes: "badged" (blue) and "moderator" (yellow). Approvers get the
// yellow one; translator moderators the same, which badges.ts colours gold.
Expand All @@ -68,13 +75,14 @@ export const pictureShape = (u: User) => (hasOrgBadge(u) ? "Square" : "Circle");
function verificationInfo(u: User) {
if (!isVerified(u)) return { is_identity_verified: false };
const instance = settings().instance_name;
const parts: (string | { text: string; url: string })[] = u.org
const type = shownVerifiedType(u);
const parts: (string | { text: string; url: string })[] = u.org && affiliateChecked(u)
? ["This account is Premium because it’s an affiliate of ", { text: `@${u.org.screen_name}`, url: `${PUBLIC_ORIGIN}/${u.org.screen_name}` }, ` on ${instance}. `]
: u.verified_type === "business"
: type === "business"
? [`This account is verified because it’s an organisation on ${instance}. `]
: u.verified_type === "government"
: type === "government"
? [`This account is verified because it is a government or multilateral organization account on ${instance}. `]
: u.verified_type === "government_individual"
: type === "government_individual"
? [`This account is verified because it belongs to a government or multilateral organization official on ${instance}. `]
: [`This account is Premium because the ${instance} team verified it. `];
parts.push({ text: "Learn more", url: `${PUBLIC_ORIGIN}/rules` });
Expand All @@ -97,8 +105,9 @@ function verificationInfo(u: User) {
}

function verification(u: User) {
if (u.verified_type === "business") return { verified: false, verified_type: "Business" };
if (u.verified_type === "government" || u.verified_type === "government_individual") return { verified: false, verified_type: "Government" };
const type = shownVerifiedType(u);
if (type === "business") return { verified: false, verified_type: "Business" };
if (type === "government" || type === "government_individual") return { verified: false, verified_type: "Government" };
return { verified: false };
}

Expand Down Expand Up @@ -318,8 +327,8 @@ export function legacyUser(u: User) {
url: u.website ?? "",
pinned_tweet_ids_str: u.pinned_post_id ? [u.pinned_post_id] : [],
is_blue_verified: isVerified(u),
...(u.verified_type === "business" ? { verified_type: "Business" } : {}),
...(u.verified_type === "government" || u.verified_type === "government_individual" ? { verified_type: "Government" } : {}),
...(shownVerifiedType(u) === "business" ? { verified_type: "Business" } : {}),
...(shownVerifiedType(u) === "government" || shownVerifiedType(u) === "government_individual" ? { verified_type: "Government" } : {}),
super_follow_eligible: false,
super_followed_by: false,
super_following: false,
Expand Down
6 changes: 3 additions & 3 deletions src/money.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { hateIn } from "./hate";
import { newId } from "./ids";
import { linksIn } from "./links";
import { absolute } from "./media";
import { avatarUrl } from "./model";
import { avatarUrl, remoteCheckHidden } from "./model";
import { feature, hasBlockedPhrase, settings } from "./settings";
import { MONEY_ICON } from "./tidy";
import { isPeer } from "./federation";
Expand Down Expand Up @@ -346,7 +346,7 @@ const plainUrl = (u: string | null) => (u && /^(https?:\/\/|\/)[^\s"'()\\<>]+$/.

export async function parties(ids: string[]): Promise<Map<string, Party>> {
if (!ids.length) return new Map();
const rows = await sql`select u.id::text, u.screen_name, u.name, u.avatar_url, u.banner_url, u.verified_type,
const rows = await sql`select u.id::text, u.screen_name, u.name, u.avatar_url, u.banner_url, u.verified_type, u.instance,
(select json_build_object('name', o.name, 'screen_name', o.screen_name, 'avatar', o.avatar_url) from affiliations af join users o on o.id = af.org_id
where af.user_id = u.id order by af.created_at, af.org_id limit 1) as org,
m.user_id is not null and u.verified_type = 'business' as merchant, m.name as m_name, m.show_banner
Expand All @@ -359,7 +359,7 @@ export async function parties(ids: string[]): Promise<Map<string, Party>> {
screen_name: r.screen_name,
name: r.name || r.screen_name,
avatar: avatarUrl(r.avatar_url),
badge: r.verified_type ?? (r.org ? "blue" : null),
badge: remoteCheckHidden(r) ? null : (r.verified_type ?? (r.org && feature("affiliate_checkmarks") ? "blue" : null)),
org: r.org ? { name: r.org.name, screen_name: r.org.screen_name, avatar: avatarUrl(r.org.avatar) } : null,
square: r.verified_type === "business" || r.verified_type === "government",
merchant: r.merchant ? { name: r.m_name || r.name || r.screen_name, banner: r.show_banner && r.banner_url ? plainUrl(absolute(r.banner_url)) : null } : null,
Expand Down
4 changes: 2 additions & 2 deletions src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { sql } from "./db";
import { callOperation } from "./graphql";
import { isId, newId } from "./ids";
import { absolute, mediaUpload, mediaUrl, posterUrl } from "./media";
import { type PostRow, badgeList, isOrganization, isVerified, verifiedSince } from "./model";
import { type PostRow, badgeList, isOrganization, isVerified, shownVerifiedType, verifiedSince } from "./model";
import { PostError, feed, getPost, likerIds, postsByIds, replies, type FeedItem } from "./posts";
import { setProfileImage, updateProfileText } from "./rest";
import { ReplyControlError } from "./reply-controls";
Expand Down Expand Up @@ -103,7 +103,7 @@ function userJson(u: User) {
website: u.website || null,
avatar_url: absolute(u.avatar_url),
banner_url: absolute(u.banner_url),
verified_type: u.verified_type,
verified_type: shownVerifiedType(u),
// what the web client shows, from the same rules (model.ts): a checkmark
// also for an organisation's affiliates, and its "Verified since" date
verified: isVerified(u),
Expand Down
Loading
Loading