@theihtisham/mcp-server-firebase 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +79 -0
  5. package/dist/services/firebase.d.ts +14 -0
  6. package/dist/services/firebase.js +163 -0
  7. package/dist/tools/auth.d.ts +3 -0
  8. package/dist/tools/auth.js +346 -0
  9. package/dist/tools/firestore.d.ts +3 -0
  10. package/dist/tools/firestore.js +802 -0
  11. package/dist/tools/functions.d.ts +3 -0
  12. package/dist/tools/functions.js +168 -0
  13. package/dist/tools/index.d.ts +10 -0
  14. package/dist/tools/index.js +30 -0
  15. package/dist/tools/messaging.d.ts +3 -0
  16. package/dist/tools/messaging.js +296 -0
  17. package/dist/tools/realtime-db.d.ts +4 -0
  18. package/dist/tools/realtime-db.js +271 -0
  19. package/dist/tools/storage.d.ts +3 -0
  20. package/dist/tools/storage.js +279 -0
  21. package/dist/tools/types.d.ts +11 -0
  22. package/dist/tools/types.js +3 -0
  23. package/dist/utils/cache.d.ts +16 -0
  24. package/dist/utils/cache.js +75 -0
  25. package/dist/utils/errors.d.ts +15 -0
  26. package/dist/utils/errors.js +94 -0
  27. package/dist/utils/index.d.ts +5 -0
  28. package/dist/utils/index.js +37 -0
  29. package/dist/utils/pagination.d.ts +28 -0
  30. package/dist/utils/pagination.js +75 -0
  31. package/dist/utils/validation.d.ts +22 -0
  32. package/dist/utils/validation.js +172 -0
  33. package/package.json +53 -0
  34. package/src/index.ts +94 -0
  35. package/src/services/firebase.ts +140 -0
  36. package/src/tools/auth.ts +375 -0
  37. package/src/tools/firestore.ts +931 -0
  38. package/src/tools/functions.ts +189 -0
  39. package/src/tools/index.ts +24 -0
  40. package/src/tools/messaging.ts +324 -0
  41. package/src/tools/realtime-db.ts +307 -0
  42. package/src/tools/storage.ts +314 -0
  43. package/src/tools/types.ts +10 -0
  44. package/src/utils/cache.ts +82 -0
  45. package/src/utils/errors.ts +110 -0
  46. package/src/utils/index.ts +4 -0
  47. package/src/utils/pagination.ts +105 -0
  48. package/src/utils/validation.ts +212 -0
  49. package/tests/cache.test.ts +139 -0
  50. package/tests/errors.test.ts +132 -0
  51. package/tests/firebase-service.test.ts +46 -0
  52. package/tests/pagination.test.ts +26 -0
  53. package/tests/tools.test.ts +226 -0
  54. package/tests/validation.test.ts +216 -0
  55. package/tsconfig.json +26 -0
  56. package/vitest.config.ts +15 -0
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FirebaseToolError, handleFirebaseError, formatSuccess, formatListResult } from '../src/utils/errors.js';
3
+
4
+ describe('FirebaseToolError', () => {
5
+ it('creates error with context', () => {
6
+ const err = new FirebaseToolError('test error', {
7
+ service: 'firestore',
8
+ operation: 'query',
9
+ });
10
+ expect(err.message).toBe('test error');
11
+ expect(err.context.service).toBe('firestore');
12
+ expect(err.context.operation).toBe('query');
13
+ });
14
+
15
+ it('formats structured message with suggestion', () => {
16
+ const err = new FirebaseToolError('not found', {
17
+ service: 'firestore',
18
+ operation: 'get_document',
19
+ suggestion: 'Check the document path.',
20
+ details: 'Document does not exist.',
21
+ });
22
+ const msg = err.toStructuredMessage();
23
+ expect(msg).toContain('[firestore/get_document]');
24
+ expect(msg).toContain('not found');
25
+ expect(msg).toContain('Suggestion: Check the document path.');
26
+ expect(msg).toContain('Details: Document does not exist.');
27
+ });
28
+
29
+ it('formats structured message without optional fields', () => {
30
+ const err = new FirebaseToolError('simple error', {
31
+ service: 'auth',
32
+ operation: 'create_user',
33
+ });
34
+ const msg = err.toStructuredMessage();
35
+ expect(msg).toContain('[auth/create_user]');
36
+ expect(msg).toContain('simple error');
37
+ expect(msg).not.toContain('Suggestion:');
38
+ expect(msg).not.toContain('Details:');
39
+ });
40
+ });
41
+
42
+ describe('handleFirebaseError', () => {
43
+ it('re-throws FirebaseToolError as-is', () => {
44
+ const original = new FirebaseToolError('original', {
45
+ service: 'firestore',
46
+ operation: 'query',
47
+ });
48
+ expect(() => handleFirebaseError(original, 'auth', 'create')).toThrow(original);
49
+ });
50
+
51
+ it('wraps permission-denied errors with suggestion', () => {
52
+ try {
53
+ handleFirebaseError(
54
+ Object.assign(new Error('Permission denied'), { code: 'permission-denied' }),
55
+ 'firestore',
56
+ 'query'
57
+ );
58
+ } catch (err) {
59
+ expect(err).toBeInstanceOf(FirebaseToolError);
60
+ const fte = err as FirebaseToolError;
61
+ expect(fte.context.suggestion).toContain('IAM permissions');
62
+ }
63
+ });
64
+
65
+ it('wraps not-found errors with suggestion', () => {
66
+ try {
67
+ handleFirebaseError(
68
+ Object.assign(new Error('Not found'), { code: 'not-found' }),
69
+ 'firestore',
70
+ 'get'
71
+ );
72
+ } catch (err) {
73
+ expect(err).toBeInstanceOf(FirebaseToolError);
74
+ const fte = err as FirebaseToolError;
75
+ expect(fte.context.suggestion).toContain('Verify that the resource');
76
+ }
77
+ });
78
+
79
+ it('wraps unauthenticated errors with suggestion', () => {
80
+ try {
81
+ handleFirebaseError(
82
+ Object.assign(new Error('Unauthenticated'), { code: 'unauthenticated' }),
83
+ 'firestore',
84
+ 'get'
85
+ );
86
+ } catch (err) {
87
+ expect(err).toBeInstanceOf(FirebaseToolError);
88
+ const fte = err as FirebaseToolError;
89
+ expect(fte.context.suggestion).toContain('Service account');
90
+ }
91
+ });
92
+
93
+ it('wraps unknown errors without specific suggestion', () => {
94
+ try {
95
+ handleFirebaseError(new Error('Something went wrong'), 'firestore', 'query');
96
+ } catch (err) {
97
+ expect(err).toBeInstanceOf(FirebaseToolError);
98
+ const fte = err as FirebaseToolError;
99
+ expect(fte.context.suggestion).toBeUndefined();
100
+ }
101
+ });
102
+ });
103
+
104
+ describe('formatSuccess', () => {
105
+ it('formats success response', () => {
106
+ const result = formatSuccess({ id: '123' }, 'create');
107
+ const parsed = JSON.parse(result);
108
+ expect(parsed.success).toBe(true);
109
+ expect(parsed.operation).toBe('create');
110
+ expect(parsed.data).toEqual({ id: '123' });
111
+ expect(parsed.timestamp).toBeDefined();
112
+ });
113
+ });
114
+
115
+ describe('formatListResult', () => {
116
+ it('formats list response without pagination', () => {
117
+ const result = formatListResult([{ id: '1' }, { id: '2' }]);
118
+ const parsed = JSON.parse(result);
119
+ expect(parsed.success).toBe(true);
120
+ expect(parsed.count).toBe(2);
121
+ expect(parsed.items).toHaveLength(2);
122
+ expect(parsed.nextPageToken).toBeUndefined();
123
+ });
124
+
125
+ it('formats list response with pagination', () => {
126
+ const result = formatListResult([{ id: '1' }], 'next_token', 100);
127
+ const parsed = JSON.parse(result);
128
+ expect(parsed.count).toBe(1);
129
+ expect(parsed.nextPageToken).toBe('next_token');
130
+ expect(parsed.totalCount).toBe(100);
131
+ });
132
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { validateRtdbPath } from '../src/tools/realtime-db.js';
3
+
4
+ // We can't test the actual Firebase initialization without credentials,
5
+ // but we test the validation functions that are exported from tool files.
6
+
7
+ describe('Realtime DB path validation', () => {
8
+ it('accepts valid paths', () => {
9
+ expect(validateRtdbPath('/users')).toBe('/users');
10
+ expect(validateRtdbPath('/users/uid123')).toBe('/users/uid123');
11
+ expect(validateRtdbPath('/')).toBe('/');
12
+ });
13
+
14
+ it('rejects empty path', () => {
15
+ expect(() => validateRtdbPath('')).toThrow('cannot be empty');
16
+ expect(() => validateRtdbPath(' ')).toThrow('cannot be empty');
17
+ });
18
+
19
+ it('rejects paths without leading slash', () => {
20
+ expect(() => validateRtdbPath('users')).toThrow('must start with "/"');
21
+ });
22
+
23
+ it('rejects paths with double slashes', () => {
24
+ expect(() => validateRtdbPath('/users//uid')).toThrow('must not contain "//"');
25
+ });
26
+
27
+ it('rejects path traversal', () => {
28
+ expect(() => validateRtdbPath('/users/../admin')).toThrow('must not contain ".."');
29
+ });
30
+
31
+ it('rejects segments with dots', () => {
32
+ expect(() => validateRtdbPath('/users/name.test')).toThrow('must not contain "."');
33
+ });
34
+
35
+ it('rejects segments with dollar signs', () => {
36
+ expect(() => validateRtdbPath('/users/$uid')).toThrow('must not contain "$"');
37
+ });
38
+
39
+ it('rejects segments with hash', () => {
40
+ expect(() => validateRtdbPath('/users/#priority')).toThrow('must not contain "#"');
41
+ });
42
+
43
+ it('rejects segments with brackets', () => {
44
+ expect(() => validateRtdbPath('/users/[uid]')).toThrow('must not contain "["');
45
+ });
46
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { encodePageToken, decodePageToken } from '../src/utils/pagination.js';
3
+
4
+ describe('encodePageToken / decodePageToken', () => {
5
+ it('round-trips a document path', () => {
6
+ const path = 'users/uid123/orders/order456';
7
+ const token = encodePageToken(path);
8
+ expect(decodePageToken(token)).toBe(path);
9
+ });
10
+
11
+ it('produces base64 strings', () => {
12
+ const token = encodePageToken('users/abc');
13
+ // Base64 strings only contain A-Z, a-z, 0-9, +, /, =
14
+ expect(/^[A-Za-z0-9+/=]+$/.test(token)).toBe(true);
15
+ });
16
+
17
+ it('handles paths with special characters', () => {
18
+ const path = 'my-collection/doc_123';
19
+ expect(decodePageToken(encodePageToken(path))).toBe(path);
20
+ });
21
+
22
+ it('handles empty path', () => {
23
+ const path = '';
24
+ expect(decodePageToken(encodePageToken(path))).toBe(path);
25
+ });
26
+ });
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { allTools } from '../src/tools/index.js';
3
+ import {
4
+ firestoreTools,
5
+ authTools,
6
+ storageTools,
7
+ realtimeDbTools,
8
+ functionsTools,
9
+ messagingTools,
10
+ } from '../src/tools/index.js';
11
+
12
+ describe('Tool registration', () => {
13
+ it('registers at least 30 tools total', () => {
14
+ expect(allTools.length).toBeGreaterThanOrEqual(30);
15
+ });
16
+
17
+ it('every tool has required fields', () => {
18
+ for (const tool of allTools) {
19
+ expect(tool.name).toBeTruthy();
20
+ expect(tool.description).toBeTruthy();
21
+ expect(tool.inputSchema).toBeDefined();
22
+ expect(tool.inputSchema.type).toBe('object');
23
+ expect(tool.inputSchema.properties).toBeDefined();
24
+ expect(typeof tool.handler).toBe('function');
25
+ }
26
+ });
27
+
28
+ it('tool names follow service prefix convention', () => {
29
+ const prefixes = ['firestore_', 'auth_', 'storage_', 'rtdb_', 'functions_', 'messaging_'];
30
+ for (const tool of allTools) {
31
+ const hasPrefix = prefixes.some((p) => tool.name.startsWith(p));
32
+ expect(hasPrefix, `Tool "${tool.name}" should start with a service prefix`).toBe(true);
33
+ }
34
+ });
35
+
36
+ it('no duplicate tool names', () => {
37
+ const names = allTools.map((t) => t.name);
38
+ const unique = new Set(names);
39
+ expect(unique.size).toBe(names.length);
40
+ });
41
+ });
42
+
43
+ describe('Firestore tools', () => {
44
+ it('has all required tools', () => {
45
+ const names = new Set(firestoreTools.map((t) => t.name));
46
+ expect(names.has('firestore_query')).toBe(true);
47
+ expect(names.has('firestore_get_document')).toBe(true);
48
+ expect(names.has('firestore_add_document')).toBe(true);
49
+ expect(names.has('firestore_set_document')).toBe(true);
50
+ expect(names.has('firestore_update_document')).toBe(true);
51
+ expect(names.has('firestore_delete_document')).toBe(true);
52
+ expect(names.has('firestore_batch_write')).toBe(true);
53
+ expect(names.has('firestore_transaction')).toBe(true);
54
+ expect(names.has('firestore_list_collections')).toBe(true);
55
+ expect(names.has('firestore_list_subcollections')).toBe(true);
56
+ expect(names.has('firestore_aggregate_query')).toBe(true);
57
+ expect(names.has('firestore_listen_changes')).toBe(true);
58
+ expect(names.has('firestore_infer_schema')).toBe(true);
59
+ });
60
+ });
61
+
62
+ describe('Auth tools', () => {
63
+ it('has all required tools', () => {
64
+ const names = new Set(authTools.map((t) => t.name));
65
+ expect(names.has('auth_create_user')).toBe(true);
66
+ expect(names.has('auth_get_user')).toBe(true);
67
+ expect(names.has('auth_list_users')).toBe(true);
68
+ expect(names.has('auth_update_user')).toBe(true);
69
+ expect(names.has('auth_delete_user')).toBe(true);
70
+ expect(names.has('auth_verify_token')).toBe(true);
71
+ expect(names.has('auth_set_custom_claims')).toBe(true);
72
+ });
73
+ });
74
+
75
+ describe('Storage tools', () => {
76
+ it('has all required tools', () => {
77
+ const names = new Set(storageTools.map((t) => t.name));
78
+ expect(names.has('storage_upload_file')).toBe(true);
79
+ expect(names.has('storage_download_file')).toBe(true);
80
+ expect(names.has('storage_list_files')).toBe(true);
81
+ expect(names.has('storage_delete_file')).toBe(true);
82
+ expect(names.has('storage_get_signed_url')).toBe(true);
83
+ expect(names.has('storage_get_metadata')).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe('Realtime DB tools', () => {
88
+ it('has all required tools', () => {
89
+ const names = new Set(realtimeDbTools.map((t) => t.name));
90
+ expect(names.has('rtdb_get_data')).toBe(true);
91
+ expect(names.has('rtdb_set_data')).toBe(true);
92
+ expect(names.has('rtdb_push_data')).toBe(true);
93
+ expect(names.has('rtdb_update_data')).toBe(true);
94
+ expect(names.has('rtdb_remove_data')).toBe(true);
95
+ expect(names.has('rtdb_query_data')).toBe(true);
96
+ });
97
+ });
98
+
99
+ describe('Functions tools', () => {
100
+ it('has all required tools', () => {
101
+ const names = new Set(functionsTools.map((t) => t.name));
102
+ expect(names.has('functions_list')).toBe(true);
103
+ expect(names.has('functions_trigger')).toBe(true);
104
+ expect(names.has('functions_get_logs')).toBe(true);
105
+ });
106
+ });
107
+
108
+ describe('Messaging tools', () => {
109
+ it('has all required tools', () => {
110
+ const names = new Set(messagingTools.map((t) => t.name));
111
+ expect(names.has('messaging_send')).toBe(true);
112
+ expect(names.has('messaging_send_multicast')).toBe(true);
113
+ expect(names.has('messaging_subscribe_topic')).toBe(true);
114
+ expect(names.has('messaging_unsubscribe_topic')).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe('Tool input validation (without Firebase connection)', () => {
119
+ it('firestore_query rejects invalid collection', async () => {
120
+ const tool = allTools.find((t) => t.name === 'firestore_query')!;
121
+ await expect(tool.handler({ collection: '' })).rejects.toThrow('cannot be empty');
122
+ });
123
+
124
+ it('firestore_get_document rejects invalid path', async () => {
125
+ const tool = allTools.find((t) => t.name === 'firestore_get_document')!;
126
+ await expect(tool.handler({ path: '' })).rejects.toThrow('cannot be empty');
127
+ });
128
+
129
+ it('firestore_add_document rejects empty collection', async () => {
130
+ const tool = allTools.find((t) => t.name === 'firestore_add_document')!;
131
+ await expect(tool.handler({ collection: '', data: {} })).rejects.toThrow('cannot be empty');
132
+ });
133
+
134
+ it('firestore_batch_write rejects empty operations', async () => {
135
+ const tool = allTools.find((t) => t.name === 'firestore_batch_write')!;
136
+ await expect(tool.handler({ operations: [] })).rejects.toThrow('cannot be empty');
137
+ });
138
+
139
+ it('firestore_batch_write rejects too many operations', async () => {
140
+ const tool = allTools.find((t) => t.name === 'firestore_batch_write')!;
141
+ const ops = Array.from({ length: 501 }, (_, i) => ({
142
+ type: 'delete' as const,
143
+ path: `col/doc${i}`,
144
+ }));
145
+ await expect(tool.handler({ operations: ops })).rejects.toThrow('exceeds maximum');
146
+ });
147
+
148
+ it('auth_create_user rejects short password', async () => {
149
+ const tool = allTools.find((t) => t.name === 'auth_create_user')!;
150
+ await expect(
151
+ tool.handler({ email: 'test@test.com', password: '12345' })
152
+ ).rejects.toThrow('at least 6 characters');
153
+ });
154
+
155
+ it('auth_delete_user rejects empty uid', async () => {
156
+ const tool = allTools.find((t) => t.name === 'auth_delete_user')!;
157
+ await expect(tool.handler({ uid: '' })).rejects.toThrow('cannot be empty');
158
+ });
159
+
160
+ it('storage_upload_file rejects empty base64', async () => {
161
+ const tool = allTools.find((t) => t.name === 'storage_upload_file')!;
162
+ await expect(
163
+ tool.handler({ path: 'test.txt', contentBase64: ' ' })
164
+ ).rejects.toThrow('cannot be empty');
165
+ });
166
+
167
+ it('storage_upload_file rejects path traversal', async () => {
168
+ const tool = allTools.find((t) => t.name === 'storage_upload_file')!;
169
+ await expect(
170
+ tool.handler({ path: '../secret', contentBase64: 'dGVzdA==' })
171
+ ).rejects.toThrow('must not start with ".."');
172
+ });
173
+
174
+ it('rtdb_get_data rejects path without leading slash', async () => {
175
+ const tool = allTools.find((t) => t.name === 'rtdb_get_data')!;
176
+ await expect(tool.handler({ path: 'no-slash' })).rejects.toThrow('must start with "/"');
177
+ });
178
+
179
+ it('rtdb_get_data rejects path with double slashes', async () => {
180
+ const tool = allTools.find((t) => t.name === 'rtdb_get_data')!;
181
+ await expect(tool.handler({ path: '/users//uid' })).rejects.toThrow('must not contain "//"');
182
+ });
183
+
184
+ it('messaging_send_multicast rejects too many tokens', async () => {
185
+ const tool = allTools.find((t) => t.name === 'messaging_send_multicast')!;
186
+ const tokens = Array.from({ length: 501 }, () => 'token');
187
+ await expect(
188
+ tool.handler({ tokens })
189
+ ).rejects.toThrow('Too many tokens');
190
+ });
191
+
192
+ it('messaging_subscribe_topic rejects invalid topic name', async () => {
193
+ const tool = allTools.find((t) => t.name === 'messaging_subscribe_topic')!;
194
+ await expect(
195
+ tool.handler({ tokens: ['tok'], topic: 'invalid topic!' })
196
+ ).rejects.toThrow('Invalid topic name');
197
+ });
198
+
199
+ it('firestore_aggregate_query rejects sum without field', async () => {
200
+ const tool = allTools.find((t) => t.name === 'firestore_aggregate_query')!;
201
+ await expect(
202
+ tool.handler({ collection: 'users', aggregations: [{ type: 'sum' }] })
203
+ ).rejects.toThrow('requires a "field" parameter');
204
+ });
205
+
206
+ it('firestore_aggregate_query rejects avg without field', async () => {
207
+ const tool = allTools.find((t) => t.name === 'firestore_aggregate_query')!;
208
+ await expect(
209
+ tool.handler({ collection: 'users', aggregations: [{ type: 'avg' }] })
210
+ ).rejects.toThrow('requires a "field" parameter');
211
+ });
212
+
213
+ it('storage_get_signed_url rejects too-short expiry', async () => {
214
+ const tool = allTools.find((t) => t.name === 'storage_get_signed_url')!;
215
+ await expect(
216
+ tool.handler({ path: 'test.txt', expiresInMs: 1000 })
217
+ ).rejects.toThrow('at least 60 seconds');
218
+ });
219
+
220
+ it('storage_get_signed_url rejects too-long expiry', async () => {
221
+ const tool = allTools.find((t) => t.name === 'storage_get_signed_url')!;
222
+ await expect(
223
+ tool.handler({ path: 'test.txt', expiresInMs: 8 * 24 * 3600000 })
224
+ ).rejects.toThrow('more than 7 days');
225
+ });
226
+ });
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ validateCollectionPath,
4
+ validateDocumentPath,
5
+ validateStoragePath,
6
+ validateEmail,
7
+ validateUid,
8
+ validateLimit,
9
+ validatePageSize,
10
+ validateWhereField,
11
+ validateOperator,
12
+ sanitizeData,
13
+ } from '../src/utils/validation.js';
14
+
15
+ describe('validateCollectionPath', () => {
16
+ it('accepts simple collection names', () => {
17
+ expect(validateCollectionPath('users')).toBe('users');
18
+ expect(validateCollectionPath('orders-2024')).toBe('orders-2024');
19
+ expect(validateCollectionPath('my_collection')).toBe('my_collection');
20
+ });
21
+
22
+ it('accepts nested collection paths', () => {
23
+ expect(validateCollectionPath('users/uid123/orders')).toBe('users/uid123/orders');
24
+ expect(validateCollectionPath('a/b/c')).toBe('a/b/c');
25
+ });
26
+
27
+ it('rejects empty path', () => {
28
+ expect(() => validateCollectionPath('')).toThrow('cannot be empty');
29
+ expect(() => validateCollectionPath(' ')).toThrow('cannot be empty');
30
+ });
31
+
32
+ it('rejects paths with special characters', () => {
33
+ expect(() => validateCollectionPath('users$')).toThrow('Invalid collection path');
34
+ expect(() => validateCollectionPath('my.collection')).toThrow('Invalid collection path');
35
+ });
36
+
37
+ it('rejects overly long paths', () => {
38
+ const longPath = 'a'.repeat(1501);
39
+ expect(() => validateCollectionPath(longPath)).toThrow('exceeds maximum length');
40
+ });
41
+ });
42
+
43
+ describe('validateDocumentPath', () => {
44
+ it('accepts valid document paths', () => {
45
+ expect(validateDocumentPath('users/uid123')).toBe('users/uid123');
46
+ expect(validateDocumentPath('users/uid123/orders/order1')).toBe('users/uid123/orders/order1');
47
+ });
48
+
49
+ it('rejects odd number of segments', () => {
50
+ expect(() => validateDocumentPath('users')).toThrow('even number of segments');
51
+ expect(() => validateDocumentPath('users/uid123/orders')).toThrow('even number of segments');
52
+ });
53
+
54
+ it('rejects empty path', () => {
55
+ expect(() => validateDocumentPath('')).toThrow('cannot be empty');
56
+ });
57
+
58
+ it('rejects paths with special characters', () => {
59
+ expect(() => validateDocumentPath('users/uid$123')).toThrow('Invalid segment');
60
+ });
61
+ });
62
+
63
+ describe('validateStoragePath', () => {
64
+ it('accepts valid storage paths', () => {
65
+ expect(validateStoragePath('images/photo.jpg')).toBe('images/photo.jpg');
66
+ expect(validateStoragePath('documents/report.pdf')).toBe('documents/report.pdf');
67
+ });
68
+
69
+ it('rejects paths starting with slash', () => {
70
+ expect(() => validateStoragePath('/etc/passwd')).toThrow('must not start with "/"');
71
+ });
72
+
73
+ it('rejects path traversal', () => {
74
+ expect(() => validateStoragePath('../secret')).toThrow('must not start with ".."');
75
+ expect(() => validateStoragePath('images/../secret')).toThrow('must not contain ".."');
76
+ });
77
+
78
+ it('rejects empty path', () => {
79
+ expect(() => validateStoragePath('')).toThrow('cannot be empty');
80
+ });
81
+ });
82
+
83
+ describe('validateEmail', () => {
84
+ it('accepts valid emails', () => {
85
+ expect(validateEmail('user@example.com')).toBe('user@example.com');
86
+ expect(validateEmail('User@Example.COM')).toBe('user@example.com');
87
+ });
88
+
89
+ it('rejects invalid emails', () => {
90
+ expect(() => validateEmail('notanemail')).toThrow('Invalid email');
91
+ expect(() => validateEmail('@example.com')).toThrow('Invalid email');
92
+ expect(() => validateEmail('user@')).toThrow('Invalid email');
93
+ });
94
+ });
95
+
96
+ describe('validateUid', () => {
97
+ it('accepts valid UIDs', () => {
98
+ expect(validateUid('abc123')).toBe('abc123');
99
+ expect(validateUid('a-b-c')).toBe('a-b-c');
100
+ });
101
+
102
+ it('rejects empty UIDs', () => {
103
+ expect(() => validateUid('')).toThrow('cannot be empty');
104
+ expect(() => validateUid(' ')).toThrow('cannot be empty');
105
+ });
106
+
107
+ it('rejects overly long UIDs', () => {
108
+ expect(() => validateUid('a'.repeat(129))).toThrow('exceeds maximum length');
109
+ });
110
+ });
111
+
112
+ describe('validateLimit', () => {
113
+ it('accepts valid limits', () => {
114
+ expect(validateLimit(1)).toBe(1);
115
+ expect(validateLimit(100)).toBe(100);
116
+ expect(validateLimit(10000, 10000)).toBe(10000);
117
+ });
118
+
119
+ it('rejects non-positive limits', () => {
120
+ expect(() => validateLimit(0)).toThrow('positive integer');
121
+ expect(() => validateLimit(-1)).toThrow('positive integer');
122
+ expect(() => validateLimit(1.5)).toThrow('positive integer');
123
+ });
124
+
125
+ it('rejects limits exceeding max', () => {
126
+ expect(() => validateLimit(10001)).toThrow('exceeds maximum');
127
+ });
128
+ });
129
+
130
+ describe('validatePageSize', () => {
131
+ it('accepts valid page sizes', () => {
132
+ expect(validatePageSize(1)).toBe(1);
133
+ expect(validatePageSize(100)).toBe(100);
134
+ });
135
+
136
+ it('rejects invalid page sizes', () => {
137
+ expect(() => validatePageSize(0)).toThrow('positive integer');
138
+ expect(() => validatePageSize(1001)).toThrow('exceeds maximum');
139
+ });
140
+ });
141
+
142
+ describe('validateWhereField', () => {
143
+ it('accepts valid field names', () => {
144
+ expect(validateWhereField('name')).toBe('name');
145
+ expect(validateWhereField('address.city')).toBe('address.city');
146
+ });
147
+
148
+ it('rejects fields starting with __', () => {
149
+ expect(() => validateWhereField('__internal')).toThrow('must not start with "__"');
150
+ });
151
+
152
+ it('rejects fields containing $', () => {
153
+ expect(() => validateWhereField('field$name')).toThrow('must not start with "__" or contain "$"');
154
+ });
155
+
156
+ it('rejects empty fields', () => {
157
+ expect(() => validateWhereField('')).toThrow('cannot be empty');
158
+ });
159
+ });
160
+
161
+ describe('validateOperator', () => {
162
+ it('accepts valid operators', () => {
163
+ const ops = ['==', '!=', '<', '<=', '>', '>=', 'array-contains', 'array-contains-any', 'in', 'not-in'];
164
+ for (const op of ops) {
165
+ expect(validateOperator(op)).toBe(op);
166
+ }
167
+ });
168
+
169
+ it('rejects invalid operators', () => {
170
+ expect(() => validateOperator('like')).toThrow('Invalid operator');
171
+ expect(() => validateOperator('=')).toThrow('Invalid operator');
172
+ });
173
+ });
174
+
175
+ describe('sanitizeData', () => {
176
+ it('passes through primitive values', () => {
177
+ expect(sanitizeData('hello')).toBe('hello');
178
+ expect(sanitizeData(42)).toBe(42);
179
+ expect(sanitizeData(true)).toBe(true);
180
+ expect(sanitizeData(null)).toBe(null);
181
+ });
182
+
183
+ it('sanitizes nested objects', () => {
184
+ const input = { name: 'test', count: 5 };
185
+ expect(sanitizeData(input)).toEqual({ name: 'test', count: 5 });
186
+ });
187
+
188
+ it('sanitizes arrays', () => {
189
+ const input = [1, 'two', { three: 3 }];
190
+ expect(sanitizeData(input)).toEqual([1, 'two', { three: 3 }]);
191
+ });
192
+
193
+ it('rejects keys starting with $', () => {
194
+ expect(() => sanitizeData({ $gt: 5 })).toThrow('must not start with "$"');
195
+ });
196
+
197
+ it('rejects keys containing dots', () => {
198
+ expect(() => sanitizeData({ 'a.b': 'value' })).toThrow('must not contain "."');
199
+ });
200
+
201
+ it('handles deeply nested objects', () => {
202
+ const input = {
203
+ user: {
204
+ profile: {
205
+ name: 'test',
206
+ },
207
+ },
208
+ };
209
+ expect(sanitizeData(input)).toEqual(input);
210
+ });
211
+
212
+ it('rejects nested injection attempts', () => {
213
+ const input = { data: { $where: 'evil' } };
214
+ expect(() => sanitizeData(input)).toThrow('must not start with "$"');
215
+ });
216
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "sourceMap": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "noImplicitReturns": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "exactOptionalPropertyTypes": false,
21
+ "noUncheckedIndexedAccess": true,
22
+ "lib": ["ES2022"]
23
+ },
24
+ "include": ["src/**/*"],
25
+ "exclude": ["node_modules", "dist", "tests"]
26
+ }