@donotdev/functions 0.0.10 → 0.0.12

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 (81) hide show
  1. package/README.md +1 -1
  2. package/package.json +32 -8
  3. package/src/firebase/auth/setCustomClaims.ts +26 -4
  4. package/src/firebase/baseFunction.ts +43 -20
  5. package/src/firebase/billing/cancelSubscription.ts +9 -1
  6. package/src/firebase/billing/changePlan.ts +8 -2
  7. package/src/firebase/billing/createCheckoutSession.ts +3 -1
  8. package/src/firebase/billing/createCustomerPortal.ts +16 -2
  9. package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
  10. package/src/firebase/billing/webhookHandler.ts +13 -1
  11. package/src/firebase/crud/aggregate.ts +20 -5
  12. package/src/firebase/crud/create.ts +31 -7
  13. package/src/firebase/crud/list.ts +36 -24
  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 +2 -2
  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 +22 -60
  28. package/src/shared/firebase.ts +1 -25
  29. package/src/shared/index.ts +2 -1
  30. package/src/shared/logger.ts +3 -7
  31. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  32. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  33. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  34. package/src/shared/utils/external/subscription.ts +12 -2
  35. package/src/shared/utils/internal/auth.ts +140 -16
  36. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  37. package/src/shared/utils/internal/validation.ts +47 -3
  38. package/src/shared/utils.ts +170 -66
  39. package/src/supabase/auth/deleteAccount.ts +52 -0
  40. package/src/supabase/auth/getCustomClaims.ts +56 -0
  41. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  42. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  43. package/src/supabase/auth/setCustomClaims.ts +73 -0
  44. package/src/supabase/baseFunction.ts +306 -0
  45. package/src/supabase/billing/cancelSubscription.ts +57 -0
  46. package/src/supabase/billing/changePlan.ts +62 -0
  47. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  48. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  49. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  50. package/src/supabase/crud/aggregate.ts +169 -0
  51. package/src/supabase/crud/create.ts +225 -0
  52. package/src/supabase/crud/delete.ts +154 -0
  53. package/src/supabase/crud/get.ts +89 -0
  54. package/src/supabase/crud/index.ts +24 -0
  55. package/src/supabase/crud/list.ts +388 -0
  56. package/src/supabase/crud/update.ts +199 -0
  57. package/src/supabase/helpers/authProvider.ts +45 -0
  58. package/src/supabase/index.ts +73 -0
  59. package/src/supabase/registerCrudFunctions.ts +180 -0
  60. package/src/supabase/utils/idempotency.ts +141 -0
  61. package/src/supabase/utils/monitoring.ts +187 -0
  62. package/src/supabase/utils/rateLimiter.ts +216 -0
  63. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  64. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  65. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  66. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  67. package/src/vercel/api/billing/cancel.ts +2 -1
  68. package/src/vercel/api/billing/change-plan.ts +3 -1
  69. package/src/vercel/api/billing/customer-portal.ts +4 -1
  70. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  71. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  72. package/src/vercel/api/crud/create.ts +14 -8
  73. package/src/vercel/api/crud/delete.ts +15 -6
  74. package/src/vercel/api/crud/get.ts +16 -8
  75. package/src/vercel/api/crud/list.ts +22 -10
  76. package/src/vercel/api/crud/update.ts +16 -10
  77. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  78. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  79. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  80. package/src/vercel/api/utils/cors.ts +13 -2
  81. package/src/vercel/baseFunction.ts +40 -25
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.10",
3
+ "version": "0.0.12",
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",
@@ -34,7 +39,7 @@
34
39
  "node": ">=18.0.0"
35
40
  },
36
41
  "scripts": {
37
- "type-check": "tsc --noEmit",
42
+ "type-check": "bunx tsc --noEmit",
38
43
  "build": "node build.mjs && tsc -p tsconfig.json",
39
44
  "build:types": "tsc -p tsconfig.json",
40
45
  "prepublishOnly": "bun run build",
@@ -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.25",
51
+ "@donotdev/firebase": "^0.0.12",
52
+ "@donotdev/supabase": "^0.0.2"
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.76.11",
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
+ "@sentry/node": {
89
+ "optional": true
90
+ },
91
+ "@supabase/supabase-js": {
92
+ "optional": true
93
+ },
94
+ "firebase-admin": {
95
+ "optional": true
96
+ },
97
+ "firebase-functions": {
98
+ "optional": true
99
+ },
100
+ "next": {
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];
@@ -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,
@@ -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
 
@@ -115,13 +115,40 @@ 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 (!listFields.includes(field) && field !== 'status' && field !== 'id') {
141
+ throw new DoNotDevError(
142
+ `Where field '${field}' is not allowed`,
143
+ 'invalid-argument'
144
+ );
145
+ }
146
+ } else if (field.startsWith('_') || field.includes('.')) {
147
+ throw new DoNotDevError(
148
+ `Where field '${field}' is not allowed`,
149
+ 'invalid-argument'
150
+ );
151
+ }
125
152
  query = query.where(field, operator, value);
126
153
  }
127
154
 
@@ -144,31 +171,16 @@ function listEntitiesLogicFactory(
144
171
  query = query.startAfter(startAfterDoc);
145
172
  }
146
173
 
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
- );
174
+ // W13: Cap at MAX_LIST_LIMIT to prevent unbounded reads (DoS via cost amplification).
175
+ const MAX_LIST_LIMIT = 500;
176
+ const effectiveLimit = limit !== undefined && limit > 0
177
+ ? Math.min(limit, MAX_LIST_LIMIT)
178
+ : MAX_LIST_LIMIT;
179
+ query = query.limit(effectiveLimit);
159
180
 
160
181
  // Execute the query
161
182
  const snapshot = await query.get();
162
183
 
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
184
  // Helper: Check if value is a Picture object (has thumbUrl and fullUrl)
173
185
  const isPictureObject = (value: any): boolean => {
174
186
  return (
@@ -240,7 +252,7 @@ function listEntitiesLogicFactory(
240
252
  items: transformFirestoreData(docs),
241
253
  lastVisible: snapshot.docs[snapshot.docs.length - 1]?.id || null,
242
254
  count: snapshot.docs.length,
243
- hasMore: snapshot.docs.length === limit,
255
+ hasMore: snapshot.docs.length === effectiveLimit,
244
256
  };
245
257
  };
246
258
  }
@@ -272,7 +284,7 @@ export const listEntities = (
272
284
  orderBy: v.optional(
273
285
  v.array(v.tuple([v.string(), v.picklist(['asc', 'desc'])]))
274
286
  ),
275
- limit: v.optional(v.pipe(v.number(), v.minValue(1))),
287
+ limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(500))),
276
288
  startAfterId: v.optional(v.string()),
277
289
  search: v.optional(
278
290
  v.object({