@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 +1 -1
- package/package.json +9 -9
- package/src/firebase/billing/createCheckoutSession.ts +3 -1
- package/src/firebase/crud/list.ts +29 -2
- package/src/shared/errorHandling.ts +6 -6
- package/src/shared/index.ts +2 -1
- package/src/shared/logger.ts +3 -7
- package/src/shared/utils/external/subscription.ts +2 -2
- package/src/shared/utils.ts +18 -29
- package/src/supabase/auth/deleteAccount.ts +3 -10
- package/src/supabase/baseFunction.ts +5 -1
- package/src/supabase/crud/list.ts +32 -1
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/
|
|
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.
|
|
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.
|
|
51
|
-
"@donotdev/firebase": "^0.0.
|
|
52
|
-
"@donotdev/supabase": "^0.0.
|
|
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.
|
|
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 ===
|
|
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
|
-
//
|
|
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
|
|
package/src/shared/index.ts
CHANGED
|
@@ -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.
|
package/src/shared/logger.ts
CHANGED
|
@@ -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:
|
|
32
|
-
|
|
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
|
|
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.
|
|
174
|
+
updatedAt: new Date().toISOString(),
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
console.log(`Subscription canceled for user ${firebaseUid}:`, {
|
package/src/shared/utils.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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
|
-
|
|
124
|
+
const uid = auth?.uid;
|
|
125
|
+
if (!uid) {
|
|
117
126
|
throw new Error('User must be authenticated');
|
|
118
127
|
}
|
|
119
|
-
return
|
|
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
|
-
* @
|
|
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
|
-
|
|
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 (
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|