Skip to content

Commit 37c2171

Browse files
committed
improvements
1 parent dcf764c commit 37c2171

2 files changed

Lines changed: 127 additions & 6 deletions

File tree

packages/hypergraph/src/space/find-many-public.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Config } from '@graphprotocol/hypergraph';
33
import * as Either from 'effect/Either';
44
import * as EffectSchema from 'effect/Schema';
55
import { request } from 'graphql-request';
6+
import { parseGeoId } from '../utils/geo-id.js';
67

78
const spaceFields = `
89
id
@@ -127,19 +128,33 @@ export type FindManyPublicParams = Readonly<{
127128
filter?: FindManyPublicFilter;
128129
}>;
129130

130-
const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => {
131+
const validateSpaceType = (spaceType: SpaceType): SpaceType => {
132+
const result = EffectSchema.decodeUnknownEither(SpaceTypeSchema)(spaceType);
133+
if (Either.isLeft(result)) {
134+
throw new Error(`Invalid spaceType: ${spaceType}. Must be 'PERSONAL' or 'DAO'.`);
135+
}
136+
return result.right;
137+
};
138+
139+
export const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => {
131140
const conditions: string[] = [];
132141

133142
if (filter?.memberId) {
134-
conditions.push(`members: {some: {memberSpaceId: {is: "${filter.memberId}"}}}`);
143+
// Validate memberId is a valid GeoId to prevent injection attacks
144+
const validatedMemberId = parseGeoId(filter.memberId);
145+
conditions.push(`members: {some: {memberSpaceId: {is: "${validatedMemberId}"}}}`);
135146
}
136147

137148
if (filter?.editorId) {
138-
conditions.push(`editors: {some: {memberSpaceId: {is: "${filter.editorId}"}}}`);
149+
// Validate editorId is a valid GeoId to prevent injection attacks
150+
const validatedEditorId = parseGeoId(filter.editorId);
151+
conditions.push(`editors: {some: {memberSpaceId: {is: "${validatedEditorId}"}}}`);
139152
}
140153

141154
if (filter?.spaceType) {
142-
conditions.push(`type: {is: ${filter.spaceType}}`);
155+
// Validate spaceType at runtime to ensure it's a valid value
156+
const validatedSpaceType = validateSpaceType(filter.spaceType);
157+
conditions.push(`type: {is: ${validatedSpaceType}}`);
143158
}
144159

145160
if (conditions.length === 0) {
@@ -149,7 +164,7 @@ const buildFilterString = (filter?: FindManyPublicFilter): string | undefined =>
149164
return `filter: {${conditions.join(', ')}}`;
150165
};
151166

152-
const buildSpacesQuery = (filter?: FindManyPublicFilter): string => {
167+
export const buildSpacesQuery = (filter?: FindManyPublicFilter): string => {
153168
const filterString = buildFilterString(filter);
154169
const filterClause = filterString ? `(${filterString})` : '';
155170

packages/hypergraph/test/space/find-many-public.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { parseSpacesQueryResult } from '../../src/space/find-many-public.js';
2+
import { buildFilterString, buildSpacesQuery, parseSpacesQueryResult } from '../../src/space/find-many-public.js';
33

44
const buildQuerySpace = ({
55
id = 'space-id',
@@ -141,3 +141,109 @@ describe('parseSpacesQueryResult', () => {
141141
]);
142142
});
143143
});
144+
145+
describe('buildFilterString', () => {
146+
it('returns undefined when no filter is provided', () => {
147+
expect(buildFilterString()).toBeUndefined();
148+
expect(buildFilterString({})).toBeUndefined();
149+
});
150+
151+
it('builds filter string with memberId', () => {
152+
const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f' });
153+
expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}');
154+
});
155+
156+
it('builds filter string with editorId', () => {
157+
const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f' });
158+
expect(result).toBe('filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}');
159+
});
160+
161+
it('builds filter string with spaceType PERSONAL', () => {
162+
const result = buildFilterString({ spaceType: 'PERSONAL' });
163+
expect(result).toBe('filter: {type: {is: PERSONAL}}');
164+
});
165+
166+
it('builds filter string with spaceType DAO', () => {
167+
const result = buildFilterString({ spaceType: 'DAO' });
168+
expect(result).toBe('filter: {type: {is: DAO}}');
169+
});
170+
171+
it('builds filter string with memberId and spaceType', () => {
172+
const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' });
173+
expect(result).toBe(
174+
'filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}}',
175+
);
176+
});
177+
178+
it('builds filter string with editorId and spaceType', () => {
179+
const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'DAO' });
180+
expect(result).toBe(
181+
'filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: DAO}}',
182+
);
183+
});
184+
185+
it('normalizes UUID with dashes to dashless format', () => {
186+
const result = buildFilterString({ memberId: '1e5e39da-a00d-4fd8-b53b-98095337112f' });
187+
expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}');
188+
});
189+
190+
it('throws error for invalid memberId', () => {
191+
expect(() => buildFilterString({ memberId: 'invalid-id' })).toThrow('Invalid Geo ID');
192+
});
193+
194+
it('throws error for invalid editorId', () => {
195+
expect(() => buildFilterString({ editorId: 'invalid"; DROP TABLE spaces; --' })).toThrow('Invalid Geo ID');
196+
});
197+
198+
it('throws error for invalid spaceType', () => {
199+
// @ts-expect-error - testing runtime validation with invalid value
200+
expect(() => buildFilterString({ spaceType: 'INVALID' })).toThrow(
201+
"Invalid spaceType: INVALID. Must be 'PERSONAL' or 'DAO'.",
202+
);
203+
});
204+
});
205+
206+
describe('buildSpacesQuery', () => {
207+
it('builds query without filter', () => {
208+
const query = buildSpacesQuery();
209+
expect(query).toContain('query spaces {');
210+
// Check that the top-level spaces query doesn't have a filter (spaces { not spaces(filter:)
211+
expect(query).toMatch(/spaces\s*\{/);
212+
expect(query).not.toMatch(/spaces\s*\(filter:/);
213+
});
214+
215+
it('builds query with memberId filter', () => {
216+
const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f' });
217+
expect(query).toContain(
218+
'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})',
219+
);
220+
});
221+
222+
it('builds query with editorId filter', () => {
223+
const query = buildSpacesQuery({ editorId: '1e5e39daa00d4fd8b53b98095337112f' });
224+
expect(query).toContain(
225+
'spaces(filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})',
226+
);
227+
});
228+
229+
it('builds query with spaceType filter only', () => {
230+
const query = buildSpacesQuery({ spaceType: 'DAO' });
231+
expect(query).toContain('spaces(filter: {type: {is: DAO}})');
232+
});
233+
234+
it('builds query with combined filters', () => {
235+
const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' });
236+
expect(query).toContain(
237+
'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}})',
238+
);
239+
});
240+
241+
it('includes required space fields in query', () => {
242+
const query = buildSpacesQuery();
243+
expect(query).toContain('id');
244+
expect(query).toContain('type');
245+
expect(query).toContain('page {');
246+
expect(query).toContain('editorsList {');
247+
expect(query).toContain('membersList {');
248+
});
249+
});

0 commit comments

Comments
 (0)