@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.
- package/package.json +31 -7
- package/src/firebase/auth/setCustomClaims.ts +26 -4
- package/src/firebase/baseFunction.ts +43 -20
- package/src/firebase/billing/cancelSubscription.ts +9 -1
- package/src/firebase/billing/changePlan.ts +8 -2
- package/src/firebase/billing/createCustomerPortal.ts +16 -2
- package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
- package/src/firebase/billing/webhookHandler.ts +13 -1
- package/src/firebase/config/constants.ts +0 -3
- package/src/firebase/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/get.ts +16 -8
- package/src/firebase/crud/list.ts +70 -29
- package/src/firebase/crud/update.ts +29 -7
- package/src/firebase/oauth/exchangeToken.ts +30 -4
- package/src/firebase/oauth/githubAccess.ts +8 -3
- package/src/firebase/registerCrudFunctions.ts +15 -4
- package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
- package/src/shared/__tests__/detectFirestore.test.ts +52 -0
- package/src/shared/__tests__/errorHandling.test.ts +144 -0
- package/src/shared/__tests__/idempotency.test.ts +95 -0
- package/src/shared/__tests__/rateLimiter.test.ts +142 -0
- package/src/shared/__tests__/validation.test.ts +172 -0
- package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
- package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
- package/src/shared/billing/webhookHandler.ts +16 -7
- package/src/shared/errorHandling.ts +16 -54
- package/src/shared/firebase.ts +1 -25
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
- package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
- package/src/shared/schema.ts +7 -1
- package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
- package/src/shared/utils/external/subscription.ts +10 -0
- package/src/shared/utils/internal/auth.ts +140 -16
- package/src/shared/utils/internal/rateLimiter.ts +101 -90
- package/src/shared/utils/internal/validation.ts +47 -3
- package/src/shared/utils.ts +154 -39
- package/src/supabase/auth/deleteAccount.ts +59 -0
- package/src/supabase/auth/getCustomClaims.ts +56 -0
- package/src/supabase/auth/getUserAuthStatus.ts +64 -0
- package/src/supabase/auth/removeCustomClaims.ts +75 -0
- package/src/supabase/auth/setCustomClaims.ts +73 -0
- package/src/supabase/baseFunction.ts +302 -0
- package/src/supabase/billing/cancelSubscription.ts +57 -0
- package/src/supabase/billing/changePlan.ts +62 -0
- package/src/supabase/billing/createCheckoutSession.ts +82 -0
- package/src/supabase/billing/createCustomerPortal.ts +58 -0
- package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
- package/src/supabase/crud/aggregate.ts +169 -0
- package/src/supabase/crud/create.ts +225 -0
- package/src/supabase/crud/delete.ts +154 -0
- package/src/supabase/crud/get.ts +89 -0
- package/src/supabase/crud/index.ts +24 -0
- package/src/supabase/crud/list.ts +357 -0
- package/src/supabase/crud/update.ts +199 -0
- package/src/supabase/helpers/authProvider.ts +45 -0
- package/src/supabase/index.ts +73 -0
- package/src/supabase/registerCrudFunctions.ts +180 -0
- package/src/supabase/utils/idempotency.ts +141 -0
- package/src/supabase/utils/monitoring.ts +187 -0
- package/src/supabase/utils/rateLimiter.ts +216 -0
- package/src/vercel/api/auth/get-custom-claims.ts +3 -2
- package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
- package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
- package/src/vercel/api/auth/set-custom-claims.ts +5 -2
- package/src/vercel/api/billing/cancel.ts +2 -1
- package/src/vercel/api/billing/change-plan.ts +3 -1
- package/src/vercel/api/billing/customer-portal.ts +4 -1
- package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
- package/src/vercel/api/billing/webhook-handler.ts +24 -4
- package/src/vercel/api/crud/create.ts +14 -8
- package/src/vercel/api/crud/delete.ts +15 -6
- package/src/vercel/api/crud/get.ts +16 -8
- package/src/vercel/api/crud/list.ts +22 -10
- package/src/vercel/api/crud/update.ts +16 -10
- package/src/vercel/api/oauth/check-github-access.ts +2 -5
- package/src/vercel/api/oauth/grant-github-access.ts +1 -5
- package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
- package/src/vercel/api/utils/cors.ts +13 -2
- 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.
|
|
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.
|
|
46
|
-
"@donotdev/firebase": "^0.0.
|
|
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.
|
|
50
|
-
"
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
223
|
-
if (process.env.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
const isAdmin =
|
|
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
|
-
//
|
|
266
|
-
|
|
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
|
|
95
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
package/src/firebase/crud/get.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
};
|