@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donotdev/functions",
3
- "version": "0.0.9",
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.19",
46
- "@donotdev/firebase": "^0.0.8"
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);
@@ -30,15 +30,12 @@ export const FUNCTION_CONFIG = {
30
30
  cors: true, // Enable CORS by default for all functions (required for web apps)
31
31
  } as const;
32
32
 
33
- import { stripeSecretKey, stripeWebhookSecret } from './secrets.js';
34
-
35
33
  /** Stripe/billing functions */
36
34
  export const STRIPE_CONFIG = {
37
35
  ...BASE_CONFIG,
38
36
  memory: '512MiB' as const,
39
37
  timeoutSeconds: 30,
40
38
  cors: true,
41
- secrets: [stripeSecretKey, stripeWebhookSecret],
42
39
  };
43
40
 
44
41
  /** Auth functions */
@@ -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
 
@@ -16,7 +16,7 @@ import {
16
16
  hasRoleAccess,
17
17
  HIDDEN_STATUSES,
18
18
  } from '@donotdev/core/server';
19
- import type { UserRole } from '@donotdev/core/server';
19
+ import type { EntityOwnershipConfig, UserRole } from '@donotdev/core/server';
20
20
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
21
21
 
22
22
  import { transformFirestoreData } from '../../shared/index.js';
@@ -37,14 +37,15 @@ export type GetEntityRequest = { id: string };
37
37
  */
38
38
  function getEntityLogicFactory(
39
39
  collection: string,
40
- documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
40
+ documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
41
+ ownership?: EntityOwnershipConfig
41
42
  ) {
42
43
  return async function getEntityLogic(
43
44
  data: GetEntityRequest,
44
45
  context: { uid: string; userRole: UserRole; request: CallableRequest<any> }
45
46
  ) {
46
47
  const { id } = data;
47
- const { userRole } = context;
48
+ const { userRole, uid } = context;
48
49
 
49
50
  // Get the document reference
50
51
  const db = getFirebaseAdminFirestore();
@@ -69,11 +70,16 @@ function getEntityLogicFactory(
69
70
  throw new DoNotDevError('Entity not found', 'not-found');
70
71
  }
71
72
 
72
- // Filter fields based on visibility and user role
73
+ const rawData = docData || {};
74
+ const visibilityOptions =
75
+ ownership && uid ? { documentData: rawData, uid, ownership } : undefined;
76
+
77
+ // Filter fields based on visibility and user role (and ownership for visibility: 'owner')
73
78
  const filteredData = filterVisibleFields(
74
- docData || {},
79
+ rawData,
75
80
  documentSchema,
76
- userRole
81
+ userRole,
82
+ visibilityOptions
77
83
  );
78
84
 
79
85
  // Transform the document data back to the application format
@@ -90,13 +96,15 @@ function getEntityLogicFactory(
90
96
  * @param documentSchema - The Valibot schema for document validation
91
97
  * @param requiredRole - Minimum role required for this operation
92
98
  * @param customSchema - Optional custom request schema
99
+ * @param ownership - Optional ownership config for visibility: 'owner' field masking
93
100
  * @returns Firebase callable function
94
101
  */
95
102
  export const getEntity = (
96
103
  collection: string,
97
104
  documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
98
105
  requiredRole: UserRole,
99
- customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>
106
+ customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
107
+ ownership?: EntityOwnershipConfig
100
108
  ): CallableFunction<GetEntityRequest, Promise<any>> => {
101
109
  const requestSchema =
102
110
  customSchema ||
@@ -108,7 +116,7 @@ export const getEntity = (
108
116
  CRUD_READ_CONFIG,
109
117
  requestSchema,
110
118
  'get_entity',
111
- getEntityLogicFactory(collection, documentSchema),
119
+ getEntityLogicFactory(collection, documentSchema, ownership),
112
120
  requiredRole
113
121
  );
114
122
  };