@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
@@ -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 { assertAuthenticated } from '../../../shared/utils.js';
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
- const uid = assertAuthenticated(req.headers.authorization);
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 { assertAuthenticated } from '../../../shared/utils.js';
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
- const uid = assertAuthenticated(req.headers.authorization);
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 { assertAuthenticated } from '../../../shared/utils.js';
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
- const uid = assertAuthenticated(req.headers.authorization);
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 { assertAuthenticated } from '../../../shared/utils.js';
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
- const uid = assertAuthenticated(req.headers.authorization);
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
- const { userId } = data;
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
- const { userId, newPriceId, billingConfigKey } = data;
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
- const { userId, returnUrl } = data;
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
- const { userId } = data;
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
- null // No authProvider needed for Vercel implementation
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
- chunks.push(
77
- typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer)
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
- validateDocument,
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
- // Verify authentication
39
- const uid = assertAuthenticated(req.headers.authorization);
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
- throw handleError(new Error('Missing schema or payload'));
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
- throw handleError(error);
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 { assertAuthenticated } from '../../../shared/utils.js';
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
- // Verify authentication
30
- const uid = assertAuthenticated(req.headers.authorization);
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
- throw handleError(new Error('Missing schema or id'));
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
- throw handleError(new Error('Document not found'));
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
- throw handleError(error);
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 { assertAuthenticated } from '../../../shared/utils.js';
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
- // Verify authentication
33
- const uid = assertAuthenticated(req.headers.authorization as any);
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
- throw handleError(new Error('Missing schema or id'));
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
- throw handleError(new Error('Document not found'));
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
- throw handleError(new Error('Document not found'));
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
- const errorResponse = handleError(error);
70
- return res.status(500).json(errorResponse);
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 { assertAuthenticated } from '../../../shared/utils.js';
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
- // Verify authentication
33
- const uid = assertAuthenticated(req.headers.authorization as any);
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
- throw handleError(new Error('Missing schema'));
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(parseInt(limit as string));
58
- query = query.offset(parseInt(offset as string));
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: parseInt(limit as string),
75
- offset: parseInt(offset as string),
82
+ limit: parsedLimit,
83
+ offset: parsedOffset,
76
84
  });
77
85
  } catch (error) {
78
- const errorResponse = handleError(error);
79
- return res.status(500).json(errorResponse);
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
  }