@donotdev/functions 0.0.11 → 0.0.13

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 (55) hide show
  1. package/README.md +1 -1
  2. package/package.json +9 -9
  3. package/src/firebase/auth/setCustomClaims.ts +19 -5
  4. package/src/firebase/baseFunction.ts +11 -3
  5. package/src/firebase/billing/changePlan.ts +5 -1
  6. package/src/firebase/billing/createCheckoutSession.ts +3 -1
  7. package/src/firebase/billing/createCustomerPortal.ts +6 -2
  8. package/src/firebase/billing/webhookHandler.ts +4 -1
  9. package/src/firebase/crud/aggregate.ts +5 -1
  10. package/src/firebase/crud/create.ts +17 -4
  11. package/src/firebase/crud/list.ts +37 -5
  12. package/src/firebase/crud/update.ts +17 -4
  13. package/src/firebase/oauth/exchangeToken.ts +17 -4
  14. package/src/shared/__tests__/validation.test.ts +5 -3
  15. package/src/shared/billing/__tests__/createCheckout.test.ts +123 -22
  16. package/src/shared/billing/__tests__/webhookHandler.test.ts +121 -30
  17. package/src/shared/errorHandling.ts +6 -6
  18. package/src/shared/firebase.ts +1 -1
  19. package/src/shared/index.ts +2 -1
  20. package/src/shared/logger.ts +9 -7
  21. package/src/shared/oauth/__tests__/exchangeToken.test.ts +6 -8
  22. package/src/shared/oauth/__tests__/grantAccess.test.ts +31 -14
  23. package/src/shared/utils/external/subscription.ts +2 -2
  24. package/src/shared/utils/internal/auth.ts +10 -3
  25. package/src/shared/utils/internal/rateLimiter.ts +8 -2
  26. package/src/shared/utils.ts +23 -30
  27. package/src/supabase/auth/deleteAccount.ts +4 -11
  28. package/src/supabase/auth/getCustomClaims.ts +5 -3
  29. package/src/supabase/auth/getUserAuthStatus.ts +5 -3
  30. package/src/supabase/auth/removeCustomClaims.ts +10 -5
  31. package/src/supabase/auth/setCustomClaims.ts +9 -4
  32. package/src/supabase/baseFunction.ts +80 -21
  33. package/src/supabase/billing/cancelSubscription.ts +9 -3
  34. package/src/supabase/billing/changePlan.ts +20 -5
  35. package/src/supabase/billing/createCheckoutSession.ts +20 -5
  36. package/src/supabase/billing/createCustomerPortal.ts +14 -4
  37. package/src/supabase/billing/refreshSubscriptionStatus.ts +29 -9
  38. package/src/supabase/crud/aggregate.ts +14 -4
  39. package/src/supabase/crud/create.ts +30 -11
  40. package/src/supabase/crud/delete.ts +11 -3
  41. package/src/supabase/crud/get.ts +25 -3
  42. package/src/supabase/crud/list.ts +106 -21
  43. package/src/supabase/crud/update.ts +32 -10
  44. package/src/supabase/helpers/authProvider.ts +5 -2
  45. package/src/supabase/index.ts +1 -4
  46. package/src/supabase/registerCrudFunctions.ts +11 -9
  47. package/src/supabase/utils/idempotency.ts +13 -15
  48. package/src/supabase/utils/monitoring.ts +5 -1
  49. package/src/supabase/utils/rateLimiter.ts +13 -3
  50. package/src/vercel/api/billing/webhook-handler.ts +6 -2
  51. package/src/vercel/api/crud/create.ts +7 -2
  52. package/src/vercel/api/crud/delete.ts +3 -1
  53. package/src/vercel/api/crud/get.ts +3 -1
  54. package/src/vercel/api/crud/list.ts +3 -1
  55. package/src/vercel/api/crud/update.ts +7 -2
package/README.md CHANGED
@@ -482,7 +482,7 @@ bun run typecheck
482
482
  bun run dev:firebase
483
483
 
484
484
  # Terminal 2: Forward webhooks
485
- stripe listen --forward-to localhost:5001/your-project/us-central1/stripeWebhook
485
+ stripe listen --forward-to localhost:5001/your-project/europe-west1/stripeWebhook
486
486
  ```
487
487
 
488
488
  #### Vercel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donotdev/functions",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "private": false,
5
5
  "description": "Backend functions for DoNotDev Framework - Firebase, Vercel, and platform-agnostic implementations for auth, billing, CRUD, and OAuth",
6
6
  "main": "./lib/firebase/index.js",
@@ -39,7 +39,7 @@
39
39
  "node": ">=18.0.0"
40
40
  },
41
41
  "scripts": {
42
- "type-check": "tsc --noEmit",
42
+ "type-check": "bunx tsc --noEmit",
43
43
  "build": "node build.mjs && tsc -p tsconfig.json",
44
44
  "build:types": "tsc -p tsconfig.json",
45
45
  "prepublishOnly": "bun run build",
@@ -47,13 +47,13 @@
47
47
  "serve": "firebase emulators:start --only functions"
48
48
  },
49
49
  "dependencies": {
50
- "@donotdev/core": "^0.0.24",
51
- "@donotdev/firebase": "^0.0.11",
52
- "@donotdev/supabase": "^0.0.1"
50
+ "@donotdev/core": "^0.0.26",
51
+ "@donotdev/firebase": "^0.0.13",
52
+ "@donotdev/supabase": "^0.0.3"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@sentry/node": "^10.39.0",
56
- "@supabase/supabase-js": "^2.49.0",
56
+ "@supabase/supabase-js": "^2.76.11",
57
57
  "firebase-admin": "^13.6.1",
58
58
  "firebase-functions": "^7.0.5",
59
59
  "next": "^16.1.6",
@@ -85,6 +85,9 @@
85
85
  "access": "public"
86
86
  },
87
87
  "peerDependenciesMeta": {
88
+ "@sentry/node": {
89
+ "optional": true
90
+ },
88
91
  "@supabase/supabase-js": {
89
92
  "optional": true
90
93
  },
@@ -96,9 +99,6 @@
96
99
  },
97
100
  "next": {
98
101
  "optional": true
99
- },
100
- "@sentry/node": {
101
- "optional": true
102
102
  }
103
103
  }
104
104
  }
@@ -60,11 +60,19 @@ async function setCustomClaimsLogic(
60
60
 
61
61
  // W17: Validate idempotency key to prevent oversized or malformed inputs.
62
62
  if (idempotencyKey !== undefined) {
63
- if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
64
- throw new Error('idempotencyKey must be a non-empty string of at most 256 characters');
63
+ if (
64
+ typeof idempotencyKey !== 'string' ||
65
+ idempotencyKey.length === 0 ||
66
+ idempotencyKey.length > 256
67
+ ) {
68
+ throw new Error(
69
+ 'idempotencyKey must be a non-empty string of at most 256 characters'
70
+ );
65
71
  }
66
72
  if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
67
- throw new Error('idempotencyKey contains invalid characters (allowed: alphanumeric, -, _, ., :, @)');
73
+ throw new Error(
74
+ 'idempotencyKey contains invalid characters (allowed: alphanumeric, -, _, ., :, @)'
75
+ );
68
76
  }
69
77
  }
70
78
 
@@ -85,11 +93,17 @@ async function setCustomClaimsLogic(
85
93
  alreadyProcessed = true;
86
94
  return;
87
95
  }
88
- tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
96
+ tx.set(idempotencyRef, {
97
+ processing: true,
98
+ reservedAt: new Date().toISOString(),
99
+ });
89
100
  });
90
101
 
91
102
  if (alreadyProcessed) {
92
- return existingResult as { success: boolean; customClaims: Record<string, any> };
103
+ return existingResult as {
104
+ success: boolean;
105
+ customClaims: Record<string, any>;
106
+ };
93
107
  }
94
108
  }
95
109
 
@@ -21,7 +21,10 @@ import { FUNCTION_CONFIG } from './config/constants.js';
21
21
  import { handleError } from '../shared/errorHandling.js';
22
22
  import { assertAuthenticated, getUserRole } from '../shared/utils.js';
23
23
 
24
- import type { CallableRequest, CallableOptions } from 'firebase-functions/v2/https';
24
+ import type {
25
+ CallableRequest,
26
+ CallableOptions,
27
+ } from 'firebase-functions/v2/https';
25
28
 
26
29
  // Optional monitoring imports - only used when enabled
27
30
  // Lazy loaded to avoid unnecessary Firestore operations
@@ -76,9 +79,14 @@ async function loadMonitoring() {
76
79
  function getClientIp(request: CallableRequest<unknown>): string {
77
80
  const forwardedFor = request.rawRequest.headers['x-forwarded-for'];
78
81
  if (forwardedFor) {
79
- const raw = Array.isArray(forwardedFor) ? forwardedFor.join(',') : forwardedFor;
82
+ const raw = Array.isArray(forwardedFor)
83
+ ? forwardedFor.join(',')
84
+ : forwardedFor;
80
85
  // Split and take the RIGHTMOST entry (last untrusted / first-to-be-trusted)
81
- const ips = raw.split(',').map((s) => s.trim()).filter(Boolean);
86
+ const ips = raw
87
+ .split(',')
88
+ .map((s) => s.trim())
89
+ .filter(Boolean);
82
90
  if (ips.length > 0) {
83
91
  return ips[ips.length - 1]!;
84
92
  }
@@ -17,7 +17,11 @@ import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
17
17
 
18
18
  import { updateUserSubscription } from '../../shared/billing/helpers/updateUserSubscription.js';
19
19
  import { handleError } from '../../shared/errorHandling.js';
20
- import { stripe, validateStripeEnvironment, initStripe } from '../../shared/utils.js';
20
+ import {
21
+ stripe,
22
+ validateStripeEnvironment,
23
+ initStripe,
24
+ } from '../../shared/utils.js';
21
25
  import { createBaseFunction } from '../baseFunction.js';
22
26
  import { STRIPE_CONFIG } from '../config/constants.js';
23
27
  import { stripeSecretKey } from '../config/secrets.js';
@@ -63,7 +63,6 @@ async function createCheckoutSessionLogic(
63
63
 
64
64
  const {
65
65
  priceId,
66
- userId,
67
66
  customerEmail,
68
67
  metadata = {},
69
68
  successUrl,
@@ -72,6 +71,9 @@ async function createCheckoutSessionLogic(
72
71
  mode = 'payment',
73
72
  } = data;
74
73
 
74
+ // Use authenticated uid from context — client no longer sends userId
75
+ const userId = context.uid;
76
+
75
77
  logger.debug('[createCheckoutSession] Processing request', {
76
78
  priceId,
77
79
  userId,
@@ -70,10 +70,14 @@ async function createCustomerPortalLogic(
70
70
  // Use caller-supplied returnUrl or derive from FRONTEND_URL env var.
71
71
  const resolvedReturnUrl =
72
72
  returnUrl ??
73
- (process.env.FRONTEND_URL ? `${process.env.FRONTEND_URL}/dashboard` : undefined);
73
+ (process.env.FRONTEND_URL
74
+ ? `${process.env.FRONTEND_URL}/dashboard`
75
+ : undefined);
74
76
 
75
77
  if (!resolvedReturnUrl) {
76
- throw handleError(new Error('returnUrl is required (or set FRONTEND_URL env var)'));
78
+ throw handleError(
79
+ new Error('returnUrl is required (or set FRONTEND_URL env var)')
80
+ );
77
81
  }
78
82
 
79
83
  // Create portal session
@@ -102,7 +102,10 @@ export function createStripeWebhook(
102
102
  async getUser(userId: string) {
103
103
  return getFirebaseAdminAuth().getUser(userId);
104
104
  },
105
- async setCustomUserClaims(userId: string, claims: Record<string, unknown>) {
105
+ async setCustomUserClaims(
106
+ userId: string,
107
+ claims: Record<string, unknown>
108
+ ) {
106
109
  await getFirebaseAdminAuth().setCustomUserClaims(userId, claims);
107
110
  },
108
111
  };
@@ -220,7 +220,11 @@ function aggregateEntitiesLogicFactory(
220
220
  ) {
221
221
  return async function aggregateEntitiesLogic(
222
222
  data: AggregateRequest,
223
- context: { uid: string; userRole: UserRole; request: CallableRequest<AggregateRequest> }
223
+ context: {
224
+ uid: string;
225
+ userRole: UserRole;
226
+ request: CallableRequest<AggregateRequest>;
227
+ }
224
228
  ) {
225
229
  const db = getFirebaseAdminFirestore();
226
230
  const { userRole } = context;
@@ -160,11 +160,21 @@ function createEntityLogicFactory(
160
160
 
161
161
  // W17: Validate idempotency key length and content.
162
162
  if (idempotencyKey !== undefined) {
163
- if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
164
- throw new DoNotDevError('idempotencyKey must be a non-empty string of at most 256 characters', 'invalid-argument');
163
+ if (
164
+ typeof idempotencyKey !== 'string' ||
165
+ idempotencyKey.length === 0 ||
166
+ idempotencyKey.length > 256
167
+ ) {
168
+ throw new DoNotDevError(
169
+ 'idempotencyKey must be a non-empty string of at most 256 characters',
170
+ 'invalid-argument'
171
+ );
165
172
  }
166
173
  if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
167
- throw new DoNotDevError('idempotencyKey contains invalid characters', 'invalid-argument');
174
+ throw new DoNotDevError(
175
+ 'idempotencyKey contains invalid characters',
176
+ 'invalid-argument'
177
+ );
168
178
  }
169
179
  }
170
180
 
@@ -187,7 +197,10 @@ function createEntityLogicFactory(
187
197
  alreadyProcessed = true;
188
198
  return;
189
199
  }
190
- tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
200
+ tx.set(idempotencyRef, {
201
+ processing: true,
202
+ reservedAt: new Date().toISOString(),
203
+ });
191
204
  });
192
205
 
193
206
  if (alreadyProcessed) {
@@ -115,13 +115,44 @@ function listEntitiesLogicFactory(
115
115
  // Apply search if provided
116
116
  if (search) {
117
117
  const { field, query: searchQuery } = search;
118
+ // Validate search.field against entity schema (listFields as allowlist)
119
+ if (listFields && listFields.length > 0) {
120
+ if (!listFields.includes(field)) {
121
+ throw new DoNotDevError(
122
+ `Search field '${field}' is not allowed`,
123
+ 'invalid-argument'
124
+ );
125
+ }
126
+ } else if (field.startsWith('_') || field.includes('.')) {
127
+ throw new DoNotDevError(
128
+ `Search field '${field}' is not allowed`,
129
+ 'invalid-argument'
130
+ );
131
+ }
118
132
  query = query
119
133
  .where(field, '>=', searchQuery)
120
134
  .where(field, '<=', searchQuery + '\uf8ff');
121
135
  }
122
136
 
123
- // Apply where clauses for filtering
137
+ // Apply where clauses for filtering — validate field names against entity schema
124
138
  for (const [field, operator, value] of where) {
139
+ if (listFields && listFields.length > 0) {
140
+ if (
141
+ !listFields.includes(field) &&
142
+ field !== 'status' &&
143
+ field !== 'id'
144
+ ) {
145
+ throw new DoNotDevError(
146
+ `Where field '${field}' is not allowed`,
147
+ 'invalid-argument'
148
+ );
149
+ }
150
+ } else if (field.startsWith('_') || field.includes('.')) {
151
+ throw new DoNotDevError(
152
+ `Where field '${field}' is not allowed`,
153
+ 'invalid-argument'
154
+ );
155
+ }
125
156
  query = query.where(field, operator, value);
126
157
  }
127
158
 
@@ -146,9 +177,10 @@ function listEntitiesLogicFactory(
146
177
 
147
178
  // W13: Cap at MAX_LIST_LIMIT to prevent unbounded reads (DoS via cost amplification).
148
179
  const MAX_LIST_LIMIT = 500;
149
- const effectiveLimit = limit !== undefined && limit > 0
150
- ? Math.min(limit, MAX_LIST_LIMIT)
151
- : MAX_LIST_LIMIT;
180
+ const effectiveLimit =
181
+ limit !== undefined && limit > 0
182
+ ? Math.min(limit, MAX_LIST_LIMIT)
183
+ : MAX_LIST_LIMIT;
152
184
  query = query.limit(effectiveLimit);
153
185
 
154
186
  // Execute the query
@@ -225,7 +257,7 @@ function listEntitiesLogicFactory(
225
257
  items: transformFirestoreData(docs),
226
258
  lastVisible: snapshot.docs[snapshot.docs.length - 1]?.id || null,
227
259
  count: snapshot.docs.length,
228
- hasMore: snapshot.docs.length === limit,
260
+ hasMore: snapshot.docs.length === effectiveLimit,
229
261
  };
230
262
  };
231
263
  }
@@ -153,11 +153,21 @@ function updateEntityLogicFactory(
153
153
 
154
154
  // W17: Validate idempotency key length and content.
155
155
  if (idempotencyKey !== undefined) {
156
- if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
157
- throw new DoNotDevError('idempotencyKey must be a non-empty string of at most 256 characters', 'invalid-argument');
156
+ if (
157
+ typeof idempotencyKey !== 'string' ||
158
+ idempotencyKey.length === 0 ||
159
+ idempotencyKey.length > 256
160
+ ) {
161
+ throw new DoNotDevError(
162
+ 'idempotencyKey must be a non-empty string of at most 256 characters',
163
+ 'invalid-argument'
164
+ );
158
165
  }
159
166
  if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
160
- throw new DoNotDevError('idempotencyKey contains invalid characters', 'invalid-argument');
167
+ throw new DoNotDevError(
168
+ 'idempotencyKey contains invalid characters',
169
+ 'invalid-argument'
170
+ );
161
171
  }
162
172
  }
163
173
 
@@ -178,7 +188,10 @@ function updateEntityLogicFactory(
178
188
  alreadyProcessed = true;
179
189
  return;
180
190
  }
181
- tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
191
+ tx.set(idempotencyRef, {
192
+ processing: true,
193
+ reservedAt: new Date().toISOString(),
194
+ });
182
195
  });
183
196
 
184
197
  if (alreadyProcessed) {
@@ -39,11 +39,21 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
39
39
 
40
40
  // W17: Validate idempotency key length and content.
41
41
  if (idempotencyKey !== undefined) {
42
- if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
43
- throw new HttpsError('invalid-argument', 'idempotencyKey must be a non-empty string of at most 256 characters');
42
+ if (
43
+ typeof idempotencyKey !== 'string' ||
44
+ idempotencyKey.length === 0 ||
45
+ idempotencyKey.length > 256
46
+ ) {
47
+ throw new HttpsError(
48
+ 'invalid-argument',
49
+ 'idempotencyKey must be a non-empty string of at most 256 characters'
50
+ );
44
51
  }
45
52
  if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
46
- throw new HttpsError('invalid-argument', 'idempotencyKey contains invalid characters');
53
+ throw new HttpsError(
54
+ 'invalid-argument',
55
+ 'idempotencyKey contains invalid characters'
56
+ );
47
57
  }
48
58
  }
49
59
 
@@ -67,7 +77,10 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
67
77
  return;
68
78
  }
69
79
  // Reserve the key before executing business logic
70
- tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
80
+ tx.set(idempotencyRef, {
81
+ processing: true,
82
+ reservedAt: new Date().toISOString(),
83
+ });
71
84
  });
72
85
 
73
86
  if (alreadyProcessed) {
@@ -108,7 +108,9 @@ describe('validateUrl', () => {
108
108
  });
109
109
 
110
110
  it('includes custom name in error message', () => {
111
- expect(() => validateUrl('bad', 'Success URL')).toThrow('Invalid Success URL');
111
+ expect(() => validateUrl('bad', 'Success URL')).toThrow(
112
+ 'Invalid Success URL'
113
+ );
112
114
  });
113
115
  });
114
116
 
@@ -121,10 +123,10 @@ describe('validateMetadata', () => {
121
123
 
122
124
  it('rejects non-string values', () => {
123
125
  expect(() => validateMetadata({ key: 123 as any })).toThrow(
124
- "must be a string"
126
+ 'must be a string'
125
127
  );
126
128
  expect(() => validateMetadata({ key: true as any })).toThrow(
127
- "must be a string"
129
+ 'must be a string'
128
130
  );
129
131
  });
130
132