@donotdev/functions 0.0.9 → 0.0.11

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 (80) hide show
  1. package/package.json +31 -7
  2. package/src/firebase/auth/setCustomClaims.ts +26 -4
  3. package/src/firebase/baseFunction.ts +43 -20
  4. package/src/firebase/billing/cancelSubscription.ts +9 -1
  5. package/src/firebase/billing/changePlan.ts +8 -2
  6. package/src/firebase/billing/createCustomerPortal.ts +16 -2
  7. package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
  8. package/src/firebase/billing/webhookHandler.ts +13 -1
  9. package/src/firebase/config/constants.ts +0 -3
  10. package/src/firebase/crud/aggregate.ts +20 -5
  11. package/src/firebase/crud/create.ts +31 -7
  12. package/src/firebase/crud/get.ts +16 -8
  13. package/src/firebase/crud/list.ts +70 -29
  14. package/src/firebase/crud/update.ts +29 -7
  15. package/src/firebase/oauth/exchangeToken.ts +30 -4
  16. package/src/firebase/oauth/githubAccess.ts +8 -3
  17. package/src/firebase/registerCrudFunctions.ts +15 -4
  18. package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
  19. package/src/shared/__tests__/detectFirestore.test.ts +52 -0
  20. package/src/shared/__tests__/errorHandling.test.ts +144 -0
  21. package/src/shared/__tests__/idempotency.test.ts +95 -0
  22. package/src/shared/__tests__/rateLimiter.test.ts +142 -0
  23. package/src/shared/__tests__/validation.test.ts +172 -0
  24. package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
  25. package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
  26. package/src/shared/billing/webhookHandler.ts +16 -7
  27. package/src/shared/errorHandling.ts +16 -54
  28. package/src/shared/firebase.ts +1 -25
  29. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  30. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  31. package/src/shared/schema.ts +7 -1
  32. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  33. package/src/shared/utils/external/subscription.ts +10 -0
  34. package/src/shared/utils/internal/auth.ts +140 -16
  35. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  36. package/src/shared/utils/internal/validation.ts +47 -3
  37. package/src/shared/utils.ts +154 -39
  38. package/src/supabase/auth/deleteAccount.ts +59 -0
  39. package/src/supabase/auth/getCustomClaims.ts +56 -0
  40. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  41. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  42. package/src/supabase/auth/setCustomClaims.ts +73 -0
  43. package/src/supabase/baseFunction.ts +302 -0
  44. package/src/supabase/billing/cancelSubscription.ts +57 -0
  45. package/src/supabase/billing/changePlan.ts +62 -0
  46. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  47. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  48. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  49. package/src/supabase/crud/aggregate.ts +169 -0
  50. package/src/supabase/crud/create.ts +225 -0
  51. package/src/supabase/crud/delete.ts +154 -0
  52. package/src/supabase/crud/get.ts +89 -0
  53. package/src/supabase/crud/index.ts +24 -0
  54. package/src/supabase/crud/list.ts +357 -0
  55. package/src/supabase/crud/update.ts +199 -0
  56. package/src/supabase/helpers/authProvider.ts +45 -0
  57. package/src/supabase/index.ts +73 -0
  58. package/src/supabase/registerCrudFunctions.ts +180 -0
  59. package/src/supabase/utils/idempotency.ts +141 -0
  60. package/src/supabase/utils/monitoring.ts +187 -0
  61. package/src/supabase/utils/rateLimiter.ts +216 -0
  62. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  63. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  64. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  65. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  66. package/src/vercel/api/billing/cancel.ts +2 -1
  67. package/src/vercel/api/billing/change-plan.ts +3 -1
  68. package/src/vercel/api/billing/customer-portal.ts +4 -1
  69. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  70. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  71. package/src/vercel/api/crud/create.ts +14 -8
  72. package/src/vercel/api/crud/delete.ts +15 -6
  73. package/src/vercel/api/crud/get.ts +16 -8
  74. package/src/vercel/api/crud/list.ts +22 -10
  75. package/src/vercel/api/crud/update.ts +16 -10
  76. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  77. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  78. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  79. package/src/vercel/api/utils/cors.ts +13 -2
  80. package/src/vercel/baseFunction.ts +40 -25
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock detectFirestore to return false (force in-memory)
4
+ vi.mock('../utils/detectFirestore.js', () => ({
5
+ isFirestoreConfigured: vi.fn(() => false),
6
+ }));
7
+
8
+ import {
9
+ createIdempotencyStore,
10
+ resetIdempotencyStore,
11
+ } from '../billing/idempotency';
12
+
13
+ describe('InMemoryIdempotency', () => {
14
+ beforeEach(() => {
15
+ resetIdempotencyStore();
16
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
17
+ vi.spyOn(console, 'log').mockImplementation(() => {});
18
+ });
19
+
20
+ it('creates in-memory store when Firestore not configured', () => {
21
+ const store = createIdempotencyStore();
22
+
23
+ expect(store).toBeDefined();
24
+ expect(typeof store.isProcessed).toBe('function');
25
+ expect(typeof store.markProcessed).toBe('function');
26
+ });
27
+
28
+ it('returns singleton instance', () => {
29
+ const store1 = createIdempotencyStore();
30
+ const store2 = createIdempotencyStore();
31
+
32
+ expect(store1).toBe(store2);
33
+ });
34
+
35
+ it('reports event as not processed initially', async () => {
36
+ const store = createIdempotencyStore();
37
+
38
+ const result = await store.isProcessed('evt_001');
39
+ expect(result).toBe(false);
40
+ });
41
+
42
+ it('reports event as processed after marking', async () => {
43
+ const store = createIdempotencyStore();
44
+
45
+ await store.markProcessed('evt_002');
46
+ const result = await store.isProcessed('evt_002');
47
+
48
+ expect(result).toBe(true);
49
+ });
50
+
51
+ it('distinguishes between different events', async () => {
52
+ const store = createIdempotencyStore();
53
+
54
+ await store.markProcessed('evt_a');
55
+
56
+ expect(await store.isProcessed('evt_a')).toBe(true);
57
+ expect(await store.isProcessed('evt_b')).toBe(false);
58
+ });
59
+
60
+ it('warns once on first isProcessed call', async () => {
61
+ const warnSpy = vi.spyOn(console, 'warn');
62
+ const store = createIdempotencyStore();
63
+
64
+ await store.isProcessed('evt_x');
65
+ await store.isProcessed('evt_y');
66
+
67
+ // Should warn at least once about in-memory usage
68
+ const inMemoryWarns = warnSpy.mock.calls.filter(
69
+ (call) => typeof call[0] === 'string' && call[0].includes('in-memory')
70
+ );
71
+ expect(inMemoryWarns.length).toBeGreaterThanOrEqual(1);
72
+ });
73
+
74
+ it('auto-cleans after 1000 entries (keeps last entries)', async () => {
75
+ const store = createIdempotencyStore();
76
+
77
+ // Add 1001 entries
78
+ for (let i = 0; i < 1001; i++) {
79
+ await store.markProcessed(`evt_${i}`);
80
+ }
81
+
82
+ // First entry should have been evicted
83
+ expect(await store.isProcessed('evt_0')).toBe(false);
84
+ // Recent entry should still exist
85
+ expect(await store.isProcessed('evt_1000')).toBe(true);
86
+ });
87
+
88
+ it('resetIdempotencyStore clears singleton', () => {
89
+ const store1 = createIdempotencyStore();
90
+ resetIdempotencyStore();
91
+ const store2 = createIdempotencyStore();
92
+
93
+ expect(store1).not.toBe(store2);
94
+ });
95
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock firebase-functions/v2 logger
4
+ vi.mock('firebase-functions/v2', () => ({
5
+ logger: {
6
+ warn: vi.fn(),
7
+ info: vi.fn(),
8
+ error: vi.fn(),
9
+ log: vi.fn(),
10
+ },
11
+ }));
12
+
13
+ // Mock @donotdev/firebase/server (not used in in-memory path, but imported)
14
+ vi.mock('@donotdev/firebase/server', () => ({
15
+ getFirebaseAdminFirestore: vi.fn(),
16
+ }));
17
+
18
+ import {
19
+ checkRateLimit,
20
+ resetRateLimit,
21
+ getRateLimitStatus,
22
+ DEFAULT_RATE_LIMITS,
23
+ } from '../utils/internal/rateLimiter';
24
+
25
+ const TEST_CONFIG = {
26
+ maxAttempts: 3,
27
+ windowMs: 60_000,
28
+ blockDurationMs: 300_000,
29
+ };
30
+
31
+ describe('checkRateLimit (in-memory)', () => {
32
+ beforeEach(() => {
33
+ // Reset rate limit store between tests
34
+ resetRateLimit('test-key');
35
+ resetRateLimit('other-key');
36
+ });
37
+
38
+ it('allows first request', async () => {
39
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
40
+
41
+ expect(result.allowed).toBe(true);
42
+ expect(result.remaining).toBe(2); // 3 max - 1 used
43
+ expect(result.blockRemainingSeconds).toBeNull();
44
+ });
45
+
46
+ it('decrements remaining on each request', async () => {
47
+ await checkRateLimit('test-key', TEST_CONFIG);
48
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
49
+
50
+ expect(result.allowed).toBe(true);
51
+ expect(result.remaining).toBe(1); // 3 max - 2 used
52
+ });
53
+
54
+ it('blocks after max attempts exceeded', async () => {
55
+ // Use all 3 attempts
56
+ await checkRateLimit('test-key', TEST_CONFIG);
57
+ await checkRateLimit('test-key', TEST_CONFIG);
58
+ await checkRateLimit('test-key', TEST_CONFIG);
59
+
60
+ // 4th request should be blocked
61
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
62
+
63
+ expect(result.allowed).toBe(false);
64
+ expect(result.remaining).toBe(0);
65
+ expect(result.blockRemainingSeconds).toBeGreaterThan(0);
66
+ expect(result.resetAt).toBeInstanceOf(Date);
67
+ });
68
+
69
+ it('isolates keys from each other', async () => {
70
+ await checkRateLimit('test-key', TEST_CONFIG);
71
+ await checkRateLimit('test-key', TEST_CONFIG);
72
+ await checkRateLimit('test-key', TEST_CONFIG);
73
+
74
+ // Different key should still be allowed
75
+ const result = await checkRateLimit('other-key', TEST_CONFIG);
76
+ expect(result.allowed).toBe(true);
77
+ expect(result.remaining).toBe(2);
78
+ });
79
+ });
80
+
81
+ describe('resetRateLimit', () => {
82
+ it('clears rate limit allowing new requests', async () => {
83
+ // Exhaust attempts
84
+ await checkRateLimit('test-key', TEST_CONFIG);
85
+ await checkRateLimit('test-key', TEST_CONFIG);
86
+ await checkRateLimit('test-key', TEST_CONFIG);
87
+
88
+ // Reset
89
+ resetRateLimit('test-key');
90
+
91
+ // Should be allowed again
92
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
93
+ expect(result.allowed).toBe(true);
94
+ expect(result.remaining).toBe(2);
95
+ });
96
+ });
97
+
98
+ describe('getRateLimitStatus', () => {
99
+ beforeEach(() => {
100
+ resetRateLimit('test-key');
101
+ });
102
+
103
+ it('returns full remaining for unknown key', () => {
104
+ const result = getRateLimitStatus('unknown-key', TEST_CONFIG);
105
+
106
+ expect(result.allowed).toBe(true);
107
+ expect(result.remaining).toBe(3);
108
+ expect(result.resetAt).toBeNull();
109
+ expect(result.blockRemainingSeconds).toBeNull();
110
+ });
111
+
112
+ it('reflects current usage without consuming attempt', async () => {
113
+ await checkRateLimit('test-key', TEST_CONFIG);
114
+ await checkRateLimit('test-key', TEST_CONFIG);
115
+
116
+ const status = getRateLimitStatus('test-key', TEST_CONFIG);
117
+ expect(status.remaining).toBe(1);
118
+
119
+ // Check again — should be same (no attempt consumed)
120
+ const status2 = getRateLimitStatus('test-key', TEST_CONFIG);
121
+ expect(status2.remaining).toBe(1);
122
+ });
123
+ });
124
+
125
+ describe('DEFAULT_RATE_LIMITS', () => {
126
+ it('has checkout config', () => {
127
+ expect(DEFAULT_RATE_LIMITS.checkout.maxAttempts).toBe(5);
128
+ expect(DEFAULT_RATE_LIMITS.checkout.windowMs).toBe(60_000);
129
+ });
130
+
131
+ it('has webhook config', () => {
132
+ expect(DEFAULT_RATE_LIMITS.webhook.maxAttempts).toBe(100);
133
+ });
134
+
135
+ it('has auth config', () => {
136
+ expect(DEFAULT_RATE_LIMITS.auth.maxAttempts).toBe(10);
137
+ });
138
+
139
+ it('has api config', () => {
140
+ expect(DEFAULT_RATE_LIMITS.api.maxAttempts).toBe(100);
141
+ });
142
+ });
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // Mock firebase-functions/v2 logger
4
+ vi.mock('firebase-functions/v2', () => ({
5
+ logger: {
6
+ warn: vi.fn(),
7
+ info: vi.fn(),
8
+ error: vi.fn(),
9
+ log: vi.fn(),
10
+ },
11
+ }));
12
+
13
+ import {
14
+ validateGitHubUsername,
15
+ validateEmail,
16
+ validateStripePriceId,
17
+ validateStripeSessionId,
18
+ validateUrl,
19
+ validateMetadata,
20
+ } from '../utils/validation';
21
+
22
+ describe('validateGitHubUsername', () => {
23
+ it('accepts valid usernames', () => {
24
+ expect(validateGitHubUsername('octocat')).toBe(true);
25
+ expect(validateGitHubUsername('user-name')).toBe(true);
26
+ expect(validateGitHubUsername('a')).toBe(true);
27
+ expect(validateGitHubUsername('user123')).toBe(true);
28
+ });
29
+
30
+ it('rejects invalid usernames', () => {
31
+ expect(validateGitHubUsername('')).toBe(false);
32
+ expect(validateGitHubUsername('-leadingdash')).toBe(false);
33
+ expect(validateGitHubUsername('has spaces')).toBe(false);
34
+ expect(validateGitHubUsername('special@char')).toBe(false);
35
+ });
36
+
37
+ it('rejects username exceeding 39 characters', () => {
38
+ const longName = 'a'.repeat(40);
39
+ expect(validateGitHubUsername(longName)).toBe(false);
40
+ });
41
+
42
+ it('accepts username at max 39 characters', () => {
43
+ const maxName = 'a'.repeat(39);
44
+ expect(validateGitHubUsername(maxName)).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('validateEmail', () => {
49
+ it('accepts valid emails', () => {
50
+ expect(validateEmail('user@example.com')).toBe(true);
51
+ expect(validateEmail('name+tag@domain.co')).toBe(true);
52
+ expect(validateEmail('a@b.c')).toBe(true);
53
+ });
54
+
55
+ it('rejects invalid emails', () => {
56
+ expect(validateEmail('')).toBe(false);
57
+ expect(validateEmail('noemail')).toBe(false);
58
+ expect(validateEmail('@missing.local')).toBe(false);
59
+ expect(validateEmail('missing@')).toBe(false);
60
+ expect(validateEmail('has space@test.com')).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('validateStripePriceId', () => {
65
+ it('accepts valid price IDs', () => {
66
+ expect(validateStripePriceId('price_1234567890')).toBe(true);
67
+ expect(validateStripePriceId('price_abc')).toBe(true);
68
+ });
69
+
70
+ it('rejects invalid price IDs', () => {
71
+ expect(validateStripePriceId('price_')).toBe(false);
72
+ expect(validateStripePriceId('prod_123')).toBe(false);
73
+ expect(validateStripePriceId('')).toBe(false);
74
+ expect(validateStripePriceId('123')).toBe(false);
75
+ });
76
+ });
77
+
78
+ describe('validateStripeSessionId', () => {
79
+ it('accepts valid session IDs', () => {
80
+ expect(validateStripeSessionId('cs_test_abc123')).toBe(true);
81
+ expect(validateStripeSessionId('cs_live_xyz')).toBe(true);
82
+ });
83
+
84
+ it('rejects invalid session IDs', () => {
85
+ expect(validateStripeSessionId('cs_')).toBe(false);
86
+ expect(validateStripeSessionId('sess_123')).toBe(false);
87
+ expect(validateStripeSessionId('')).toBe(false);
88
+ });
89
+ });
90
+
91
+ describe('validateUrl', () => {
92
+ it('accepts valid HTTP/HTTPS URLs', () => {
93
+ expect(validateUrl('https://example.com')).toBe('https://example.com');
94
+ expect(validateUrl('http://localhost:3000')).toBe('http://localhost:3000');
95
+ expect(validateUrl('https://app.example.com/path?q=1')).toBe(
96
+ 'https://app.example.com/path?q=1'
97
+ );
98
+ });
99
+
100
+ it('rejects invalid URLs', () => {
101
+ expect(() => validateUrl('not-a-url')).toThrow();
102
+ expect(() => validateUrl('')).toThrow();
103
+ });
104
+
105
+ it('rejects non-HTTP protocols', () => {
106
+ expect(() => validateUrl('ftp://example.com')).toThrow('Invalid protocol');
107
+ expect(() => validateUrl('file:///etc/passwd')).toThrow('Invalid protocol');
108
+ });
109
+
110
+ it('includes custom name in error message', () => {
111
+ expect(() => validateUrl('bad', 'Success URL')).toThrow('Invalid Success URL');
112
+ });
113
+ });
114
+
115
+ describe('validateMetadata', () => {
116
+ it('passes through valid string metadata', () => {
117
+ const result = validateMetadata({ key: 'value', name: 'test' });
118
+
119
+ expect(result).toEqual({ key: 'value', name: 'test' });
120
+ });
121
+
122
+ it('rejects non-string values', () => {
123
+ expect(() => validateMetadata({ key: 123 as any })).toThrow(
124
+ "must be a string"
125
+ );
126
+ expect(() => validateMetadata({ key: true as any })).toThrow(
127
+ "must be a string"
128
+ );
129
+ });
130
+
131
+ it('sanitizes script tags', () => {
132
+ const result = validateMetadata({
133
+ xss: '<script>alert("xss")</script>',
134
+ });
135
+
136
+ expect(result.xss).not.toContain('<script');
137
+ });
138
+
139
+ it('sanitizes javascript: protocol', () => {
140
+ const result = validateMetadata({
141
+ link: 'javascript:alert(1)',
142
+ });
143
+
144
+ expect(result.link).not.toContain('javascript:');
145
+ });
146
+
147
+ it('sanitizes inline event handlers', () => {
148
+ const result = validateMetadata({
149
+ html: 'onclick=alert(1)',
150
+ });
151
+
152
+ expect(result.html).not.toContain('onclick=');
153
+ });
154
+
155
+ it('rejects values exceeding 1000 characters', () => {
156
+ const longValue = 'a'.repeat(1001);
157
+
158
+ expect(() => validateMetadata({ key: longValue })).toThrow('too long');
159
+ });
160
+
161
+ it('rejects keys exceeding 100 characters', () => {
162
+ const longKey = 'k'.repeat(101);
163
+
164
+ expect(() => validateMetadata({ [longKey]: 'value' })).toThrow('too long');
165
+ });
166
+
167
+ it('handles empty metadata object', () => {
168
+ const result = validateMetadata({});
169
+
170
+ expect(result).toEqual({});
171
+ });
172
+ });