@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
|
@@ -89,8 +89,13 @@ export async function updateUserSubscription(
|
|
|
89
89
|
const auth = getFirebaseAdminAuth();
|
|
90
90
|
const db = getFirebaseAdminFirestore();
|
|
91
91
|
|
|
92
|
+
// Read existing claims and merge to avoid overwriting other claims
|
|
93
|
+
const user = await auth.getUser(firebaseUid);
|
|
94
|
+
const currentClaims = user.customClaims || {};
|
|
95
|
+
|
|
92
96
|
// Update Firebase Auth custom claims
|
|
93
97
|
await auth.setCustomUserClaims(firebaseUid, {
|
|
98
|
+
...currentClaims,
|
|
94
99
|
subscription: subscriptionClaims,
|
|
95
100
|
});
|
|
96
101
|
|
|
@@ -150,8 +155,13 @@ export async function cancelUserSubscription(
|
|
|
150
155
|
const auth = getFirebaseAdminAuth();
|
|
151
156
|
const db = getFirebaseAdminFirestore();
|
|
152
157
|
|
|
158
|
+
// Read existing claims and merge to avoid overwriting other claims
|
|
159
|
+
const user = await auth.getUser(firebaseUid);
|
|
160
|
+
const currentClaims = user.customClaims || {};
|
|
161
|
+
|
|
153
162
|
// Update Firebase Auth custom claims
|
|
154
163
|
await auth.setCustomUserClaims(firebaseUid, {
|
|
164
|
+
...currentClaims,
|
|
155
165
|
subscription: subscriptionClaims,
|
|
156
166
|
});
|
|
157
167
|
|
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @fileoverview Authentication utility functions
|
|
5
|
-
* @description
|
|
5
|
+
* @description Provider-agnostic functions for user authentication and authorization.
|
|
6
|
+
* Uses IServerAuthAdapter from provider registry when configured, falls back to Firebase Admin SDK.
|
|
6
7
|
*
|
|
7
|
-
* @version 0.0.
|
|
8
|
+
* @version 0.0.2
|
|
8
9
|
* @since 0.0.1
|
|
9
10
|
* @author AMBROISE PARK Consulting
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
14
|
+
import { hasProvider, getProvider } from '@donotdev/core/server';
|
|
13
15
|
|
|
14
16
|
import type { NextApiRequest } from 'next';
|
|
15
17
|
|
|
18
|
+
/** Auth provider type for explicit configuration */
|
|
19
|
+
export type AuthProvider = 'firebase' | 'supabase';
|
|
20
|
+
|
|
16
21
|
// IMPORTANT: Don't call getAuth() at module load - breaks Firebase deployment
|
|
17
|
-
// const auth = getAuth(); // REMOVED - use getAuth() directly in functions instead
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Validates that user is authenticated
|
|
@@ -31,9 +35,10 @@ export function assertAuthenticated(uid: string): string {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
* Validates that user has admin privileges
|
|
38
|
+
* Validates that user has admin privileges.
|
|
39
|
+
* Uses IServerAuthAdapter when configured, falls back to Firebase Admin SDK.
|
|
35
40
|
*
|
|
36
|
-
* @version 0.0.
|
|
41
|
+
* @version 0.0.2
|
|
37
42
|
* @since 0.0.1
|
|
38
43
|
* @author AMBROISE PARK Consulting
|
|
39
44
|
*/
|
|
@@ -41,8 +46,25 @@ export async function assertAdmin(uid: string): Promise<string> {
|
|
|
41
46
|
assertAuthenticated(uid);
|
|
42
47
|
|
|
43
48
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
let claims: Record<string, unknown> = {};
|
|
50
|
+
|
|
51
|
+
if (hasProvider('serverAuth')) {
|
|
52
|
+
const user = await getProvider('serverAuth').getUser(uid);
|
|
53
|
+
claims = (user?.customClaims as Record<string, unknown>) ?? {};
|
|
54
|
+
} else {
|
|
55
|
+
// Legacy Firebase path
|
|
56
|
+
const user = await getFirebaseAdminAuth().getUser(uid);
|
|
57
|
+
claims = user.customClaims ?? {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// W8: Unified role-check logic consistent with shared/utils.ts:assertAdmin.
|
|
61
|
+
// Check role string first (standard), then legacy boolean flags.
|
|
62
|
+
const role = claims.role;
|
|
63
|
+
const isAdmin =
|
|
64
|
+
role === 'admin' ||
|
|
65
|
+
role === 'super' ||
|
|
66
|
+
claims.isAdmin === true ||
|
|
67
|
+
claims.isSuper === true;
|
|
46
68
|
|
|
47
69
|
if (!isAdmin) {
|
|
48
70
|
throw new Error('Admin privileges required');
|
|
@@ -50,22 +72,23 @@ export async function assertAdmin(uid: string): Promise<string> {
|
|
|
50
72
|
|
|
51
73
|
return uid;
|
|
52
74
|
} catch (error) {
|
|
75
|
+
// C4: Re-throw permission-denied as-is so callers can distinguish it from
|
|
76
|
+
// infrastructure failures. Only wrap genuine unexpected errors.
|
|
77
|
+
if (error instanceof Error && error.message === 'Admin privileges required') {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
53
80
|
throw new Error('Failed to verify admin status');
|
|
54
81
|
}
|
|
55
82
|
}
|
|
56
83
|
|
|
57
84
|
/**
|
|
58
|
-
*
|
|
85
|
+
* Extract Bearer token from authorization header.
|
|
59
86
|
*
|
|
60
87
|
* @version 0.0.1
|
|
61
|
-
* @since 0.0
|
|
88
|
+
* @since 0.5.0
|
|
62
89
|
* @author AMBROISE PARK Consulting
|
|
63
90
|
*/
|
|
64
|
-
|
|
65
|
-
req: NextApiRequest
|
|
66
|
-
): Promise<string> {
|
|
67
|
-
const authHeader = req.headers.authorization;
|
|
68
|
-
|
|
91
|
+
function extractBearerToken(authHeader: string | undefined): string {
|
|
69
92
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
70
93
|
throw new Error('Missing or invalid authorization header');
|
|
71
94
|
}
|
|
@@ -76,10 +99,111 @@ export async function verifyFirebaseAuthToken(
|
|
|
76
99
|
throw new Error('Missing token in authorization header');
|
|
77
100
|
}
|
|
78
101
|
|
|
102
|
+
return token;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Verify a Bearer token using the specified auth provider.
|
|
107
|
+
* Returns `{ uid: string }` on success, throws on failure.
|
|
108
|
+
*
|
|
109
|
+
* Provider resolution order:
|
|
110
|
+
* 1. Explicit `provider` parameter ('firebase' | 'supabase')
|
|
111
|
+
* 2. Provider registry (IServerAuthAdapter via `hasProvider('serverAuth')`)
|
|
112
|
+
* 3. Firebase Admin SDK fallback
|
|
113
|
+
*
|
|
114
|
+
* @param token - Raw JWT token (without "Bearer " prefix)
|
|
115
|
+
* @param provider - Optional explicit auth provider
|
|
116
|
+
* @returns Object with verified user's uid
|
|
117
|
+
*
|
|
118
|
+
* @version 0.0.1
|
|
119
|
+
* @since 0.5.0
|
|
120
|
+
* @author AMBROISE PARK Consulting
|
|
121
|
+
*/
|
|
122
|
+
export async function verifyToken(
|
|
123
|
+
token: string,
|
|
124
|
+
provider?: AuthProvider
|
|
125
|
+
): Promise<{ uid: string }> {
|
|
79
126
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
127
|
+
// Explicit provider
|
|
128
|
+
if (provider === 'supabase') {
|
|
129
|
+
return await verifySupabaseToken(token);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (provider === 'firebase') {
|
|
133
|
+
const decodedToken = await getFirebaseAdminAuth().verifyIdToken(token);
|
|
134
|
+
return { uid: decodedToken.uid };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Auto-detect: provider registry first, then Firebase fallback
|
|
138
|
+
if (hasProvider('serverAuth')) {
|
|
139
|
+
const verified = await getProvider('serverAuth').verifyToken(token);
|
|
140
|
+
return { uid: verified.uid };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Legacy Firebase path
|
|
144
|
+
const decodedToken = await getFirebaseAdminAuth().verifyIdToken(token);
|
|
145
|
+
return { uid: decodedToken.uid };
|
|
82
146
|
} catch (error) {
|
|
83
147
|
throw new Error('Invalid or expired token');
|
|
84
148
|
}
|
|
85
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Verify a Supabase JWT token using the Supabase admin client.
|
|
153
|
+
* Requires SUPABASE_URL and SUPABASE_SECRET_KEY (or SUPABASE_SERVICE_ROLE_KEY) env vars.
|
|
154
|
+
*
|
|
155
|
+
* @version 0.0.1
|
|
156
|
+
* @since 0.5.0
|
|
157
|
+
* @author AMBROISE PARK Consulting
|
|
158
|
+
*/
|
|
159
|
+
async function verifySupabaseToken(token: string): Promise<{ uid: string }> {
|
|
160
|
+
// Lazy import to avoid pulling in @supabase/supabase-js when using Firebase
|
|
161
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
162
|
+
|
|
163
|
+
const supabaseUrl = process.env.SUPABASE_URL;
|
|
164
|
+
const secretKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
165
|
+
|
|
166
|
+
if (!supabaseUrl || !secretKey) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'Missing SUPABASE_URL or SUPABASE_SECRET_KEY/SUPABASE_SERVICE_ROLE_KEY environment variables'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const supabaseAdmin = createClient(supabaseUrl, secretKey, {
|
|
173
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const { data: { user }, error } = await supabaseAdmin.auth.getUser(token);
|
|
177
|
+
|
|
178
|
+
if (error || !user) {
|
|
179
|
+
throw new Error('Invalid or expired token');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { uid: user.id };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Verifies auth token from request.
|
|
187
|
+
* Uses IServerAuthAdapter when configured, falls back to Firebase Admin SDK.
|
|
188
|
+
*
|
|
189
|
+
* @param req - Next.js API request
|
|
190
|
+
* @param provider - Optional explicit auth provider ('firebase' | 'supabase')
|
|
191
|
+
* @returns Verified user's uid
|
|
192
|
+
*
|
|
193
|
+
* @version 0.0.3
|
|
194
|
+
* @since 0.0.1
|
|
195
|
+
* @author AMBROISE PARK Consulting
|
|
196
|
+
*/
|
|
197
|
+
export async function verifyAuthToken(
|
|
198
|
+
req: NextApiRequest,
|
|
199
|
+
provider?: AuthProvider
|
|
200
|
+
): Promise<string> {
|
|
201
|
+
const token = extractBearerToken(req.headers.authorization);
|
|
202
|
+
const { uid } = await verifyToken(token, provider);
|
|
203
|
+
return uid;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @deprecated Use verifyAuthToken instead. Kept for backwards compatibility.
|
|
208
|
+
*/
|
|
209
|
+
export const verifyFirebaseAuthToken = verifyAuthToken;
|
|
@@ -12,19 +12,9 @@
|
|
|
12
12
|
import { logger } from 'firebase-functions/v2';
|
|
13
13
|
|
|
14
14
|
import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
15
|
+
import type { ServerRateLimitConfig as RateLimitConfig, ServerRateLimitResult as RateLimitResult } from '@donotdev/core';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
maxAttempts: number;
|
|
18
|
-
windowMs: number;
|
|
19
|
-
blockDurationMs: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface RateLimitResult {
|
|
23
|
-
allowed: boolean;
|
|
24
|
-
remaining: number;
|
|
25
|
-
resetAt: Date | null;
|
|
26
|
-
blockRemainingSeconds: number | null;
|
|
27
|
-
}
|
|
17
|
+
export type { RateLimitConfig, RateLimitResult };
|
|
28
18
|
|
|
29
19
|
interface RateLimitEntry {
|
|
30
20
|
attempts: number;
|
|
@@ -104,8 +94,21 @@ export async function checkRateLimit(
|
|
|
104
94
|
}
|
|
105
95
|
|
|
106
96
|
/**
|
|
107
|
-
* Check rate limit using Firestore for persistent storage
|
|
108
|
-
* This is the recommended approach for production
|
|
97
|
+
* Check rate limit using Firestore for persistent storage.
|
|
98
|
+
* This is the recommended approach for production.
|
|
99
|
+
*
|
|
100
|
+
* **Architecture decision — Firestore transaction for rate limiting:**
|
|
101
|
+
* The read-modify-write cycle is wrapped in a Firestore transaction to
|
|
102
|
+
* eliminate TOCTOU races. While transactions add some latency, rate limiting
|
|
103
|
+
* is a best-effort abuse-prevention mechanism, not a hard security boundary.
|
|
104
|
+
* The window-based approach (maxAttempts per windowMs) tolerates minor
|
|
105
|
+
* timing variations. If transaction contention becomes an issue under extreme
|
|
106
|
+
* load, the catch block fails open (allows the request) to avoid blocking
|
|
107
|
+
* legitimate traffic — this is an intentional trade-off favoring availability
|
|
108
|
+
* over strict enforcement.
|
|
109
|
+
*
|
|
110
|
+
* For stricter rate limiting (e.g. payment endpoints), consumers can use
|
|
111
|
+
* Redis-backed rate limiters or API gateway-level throttling.
|
|
109
112
|
*
|
|
110
113
|
* @version 0.0.1
|
|
111
114
|
* @since 0.0.1
|
|
@@ -120,89 +123,97 @@ export async function checkRateLimitWithFirestore(
|
|
|
120
123
|
|
|
121
124
|
try {
|
|
122
125
|
const now = Date.now();
|
|
123
|
-
const doc = await rateLimitRef.get();
|
|
124
|
-
|
|
125
|
-
if (!doc.exists) {
|
|
126
|
-
// First attempt
|
|
127
|
-
await rateLimitRef.set({
|
|
128
|
-
attempts: 1,
|
|
129
|
-
windowStart: now,
|
|
130
|
-
blockUntil: null,
|
|
131
|
-
lastUpdated: now,
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
allowed: true,
|
|
136
|
-
remaining: config.maxAttempts - 1,
|
|
137
|
-
resetAt: new Date(now + config.windowMs),
|
|
138
|
-
blockRemainingSeconds: null,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
126
|
|
|
142
|
-
|
|
127
|
+
// W2: Wrap the entire read-modify-write in a Firestore transaction to
|
|
128
|
+
// eliminate the TOCTOU race where two concurrent requests both read the
|
|
129
|
+
// same counter and both increment it independently.
|
|
130
|
+
let result: RateLimitResult = {
|
|
131
|
+
allowed: true,
|
|
132
|
+
remaining: config.maxAttempts - 1,
|
|
133
|
+
resetAt: new Date(now + config.windowMs),
|
|
134
|
+
blockRemainingSeconds: null,
|
|
135
|
+
};
|
|
143
136
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
137
|
+
await db.runTransaction(async (tx) => {
|
|
138
|
+
const doc = await tx.get(rateLimitRef);
|
|
139
|
+
|
|
140
|
+
if (!doc.exists) {
|
|
141
|
+
tx.set(rateLimitRef, {
|
|
142
|
+
attempts: 1,
|
|
143
|
+
windowStart: now,
|
|
144
|
+
blockUntil: null,
|
|
145
|
+
lastUpdated: now,
|
|
146
|
+
});
|
|
147
|
+
result = {
|
|
148
|
+
allowed: true,
|
|
149
|
+
remaining: config.maxAttempts - 1,
|
|
150
|
+
resetAt: new Date(now + config.windowMs),
|
|
151
|
+
blockRemainingSeconds: null,
|
|
152
|
+
};
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const data = doc.data() as RateLimitEntry & { lastUpdated: number };
|
|
157
|
+
|
|
158
|
+
// Check if currently blocked
|
|
159
|
+
if (data.blockUntil && now < data.blockUntil) {
|
|
160
|
+
result = {
|
|
161
|
+
allowed: false,
|
|
162
|
+
remaining: 0,
|
|
163
|
+
resetAt: new Date(data.blockUntil),
|
|
164
|
+
blockRemainingSeconds: Math.ceil((data.blockUntil - now) / 1000),
|
|
165
|
+
};
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check if window expired — reset
|
|
170
|
+
if (now >= data.windowStart + config.windowMs) {
|
|
171
|
+
tx.set(rateLimitRef, {
|
|
172
|
+
attempts: 1,
|
|
173
|
+
windowStart: now,
|
|
174
|
+
blockUntil: null,
|
|
175
|
+
lastUpdated: now,
|
|
176
|
+
});
|
|
177
|
+
result = {
|
|
178
|
+
allowed: true,
|
|
179
|
+
remaining: config.maxAttempts - 1,
|
|
180
|
+
resetAt: new Date(now + config.windowMs),
|
|
181
|
+
blockRemainingSeconds: null,
|
|
182
|
+
};
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check if limit exceeded
|
|
187
|
+
if (data.attempts >= config.maxAttempts) {
|
|
188
|
+
const blockUntil = now + config.blockDurationMs;
|
|
189
|
+
tx.update(rateLimitRef, { blockUntil, lastUpdated: now });
|
|
190
|
+
|
|
191
|
+
logger.warn('Rate limit exceeded (Firestore)', {
|
|
192
|
+
key,
|
|
193
|
+
attempts: data.attempts,
|
|
194
|
+
blockUntil: new Date(blockUntil),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
result = {
|
|
198
|
+
allowed: false,
|
|
199
|
+
remaining: 0,
|
|
200
|
+
resetAt: new Date(blockUntil),
|
|
201
|
+
blockRemainingSeconds: Math.ceil(config.blockDurationMs / 1000),
|
|
202
|
+
};
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Increment attempts
|
|
207
|
+
tx.update(rateLimitRef, { attempts: data.attempts + 1, lastUpdated: now });
|
|
208
|
+
result = {
|
|
165
209
|
allowed: true,
|
|
166
|
-
remaining: config.maxAttempts - 1,
|
|
167
|
-
resetAt: new Date(
|
|
210
|
+
remaining: config.maxAttempts - (data.attempts + 1),
|
|
211
|
+
resetAt: new Date(data.windowStart + config.windowMs),
|
|
168
212
|
blockRemainingSeconds: null,
|
|
169
213
|
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Check if limit exceeded
|
|
173
|
-
if (data.attempts >= config.maxAttempts) {
|
|
174
|
-
const blockUntil = now + config.blockDurationMs;
|
|
175
|
-
await rateLimitRef.update({
|
|
176
|
-
blockUntil,
|
|
177
|
-
lastUpdated: now,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
logger.warn('Rate limit exceeded (Firestore)', {
|
|
181
|
-
key,
|
|
182
|
-
attempts: data.attempts,
|
|
183
|
-
blockUntil: new Date(blockUntil),
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
allowed: false,
|
|
188
|
-
remaining: 0,
|
|
189
|
-
resetAt: new Date(blockUntil),
|
|
190
|
-
blockRemainingSeconds: Math.ceil(config.blockDurationMs / 1000),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Increment attempts
|
|
195
|
-
await rateLimitRef.update({
|
|
196
|
-
attempts: data.attempts + 1,
|
|
197
|
-
lastUpdated: now,
|
|
198
214
|
});
|
|
199
215
|
|
|
200
|
-
return
|
|
201
|
-
allowed: true,
|
|
202
|
-
remaining: config.maxAttempts - (data.attempts + 1),
|
|
203
|
-
resetAt: new Date(data.windowStart + config.windowMs),
|
|
204
|
-
blockRemainingSeconds: null,
|
|
205
|
-
};
|
|
216
|
+
return result;
|
|
206
217
|
} catch (error) {
|
|
207
218
|
logger.error('Rate limit check failed', {
|
|
208
219
|
key,
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* @fileoverview Validation utility functions
|
|
5
5
|
* @description Functions for validating environment and data
|
|
6
6
|
*
|
|
7
|
-
* @version 0.0.
|
|
7
|
+
* @version 0.0.2
|
|
8
8
|
* @since 0.0.1
|
|
9
9
|
* @author AMBROISE PARK Consulting
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import * as v from 'valibot';
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* Validates environment variables required for Stripe
|
|
14
16
|
*
|
|
@@ -43,19 +45,61 @@ export function safeJsonParse<T = any>(json: string): T | null {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
* Validates
|
|
48
|
+
* Validates a Firestore collection name from client-supplied schema.
|
|
49
|
+
*
|
|
50
|
+
* W22: Vercel CRUD handlers accept `schema` (including collection name) from
|
|
51
|
+
* the client. This is a known design limitation. As a defense-in-depth measure,
|
|
52
|
+
* reject collection names that could be used for path traversal or access to
|
|
53
|
+
* internal collections.
|
|
54
|
+
*
|
|
55
|
+
* @param name - Collection name to validate
|
|
56
|
+
* @throws Error if the name is unsafe
|
|
47
57
|
*
|
|
48
58
|
* @version 0.0.1
|
|
49
59
|
* @since 0.0.1
|
|
50
60
|
* @author AMBROISE PARK Consulting
|
|
51
61
|
*/
|
|
62
|
+
export function validateCollectionName(name: string): void {
|
|
63
|
+
if (!name || typeof name !== 'string') {
|
|
64
|
+
throw new Error('Collection name is required');
|
|
65
|
+
}
|
|
66
|
+
if (name.includes('/') || name.includes('..') || name.startsWith('_')) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'Invalid collection name: must not contain "/", "..", or start with "_"'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validates document data against an optional Valibot schema.
|
|
75
|
+
*
|
|
76
|
+
* W1: Previous stub ignored the schema parameter entirely. Now performs
|
|
77
|
+
* actual schema validation when a schema is provided.
|
|
78
|
+
*
|
|
79
|
+
* @version 0.0.2
|
|
80
|
+
* @since 0.0.1
|
|
81
|
+
* @author AMBROISE PARK Consulting
|
|
82
|
+
*/
|
|
52
83
|
export function validateDocument(data: any, schema?: any): void {
|
|
53
84
|
if (!data || typeof data !== 'object') {
|
|
54
85
|
throw new Error('Invalid document data');
|
|
55
86
|
}
|
|
56
87
|
|
|
57
|
-
// Basic validation - can be extended with schema validation
|
|
58
88
|
if (Array.isArray(data)) {
|
|
59
89
|
throw new Error('Document data cannot be an array');
|
|
60
90
|
}
|
|
91
|
+
|
|
92
|
+
// W1: Perform schema validation when a Valibot schema is supplied.
|
|
93
|
+
if (schema) {
|
|
94
|
+
const result = v.safeParse(schema, data);
|
|
95
|
+
if (!result.success) {
|
|
96
|
+
const messages = result.issues
|
|
97
|
+
.map(
|
|
98
|
+
(issue) =>
|
|
99
|
+
`${issue.path?.map((p) => (p as any).key).join('.') || 'root'}: ${issue.message}`
|
|
100
|
+
)
|
|
101
|
+
.join('; ');
|
|
102
|
+
throw new Error(`Validation failed: ${messages}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
61
105
|
}
|