@donotdev/functions 0.0.11 → 0.0.12

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/README.md CHANGED
@@ -482,7 +482,7 @@ bun run typecheck
482
482
  bun run dev:firebase
483
483
 
484
484
  # Terminal 2: Forward webhooks
485
- stripe listen --forward-to localhost:5001/your-project/us-central1/stripeWebhook
485
+ stripe listen --forward-to localhost:5001/your-project/europe-west1/stripeWebhook
486
486
  ```
487
487
 
488
488
  #### Vercel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donotdev/functions",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
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",
@@ -39,7 +39,7 @@
39
39
  "node": ">=18.0.0"
40
40
  },
41
41
  "scripts": {
42
- "type-check": "tsc --noEmit",
42
+ "type-check": "bunx tsc --noEmit",
43
43
  "build": "node build.mjs && tsc -p tsconfig.json",
44
44
  "build:types": "tsc -p tsconfig.json",
45
45
  "prepublishOnly": "bun run build",
@@ -47,13 +47,13 @@
47
47
  "serve": "firebase emulators:start --only functions"
48
48
  },
49
49
  "dependencies": {
50
- "@donotdev/core": "^0.0.24",
51
- "@donotdev/firebase": "^0.0.11",
52
- "@donotdev/supabase": "^0.0.1"
50
+ "@donotdev/core": "^0.0.25",
51
+ "@donotdev/firebase": "^0.0.12",
52
+ "@donotdev/supabase": "^0.0.2"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@sentry/node": "^10.39.0",
56
- "@supabase/supabase-js": "^2.49.0",
56
+ "@supabase/supabase-js": "^2.76.11",
57
57
  "firebase-admin": "^13.6.1",
58
58
  "firebase-functions": "^7.0.5",
59
59
  "next": "^16.1.6",
@@ -85,6 +85,9 @@
85
85
  "access": "public"
86
86
  },
87
87
  "peerDependenciesMeta": {
88
+ "@sentry/node": {
89
+ "optional": true
90
+ },
88
91
  "@supabase/supabase-js": {
89
92
  "optional": true
90
93
  },
@@ -96,9 +99,6 @@
96
99
  },
97
100
  "next": {
98
101
  "optional": true
99
- },
100
- "@sentry/node": {
101
- "optional": true
102
102
  }
103
103
  }
104
104
  }
@@ -63,7 +63,6 @@ async function createCheckoutSessionLogic(
63
63
 
64
64
  const {
65
65
  priceId,
66
- userId,
67
66
  customerEmail,
68
67
  metadata = {},
69
68
  successUrl,
@@ -72,6 +71,9 @@ async function createCheckoutSessionLogic(
72
71
  mode = 'payment',
73
72
  } = data;
74
73
 
74
+ // Use authenticated uid from context — client no longer sends userId
75
+ const userId = context.uid;
76
+
75
77
  logger.debug('[createCheckoutSession] Processing request', {
76
78
  priceId,
77
79
  userId,
@@ -115,13 +115,40 @@ function listEntitiesLogicFactory(
115
115
  // Apply search if provided
116
116
  if (search) {
117
117
  const { field, query: searchQuery } = search;
118
+ // Validate search.field against entity schema (listFields as allowlist)
119
+ if (listFields && listFields.length > 0) {
120
+ if (!listFields.includes(field)) {
121
+ throw new DoNotDevError(
122
+ `Search field '${field}' is not allowed`,
123
+ 'invalid-argument'
124
+ );
125
+ }
126
+ } else if (field.startsWith('_') || field.includes('.')) {
127
+ throw new DoNotDevError(
128
+ `Search field '${field}' is not allowed`,
129
+ 'invalid-argument'
130
+ );
131
+ }
118
132
  query = query
119
133
  .where(field, '>=', searchQuery)
120
134
  .where(field, '<=', searchQuery + '\uf8ff');
121
135
  }
122
136
 
123
- // Apply where clauses for filtering
137
+ // Apply where clauses for filtering — validate field names against entity schema
124
138
  for (const [field, operator, value] of where) {
139
+ if (listFields && listFields.length > 0) {
140
+ if (!listFields.includes(field) && field !== 'status' && field !== 'id') {
141
+ throw new DoNotDevError(
142
+ `Where field '${field}' is not allowed`,
143
+ 'invalid-argument'
144
+ );
145
+ }
146
+ } else if (field.startsWith('_') || field.includes('.')) {
147
+ throw new DoNotDevError(
148
+ `Where field '${field}' is not allowed`,
149
+ 'invalid-argument'
150
+ );
151
+ }
125
152
  query = query.where(field, operator, value);
126
153
  }
127
154
 
@@ -225,7 +252,7 @@ function listEntitiesLogicFactory(
225
252
  items: transformFirestoreData(docs),
226
253
  lastVisible: snapshot.docs[snapshot.docs.length - 1]?.id || null,
227
254
  count: snapshot.docs.length,
228
- hasMore: snapshot.docs.length === limit,
255
+ hasMore: snapshot.docs.length === effectiveLimit,
229
256
  };
230
257
  };
231
258
  }
@@ -9,7 +9,6 @@
9
9
  * @author AMBROISE PARK Consulting
10
10
  */
11
11
 
12
- import { HttpsError } from 'firebase-functions/v2/https';
13
12
  import * as v from 'valibot';
14
13
 
15
14
  import { DoNotDevError, EntityHookError } from '@donotdev/core/server';
@@ -26,7 +25,7 @@ export { DoNotDevError };
26
25
  * @since 0.0.1
27
26
  * @author AMBROISE PARK Consulting
28
27
  */
29
- export function handleError(error: unknown): never {
28
+ export async function handleError(error: unknown): Promise<never> {
30
29
  // W11: Log only in development to avoid double-logging with platform loggers.
31
30
  // Known DoNotDevError, ValiError, and EntityHookError are handled by callers.
32
31
  if (
@@ -97,7 +96,7 @@ export function handleError(error: unknown): never {
97
96
 
98
97
  // Platform-specific error throwing
99
98
  if (isFirebaseEnvironment()) {
100
- throwFirebaseError(code, message, details);
99
+ await throwFirebaseError(code, message, details);
101
100
  } else if (isVercelEnvironment()) {
102
101
  throwVercelError(code, message, details);
103
102
  } else {
@@ -143,12 +142,13 @@ function isVercelEnvironment(): boolean {
143
142
  * @since 0.0.1
144
143
  * @author AMBROISE PARK Consulting
145
144
  */
146
- function throwFirebaseError(
145
+ async function throwFirebaseError(
147
146
  code: string,
148
147
  message: string,
149
148
  details?: any
150
- ): never {
151
- // Static import - Firebase is always available in Firebase functions
149
+ ): Promise<never> {
150
+ // Dynamic import — firebase-functions is only loaded in Firebase environments
151
+ const { HttpsError } = await import('firebase-functions/v2/https');
152
152
  throw new HttpsError(code as any, message, details);
153
153
  }
154
154
 
@@ -19,8 +19,9 @@ export * from './oauth/index.js';
19
19
  export {
20
20
  createTimestamp,
21
21
  toTimestamp,
22
- toISOString,
23
22
  isTimestamp,
24
23
  transformFirestoreData,
25
24
  prepareForFirestore,
26
25
  } from './firebase.js';
26
+ // Note: toISOString is exported from ./utils/external/date.js (handles DateValue).
27
+ // The Firebase-specific toISOString (FirestoreTimestamp only) is available via direct import from ./firebase.js.
@@ -28,10 +28,8 @@ if (typeof globalThis !== 'undefined' && 'window' in globalThis) {
28
28
  throw new Error('Server logger cannot be imported on client side');
29
29
  }
30
30
 
31
- // ServerShim: Ensure we're in a Node.js environment
32
- if (typeof process === 'undefined' || !process.versions?.node) {
33
- throw new Error('Server logger requires Node.js environment');
34
- }
31
+ // ServerShim: Warn if not in Node.js (e.g., Deno) but don't throw — allow fallback logging
32
+ const _isNodeEnv = typeof process !== 'undefined' && !!process.versions?.node;
35
33
 
36
34
  let sentryEnabled = false;
37
35
  let sentryClient: any = null;
@@ -247,9 +245,7 @@ export const logger = {
247
245
  context,
248
246
  error: context?.error,
249
247
  metadata: {
250
- pid: process.pid,
251
- nodeVersion: process.version,
252
- platform: process.platform,
248
+ ...(_isNodeEnv ? { pid: process.pid, nodeVersion: process.version, platform: process.platform } : {}),
253
249
  ...context?.metadata,
254
250
  },
255
251
  };
@@ -49,7 +49,7 @@ export function getTierFromPriceId(priceId: string): string {
49
49
  * @since 0.0.1
50
50
  * @author AMBROISE PARK Consulting
51
51
  */
52
- export async function updateUserSubscription(
52
+ export async function updateFirebaseUserSubscription(
53
53
  firebaseUid: string,
54
54
  subscription: any
55
55
  ): Promise<void> {
@@ -171,7 +171,7 @@ export async function cancelUserSubscription(
171
171
  .doc(firebaseUid)
172
172
  .update({
173
173
  ...subscriptionClaims,
174
- updatedAt: Date.now(),
174
+ updatedAt: new Date().toISOString(),
175
175
  });
176
176
 
177
177
  console.log(`Subscription canceled for user ${firebaseUid}:`, {
@@ -20,6 +20,11 @@ import {
20
20
  initFirebaseAdmin,
21
21
  } from '@donotdev/firebase/server';
22
22
 
23
+ import {
24
+ assertAuthenticated as internalAssertAuthenticated,
25
+ assertAdmin as internalAssertAdmin,
26
+ } from './utils/internal/auth.js';
27
+
23
28
  // Re-export DoNotDevError for external use
24
29
  export { DoNotDevError };
25
30
 
@@ -103,20 +108,24 @@ export const stripe = new Proxy({} as Stripe, {
103
108
 
104
109
  /**
105
110
  * Assert that a user is authenticated from a Firebase callable auth context.
106
- * For Vercel/Next.js routes, use `verifyAuthToken` from `shared/utils/internal/auth.js` instead.
111
+ *
112
+ * @deprecated Use `assertAuthenticated` from `shared/utils/internal/auth.js` instead.
113
+ * This wrapper extracts uid from a Firebase callable auth context and delegates
114
+ * to the canonical version.
107
115
  *
108
116
  * @param auth - Firebase callable request auth context (object with `.uid`)
109
117
  * @returns The authenticated user's uid
110
118
  *
111
- * @version 0.0.2
119
+ * @version 0.0.3
112
120
  * @since 0.0.1
113
121
  * @author AMBROISE PARK Consulting
114
122
  */
115
123
  export function assertAuthenticated(auth: any): string {
116
- if (!auth || !auth.uid) {
124
+ const uid = auth?.uid;
125
+ if (!uid) {
117
126
  throw new Error('User must be authenticated');
118
127
  }
119
- return auth.uid;
128
+ return internalAssertAuthenticated(uid);
120
129
  }
121
130
 
122
131
  /**
@@ -317,35 +326,15 @@ export async function handleSubscriptionCancellation(
317
326
  * Asserts that a user has admin privileges
318
327
  * Uses role hierarchy: super > admin > user > guest
319
328
  *
320
- * @version 0.0.2
329
+ * @deprecated Use `assertAdmin` from `shared/utils/internal/auth.js` instead.
330
+ * This is a thin wrapper that delegates to the canonical provider-agnostic version.
331
+ *
332
+ * @version 0.0.3
321
333
  * @since 0.0.1
322
334
  * @author AMBROISE PARK Consulting
323
335
  */
324
336
  export async function assertAdmin(uid: string): Promise<string> {
325
- if (!uid) {
326
- throw new DoNotDevError('Authentication required', 'unauthenticated');
327
- }
328
-
329
- try {
330
- const user = await getFirebaseAdminAuth().getUser(uid);
331
- const claims = user.customClaims || {};
332
-
333
- // Check role claim first (standard pattern)
334
- const role = claims.role;
335
- if (role === 'admin' || role === 'super') {
336
- return uid;
337
- }
338
-
339
- // Fallback: check legacy boolean flags
340
- if (claims.isAdmin === true || claims.isSuper === true) {
341
- return uid;
342
- }
343
-
344
- throw new DoNotDevError('Admin privileges required', 'permission-denied');
345
- } catch (error) {
346
- if (error instanceof DoNotDevError) throw error;
347
- throw new DoNotDevError('Failed to verify admin status', 'internal');
348
- }
337
+ return internalAssertAdmin(uid);
349
338
  }
350
339
 
351
340
  /**
@@ -17,9 +17,7 @@ import { createSupabaseHandler } from '../baseFunction.js';
17
17
  // Schema
18
18
  // =============================================================================
19
19
 
20
- const deleteAccountSchema = v.object({
21
- userId: v.pipe(v.string(), v.minLength(1, 'User ID is required')),
22
- });
20
+ const deleteAccountSchema = v.object({});
23
21
 
24
22
  // =============================================================================
25
23
  // Handler
@@ -44,13 +42,8 @@ export function createDeleteAccount() {
44
42
  return createSupabaseHandler(
45
43
  'delete-account',
46
44
  deleteAccountSchema,
47
- async (data, ctx) => {
48
- // Guard: users can only delete their own account
49
- if (data.userId !== ctx.uid) {
50
- throw new Error('Forbidden: cannot delete another user');
51
- }
52
-
53
- const { error } = await ctx.supabaseAdmin.auth.admin.deleteUser(data.userId);
45
+ async (_data, ctx) => {
46
+ const { error } = await ctx.supabaseAdmin.auth.admin.deleteUser(ctx.uid);
54
47
  if (error) throw error;
55
48
 
56
49
  return { success: true };
@@ -122,7 +122,11 @@ export function createSupabaseHandler<TReq, TRes>(
122
122
 
123
123
  // Create admin client
124
124
  const supabaseUrl = getEnvOrThrow('SUPABASE_URL');
125
- const secretKey = getEnvOrThrow('SUPABASE_SECRET_KEY') || getEnvOrThrow('SUPABASE_SERVICE_ROLE_KEY'); // New: sb_secret_..., Legacy: service_role
125
+ // Try new env var first, fall back to legacy name
126
+ const secretKey = getEnv('SUPABASE_SECRET_KEY') || getEnv('SUPABASE_SERVICE_ROLE_KEY');
127
+ if (!secretKey) {
128
+ throw new Error('Missing environment variable: SUPABASE_SECRET_KEY or SUPABASE_SERVICE_ROLE_KEY');
129
+ }
126
130
  const supabaseAdmin = createClient(supabaseUrl, secretKey, {
127
131
  auth: { autoRefreshToken: false, persistSession: false },
128
132
  });
@@ -182,10 +182,41 @@ export function createSupabaseListEntities(
182
182
  }
183
183
 
184
184
  if (search) {
185
- query = query.ilike(toBackendColumn(search.field), `%${search.query}%`);
185
+ // Validate search.field against entity schema (listFields as allowlist)
186
+ if (safeListFields && safeListFields.length > 0) {
187
+ if (!safeListFields.includes(search.field)) {
188
+ throw new DoNotDevError(
189
+ `Search field '${search.field}' is not allowed`,
190
+ 'invalid-argument'
191
+ );
192
+ }
193
+ } else if (search.field.startsWith('_') || search.field.includes('.')) {
194
+ // No schema available — reject obviously unsafe field names
195
+ throw new DoNotDevError(
196
+ `Search field '${search.field}' is not allowed`,
197
+ 'invalid-argument'
198
+ );
199
+ }
200
+ // Escape SQL ILIKE wildcards to prevent wildcard injection
201
+ const escapedQuery = search.query.replace(/%/g, '\\%').replace(/_/g, '\\_');
202
+ query = query.ilike(toBackendColumn(search.field), `%${escapedQuery}%`);
186
203
  }
187
204
 
205
+ // Validate where clause fields against entity schema
188
206
  for (const [field, operator, value] of where) {
207
+ if (safeListFields && safeListFields.length > 0) {
208
+ if (!safeListFields.includes(field) && field !== 'status' && field !== 'id') {
209
+ throw new DoNotDevError(
210
+ `Where field '${field}' is not allowed`,
211
+ 'invalid-argument'
212
+ );
213
+ }
214
+ } else if (field.startsWith('_') || field.includes('.')) {
215
+ throw new DoNotDevError(
216
+ `Where field '${field}' is not allowed`,
217
+ 'invalid-argument'
218
+ );
219
+ }
189
220
  query = applyOperator(query, toBackendColumn(field), operator, value);
190
221
  }
191
222