@donotdev/functions 0.0.10 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/list.ts +7 -22
- 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 +2 -2
- 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/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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// packages/functions/src/supabase/utils/rateLimiter.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Rate limiting utilities for Supabase Edge Functions
|
|
5
|
+
* @description Postgres-based rate limiting via an atomic PostgreSQL function (RPC).
|
|
6
|
+
*
|
|
7
|
+
* **Why RPC?**
|
|
8
|
+
* The previous SELECT → UPDATE pattern had a TOCTOU race condition: two concurrent
|
|
9
|
+
* requests could both read `attempts < maxAttempts`, both increment, and both be allowed —
|
|
10
|
+
* effectively bypassing the rate limit under load. The PostgreSQL function executes as a
|
|
11
|
+
* single atomic transaction, eliminating the race.
|
|
12
|
+
*
|
|
13
|
+
* **Required migration:**
|
|
14
|
+
* Deploy `RATE_LIMIT_CHECK_SQL` to your Supabase project before using this function.
|
|
15
|
+
* Run: `supabase migration new rate_limit_check` then paste the SQL into the file.
|
|
16
|
+
*
|
|
17
|
+
* **Fail behaviour:**
|
|
18
|
+
* On RPC error or unexpected failure, this function FAILS CLOSED (blocks the request).
|
|
19
|
+
* A broken rate limiter must not silently allow unlimited access to protected resources.
|
|
20
|
+
*
|
|
21
|
+
* @version 0.0.2
|
|
22
|
+
* @since 0.5.0
|
|
23
|
+
* @author AMBROISE PARK Consulting
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
27
|
+
import type { ServerRateLimitConfig as RateLimitConfig, ServerRateLimitResult as RateLimitResult } from '@donotdev/core';
|
|
28
|
+
|
|
29
|
+
export type { RateLimitConfig, RateLimitResult };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* PostgreSQL function required for atomic rate limiting.
|
|
33
|
+
* Deploy this SQL to your Supabase project via a migration.
|
|
34
|
+
*
|
|
35
|
+
* The function uses `INSERT … ON CONFLICT DO UPDATE` which executes as a single
|
|
36
|
+
* atomic statement — no TOCTOU race between SELECT and UPDATE.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```bash
|
|
40
|
+
* supabase migration new rate_limit_check
|
|
41
|
+
* # Paste RATE_LIMIT_CHECK_SQL into the generated migration file
|
|
42
|
+
* supabase db push
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export const RATE_LIMIT_CHECK_SQL = `
|
|
46
|
+
-- Required table (create once if not already present)
|
|
47
|
+
CREATE TABLE IF NOT EXISTS public.rate_limits (
|
|
48
|
+
key TEXT PRIMARY KEY,
|
|
49
|
+
attempts INT NOT NULL DEFAULT 0,
|
|
50
|
+
window_start TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
51
|
+
block_until TIMESTAMPTZ,
|
|
52
|
+
last_updated TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
-- Required atomic rate-limit function
|
|
56
|
+
CREATE OR REPLACE FUNCTION public.rate_limit_check(
|
|
57
|
+
p_key TEXT,
|
|
58
|
+
p_max_attempts INT,
|
|
59
|
+
p_window_ms BIGINT,
|
|
60
|
+
p_block_duration_ms BIGINT
|
|
61
|
+
)
|
|
62
|
+
RETURNS jsonb
|
|
63
|
+
LANGUAGE plpgsql
|
|
64
|
+
SECURITY DEFINER
|
|
65
|
+
AS $$
|
|
66
|
+
DECLARE
|
|
67
|
+
v_now TIMESTAMPTZ := clock_timestamp();
|
|
68
|
+
v_window_interval INTERVAL := (p_window_ms || ' milliseconds')::INTERVAL;
|
|
69
|
+
v_block_interval INTERVAL := (p_block_duration_ms || ' milliseconds')::INTERVAL;
|
|
70
|
+
v_row public.rate_limits%ROWTYPE;
|
|
71
|
+
BEGIN
|
|
72
|
+
-- Single atomic upsert: INSERT on first call, conditional UPDATE thereafter.
|
|
73
|
+
-- No separate SELECT → eliminates the TOCTOU race of the previous implementation.
|
|
74
|
+
INSERT INTO public.rate_limits (key, attempts, window_start, block_until, last_updated)
|
|
75
|
+
VALUES (p_key, 1, v_now, NULL, v_now)
|
|
76
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
77
|
+
window_start = CASE
|
|
78
|
+
WHEN rate_limits.window_start + v_window_interval <= v_now THEN v_now
|
|
79
|
+
ELSE rate_limits.window_start
|
|
80
|
+
END,
|
|
81
|
+
attempts = CASE
|
|
82
|
+
-- Currently blocked: do not increment (request will be rejected below)
|
|
83
|
+
WHEN rate_limits.block_until IS NOT NULL AND v_now < rate_limits.block_until
|
|
84
|
+
THEN rate_limits.attempts
|
|
85
|
+
-- Window expired: reset counter
|
|
86
|
+
WHEN rate_limits.window_start + v_window_interval <= v_now
|
|
87
|
+
THEN 1
|
|
88
|
+
-- Within window: increment
|
|
89
|
+
ELSE rate_limits.attempts + 1
|
|
90
|
+
END,
|
|
91
|
+
block_until = CASE
|
|
92
|
+
-- Preserve existing block
|
|
93
|
+
WHEN rate_limits.block_until IS NOT NULL AND v_now < rate_limits.block_until
|
|
94
|
+
THEN rate_limits.block_until
|
|
95
|
+
-- Window just reset — no block
|
|
96
|
+
WHEN rate_limits.window_start + v_window_interval <= v_now
|
|
97
|
+
THEN NULL
|
|
98
|
+
-- Threshold just exceeded — apply block
|
|
99
|
+
WHEN rate_limits.attempts + 1 >= p_max_attempts
|
|
100
|
+
THEN v_now + v_block_interval
|
|
101
|
+
ELSE NULL
|
|
102
|
+
END,
|
|
103
|
+
last_updated = v_now
|
|
104
|
+
RETURNING * INTO v_row;
|
|
105
|
+
|
|
106
|
+
-- Blocked?
|
|
107
|
+
IF v_row.block_until IS NOT NULL AND v_now < v_row.block_until THEN
|
|
108
|
+
RETURN jsonb_build_object(
|
|
109
|
+
'allowed', false,
|
|
110
|
+
'remaining', 0,
|
|
111
|
+
'reset_at_epoch_ms', EXTRACT(EPOCH FROM v_row.block_until)::BIGINT * 1000,
|
|
112
|
+
'block_remaining_seconds', CEIL(EXTRACT(EPOCH FROM (v_row.block_until - v_now)))::INT
|
|
113
|
+
);
|
|
114
|
+
END IF;
|
|
115
|
+
|
|
116
|
+
RETURN jsonb_build_object(
|
|
117
|
+
'allowed', true,
|
|
118
|
+
'remaining', GREATEST(0, p_max_attempts - v_row.attempts),
|
|
119
|
+
'reset_at_epoch_ms', EXTRACT(EPOCH FROM (v_row.window_start + v_window_interval))::BIGINT * 1000,
|
|
120
|
+
'block_remaining_seconds', NULL
|
|
121
|
+
);
|
|
122
|
+
END;
|
|
123
|
+
$$;
|
|
124
|
+
`;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Default rate limits (same as Firebase equivalent).
|
|
128
|
+
*/
|
|
129
|
+
export const DEFAULT_RATE_LIMITS: Record<string, RateLimitConfig> = {
|
|
130
|
+
api: {
|
|
131
|
+
maxAttempts: 100,
|
|
132
|
+
windowMs: 60 * 1000, // 1 minute
|
|
133
|
+
blockDurationMs: 5 * 60 * 1000, // 5 minutes
|
|
134
|
+
},
|
|
135
|
+
create: {
|
|
136
|
+
maxAttempts: 20,
|
|
137
|
+
windowMs: 60 * 1000,
|
|
138
|
+
blockDurationMs: 5 * 60 * 1000,
|
|
139
|
+
},
|
|
140
|
+
update: {
|
|
141
|
+
maxAttempts: 30,
|
|
142
|
+
windowMs: 60 * 1000,
|
|
143
|
+
blockDurationMs: 5 * 60 * 1000,
|
|
144
|
+
},
|
|
145
|
+
delete: {
|
|
146
|
+
maxAttempts: 10,
|
|
147
|
+
windowMs: 60 * 1000,
|
|
148
|
+
blockDurationMs: 10 * 60 * 1000,
|
|
149
|
+
},
|
|
150
|
+
read: {
|
|
151
|
+
maxAttempts: 200,
|
|
152
|
+
windowMs: 60 * 1000,
|
|
153
|
+
blockDurationMs: 5 * 60 * 1000,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Shape returned by the `rate_limit_check` PostgreSQL function. */
|
|
158
|
+
interface RpcResult {
|
|
159
|
+
allowed: boolean;
|
|
160
|
+
remaining: number;
|
|
161
|
+
reset_at_epoch_ms: number;
|
|
162
|
+
block_remaining_seconds: number | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Atomically check and increment a rate limit counter using the `rate_limit_check`
|
|
167
|
+
* PostgreSQL function. Requires the function to be deployed (see `RATE_LIMIT_CHECK_SQL`).
|
|
168
|
+
*
|
|
169
|
+
* **Fail behaviour:** FAILS CLOSED on any error — a broken rate limiter must not
|
|
170
|
+
* allow unlimited access. Log the error and alert your team; do not suppress it.
|
|
171
|
+
*
|
|
172
|
+
* @param supabaseAdmin - Supabase admin client (service role key — never expose to clients)
|
|
173
|
+
* @param key - Rate limit key, e.g. `"create_userId"` or `"api_ip"`
|
|
174
|
+
* @param config - Rate limit configuration
|
|
175
|
+
*/
|
|
176
|
+
export async function checkRateLimitWithPostgres(
|
|
177
|
+
supabaseAdmin: SupabaseClient,
|
|
178
|
+
key: string,
|
|
179
|
+
config: RateLimitConfig
|
|
180
|
+
): Promise<RateLimitResult> {
|
|
181
|
+
const now = Date.now();
|
|
182
|
+
|
|
183
|
+
/** Fail-closed response — blocks the caller on any infrastructure error. */
|
|
184
|
+
const failClosed = (): RateLimitResult => ({
|
|
185
|
+
allowed: false,
|
|
186
|
+
remaining: 0,
|
|
187
|
+
resetAt: new Date(now + config.windowMs),
|
|
188
|
+
blockRemainingSeconds: Math.ceil(config.windowMs / 1000),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const { data, error } = await supabaseAdmin.rpc('rate_limit_check', {
|
|
193
|
+
p_key: key,
|
|
194
|
+
p_max_attempts: config.maxAttempts,
|
|
195
|
+
p_window_ms: config.windowMs,
|
|
196
|
+
p_block_duration_ms: config.blockDurationMs,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (error) {
|
|
200
|
+
// Log for observability — infrastructure errors here need immediate attention.
|
|
201
|
+
console.error('[rateLimit] rate_limit_check RPC failed:', error.message, error.code);
|
|
202
|
+
return failClosed();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = data as RpcResult;
|
|
206
|
+
return {
|
|
207
|
+
allowed: result.allowed,
|
|
208
|
+
remaining: result.remaining,
|
|
209
|
+
resetAt: new Date(result.reset_at_epoch_ms),
|
|
210
|
+
blockRemainingSeconds: result.block_remaining_seconds,
|
|
211
|
+
};
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error('[rateLimit] Unexpected error in checkRateLimitWithPostgres:', error);
|
|
214
|
+
return failClosed();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
13
13
|
|
|
14
14
|
import { handleError } from '../../../shared/errorHandling.js';
|
|
15
|
-
import {
|
|
15
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
16
16
|
|
|
17
17
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
18
18
|
|
|
@@ -25,7 +25,8 @@ export default async function handler(
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// C1: verify JWT — previously only checked header presence.
|
|
29
|
+
const uid = await verifyAuthToken(req);
|
|
29
30
|
const user = await getFirebaseAdminAuth().getUser(uid);
|
|
30
31
|
return res.status(200).json({ customClaims: user.customClaims || {} });
|
|
31
32
|
} catch (error) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
13
13
|
|
|
14
14
|
import { handleError } from '../../../shared/errorHandling.js';
|
|
15
|
-
import {
|
|
15
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
16
16
|
|
|
17
17
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
18
18
|
|
|
@@ -25,7 +25,8 @@ export default async function handler(
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// C1: verify JWT — previously only checked header presence.
|
|
29
|
+
const uid = await verifyAuthToken(req);
|
|
29
30
|
const user = await getFirebaseAdminAuth().getUser(uid);
|
|
30
31
|
|
|
31
32
|
return res.status(200).json({
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
13
13
|
|
|
14
14
|
import { handleError } from '../../../shared/errorHandling.js';
|
|
15
|
-
import {
|
|
15
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
16
16
|
|
|
17
17
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
18
18
|
|
|
@@ -25,7 +25,8 @@ export default async function handler(
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// C1: verify JWT — previously only checked header presence.
|
|
29
|
+
const uid = await verifyAuthToken(req);
|
|
29
30
|
const { claimsToRemove } = req.body;
|
|
30
31
|
|
|
31
32
|
if (!Array.isArray(claimsToRemove)) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
13
13
|
|
|
14
14
|
import { handleError } from '../../../shared/errorHandling.js';
|
|
15
|
-
import {
|
|
15
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
16
16
|
|
|
17
17
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
18
18
|
|
|
@@ -25,7 +25,10 @@ export default async function handler(
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
|
-
|
|
28
|
+
// C1: verifyAuthToken extracts the Bearer token and verifies the JWT.
|
|
29
|
+
// The previous assertAuthenticated(req.headers.authorization) only checked
|
|
30
|
+
// that the header was non-empty — it never verified the signature.
|
|
31
|
+
const uid = await verifyAuthToken(req);
|
|
29
32
|
const { customClaims } = req.body;
|
|
30
33
|
|
|
31
34
|
if (!customClaims || typeof customClaims !== 'object') {
|
|
@@ -36,7 +36,8 @@ async function cancelSubscriptionLogic(
|
|
|
36
36
|
data: { userId: string },
|
|
37
37
|
context: { uid: string }
|
|
38
38
|
) {
|
|
39
|
-
|
|
39
|
+
// C7/W5: Use context.uid — ignore client-supplied userId to prevent IDOR.
|
|
40
|
+
const userId = context.uid;
|
|
40
41
|
|
|
41
42
|
const authProvider = {
|
|
42
43
|
async getUser(uid: string) {
|
|
@@ -45,7 +45,9 @@ async function changePlanLogic(
|
|
|
45
45
|
) {
|
|
46
46
|
validateStripeEnvironment();
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// C7/W5: Use context.uid — ignore client-supplied userId to prevent IDOR.
|
|
49
|
+
const userId = context.uid;
|
|
50
|
+
const { newPriceId, billingConfigKey } = data;
|
|
49
51
|
|
|
50
52
|
// Validate new plan
|
|
51
53
|
const billingItem = billingConfig[billingConfigKey];
|
|
@@ -32,7 +32,10 @@ async function createCustomerPortalLogic(
|
|
|
32
32
|
) {
|
|
33
33
|
validateStripeEnvironment();
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// W6: Ignore client-supplied userId — use the verified uid from auth context
|
|
36
|
+
// to prevent IDOR (any authenticated user opening any user's billing portal).
|
|
37
|
+
const userId = context.uid;
|
|
38
|
+
const { returnUrl } = data;
|
|
36
39
|
|
|
37
40
|
// Get customer ID
|
|
38
41
|
const user = await getFirebaseAdminAuth().getUser(userId);
|
|
@@ -48,7 +48,9 @@ async function refreshSubscriptionStatusLogic(
|
|
|
48
48
|
// Validate environment
|
|
49
49
|
validateStripeEnvironment();
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// W5: Ignore client-supplied userId — use the verified uid from the auth context
|
|
52
|
+
// to prevent IDOR (any authenticated user overwriting any user's subscription claims).
|
|
53
|
+
const userId = context.uid;
|
|
52
54
|
|
|
53
55
|
// Get user from Firebase
|
|
54
56
|
const user = await getFirebaseAdminAuth().getUser(userId);
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { logger } from 'firebase-functions/v2';
|
|
13
13
|
|
|
14
14
|
import type { StripeBackConfig } from '@donotdev/core/server';
|
|
15
|
+
import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
15
16
|
|
|
16
17
|
import { updateUserSubscription } from '../../../shared/billing/helpers/updateUserSubscription.js';
|
|
17
18
|
import { processWebhook } from '../../../shared/billing/webhookHandler.js';
|
|
@@ -47,6 +48,18 @@ export function createStripeWebhook(billingConfig: StripeBackConfig) {
|
|
|
47
48
|
// Get raw body
|
|
48
49
|
const rawBody = await getRawBody(req);
|
|
49
50
|
|
|
51
|
+
// C5: Build a proper authProvider so subscription webhook events can activate.
|
|
52
|
+
// null was previously passed which caused processWebhook to throw on every
|
|
53
|
+
// checkout.session.completed / invoice.payment_succeeded event.
|
|
54
|
+
const authProvider = {
|
|
55
|
+
async getUser(userId: string) {
|
|
56
|
+
return getFirebaseAdminAuth().getUser(userId);
|
|
57
|
+
},
|
|
58
|
+
async setCustomUserClaims(userId: string, claims: Record<string, unknown>) {
|
|
59
|
+
await getFirebaseAdminAuth().setCustomUserClaims(userId, claims);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
50
63
|
// Call shared algorithm
|
|
51
64
|
await processWebhook(
|
|
52
65
|
rawBody,
|
|
@@ -55,7 +68,7 @@ export function createStripeWebhook(billingConfig: StripeBackConfig) {
|
|
|
55
68
|
stripe,
|
|
56
69
|
billingConfig,
|
|
57
70
|
updateUserSubscription,
|
|
58
|
-
|
|
71
|
+
authProvider
|
|
59
72
|
);
|
|
60
73
|
|
|
61
74
|
res.status(200).json({ received: true });
|
|
@@ -70,12 +83,19 @@ export function createStripeWebhook(billingConfig: StripeBackConfig) {
|
|
|
70
83
|
};
|
|
71
84
|
}
|
|
72
85
|
|
|
86
|
+
// W9: Cap raw-body reads at 1 MiB to prevent memory-exhaustion DoS.
|
|
87
|
+
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
88
|
+
|
|
73
89
|
async function getRawBody(req: NextApiRequest): Promise<Buffer> {
|
|
74
90
|
const chunks: Buffer[] = [];
|
|
91
|
+
let totalBytes = 0;
|
|
75
92
|
for await (const chunk of req) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
93
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer);
|
|
94
|
+
totalBytes += buf.length;
|
|
95
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
96
|
+
throw new Error('Request body too large');
|
|
97
|
+
}
|
|
98
|
+
chunks.push(buf);
|
|
79
99
|
}
|
|
80
100
|
return Buffer.concat(chunks as readonly Uint8Array[]);
|
|
81
101
|
}
|
|
@@ -19,10 +19,8 @@ import {
|
|
|
19
19
|
transformFirestoreData,
|
|
20
20
|
} from '../../../shared/index.js';
|
|
21
21
|
import { createMetadata } from '../../../shared/index.js';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
assertAuthenticated,
|
|
25
|
-
} from '../../../shared/utils.js';
|
|
22
|
+
import { validateCollectionName, validateDocument } from '../../../shared/utils.js';
|
|
23
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
26
24
|
|
|
27
25
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
28
26
|
|
|
@@ -35,15 +33,18 @@ export default async function handler(
|
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
try {
|
|
38
|
-
//
|
|
39
|
-
const uid =
|
|
36
|
+
// C1: verify JWT — previously only checked header presence.
|
|
37
|
+
const uid = await verifyAuthToken(req);
|
|
40
38
|
|
|
41
39
|
const { schema, payload } = req.body as CreateEntityData<any>;
|
|
42
40
|
|
|
43
41
|
if (!schema || !payload) {
|
|
44
|
-
|
|
42
|
+
handleError(new Error('Missing schema or payload'));
|
|
45
43
|
}
|
|
46
44
|
|
|
45
|
+
// W22: Validate collection name from client-supplied schema
|
|
46
|
+
validateCollectionName(schema.metadata.collection);
|
|
47
|
+
|
|
47
48
|
// Determine status (default to draft if not provided)
|
|
48
49
|
const status = payload.status ?? DEFAULT_STATUS_VALUE;
|
|
49
50
|
const isDraft = status === 'draft';
|
|
@@ -79,6 +80,11 @@ export default async function handler(
|
|
|
79
80
|
|
|
80
81
|
return res.status(200).json(result);
|
|
81
82
|
} catch (error) {
|
|
82
|
-
|
|
83
|
+
try {
|
|
84
|
+
handleError(error);
|
|
85
|
+
} catch (handledError: any) {
|
|
86
|
+
const status = handledError.code === 'invalid-argument' ? 400 : 500;
|
|
87
|
+
return res.status(status).json({ error: handledError.message, code: handledError.code });
|
|
88
|
+
}
|
|
83
89
|
}
|
|
84
90
|
}
|
|
@@ -13,7 +13,8 @@ import type { GetEntityData } from '@donotdev/core/server';
|
|
|
13
13
|
import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
14
14
|
|
|
15
15
|
import { handleError } from '../../../shared/errorHandling.js';
|
|
16
|
-
import {
|
|
16
|
+
import { validateCollectionName } from '../../../shared/utils.js';
|
|
17
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
17
18
|
|
|
18
19
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
19
20
|
|
|
@@ -26,21 +27,24 @@ export default async function handler(
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
try {
|
|
29
|
-
//
|
|
30
|
-
const uid =
|
|
30
|
+
// C1: verify JWT — previously only checked header presence.
|
|
31
|
+
const uid = await verifyAuthToken(req);
|
|
31
32
|
|
|
32
33
|
const { schema, id } = req.body as { schema: any; id: string };
|
|
33
34
|
|
|
34
35
|
if (!schema || !id) {
|
|
35
|
-
|
|
36
|
+
handleError(new Error('Missing schema or id'));
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// W22: Validate collection name from client-supplied schema
|
|
40
|
+
validateCollectionName(schema.metadata.collection);
|
|
41
|
+
|
|
38
42
|
// Check if document exists
|
|
39
43
|
const db = getFirebaseAdminFirestore();
|
|
40
44
|
const doc = await db.collection(schema.metadata.collection).doc(id).get();
|
|
41
45
|
|
|
42
46
|
if (!doc.exists) {
|
|
43
|
-
|
|
47
|
+
handleError(new Error('Document not found'));
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
// Delete the document from Firestore
|
|
@@ -52,6 +56,11 @@ export default async function handler(
|
|
|
52
56
|
id,
|
|
53
57
|
});
|
|
54
58
|
} catch (error) {
|
|
55
|
-
|
|
59
|
+
try {
|
|
60
|
+
handleError(error);
|
|
61
|
+
} catch (handledError: any) {
|
|
62
|
+
const status = handledError.code === 'invalid-argument' ? 400 : 500;
|
|
63
|
+
return res.status(status).json({ error: handledError.message, code: handledError.code });
|
|
64
|
+
}
|
|
56
65
|
}
|
|
57
66
|
}
|
|
@@ -16,7 +16,8 @@ import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
|
16
16
|
import { handleError } from '../../../shared/errorHandling.js';
|
|
17
17
|
import { transformFirestoreData } from '../../../shared/index.js';
|
|
18
18
|
import { filterVisibleFields } from '../../../shared/index.js';
|
|
19
|
-
import {
|
|
19
|
+
import { validateCollectionName } from '../../../shared/utils.js';
|
|
20
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
20
21
|
|
|
21
22
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
22
23
|
|
|
@@ -29,15 +30,18 @@ export default async function handler(
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
try {
|
|
32
|
-
//
|
|
33
|
-
const uid =
|
|
33
|
+
// C1: verify JWT — previously only checked header presence.
|
|
34
|
+
const uid = await verifyAuthToken(req);
|
|
34
35
|
|
|
35
36
|
const { schema, id } = req.query as unknown as GetEntityData<any>;
|
|
36
37
|
|
|
37
38
|
if (!schema || !id) {
|
|
38
|
-
|
|
39
|
+
handleError(new Error('Missing schema or id'));
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// W22: Validate collection name from client-supplied schema
|
|
43
|
+
validateCollectionName(schema.metadata.collection);
|
|
44
|
+
|
|
41
45
|
// Get the document from Firestore
|
|
42
46
|
const db = getFirebaseAdminFirestore();
|
|
43
47
|
const doc = await db
|
|
@@ -46,13 +50,13 @@ export default async function handler(
|
|
|
46
50
|
.get();
|
|
47
51
|
|
|
48
52
|
if (!doc.exists) {
|
|
49
|
-
|
|
53
|
+
handleError(new Error('Document not found'));
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
// Hide drafts/deleted (Vercel routes treat all requests as non-admin)
|
|
53
57
|
const docData = doc.data();
|
|
54
58
|
if ((HIDDEN_STATUSES as readonly string[]).includes(docData?.status)) {
|
|
55
|
-
|
|
59
|
+
handleError(new Error('Document not found'));
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
// Transform the document data
|
|
@@ -66,7 +70,11 @@ export default async function handler(
|
|
|
66
70
|
|
|
67
71
|
return res.status(200).json(filteredData);
|
|
68
72
|
} catch (error) {
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
try {
|
|
74
|
+
handleError(error);
|
|
75
|
+
} catch (handledError: any) {
|
|
76
|
+
const status = handledError.code === 'invalid-argument' ? 400 : 500;
|
|
77
|
+
return res.status(status).json({ error: handledError.message, code: handledError.code });
|
|
78
|
+
}
|
|
71
79
|
}
|
|
72
80
|
}
|
|
@@ -16,7 +16,8 @@ import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
|
16
16
|
import { handleError } from '../../../shared/errorHandling.js';
|
|
17
17
|
import { transformFirestoreData } from '../../../shared/index.js';
|
|
18
18
|
import { filterVisibleFields } from '../../../shared/index.js';
|
|
19
|
-
import {
|
|
19
|
+
import { validateCollectionName } from '../../../shared/utils.js';
|
|
20
|
+
import { verifyAuthToken } from '../../../shared/utils/internal/auth.js';
|
|
20
21
|
|
|
21
22
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
22
23
|
|
|
@@ -29,8 +30,8 @@ export default async function handler(
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
try {
|
|
32
|
-
//
|
|
33
|
-
const uid =
|
|
33
|
+
// C1: verify JWT — previously only checked header presence.
|
|
34
|
+
const uid = await verifyAuthToken(req);
|
|
34
35
|
|
|
35
36
|
const {
|
|
36
37
|
schema,
|
|
@@ -42,9 +43,12 @@ export default async function handler(
|
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
if (!schema) {
|
|
45
|
-
|
|
46
|
+
handleError(new Error('Missing schema'));
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// W22: Validate collection name from client-supplied schema
|
|
50
|
+
validateCollectionName(schema.metadata.collection);
|
|
51
|
+
|
|
48
52
|
// Get documents from Firestore
|
|
49
53
|
const db = getFirebaseAdminFirestore();
|
|
50
54
|
let query: FirebaseFirestore.Query<FirebaseFirestore.DocumentData> =
|
|
@@ -53,9 +57,13 @@ export default async function handler(
|
|
|
53
57
|
// Filter out hidden statuses (Vercel routes treat all requests as non-admin)
|
|
54
58
|
query = query.where('status', 'not-in', [...HIDDEN_STATUSES]);
|
|
55
59
|
|
|
60
|
+
// W21: Clamp limit to prevent unbounded queries (max 500, default 50)
|
|
61
|
+
const parsedLimit = Math.min(parseInt(limit as string) || 50, 500);
|
|
62
|
+
const parsedOffset = parseInt(offset as string) || 0;
|
|
63
|
+
|
|
56
64
|
// Apply limit and offset
|
|
57
|
-
query = query.limit(
|
|
58
|
-
query = query.offset(
|
|
65
|
+
query = query.limit(parsedLimit);
|
|
66
|
+
query = query.offset(parsedOffset);
|
|
59
67
|
|
|
60
68
|
const snapshot = await query.get();
|
|
61
69
|
|
|
@@ -71,11 +79,15 @@ export default async function handler(
|
|
|
71
79
|
return res.status(200).json({
|
|
72
80
|
documents,
|
|
73
81
|
total: snapshot.size,
|
|
74
|
-
limit:
|
|
75
|
-
offset:
|
|
82
|
+
limit: parsedLimit,
|
|
83
|
+
offset: parsedOffset,
|
|
76
84
|
});
|
|
77
85
|
} catch (error) {
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
try {
|
|
87
|
+
handleError(error);
|
|
88
|
+
} catch (handledError: any) {
|
|
89
|
+
const status = handledError.code === 'invalid-argument' ? 400 : 500;
|
|
90
|
+
return res.status(status).json({ error: handledError.message, code: handledError.code });
|
|
91
|
+
}
|
|
80
92
|
}
|
|
81
93
|
}
|