added ability to check shared devices

This commit is contained in:
onysd 2026-08-06 02:12:46 +03:00
parent 7d0daef923
commit 72db66d4aa
14 changed files with 456 additions and 23 deletions

View file

@ -157,6 +157,33 @@ type AuthorizationRow struct {
ActiveAt time.Time ActiveAt time.Time
} }
// SharedDeviceAccount is one account whose authorizations matched a
// SharedDeviceGroup's device fingerprint.
type SharedDeviceAccount struct {
UserID int64
Phone string
Username string
FirstName string
LastName string
ActiveAt time.Time
}
// SharedDeviceGroup is a device fingerprint (device_model + system_version +
// platform + ip) shared by more than one distinct account's authorizations --
// a heuristic multi-accounting signal, not proof: device_model/system_version
// are client-reported and spoofable, and ip alone collides naturally behind
// NAT, shared wifi, or carrier CGNAT. Treat this as a lead to investigate, not
// a verdict.
type SharedDeviceGroup struct {
DeviceModel string
SystemVersion string
Platform string
IP string
AccountCount int
LastActiveAt time.Time
Accounts []SharedDeviceAccount
}
type AuditLogRow struct { type AuditLogRow struct {
ID int64 ID int64
CommandID string CommandID string
@ -787,6 +814,88 @@ LIMIT $3`, beforeActiveUS, beforeID, limit+1)
return out, hasMore, nil return out, hasMore, nil
} }
// ListSharedDeviceGroups pages through device fingerprints (device_model +
// system_version + platform + ip) that more than one distinct account has
// authorized from -- see SharedDeviceGroup's doc comment on why this is a
// heuristic, not a verdict. Pagination is plain offset/limit over the
// aggregated group list (not the raw authorizations table): the number of
// *groups* is expected to stay small relative to total session count, so an
// offset scan over the pre-aggregated CTE is cheap even though offset
// pagination over raw rows elsewhere in this file deliberately uses a keyset
// cursor instead.
func (s *readStore) ListSharedDeviceGroups(ctx context.Context, offset, limit int) ([]SharedDeviceGroup, bool, error) {
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
if offset < 0 {
offset = 0
}
rows, err := s.pool.Query(ctx, `
WITH device_groups AS (
SELECT device_model, system_version, platform, ip,
count(DISTINCT user_id)::int AS account_count,
max(active_at) AS last_active_at
FROM authorizations
WHERE device_model <> ''
GROUP BY device_model, system_version, platform, ip
HAVING count(DISTINCT user_id) > 1
ORDER BY max(active_at) DESC, device_model, system_version, platform, ip
LIMIT $1 OFFSET $2
),
members AS (
SELECT DISTINCT ON (a.device_model, a.system_version, a.platform, a.ip, a.user_id)
a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at
FROM authorizations a
JOIN device_groups g ON a.device_model = g.device_model AND a.system_version = g.system_version
AND a.platform = g.platform AND a.ip = g.ip
ORDER BY a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at DESC
)
SELECT g.device_model, g.system_version, g.platform, g.ip, g.account_count, g.last_active_at,
m.user_id, m.active_at, u.phone, u.username, u.first_name, u.last_name
FROM device_groups g
JOIN members m ON m.device_model = g.device_model AND m.system_version = g.system_version
AND m.platform = g.platform AND m.ip = g.ip
JOIN users u ON u.id = m.user_id
ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.ip, m.active_at DESC`,
limit+1, offset)
if err != nil {
return nil, false, fmt.Errorf("list shared device groups: %w", err)
}
defer rows.Close()
groups := make([]SharedDeviceGroup, 0, limit+1)
for rows.Next() {
var (
deviceModel, systemVersion, platform, ip string
accountCount int
lastActiveAt time.Time
acc SharedDeviceAccount
)
if err := rows.Scan(&deviceModel, &systemVersion, &platform, &ip, &accountCount, &lastActiveAt,
&acc.UserID, &acc.ActiveAt, &acc.Phone, &acc.Username, &acc.FirstName, &acc.LastName); err != nil {
return nil, false, err
}
if len(groups) == 0 {
groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt})
} else if last := &groups[len(groups)-1]; last.DeviceModel != deviceModel || last.SystemVersion != systemVersion || last.Platform != platform || last.IP != ip {
groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt})
}
last := &groups[len(groups)-1]
last.Accounts = append(last.Accounts, acc)
}
if err := rows.Err(); err != nil {
return nil, false, err
}
hasMore := len(groups) > limit
if hasMore {
groups = groups[:limit]
}
return groups, hasMore, nil
}
func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) { func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) {
var out AccountDetail var out AccountDetail
err := s.pool.QueryRow(ctx, ` err := s.pool.QueryRow(ctx, `

View file

@ -0,0 +1,109 @@
package main
import (
"context"
"fmt"
"testing"
"time"
)
// ListSharedDeviceGroups is hand-written SQL grouping authorizations by a
// device fingerprint tuple, so the grouping logic (which accounts land in
// which group, and that a lone different device never leaks in) can only be
// proven against the real schema. Gated on TELESRV_TEST_POSTGRES_DSN like the
// rest of this package's integration tests.
func TestReadStoreSharedDeviceGroups(t *testing.T) {
store, pool := verificationReadStore(t)
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
sharedDeviceModel := "Integration-Test-Device-" + suffix
sharedSystemVersion := "Test OS 1.0"
sharedPlatform := "test-platform"
sharedIP := "203.0.113." + suffix[len(suffix)-2:]
userA := 3_700_000_000 + time.Now().UnixNano()%1_000_000
userB := userA + 1
userC := userA + 2 // different device -- must never appear in the shared group.
t.Cleanup(func() {
for _, id := range []int64{userA, userB, userC} {
_, _ = pool.Exec(ctx, `DELETE FROM authorizations WHERE user_id=$1`, id)
_, _ = pool.Exec(ctx, `DELETE FROM auth_keys WHERE auth_key_id=$1`, id)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, id)
}
})
for i, id := range []int64{userA, userB, userC} {
if _, err := pool.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', $5, now(), now())`,
id, id, fmt.Sprintf("+1890%s%d", suffix, i), fmt.Sprintf("Shared%d", i), fmt.Sprintf("shared%d%s", i, suffix)); err != nil {
t.Fatalf("seed user %d: %v", id, err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, id); err != nil {
t.Fatalf("seed auth key %d: %v", id, err)
}
}
// userA and userB authorize from the same device fingerprint; userC from a
// distinct one, so it must not be pulled into the shared group.
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userA, sharedDeviceModel, sharedSystemVersion, sharedPlatform, sharedIP); err != nil {
t.Fatalf("seed authorization A: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userB, sharedDeviceModel, sharedSystemVersion, sharedPlatform, sharedIP); err != nil {
t.Fatalf("seed authorization B: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO authorizations (user_id, auth_key_id, device_model, system_version, platform, ip, created_at, active_at)
VALUES ($1, $1, $2, $3, $4, $5, now(), now())`, userC, "Other-Device-"+suffix, sharedSystemVersion, sharedPlatform, sharedIP+".other"); err != nil {
t.Fatalf("seed authorization C: %v", err)
}
groups, _, err := store.ListSharedDeviceGroups(ctx, 0, 200)
if err != nil {
t.Fatalf("ListSharedDeviceGroups: %v", err)
}
var found *SharedDeviceGroup
for i := range groups {
if groups[i].DeviceModel == sharedDeviceModel {
found = &groups[i]
break
}
}
if found == nil {
t.Fatalf("seeded device fingerprint %q not found among %d groups", sharedDeviceModel, len(groups))
}
if found.AccountCount != 2 {
t.Fatalf("AccountCount = %d, want 2", found.AccountCount)
}
if found.SystemVersion != sharedSystemVersion || found.Platform != sharedPlatform || found.IP != sharedIP {
t.Fatalf("group fingerprint = %+v, want system_version=%q platform=%q ip=%q", found, sharedSystemVersion, sharedPlatform, sharedIP)
}
if len(found.Accounts) != 2 {
t.Fatalf("Accounts = %#v, want exactly 2 members", found.Accounts)
}
seen := map[int64]bool{}
for _, acc := range found.Accounts {
seen[acc.UserID] = true
if acc.UserID == userC {
t.Fatalf("userC (different device) leaked into the shared group: %#v", found.Accounts)
}
}
if !seen[userA] || !seen[userB] {
t.Fatalf("Accounts = %#v, want both userA=%d and userB=%d present", found.Accounts, userA, userB)
}
// A lone device (userC's) must never itself form a "shared" group.
for i := range groups {
if groups[i].DeviceModel == "Other-Device-"+suffix {
t.Fatalf("a device with only one distinct account formed a group: %+v", groups[i])
}
}
}

View file

@ -54,6 +54,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession))) mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI))) mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI))) mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI))) mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI))) mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI))) mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
@ -632,6 +633,33 @@ func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *server) handleSharedDeviceGroupsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
return
}
offset, _ := parseInt(r.URL.Query().Get("offset"))
limit, _ := parseInt(r.URL.Query().Get("limit"))
groups, hasMore, err := s.read.ListSharedDeviceGroups(r.Context(), offset, limit)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
if limit <= 0 {
limit = accountListDefaultLimit
}
if limit > accountListMaxLimit {
limit = accountListMaxLimit
}
writeJSON(w, http.StatusOK, map[string]any{
"limit": limit,
"offset": offset,
"rows": groups,
"has_more": hasMore,
"next_offset": offset + limit,
})
}
func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) { func (s *server) handleAccountsStatsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil { if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured") writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-Defd2Shn.js"></script> <script type="module" crossorigin src="/assets/index-CvaJc5Uc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C7vmH2zd.css"> <link rel="stylesheet" crossorigin href="/assets/index-C7vmH2zd.css">
</head> </head>
<body> <body>

View file

@ -5,6 +5,7 @@ import type {
AccountRatingListResponse, AccountRatingListResponse,
AccountStatsResponse, AccountStatsResponse,
AccountStorageListResponse, AccountStorageListResponse,
SharedDeviceGroupListResponse,
StorageStatsResponse, StorageStatsResponse,
AdminLoginResult, AdminLoginResult,
AdminSession, AdminSession,
@ -154,6 +155,7 @@ export const api = {
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }), logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`), accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"), accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`), account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`), channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`), channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),

View file

@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react"; import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search, Smartphone } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
@ -99,17 +99,22 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
title={"Accounts"} title={"Accounts"}
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"} eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
actions={ actions={
<button <>
className="btn" <button className="btn icon-text" type="button" onClick={() => navigate("/accounts/shared-devices")}>
type="button" <Smartphone size={15} /> {"Shared devices"}
onClick={() => { </button>
void loadFresh(); <button
void loadStats(); className="btn"
}} type="button"
disabled={busy} onClick={() => {
> void loadFresh();
<RefreshCw size={15} /> {"Refresh"} void loadStats();
</button> }}
disabled={busy}
>
<RefreshCw size={15} /> {"Refresh"}
</button>
</>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}

View file

@ -3,6 +3,7 @@ import { AccountDetailPage } from "./AccountDetailPage";
import { AccountRatingDetailPage } from "./AccountRatingDetailPage"; import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
import { AccountRatingsPage } from "./AccountRatingsPage"; import { AccountRatingsPage } from "./AccountRatingsPage";
import { AccountsPage } from "./AccountsPage"; import { AccountsPage } from "./AccountsPage";
import { SharedDevicesPage } from "./SharedDevicesPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage"; import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage"; import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage"; import { ChannelDetailPage } from "./ChannelDetailPage";
@ -100,6 +101,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (moderationCaseID) { if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />; return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
} }
if (route.path === "/accounts/shared-devices") {
return <SharedDevicesPage navigate={navigate} />;
}
if (route.path === "/accounts") { if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />; return <AccountsPage navigate={navigate} />;
} }

View file

@ -0,0 +1,131 @@
import { ArrowLeft, ChevronDown, ChevronRight, Loader2, RefreshCw, Smartphone } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, SectionHead } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { SharedDeviceGroup } from "../types";
// SharedDevicesPage surfaces authorizations that look like they came from the
// same physical device but belong to different accounts -- a lead worth
// investigating for multi-accounting, not a verdict: device_model and
// system_version are self-reported by the client and easy to spoof, and a
// matching IP alone is common and innocent behind NAT, shared wifi, or
// carrier CGNAT. Treat every group here as "worth a look", not "guilty".
export function SharedDevicesPage({ navigate }: { navigate: Navigate }) {
const [groups, setGroups] = useState<SharedDeviceGroup[]>([]);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const at = next ? offset : 0;
const params = new URLSearchParams({ limit: "20", offset: String(at) });
try {
const result = await api.sharedDeviceGroups(params);
const page = result.rows ?? [];
setGroups((current) => (next ? [...current, ...page] : page));
setOffset(result.next_offset);
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const totalFlaggedAccounts = groups.reduce((sum, group) => sum + group.AccountCount, 0);
return (
<PageFrame
title={"Shared Devices"}
eyebrow={"Multi-account signal — device/IP overlap across different accounts"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts")}>
<ArrowLeft size={15} /> {"Back to accounts"}
</button>
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Device groups on page"} value={String(groups.length)} />
<Metric label={"Accounts flagged on page"} value={String(totalFlaggedAccounts)} tone="warn" />
</div>
<p className="about-text">
{"Each card below is a device fingerprint (device model + OS + platform + IP) that more than one account has authorized from. "}
{"device_model/system_version are self-reported by the client, and IP alone can collide innocently -- use this as a lead, not a verdict."}
</p>
<div className="stacked-sections">
{groups.map((group) => (
<section className="section-block" key={`${group.DeviceModel}|${group.SystemVersion}|${group.Platform}|${group.IP}`}>
<SectionHead
title={group.DeviceModel || "Unknown device"}
text={`${group.Platform || "unknown platform"} ${group.SystemVersion} · ${group.IP} · last active ${formatDate(group.LastActiveAt)}`}
action={<Badge tone="warn"><Smartphone size={12} /> {`${group.AccountCount} accounts`}</Badge>}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th className="avatar-col"></th>
<th>{"User ID"}</th>
<th>{"Phone"}</th>
<th>{"Username"}</th>
<th>{"Name"}</th>
<th>{"Active from this device"}</th>
<th></th>
</tr>
</thead>
<tbody>
{group.Accounts.map((account) => (
<tr key={account.UserID}>
<td className="avatar-col"><Avatar id={account.UserID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} /></td>
<td className="mono">{account.UserID}</td>
<td>{displayPhone(account.Phone)}</td>
<td>{displayUsername(account.Username) || "-"}</td>
<td>{displayName(account) || "-"}</td>
<td>{formatDate(account.ActiveAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${account.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
{groups.length === 0 && (
<div className="table-wrap">
<table className="data-table">
<tbody>
<EmptyRow colSpan={7} />
</tbody>
</table>
</div>
)}
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}

View file

@ -22,6 +22,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames"; if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
if (pathname.startsWith("/account-ratings")) return "Account Rating"; if (pathname.startsWith("/account-ratings")) return "Account Rating";
if (pathname.startsWith("/storage")) return "Storage"; if (pathname.startsWith("/storage")) return "Storage";
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
if (pathname.startsWith("/accounts")) return "Accounts"; if (pathname.startsWith("/accounts")) return "Accounts";
if (pathname.startsWith("/channels")) return "Supergroups and Channels"; if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/bots")) return "Bots"; if (pathname.startsWith("/bots")) return "Bots";

View file

@ -789,6 +789,39 @@ export type AccountStatsResponse = {
online: number; online: number;
}; };
// SharedDeviceAccount is one account whose authorizations matched a
// SharedDeviceGroup's device fingerprint.
export type SharedDeviceAccount = {
UserID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
ActiveAt: string;
};
// SharedDeviceGroup is a device fingerprint (device model + OS + platform +
// IP) shared by more than one distinct account -- a heuristic multi-account
// signal, not proof (device_model/system_version are client-reported and
// spoofable, and IP alone collides behind NAT/shared wifi/carrier CGNAT).
export type SharedDeviceGroup = {
DeviceModel: string;
SystemVersion: string;
Platform: string;
IP: string;
AccountCount: number;
LastActiveAt: string;
Accounts: SharedDeviceAccount[];
};
export type SharedDeviceGroupListResponse = {
limit: number;
offset: number;
rows: SharedDeviceGroup[];
has_more: boolean;
next_offset: number;
};
export type StorageStatsResponse = { export type StorageStatsResponse = {
PhysicalBytes: string; PhysicalBytes: string;
LogicalBytes: string; LogicalBytes: string;

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_authorizations_device_fingerprint;

View file

@ -0,0 +1,10 @@
-- Supports the admin console's "accounts sharing a device" report, which
-- groups authorizations by (device_model, system_version, platform, ip) and
-- looks for groups spanning more than one distinct user_id. Without this
-- index that GROUP BY is a full sequential scan of authorizations, a table
-- that grows roughly with active-session count and is never pruned except on
-- explicit revoke.
CREATE INDEX idx_authorizations_device_fingerprint
ON public.authorizations (device_model, system_version, platform, ip)
WHERE device_model <> '';