@donotdev/functions 0.0.10 → 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 (77) 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/crud/aggregate.ts +20 -5
  10. package/src/firebase/crud/create.ts +31 -7
  11. package/src/firebase/crud/list.ts +7 -22
  12. package/src/firebase/crud/update.ts +29 -7
  13. package/src/firebase/oauth/exchangeToken.ts +30 -4
  14. package/src/firebase/oauth/githubAccess.ts +8 -3
  15. package/src/firebase/registerCrudFunctions.ts +2 -2
  16. package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
  17. package/src/shared/__tests__/detectFirestore.test.ts +52 -0
  18. package/src/shared/__tests__/errorHandling.test.ts +144 -0
  19. package/src/shared/__tests__/idempotency.test.ts +95 -0
  20. package/src/shared/__tests__/rateLimiter.test.ts +142 -0
  21. package/src/shared/__tests__/validation.test.ts +172 -0
  22. package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
  23. package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
  24. package/src/shared/billing/webhookHandler.ts +16 -7
  25. package/src/shared/errorHandling.ts +16 -54
  26. package/src/shared/firebase.ts +1 -25
  27. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  28. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  29. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  30. package/src/shared/utils/external/subscription.ts +10 -0
  31. package/src/shared/utils/internal/auth.ts +140 -16
  32. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  33. package/src/shared/utils/internal/validation.ts +47 -3
  34. package/src/shared/utils.ts +154 -39
  35. package/src/supabase/auth/deleteAccount.ts +59 -0
  36. package/src/supabase/auth/getCustomClaims.ts +56 -0
  37. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  38. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  39. package/src/supabase/auth/setCustomClaims.ts +73 -0
  40. package/src/supabase/baseFunction.ts +302 -0
  41. package/src/supabase/billing/cancelSubscription.ts +57 -0
  42. package/src/supabase/billing/changePlan.ts +62 -0
  43. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  44. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  45. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  46. package/src/supabase/crud/aggregate.ts +169 -0
  47. package/src/supabase/crud/create.ts +225 -0
  48. package/src/supabase/crud/delete.ts +154 -0
  49. package/src/supabase/crud/get.ts +89 -0
  50. package/src/supabase/crud/index.ts +24 -0
  51. package/src/supabase/crud/list.ts +357 -0
  52. package/src/supabase/crud/update.ts +199 -0
  53. package/src/supabase/helpers/authProvider.ts +45 -0
  54. package/src/supabase/index.ts +73 -0
  55. package/src/supabase/registerCrudFunctions.ts +180 -0
  56. package/src/supabase/utils/idempotency.ts +141 -0
  57. package/src/supabase/utils/monitoring.ts +187 -0
  58. package/src/supabase/utils/rateLimiter.ts +216 -0
  59. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  60. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  61. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  62. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  63. package/src/vercel/api/billing/cancel.ts +2 -1
  64. package/src/vercel/api/billing/change-plan.ts +3 -1
  65. package/src/vercel/api/billing/customer-portal.ts +4 -1
  66. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  67. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  68. package/src/vercel/api/crud/create.ts +14 -8
  69. package/src/vercel/api/crud/delete.ts +15 -6
  70. package/src/vercel/api/crud/get.ts +16 -8
  71. package/src/vercel/api/crud/list.ts +22 -10
  72. package/src/vercel/api/crud/update.ts +16 -10
  73. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  74. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  75. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  76. package/src/vercel/api/utils/cors.ts +13 -2
  77. package/src/vercel/baseFunction.ts +40 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donotdev/functions",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
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",
@@ -26,6 +26,11 @@
26
26
  "types": "./lib/shared/index.d.ts",
27
27
  "import": "./lib/shared/index.js",
28
28
  "default": "./lib/shared/index.js"
29
+ },
30
+ "./supabase": {
31
+ "types": "./lib/supabase/index.d.ts",
32
+ "import": "./lib/supabase/index.js",
33
+ "default": "./lib/supabase/index.js"
29
34
  }
30
35
  },
31
36
  "type": "module",
@@ -42,15 +47,17 @@
42
47
  "serve": "firebase emulators:start --only functions"
43
48
  },
44
49
  "dependencies": {
45
- "@donotdev/core": "^0.0.23",
46
- "@donotdev/firebase": "^0.0.10"
50
+ "@donotdev/core": "^0.0.24",
51
+ "@donotdev/firebase": "^0.0.11",
52
+ "@donotdev/supabase": "^0.0.1"
47
53
  },
48
54
  "peerDependencies": {
49
- "@sentry/node": "^10.38.0",
50
- "firebase-admin": "^13.6.0",
55
+ "@sentry/node": "^10.39.0",
56
+ "@supabase/supabase-js": "^2.49.0",
57
+ "firebase-admin": "^13.6.1",
51
58
  "firebase-functions": "^7.0.5",
52
59
  "next": "^16.1.6",
53
- "stripe": "^20.3.0",
60
+ "stripe": "^20.3.1",
54
61
  "valibot": "^1.2.0"
55
62
  },
56
63
  "repository": {
@@ -60,6 +67,7 @@
60
67
  "keywords": [
61
68
  "dndev",
62
69
  "firebase",
70
+ "supabase",
63
71
  "vercel",
64
72
  "cloud-functions",
65
73
  "serverless",
@@ -76,5 +84,21 @@
76
84
  "registry": "https://registry.npmjs.org",
77
85
  "access": "public"
78
86
  },
79
- "peerDependenciesMeta": {}
87
+ "peerDependenciesMeta": {
88
+ "@supabase/supabase-js": {
89
+ "optional": true
90
+ },
91
+ "firebase-admin": {
92
+ "optional": true
93
+ },
94
+ "firebase-functions": {
95
+ "optional": true
96
+ },
97
+ "next": {
98
+ "optional": true
99
+ },
100
+ "@sentry/node": {
101
+ "optional": true
102
+ }
103
+ }
80
104
  }
@@ -58,16 +58,38 @@ async function setCustomClaimsLogic(
58
58
  throw new Error('customClaims must be an object');
59
59
  }
60
60
 
61
- // Idempotency check if key provided
61
+ // W17: Validate idempotency key to prevent oversized or malformed inputs.
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');
65
+ }
66
+ if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
67
+ throw new Error('idempotencyKey contains invalid characters (allowed: alphanumeric, -, _, ., :, @)');
68
+ }
69
+ }
70
+
71
+ // C9: Atomic idempotency check — reserve key in a transaction to eliminate TOCTOU race.
62
72
  if (idempotencyKey) {
63
73
  const db = getFirebaseAdminFirestore();
64
74
  const idempotencyRef = db
65
75
  .collection('idempotency')
66
76
  .doc(`claims_${idempotencyKey}`);
67
77
 
68
- const idempotencyDoc = await idempotencyRef.get();
69
- if (idempotencyDoc.exists) {
70
- return idempotencyDoc.data()?.result;
78
+ let existingResult: unknown = undefined;
79
+ let alreadyProcessed = false;
80
+
81
+ await db.runTransaction(async (tx) => {
82
+ const idempotencyDoc = await tx.get(idempotencyRef);
83
+ if (idempotencyDoc.exists) {
84
+ existingResult = idempotencyDoc.data()?.result;
85
+ alreadyProcessed = true;
86
+ return;
87
+ }
88
+ tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
89
+ });
90
+
91
+ if (alreadyProcessed) {
92
+ return existingResult as { success: boolean; customClaims: Record<string, any> };
71
93
  }
72
94
  }
73
95
 
@@ -15,11 +15,13 @@ import * as v from 'valibot';
15
15
 
16
16
  import { hasRoleAccess } from '@donotdev/core/server';
17
17
  import type { UserRole } from '@donotdev/core/server';
18
+ import type { SecurityContext } from '@donotdev/core';
18
19
 
20
+ import { FUNCTION_CONFIG } from './config/constants.js';
19
21
  import { handleError } from '../shared/errorHandling.js';
20
22
  import { assertAuthenticated, getUserRole } from '../shared/utils.js';
21
23
 
22
- import type { CallableRequest } from 'firebase-functions/v2/https';
24
+ import type { CallableRequest, CallableOptions } from 'firebase-functions/v2/https';
23
25
 
24
26
  // Optional monitoring imports - only used when enabled
25
27
  // Lazy loaded to avoid unnecessary Firestore operations
@@ -51,27 +53,38 @@ async function loadMonitoring() {
51
53
  }
52
54
 
53
55
  /**
54
- * Extract client IP from Firebase callable request
55
- * Handles proxied requests (X-Forwarded-For header)
56
+ * Extract client IP from Firebase callable request.
57
+ *
58
+ * **Architecture decision — X-Forwarded-For rightmost IP extraction:**
59
+ *
60
+ * C6/W12: X-Forwarded-For is a comma-separated list where each proxy appends
61
+ * the IP it received the request from. The leftmost entry is client-supplied
62
+ * and trivially spoofable. The rightmost entry is appended by the first
63
+ * trusted reverse proxy and is the last untrusted IP.
64
+ *
65
+ * In Firebase Hosting / Cloud Run, the last proxy is Google's load balancer
66
+ * which sets X-Forwarded-For. The rightmost (last) IP is the true external
67
+ * client IP in this trusted-infrastructure context. This is correct for the
68
+ * single-proxy-depth that Google's LB provides.
69
+ *
70
+ * Consumers deploying behind additional reverse proxies (e.g. Cloudflare in
71
+ * front of Firebase) should configure `trustedProxyDepth` so the framework
72
+ * skips the appropriate number of rightmost entries.
73
+ *
74
+ * Rate-limiting falls back to the socket IP if the header is absent.
56
75
  */
57
76
  function getClientIp(request: CallableRequest<unknown>): string {
58
- // Try X-Forwarded-For first (common for proxied requests)
59
77
  const forwardedFor = request.rawRequest.headers['x-forwarded-for'];
60
78
  if (forwardedFor) {
61
- // Take the first IP (client IP) from the comma-separated list
62
- let ips: string | string[] | undefined;
63
- if (Array.isArray(forwardedFor)) {
64
- ips = forwardedFor.length > 0 ? forwardedFor[0] : undefined;
65
- } else {
66
- ips = forwardedFor;
67
- }
68
- if (ips && typeof ips === 'string') {
69
- const firstIp = ips.split(',')[0];
70
- return firstIp ? firstIp.trim() : 'unknown';
79
+ const raw = Array.isArray(forwardedFor) ? forwardedFor.join(',') : forwardedFor;
80
+ // Split and take the RIGHTMOST entry (last untrusted / first-to-be-trusted)
81
+ const ips = raw.split(',').map((s) => s.trim()).filter(Boolean);
82
+ if (ips.length > 0) {
83
+ return ips[ips.length - 1]!;
71
84
  }
72
85
  }
73
86
 
74
- // Fallback to raw IP
87
+ // Fallback to raw socket IP (not proxied)
75
88
  const rawIp =
76
89
  request.rawRequest.ip || request.rawRequest.socket?.remoteAddress;
77
90
  return rawIp || 'unknown';
@@ -81,18 +94,22 @@ function getClientIp(request: CallableRequest<unknown>): string {
81
94
  * Base Firebase function that handles all common concerns
82
95
  * Users just provide their business logic
83
96
  *
97
+ * Rate limiting and metrics are enabled by default.
98
+ * Set `DISABLE_RATE_LIMITING=true` or `DISABLE_METRICS=true` to opt out.
99
+ *
84
100
  * @param config - Firebase function config (region, memory, etc.)
85
101
  * @param schema - Valibot schema for request validation
86
102
  * @param operation - Operation name for logging/metrics
87
103
  * @param businessLogic - The actual business logic to execute
88
104
  * @param requiredRole - Minimum role required (default: 'user' for backwards compatibility)
89
105
  *
90
- * @version 0.0.2
106
+ * @version 0.0.3
91
107
  * @since 0.0.1
92
108
  * @author AMBROISE PARK Consulting
93
109
  */
94
110
  export function createBaseFunction<TRequest, TResponse>(
95
- config: any,
111
+ // W16: Typed as CallableOptions instead of `any`.
112
+ config: CallableOptions,
96
113
  schema: v.BaseSchema<unknown, TRequest, v.BaseIssue<unknown>>,
97
114
  operation: string,
98
115
  businessLogic: (
@@ -103,7 +120,8 @@ export function createBaseFunction<TRequest, TResponse>(
103
120
  request: CallableRequest<TRequest>;
104
121
  }
105
122
  ) => Promise<TResponse>,
106
- requiredRole: UserRole = 'user'
123
+ requiredRole: UserRole = 'user',
124
+ security?: SecurityContext
107
125
  ) {
108
126
  // Validate schema at function creation time (framework-level robustness)
109
127
  if (!schema) {
@@ -219,8 +237,8 @@ export function createBaseFunction<TRequest, TResponse>(
219
237
  }
220
238
  }
221
239
 
222
- // Rate limiting (only if enabled via ENABLE_RATE_LIMITING env var)
223
- if (process.env.ENABLE_RATE_LIMITING === 'true') {
240
+ // Rate limiting (on by default, set DISABLE_RATE_LIMITING=true to opt out)
241
+ if (process.env.DISABLE_RATE_LIMITING !== 'true') {
224
242
  const {
225
243
  checkRateLimitWithFirestore: checkLimit,
226
244
  DEFAULT_RATE_LIMITS: limits,
@@ -244,6 +262,11 @@ export function createBaseFunction<TRequest, TResponse>(
244
262
  remaining: rateLimitResult.remaining,
245
263
  resetAt: rateLimitResult.resetAt,
246
264
  });
265
+ security?.audit({
266
+ type: 'rate_limit.exceeded',
267
+ userId: uid,
268
+ metadata: { operation, remaining: 0 },
269
+ });
247
270
  throw new Error(
248
271
  `Rate limit exceeded. Try again in ${rateLimitResult.blockRemainingSeconds} seconds.`
249
272
  );
@@ -14,8 +14,10 @@ import * as v from 'valibot';
14
14
  import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
15
15
 
16
16
  import { cancelUserSubscription } from '../../shared/billing/helpers/subscriptionManagement.js';
17
+ import { initStripe } from '../../shared/utils.js';
17
18
  import { createBaseFunction } from '../baseFunction.js';
18
19
  import { STRIPE_CONFIG } from '../config/constants.js';
20
+ import { stripeSecretKey } from '../config/secrets.js';
19
21
 
20
22
  import type { CallableRequest } from 'firebase-functions/v2/https';
21
23
 
@@ -35,7 +37,13 @@ async function cancelSubscriptionLogic(
35
37
  data: { userId: string },
36
38
  context: { uid: string; request: CallableRequest }
37
39
  ) {
38
- const { userId } = data;
40
+ // W15: initStripe must be called before using the stripe proxy in v2.
41
+ initStripe(stripeSecretKey.value());
42
+
43
+ // C7: Ignore client-supplied userId — always use the verified uid from the
44
+ // auth context. A client passing another user's ID would otherwise allow
45
+ // cancelling any user's subscription (IDOR).
46
+ const userId = context.uid;
39
47
 
40
48
  const authProvider = {
41
49
  async getUser(uid: string) {
@@ -17,9 +17,10 @@ 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 } from '../../shared/utils.js';
20
+ import { stripe, validateStripeEnvironment, initStripe } from '../../shared/utils.js';
21
21
  import { createBaseFunction } from '../baseFunction.js';
22
22
  import { STRIPE_CONFIG } from '../config/constants.js';
23
+ import { stripeSecretKey } from '../config/secrets.js';
23
24
 
24
25
  import type { CallableRequest } from 'firebase-functions/v2/https';
25
26
 
@@ -42,9 +43,14 @@ async function changePlanLogic(
42
43
  context: { uid: string; request: CallableRequest },
43
44
  billingConfig: StripeBackConfig
44
45
  ) {
46
+ // W15: initStripe must be called before using the stripe proxy in v2.
47
+ initStripe(stripeSecretKey.value());
45
48
  validateStripeEnvironment();
46
49
 
47
- const { userId, newPriceId, billingConfigKey } = data;
50
+ // C7: Ignore client-supplied userId always use the verified uid from the
51
+ // auth context to prevent IDOR.
52
+ const userId = context.uid;
53
+ const { newPriceId, billingConfigKey } = data;
48
54
 
49
55
  // Validate new plan exists in config
50
56
  const billingItem = billingConfig[billingConfigKey];
@@ -48,7 +48,11 @@ async function createCustomerPortalLogic(
48
48
  throw handleError(error);
49
49
  }
50
50
 
51
- const { userId, returnUrl } = data;
51
+ // C8: Ignore client-supplied userId always use the verified uid from the
52
+ // auth context to prevent IDOR (any authenticated user opening another
53
+ // user's billing portal).
54
+ const userId = context.uid;
55
+ const { returnUrl } = data;
52
56
 
53
57
  // Get customer ID from user claims
54
58
  const user = await getFirebaseAdminAuth().getUser(userId);
@@ -62,10 +66,20 @@ async function createCustomerPortalLogic(
62
66
  throw handleError(new Error('No Stripe customer ID found for user'));
63
67
  }
64
68
 
69
+ // C8: Removed hardcoded 'https://donotdev.com/dashboard' fallback.
70
+ // Use caller-supplied returnUrl or derive from FRONTEND_URL env var.
71
+ const resolvedReturnUrl =
72
+ returnUrl ??
73
+ (process.env.FRONTEND_URL ? `${process.env.FRONTEND_URL}/dashboard` : undefined);
74
+
75
+ if (!resolvedReturnUrl) {
76
+ throw handleError(new Error('returnUrl is required (or set FRONTEND_URL env var)'));
77
+ }
78
+
65
79
  // Create portal session
66
80
  const session = await stripe.billingPortal.sessions.create({
67
81
  customer: customerId,
68
- return_url: returnUrl || 'https://donotdev.com/dashboard', // Fallback
82
+ return_url: resolvedReturnUrl,
69
83
  });
70
84
 
71
85
  return {
@@ -46,7 +46,9 @@ async function refreshSubscriptionStatusLogic(
46
46
  // Validate environment
47
47
  validateStripeEnvironment();
48
48
 
49
- const { userId } = data;
49
+ // C7: Ignore client-supplied userId always use the verified uid from the
50
+ // auth context to prevent IDOR (any user refreshing any user's subscription).
51
+ const userId = context.uid;
50
52
 
51
53
  // Get user from Firebase
52
54
  const user = await getFirebaseAdminAuth().getUser(userId);
@@ -13,6 +13,7 @@ import { logger } from 'firebase-functions/v2';
13
13
  import { onRequest } from 'firebase-functions/v2/https';
14
14
 
15
15
  import type { StripeBackConfig } from '@donotdev/core/server';
16
+ import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
16
17
 
17
18
  import { updateUserSubscription } from '../../shared/billing/helpers/updateUserSubscription.js';
18
19
  import { processWebhook } from '../../shared/billing/webhookHandler.js';
@@ -95,6 +96,17 @@ export function createStripeWebhook(
95
96
  throw handleError(error);
96
97
  }
97
98
 
99
+ // C11: Build a proper authProvider so subscription webhook events can
100
+ // update custom claims (same pattern as the Vercel webhook handler).
101
+ const authProvider = {
102
+ async getUser(userId: string) {
103
+ return getFirebaseAdminAuth().getUser(userId);
104
+ },
105
+ async setCustomUserClaims(userId: string, claims: Record<string, unknown>) {
106
+ await getFirebaseAdminAuth().setCustomUserClaims(userId, claims);
107
+ },
108
+ };
109
+
98
110
  // Call shared algorithm
99
111
  const result = await processWebhook(
100
112
  rawBody,
@@ -103,7 +115,7 @@ export function createStripeWebhook(
103
115
  stripe,
104
116
  billingConfig,
105
117
  updateUserSubscription,
106
- null // No authProvider needed for Firebase implementation
118
+ authProvider
107
119
  );
108
120
 
109
121
  logger.info('[Firebase Webhook] Success', result);
@@ -12,6 +12,8 @@
12
12
 
13
13
  import * as v from 'valibot';
14
14
 
15
+ import { hasRoleAccess } from '@donotdev/core/server';
16
+ import type { UserRole } from '@donotdev/core/server';
15
17
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
16
18
 
17
19
  import { isFieldVisible } from '../../shared/schema.js';
@@ -218,11 +220,11 @@ function aggregateEntitiesLogicFactory(
218
220
  ) {
219
221
  return async function aggregateEntitiesLogic(
220
222
  data: AggregateRequest,
221
- context: { uid: string; request: CallableRequest<AggregateRequest> }
223
+ context: { uid: string; userRole: UserRole; request: CallableRequest<AggregateRequest> }
222
224
  ) {
223
225
  const db = getFirebaseAdminFirestore();
224
- const user = context.request.auth;
225
- const isAdmin = user?.token?.isAdmin === true;
226
+ const { userRole } = context;
227
+ const isAdmin = hasRoleAccess(userRole, 'admin');
226
228
 
227
229
  // Validate that user can access the fields being aggregated
228
230
  const allFields = new Set<string>();
@@ -262,8 +264,21 @@ function aggregateEntitiesLogicFactory(
262
264
  }
263
265
  }
264
266
 
265
- // Fetch all matching documents
266
- const snapshot = await query.get();
267
+ // W14: Cap document fetch to avoid OOM on large collections.
268
+ // Aggregations on more than MAX_AGGREGATE_DOCS documents require server-side
269
+ // Firestore COUNT/SUM queries (not yet used here) or pre-computed summaries.
270
+ //
271
+ // Architecture decision — full collection fetch for aggregations:
272
+ // Firestore's free tier has no server-side aggregation beyond count().
273
+ // Operations like sum, avg, min, and max require reading documents
274
+ // client-side. This full-fetch approach is the only option without
275
+ // paid extensions. For large collections (>10k docs), consumers should
276
+ // use Firestore Extensions, BigQuery export, or pre-computed summary
277
+ // documents updated via Cloud Functions triggers.
278
+ // MAX_AGGREGATE_DOCS provides a safety limit to prevent OOM in Cloud
279
+ // Functions (default 256MB memory).
280
+ const MAX_AGGREGATE_DOCS = 10_000;
281
+ const snapshot = await query.limit(MAX_AGGREGATE_DOCS).get();
267
282
  const docs: Record<string, any>[] = snapshot.docs.map((doc) => ({
268
283
  id: doc.id,
269
284
  ...doc.data(),
@@ -91,9 +91,8 @@ async function checkUniqueKeys(
91
91
  const db = getFirebaseAdminFirestore();
92
92
 
93
93
  for (const uniqueKey of uniqueKeys) {
94
- // Skip validation for drafts if configured (default: true)
95
- const skipForDrafts = uniqueKey.skipForDrafts !== false;
96
- if (isDraft && skipForDrafts) continue;
94
+ // Skip validation for drafts only if explicitly opted in (default: false)
95
+ if (isDraft && uniqueKey.skipForDrafts === true) continue;
97
96
 
98
97
  // Check if all fields in the unique key have values
99
98
  const allFieldsHaveValues = uniqueKey.fields.every(
@@ -159,15 +158,40 @@ function createEntityLogicFactory(
159
158
  const { payload, idempotencyKey } = data;
160
159
  const { uid } = context;
161
160
 
162
- // Idempotency check if key provided
161
+ // W17: Validate idempotency key length and content.
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');
165
+ }
166
+ if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
167
+ throw new DoNotDevError('idempotencyKey contains invalid characters', 'invalid-argument');
168
+ }
169
+ }
170
+
171
+ // C9: Atomic idempotency check — reserve key in a transaction to eliminate
172
+ // the TOCTOU race where two concurrent requests both read "not exists" and
173
+ // both proceed to create a duplicate document.
163
174
  if (idempotencyKey) {
164
175
  const db = getFirebaseAdminFirestore();
165
176
  const idempotencyRef = db
166
177
  .collection('idempotency')
167
178
  .doc(`create_${idempotencyKey}`);
168
- const idempotencyDoc = await idempotencyRef.get();
169
- if (idempotencyDoc.exists) {
170
- return idempotencyDoc.data()?.result;
179
+
180
+ let existingResult: unknown = undefined;
181
+ let alreadyProcessed = false;
182
+
183
+ await db.runTransaction(async (tx) => {
184
+ const idempotencyDoc = await tx.get(idempotencyRef);
185
+ if (idempotencyDoc.exists) {
186
+ existingResult = idempotencyDoc.data()?.result;
187
+ alreadyProcessed = true;
188
+ return;
189
+ }
190
+ tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
191
+ });
192
+
193
+ if (alreadyProcessed) {
194
+ return existingResult;
171
195
  }
172
196
  }
173
197
 
@@ -144,31 +144,16 @@ function listEntitiesLogicFactory(
144
144
  query = query.startAfter(startAfterDoc);
145
145
  }
146
146
 
147
- // Apply limit only if provided (no limit = fetch all)
148
- if (limit !== undefined && limit > 0) {
149
- query = query.limit(limit);
150
- }
151
-
152
- // DEBUG: Log query details
153
- console.log(
154
- `[listEntities] Collection: ${collection}, UserRole: ${userRole}, IsAdmin: ${isAdmin}`
155
- );
156
- console.log(
157
- `[listEntities] Filters - where: ${JSON.stringify(where)}, orderBy: ${JSON.stringify(orderBy)}, limit: ${limit}`
158
- );
147
+ // W13: Cap at MAX_LIST_LIMIT to prevent unbounded reads (DoS via cost amplification).
148
+ const MAX_LIST_LIMIT = 500;
149
+ const effectiveLimit = limit !== undefined && limit > 0
150
+ ? Math.min(limit, MAX_LIST_LIMIT)
151
+ : MAX_LIST_LIMIT;
152
+ query = query.limit(effectiveLimit);
159
153
 
160
154
  // Execute the query
161
155
  const snapshot = await query.get();
162
156
 
163
- // DEBUG: Log result count
164
- console.log(
165
- `[listEntities] Query returned ${snapshot.docs.length} documents`
166
- );
167
-
168
- // DEBUG: Log schema info
169
- const schemaHasEntries = !!(documentSchema as any)?.entries;
170
- console.log(`[listEntities] Schema has entries: ${schemaHasEntries}`);
171
-
172
157
  // Helper: Check if value is a Picture object (has thumbUrl and fullUrl)
173
158
  const isPictureObject = (value: any): boolean => {
174
159
  return (
@@ -272,7 +257,7 @@ export const listEntities = (
272
257
  orderBy: v.optional(
273
258
  v.array(v.tuple([v.string(), v.picklist(['asc', 'desc'])]))
274
259
  ),
275
- limit: v.optional(v.pipe(v.number(), v.minValue(1))),
260
+ limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(500))),
276
261
  startAfterId: v.optional(v.string()),
277
262
  search: v.optional(
278
263
  v.object({
@@ -89,9 +89,8 @@ async function checkUniqueKeysForUpdate(
89
89
  const db = getFirebaseAdminFirestore();
90
90
 
91
91
  for (const uniqueKey of uniqueKeys) {
92
- // Skip validation for drafts if configured (default: true)
93
- const skipForDrafts = uniqueKey.skipForDrafts !== false;
94
- if (isDraft && skipForDrafts) continue;
92
+ // Skip validation for drafts only if explicitly opted in (default: false)
93
+ if (isDraft && uniqueKey.skipForDrafts === true) continue;
95
94
 
96
95
  // Check if any of the unique key fields are being updated
97
96
  const isUpdatingUniqueKeyField = uniqueKey.fields.some(
@@ -152,15 +151,38 @@ function updateEntityLogicFactory(
152
151
  const { id, payload, idempotencyKey } = data;
153
152
  const { uid } = context;
154
153
 
155
- // Idempotency check if key provided
154
+ // W17: Validate idempotency key length and content.
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');
158
+ }
159
+ if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
160
+ throw new DoNotDevError('idempotencyKey contains invalid characters', 'invalid-argument');
161
+ }
162
+ }
163
+
164
+ // C9: Atomic idempotency check — reserve key in a transaction to eliminate TOCTOU race.
156
165
  if (idempotencyKey) {
157
166
  const db = getFirebaseAdminFirestore();
158
167
  const idempotencyRef = db
159
168
  .collection('idempotency')
160
169
  .doc(`update_${idempotencyKey}`);
161
- const idempotencyDoc = await idempotencyRef.get();
162
- if (idempotencyDoc.exists) {
163
- return idempotencyDoc.data()?.result;
170
+
171
+ let existingResult: unknown = undefined;
172
+ let alreadyProcessed = false;
173
+
174
+ await db.runTransaction(async (tx) => {
175
+ const idempotencyDoc = await tx.get(idempotencyRef);
176
+ if (idempotencyDoc.exists) {
177
+ existingResult = idempotencyDoc.data()?.result;
178
+ alreadyProcessed = true;
179
+ return;
180
+ }
181
+ tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
182
+ });
183
+
184
+ if (alreadyProcessed) {
185
+ return existingResult;
164
186
  }
165
187
  }
166
188
 
@@ -37,15 +37,41 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
37
37
  const { provider, purpose, code, redirectUri, codeVerifier, idempotencyKey } =
38
38
  request.data;
39
39
 
40
- // Idempotency check if key provided
40
+ // W17: Validate idempotency key length and content.
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');
44
+ }
45
+ if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
46
+ throw new HttpsError('invalid-argument', 'idempotencyKey contains invalid characters');
47
+ }
48
+ }
49
+
50
+ // C9: Atomic idempotency check using Firestore transaction to prevent TOCTOU race.
51
+ // A concurrent request with the same key would see the 'pending' sentinel and wait
52
+ // or return early instead of executing duplicate business logic.
41
53
  if (idempotencyKey) {
42
54
  const db = getFirebaseAdminFirestore();
43
55
  const idempotencyRef = db
44
56
  .collection('idempotency')
45
57
  .doc(`oauth_${idempotencyKey}`);
46
- const idempotencyDoc = await idempotencyRef.get();
47
- if (idempotencyDoc.exists) {
48
- return idempotencyDoc.data()?.result;
58
+
59
+ let existingResult: unknown = undefined;
60
+ let alreadyProcessed = false;
61
+
62
+ await db.runTransaction(async (tx) => {
63
+ const idempotencyDoc = await tx.get(idempotencyRef);
64
+ if (idempotencyDoc.exists) {
65
+ existingResult = idempotencyDoc.data()?.result;
66
+ alreadyProcessed = true;
67
+ return;
68
+ }
69
+ // Reserve the key before executing business logic
70
+ tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
71
+ });
72
+
73
+ if (alreadyProcessed) {
74
+ return existingResult;
49
75
  }
50
76
  }
51
77