Skip to content

Commit a05b45f

Browse files
sweetmantechclaude
andcommitted
review: scope owner resolution to the caller, project the avatar columns, chunk the id list
Three of cubic's six findings were real: - P2 (the important one): owner resolution considered EVERY account_catalogs link for a catalog, including accounts the caller reads nothing through. One catalog on prod carries four owner links; with a different row order the card would have named a stranger — wrong attribution, and a disclosure of another account's name and avatar. resolveCatalogOwners now takes the authorized owner set and drops links outside it before the organization tie-break. - P2: selectAccountInfos selected `*`, pulling knowledge entries and AI instructions to render an avatar. Projected to account_id + image. - P2: one .in() carrying every catalog id can outgrow PostgREST's URL limit, and selectPlaycountSnapshots reports that failure as [] — silently costing every band its real catalog age. Chunked at 50 ids per query. The aggregate-null finding was already fixed in e0d6f5d (retry + log). The JSDoc's "one Spotify call" claim is corrected to what the code now does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e0d6f5d commit a05b45f

6 files changed

Lines changed: 103 additions & 20 deletions

File tree

‎lib/catalog/__tests__/getCatalogsHandler.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ describe("getCatalogsHandler", () => {
108108

109109
expect(resolveCatalogOwners).toHaveBeenCalledWith({
110110
catalogIds: ["c1"],
111+
ownerIds: [accountId,organizationId],
111112
organizationIds: [organizationId],
112113
});
113114
});
@@ -122,7 +123,11 @@ describe("getCatalogsHandler", () => {
122123

123124
expect(body).toEqual({status: "success",catalogs: []});
124125
expect(getCatalogValuations).toHaveBeenCalledWith([]);
125-
expect(resolveCatalogOwners).toHaveBeenCalledWith({catalogIds: [],organizationIds: []});
126+
expect(resolveCatalogOwners).toHaveBeenCalledWith({
127+
catalogIds: [],
128+
ownerIds: [accountId],
129+
organizationIds: [],
130+
});
126131
});
127132

128133
it("returns 500 with a generic error, not the raw exception message",async()=>{

‎lib/catalog/__tests__/resolveCatalogOwners.test.ts‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ describe("resolveCatalogOwners", () => {
2828
beforeEach(()=>vi.clearAllMocks());
2929

3030
it("returns an empty map without querying when there are no catalogs",async()=>{
31-
constowners=awaitresolveCatalogOwners({catalogIds: [],organizationIds: [org]});
31+
constowners=awaitresolveCatalogOwners({
32+
catalogIds: [],
33+
ownerIds: [person,org],
34+
organizationIds: [org],
35+
});
3236

3337
expect(owners.size).toBe(0);
3438
expect(selectCatalogOwnerLinks).not.toHaveBeenCalled();
@@ -43,7 +47,11 @@ describe("resolveCatalogOwners", () => {
4347
{account_id: person,image: "https://img/person.png"},
4448
]asnever);
4549

46-
constowners=awaitresolveCatalogOwners({catalogIds: [catalogA],organizationIds: [org]});
50+
constowners=awaitresolveCatalogOwners({
51+
catalogIds: [catalogA],
52+
ownerIds: [person,org],
53+
organizationIds: [org],
54+
});
4755

4856
expect(owners.get(catalogA)).toEqual({
4957
id: person,
@@ -60,7 +68,11 @@ describe("resolveCatalogOwners", () => {
6068
{account_id: org,image: "https://img/org.png"},
6169
]asnever);
6270

63-
constowners=awaitresolveCatalogOwners({catalogIds: [catalogA],organizationIds: [org]});
71+
constowners=awaitresolveCatalogOwners({
72+
catalogIds: [catalogA],
73+
ownerIds: [person,org],
74+
organizationIds: [org],
75+
});
6476

6577
expect(owners.get(catalogA)?.is_organization).toBe(true);
6678
});
@@ -73,7 +85,11 @@ describe("resolveCatalogOwners", () => {
7385
vi.mocked(selectAccounts).mockResolvedValue([{id: org,name: "Duetti"}]asnever);
7486
vi.mocked(selectAccountInfos).mockResolvedValue([]);
7587

76-
constowners=awaitresolveCatalogOwners({catalogIds: [catalogA],organizationIds: [org]});
88+
constowners=awaitresolveCatalogOwners({
89+
catalogIds: [catalogA],
90+
ownerIds: [person,org],
91+
organizationIds: [org],
92+
});
7793

7894
expect(owners.get(catalogA)?.id).toBe(org);
7995
expect(owners.get(catalogA)?.is_organization).toBe(true);
@@ -84,7 +100,11 @@ describe("resolveCatalogOwners", () => {
84100
vi.mocked(selectAccounts).mockResolvedValue([{id: org,name: "Recoup"}]asnever);
85101
vi.mocked(selectAccountInfos).mockResolvedValue([{account_id: org,image: null}]asnever);
86102

87-
constowners=awaitresolveCatalogOwners({catalogIds: [catalogB],organizationIds: [org]});
103+
constowners=awaitresolveCatalogOwners({
104+
catalogIds: [catalogB],
105+
ownerIds: [person,org],
106+
organizationIds: [org],
107+
});
88108

89109
expect(owners.get(catalogB)).toEqual({
90110
id: org,
@@ -99,7 +119,11 @@ describe("resolveCatalogOwners", () => {
99119
vi.mocked(selectAccounts).mockResolvedValue([]);
100120
vi.mocked(selectAccountInfos).mockResolvedValue([]);
101121

102-
constowners=awaitresolveCatalogOwners({catalogIds: [catalogA],organizationIds: []});
122+
constowners=awaitresolveCatalogOwners({
123+
catalogIds: [catalogA],
124+
ownerIds: [person],
125+
organizationIds: [],
126+
});
103127

104128
expect(owners.get(catalogA)).toEqual({
105129
id: person,
@@ -108,4 +132,25 @@ describe("resolveCatalogOwners", () => {
108132
is_organization: false,
109133
});
110134
});
135+
136+
it("ignores owner links outside the set the caller reads through",async()=>{
137+
conststranger="550e8400-e29b-41d4-a716-446655440222";
138+
vi.mocked(selectCatalogOwnerLinks).mockResolvedValue([
139+
link(catalogA,stranger),
140+
link(catalogA,person),
141+
]);
142+
vi.mocked(selectAccounts).mockResolvedValue([{id: person,name: "Sweetman.eth"}]asnever);
143+
vi.mocked(selectAccountInfos).mockResolvedValue([]);
144+
145+
constowners=awaitresolveCatalogOwners({
146+
catalogIds: [catalogA],
147+
ownerIds: [person],
148+
organizationIds: [],
149+
});
150+
151+
// A catalog can be linked to accounts this caller has nothing to do with —
152+
// naming one would be wrong attribution and a disclosure of their identity.
153+
expect(owners.get(catalogA)?.id).toBe(person);
154+
expect(selectAccounts).toHaveBeenCalledWith([person]);
155+
});
111156
});

‎lib/catalog/getCatalogValuations.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const SPOTIFY_ALBUM_BATCH = 20;
1515
/** Requests in flight at once — fast enough for a list page, polite to the rate limit. */
1616
constSPOTIFY_CONCURRENCY=8;
1717

18+
/** Catalog ids per snapshots query, so one `.in()` can't outgrow PostgREST's URL limit. */
19+
constCATALOG_ID_BATCH=50;
20+
1821
/**
1922
* Value a set of catalogs in one pass — the list-page counterpart of the
2023
* single-catalog derivation in `getCatalogMeasurementsHandler`, using the same
@@ -23,11 +26,13 @@ const SPOTIFY_CONCURRENCY = 8;
2326
*
2427
* The naive version of this is `getCatalogEarliestReleaseDate` per catalog,
2528
* which is a snapshot query plus a Spotify round trip each. Instead the album
26-
* ids for every catalog are read in **one** snapshots query and fetched from
27-
* Spotify in **one** batched call, then each catalog takes the earliest release
28-
* date among its own albums. That leaves the per-catalog aggregate RPC — which
29-
* has no batched form — as the only work that scales with catalog count, and
30-
* those run concurrently.
29+
* ids for every catalog are read in one snapshots query per 50 catalogs and
30+
* fetched from Spotify 20 per request, 8 requests at a time, then each catalog
31+
* takes the earliest release date among its own albums. That leaves the
32+
* per-catalog aggregate RPC — which has no batched form — as the only work that
33+
* scales one-for-one with catalog count, and those run concurrently.
34+
*
35+
* Measured on a 41-catalog account: 730 deduped album ids, ~3.1s warm.
3136
*
3237
* Best-effort on the age input, exactly like the single-catalog path: no
3338
* snapshot, no album ids, or an unavailable Spotify all fall back to the
@@ -98,7 +103,17 @@ async function aggregateWithRetry(catalogId: string) {
98103
asyncfunctiongetEarliestReleaseDates(catalogIds: string[]): Promise<Map<string,string>>{
99104
constearliest=newMap<string,string>();
100105

101-
constsnapshots=awaitselectPlaycountSnapshots({catalogs: catalogIds});
106+
// One .in() with every catalog id would eventually exceed PostgREST's URL
107+
// limit, and selectPlaycountSnapshots reports that failure as [] — which here
108+
// would silently mean "no release dates", i.e. every band computed at the
109+
// default age. Chunked so the request length stays bounded.
110+
constcatalogChunks: string[][]=[];
111+
for(leti=0;i<catalogIds.length;i+=CATALOG_ID_BATCH){
112+
catalogChunks.push(catalogIds.slice(i,i+CATALOG_ID_BATCH));
113+
}
114+
constsnapshots=(
115+
awaitPromise.all(catalogChunks.map(catalogs=>selectPlaycountSnapshots({ catalogs })))
116+
).flat();
102117
constalbumIdsByCatalog=newMap<string,string[]>();
103118
for(constsnapshotofsnapshots){
104119
// selectPlaycountSnapshots is newest-first, so the first snapshot carrying

‎lib/catalog/getCatalogsHandler.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function getCatalogsHandler(
4444
constorganizationIds=ownerIds.filter(ownerId=>ownerId!==validated.accountId);
4545
const[valuations,owners]=awaitPromise.all([
4646
getCatalogValuations(catalogIds),
47-
resolveCatalogOwners({ catalogIds, organizationIds }),
47+
resolveCatalogOwners({ catalogIds,ownerIds,organizationIds }),
4848
]);
4949

5050
returnNextResponse.json(

‎lib/catalog/resolveCatalogOwners.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,37 +22,48 @@ export type CatalogOwner = {
2222
* is one of the caller's organizations, the same set `getCatalogOwnerIds`
2323
* resolves for visibility (chat#1938).
2424
*
25+
* **Only owners the caller was authorized through are eligible.** A catalog can
26+
* be linked to accounts that have nothing to do with this caller — one catalog
27+
* on prod carries four owner links — and naming a stranger on the card would be
28+
* both wrong attribution and a disclosure of another account's name and avatar.
29+
* Links outside `ownerIds` are dropped before the organization tie-break.
30+
*
2531
* Three batched reads regardless of how many catalogs are passed.
2632
*
2733
* @param params.catalogIds - Catalogs to resolve owners for
28-
* @param params.organizationIds - The caller's organization ids
34+
* @param params.ownerIds - The owner set the caller reads through: their account plus its organizations
35+
* @param params.organizationIds - The caller's organization ids, a subset of ownerIds
2936
* @returns catalog id → owner
3037
*/
3138
exportasyncfunctionresolveCatalogOwners({
3239
catalogIds,
40+
ownerIds,
3341
organizationIds,
3442
}: {
3543
catalogIds: string[];
44+
ownerIds: string[];
3645
organizationIds: string[];
3746
}): Promise<Map<string,CatalogOwner>>{
3847
constowners=newMap<string,CatalogOwner>();
3948
if(!catalogIds.length)returnowners;
4049

4150
constlinks=awaitselectCatalogOwnerLinks(catalogIds);
4251
constorganizations=newSet(organizationIds);
52+
constauthorized=newSet(ownerIds);
4353

4454
constownerIdByCatalog=newMap<string,string>();
4555
for(constlinkoflinks){
56+
if(!authorized.has(link.account))continue;
4657
constcurrent=ownerIdByCatalog.get(link.catalog);
4758
if(!current||(organizations.has(link.account)&&!organizations.has(current))){
4859
ownerIdByCatalog.set(link.catalog,link.account);
4960
}
5061
}
5162

52-
constownerIds=[...newSet(ownerIdByCatalog.values())];
63+
constresolvedOwnerIds=[...newSet(ownerIdByCatalog.values())];
5364
const[accounts,infos]=awaitPromise.all([
54-
selectAccounts(ownerIds),
55-
selectAccountInfos(ownerIds),
65+
selectAccounts(resolvedOwnerIds),
66+
selectAccountInfos(resolvedOwnerIds),
5667
]);
5768
constnameById=newMap(accounts.map(account=>[account.id,account.name??null]));
5869
constimageById=newMap(infos.map(info=>[info.account_id,info.image??null]));

‎lib/supabase/account_info/selectAccountInfos.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
importsupabasefrom"../serverClient";
22
importtype{Tables}from"@/types/database.types";
33

4+
/** Only what an avatar needs — `account_info` also carries knowledge blobs and instructions. */
5+
exporttypeAccountInfoAvatar=Pick<Tables<"account_info">,"account_id"|"image">;
6+
47
/**
58
* Retrieves `account_info` rows for several accounts at once — the plural
69
* sibling of `selectAccountInfo`, which reads one account and is the wrong
710
* shape for a list response that would otherwise issue a query per row.
811
*
12+
* Projects `account_id` and `image` only: `account_info` also holds knowledge
13+
* entries and AI instructions, which a list of avatars has no use for and would
14+
* pay for in payload and deserialization.
15+
*
916
* @param accountIds - The account IDs to read info for
1017
* @returns The matching rows, or [] when none were asked for or the query fails
1118
*/
12-
exportasyncfunctionselectAccountInfos(accountIds: string[]): Promise<Tables<"account_info">[]>{
19+
exportasyncfunctionselectAccountInfos(accountIds: string[]): Promise<AccountInfoAvatar[]>{
1320
if(!accountIds.length)return[];
1421

1522
const{ data, error }=awaitsupabase
1623
.from("account_info")
17-
.select("*")
24+
.select("account_id, image")
1825
.in("account_id",accountIds);
1926

2027
if(error){

0 commit comments

Comments
 (0)