@donotdev/functions 0.0.11 → 0.0.13
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/auth/setCustomClaims.ts +19 -5
- package/src/firebase/baseFunction.ts +11 -3
- package/src/firebase/billing/changePlan.ts +5 -1
- package/src/firebase/billing/createCheckoutSession.ts +3 -1
- package/src/firebase/billing/createCustomerPortal.ts +6 -2
- package/src/firebase/billing/webhookHandler.ts +4 -1
- package/src/firebase/crud/aggregate.ts +5 -1
- package/src/firebase/crud/create.ts +17 -4
- package/src/firebase/crud/list.ts +37 -5
- package/src/firebase/crud/update.ts +17 -4
- package/src/firebase/oauth/exchangeToken.ts +17 -4
- package/src/shared/__tests__/validation.test.ts +5 -3
- package/src/shared/billing/__tests__/createCheckout.test.ts +123 -22
- package/src/shared/billing/__tests__/webhookHandler.test.ts +121 -30
- package/src/shared/errorHandling.ts +6 -6
- package/src/shared/firebase.ts +1 -1
- package/src/shared/index.ts +2 -1
- package/src/shared/logger.ts +9 -7
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +6 -8
- package/src/shared/oauth/__tests__/grantAccess.test.ts +31 -14
- package/src/shared/utils/external/subscription.ts +2 -2
- package/src/shared/utils/internal/auth.ts +10 -3
- package/src/shared/utils/internal/rateLimiter.ts +8 -2
- package/src/shared/utils.ts +23 -30
- package/src/supabase/auth/deleteAccount.ts +4 -11
- package/src/supabase/auth/getCustomClaims.ts +5 -3
- package/src/supabase/auth/getUserAuthStatus.ts +5 -3
- package/src/supabase/auth/removeCustomClaims.ts +10 -5
- package/src/supabase/auth/setCustomClaims.ts +9 -4
- package/src/supabase/baseFunction.ts +80 -21
- package/src/supabase/billing/cancelSubscription.ts +9 -3
- package/src/supabase/billing/changePlan.ts +20 -5
- package/src/supabase/billing/createCheckoutSession.ts +20 -5
- package/src/supabase/billing/createCustomerPortal.ts +14 -4
- package/src/supabase/billing/refreshSubscriptionStatus.ts +29 -9
- package/src/supabase/crud/aggregate.ts +14 -4
- package/src/supabase/crud/create.ts +30 -11
- package/src/supabase/crud/delete.ts +11 -3
- package/src/supabase/crud/get.ts +25 -3
- package/src/supabase/crud/list.ts +106 -21
- package/src/supabase/crud/update.ts +32 -10
- package/src/supabase/helpers/authProvider.ts +5 -2
- package/src/supabase/index.ts +1 -4
- package/src/supabase/registerCrudFunctions.ts +11 -9
- package/src/supabase/utils/idempotency.ts +13 -15
- package/src/supabase/utils/monitoring.ts +5 -1
- package/src/supabase/utils/rateLimiter.ts +13 -3
- package/src/vercel/api/billing/webhook-handler.ts +6 -2
- package/src/vercel/api/crud/create.ts +7 -2
- package/src/vercel/api/crud/delete.ts +3 -1
- package/src/vercel/api/crud/get.ts +3 -1
- package/src/vercel/api/crud/list.ts +3 -1
- package/src/vercel/api/crud/update.ts +7 -2
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
import { describe, it, expect, vi } from 'vitest';
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
import { exchangeTokenAlgorithm, type OAuthProvider } from '../exchangeToken';
|
|
15
|
+
import type {
|
|
16
|
+
ExchangeTokenRequest,
|
|
17
|
+
TokenResponse,
|
|
18
|
+
} from '@donotdev/core/server';
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// Helpers
|
|
@@ -94,9 +94,7 @@ describe('exchangeTokenAlgorithm', () => {
|
|
|
94
94
|
|
|
95
95
|
describe('error scenarios', () => {
|
|
96
96
|
it('re-throws errors from provider', async () => {
|
|
97
|
-
const oauthProvider = makeRejectingProvider(
|
|
98
|
-
new Error('invalid_grant')
|
|
99
|
-
);
|
|
97
|
+
const oauthProvider = makeRejectingProvider(new Error('invalid_grant'));
|
|
100
98
|
|
|
101
99
|
await expect(
|
|
102
100
|
exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider)
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
grantAccessAlgorithm,
|
|
5
|
-
type OAuthGrantProvider,
|
|
6
|
-
} from '../grantAccess';
|
|
3
|
+
import { grantAccessAlgorithm, type OAuthGrantProvider } from '../grantAccess';
|
|
7
4
|
|
|
8
5
|
// ---------------------------------------------------------------------------
|
|
9
6
|
// Helpers
|
|
10
7
|
// ---------------------------------------------------------------------------
|
|
11
8
|
|
|
12
|
-
function makeProvider(
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
function makeProvider(result: {
|
|
10
|
+
success: boolean;
|
|
11
|
+
message: string;
|
|
12
|
+
}): OAuthGrantProvider {
|
|
15
13
|
return { grantAccess: vi.fn().mockResolvedValue(result) };
|
|
16
14
|
}
|
|
17
15
|
|
|
@@ -187,7 +185,10 @@ describe('grantAccessAlgorithm', () => {
|
|
|
187
185
|
);
|
|
188
186
|
|
|
189
187
|
expect(oauthProvider.grantAccess).toHaveBeenCalledWith(
|
|
190
|
-
expect.objectContaining({
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
provider: providerName,
|
|
190
|
+
accessToken: token,
|
|
191
|
+
})
|
|
191
192
|
);
|
|
192
193
|
}
|
|
193
194
|
);
|
|
@@ -199,12 +200,16 @@ describe('grantAccessAlgorithm', () => {
|
|
|
199
200
|
|
|
200
201
|
describe('error scenarios', () => {
|
|
201
202
|
it('re-throws synchronous errors from provider', async () => {
|
|
202
|
-
const oauthProvider = makeRejectingProvider(
|
|
203
|
-
new Error('network failure')
|
|
204
|
-
);
|
|
203
|
+
const oauthProvider = makeRejectingProvider(new Error('network failure'));
|
|
205
204
|
|
|
206
205
|
await expect(
|
|
207
|
-
grantAccessAlgorithm(
|
|
206
|
+
grantAccessAlgorithm(
|
|
207
|
+
userId,
|
|
208
|
+
provider,
|
|
209
|
+
accessToken,
|
|
210
|
+
refreshToken,
|
|
211
|
+
oauthProvider
|
|
212
|
+
)
|
|
208
213
|
).rejects.toThrow('network failure');
|
|
209
214
|
});
|
|
210
215
|
|
|
@@ -220,7 +225,13 @@ describe('grantAccessAlgorithm', () => {
|
|
|
220
225
|
const oauthProvider = makeRejectingProvider(originalError);
|
|
221
226
|
|
|
222
227
|
await expect(
|
|
223
|
-
grantAccessAlgorithm(
|
|
228
|
+
grantAccessAlgorithm(
|
|
229
|
+
userId,
|
|
230
|
+
provider,
|
|
231
|
+
accessToken,
|
|
232
|
+
refreshToken,
|
|
233
|
+
oauthProvider
|
|
234
|
+
)
|
|
224
235
|
).rejects.toThrow('token expired');
|
|
225
236
|
});
|
|
226
237
|
|
|
@@ -228,7 +239,13 @@ describe('grantAccessAlgorithm', () => {
|
|
|
228
239
|
const oauthProvider = makeRejectingProvider(new Error('boom'));
|
|
229
240
|
|
|
230
241
|
await expect(
|
|
231
|
-
grantAccessAlgorithm(
|
|
242
|
+
grantAccessAlgorithm(
|
|
243
|
+
userId,
|
|
244
|
+
provider,
|
|
245
|
+
accessToken,
|
|
246
|
+
refreshToken,
|
|
247
|
+
oauthProvider
|
|
248
|
+
)
|
|
232
249
|
).rejects.toThrow('boom');
|
|
233
250
|
|
|
234
251
|
expect(oauthProvider.grantAccess).toHaveBeenCalledOnce();
|
|
@@ -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}:`, {
|
|
@@ -74,7 +74,10 @@ export async function assertAdmin(uid: string): Promise<string> {
|
|
|
74
74
|
} catch (error) {
|
|
75
75
|
// C4: Re-throw permission-denied as-is so callers can distinguish it from
|
|
76
76
|
// infrastructure failures. Only wrap genuine unexpected errors.
|
|
77
|
-
if (
|
|
77
|
+
if (
|
|
78
|
+
error instanceof Error &&
|
|
79
|
+
error.message === 'Admin privileges required'
|
|
80
|
+
) {
|
|
78
81
|
throw error;
|
|
79
82
|
}
|
|
80
83
|
throw new Error('Failed to verify admin status');
|
|
@@ -161,7 +164,8 @@ async function verifySupabaseToken(token: string): Promise<{ uid: string }> {
|
|
|
161
164
|
const { createClient } = await import('@supabase/supabase-js');
|
|
162
165
|
|
|
163
166
|
const supabaseUrl = process.env.SUPABASE_URL;
|
|
164
|
-
const secretKey =
|
|
167
|
+
const secretKey =
|
|
168
|
+
process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
165
169
|
|
|
166
170
|
if (!supabaseUrl || !secretKey) {
|
|
167
171
|
throw new Error(
|
|
@@ -173,7 +177,10 @@ async function verifySupabaseToken(token: string): Promise<{ uid: string }> {
|
|
|
173
177
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
174
178
|
});
|
|
175
179
|
|
|
176
|
-
const {
|
|
180
|
+
const {
|
|
181
|
+
data: { user },
|
|
182
|
+
error,
|
|
183
|
+
} = await supabaseAdmin.auth.getUser(token);
|
|
177
184
|
|
|
178
185
|
if (error || !user) {
|
|
179
186
|
throw new Error('Invalid or expired token');
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
import { logger } from 'firebase-functions/v2';
|
|
13
13
|
|
|
14
14
|
import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
ServerRateLimitConfig as RateLimitConfig,
|
|
17
|
+
ServerRateLimitResult as RateLimitResult,
|
|
18
|
+
} from '@donotdev/core';
|
|
16
19
|
|
|
17
20
|
export type { RateLimitConfig, RateLimitResult };
|
|
18
21
|
|
|
@@ -204,7 +207,10 @@ export async function checkRateLimitWithFirestore(
|
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
// Increment attempts
|
|
207
|
-
tx.update(rateLimitRef, {
|
|
210
|
+
tx.update(rateLimitRef, {
|
|
211
|
+
attempts: data.attempts + 1,
|
|
212
|
+
lastUpdated: now,
|
|
213
|
+
});
|
|
208
214
|
result = {
|
|
209
215
|
allowed: true,
|
|
210
216
|
remaining: config.maxAttempts - (data.attempts + 1),
|
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
|
/**
|
|
@@ -450,7 +439,11 @@ export async function findReferences(
|
|
|
450
439
|
}>;
|
|
451
440
|
}
|
|
452
441
|
): Promise<Array<{ collection: string; field: string; count: number }>> {
|
|
453
|
-
const references: Array<{
|
|
442
|
+
const references: Array<{
|
|
443
|
+
collection: string;
|
|
444
|
+
field: string;
|
|
445
|
+
count: number;
|
|
446
|
+
}> = [];
|
|
454
447
|
|
|
455
448
|
if (!referenceMetadata?.incoming?.length) {
|
|
456
449
|
return references;
|
|
@@ -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,16 +42,11 @@ 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 };
|
|
57
|
-
}
|
|
50
|
+
}
|
|
58
51
|
);
|
|
59
52
|
}
|
|
@@ -43,14 +43,16 @@ export function createGetCustomClaims() {
|
|
|
43
43
|
'get-custom-claims',
|
|
44
44
|
getCustomClaimsSchema,
|
|
45
45
|
async (_data, ctx) => {
|
|
46
|
-
const {
|
|
47
|
-
|
|
46
|
+
const {
|
|
47
|
+
data: { user },
|
|
48
|
+
error,
|
|
49
|
+
} = await ctx.supabaseAdmin.auth.admin.getUserById(ctx.uid);
|
|
48
50
|
if (error || !user) {
|
|
49
51
|
throw new Error('User not found');
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
return { customClaims: user.app_metadata ?? {} };
|
|
53
55
|
},
|
|
54
|
-
'user'
|
|
56
|
+
'user'
|
|
55
57
|
);
|
|
56
58
|
}
|
|
@@ -45,8 +45,10 @@ export function createGetUserAuthStatus() {
|
|
|
45
45
|
'get-user-auth-status',
|
|
46
46
|
getUserAuthStatusSchema,
|
|
47
47
|
async (_data, ctx) => {
|
|
48
|
-
const {
|
|
49
|
-
|
|
48
|
+
const {
|
|
49
|
+
data: { user },
|
|
50
|
+
error,
|
|
51
|
+
} = await ctx.supabaseAdmin.auth.admin.getUserById(ctx.uid);
|
|
50
52
|
if (error || !user) {
|
|
51
53
|
throw new Error('User not found');
|
|
52
54
|
}
|
|
@@ -59,6 +61,6 @@ export function createGetUserAuthStatus() {
|
|
|
59
61
|
disabled: user.banned_until != null,
|
|
60
62
|
};
|
|
61
63
|
},
|
|
62
|
-
'user'
|
|
64
|
+
'user'
|
|
63
65
|
);
|
|
64
66
|
}
|
|
@@ -20,7 +20,7 @@ import { createSupabaseHandler } from '../baseFunction.js';
|
|
|
20
20
|
const removeCustomClaimsSchema = v.object({
|
|
21
21
|
claimsToRemove: v.pipe(
|
|
22
22
|
v.array(v.string()),
|
|
23
|
-
v.minLength(1, 'At least one claim key is required')
|
|
23
|
+
v.minLength(1, 'At least one claim key is required')
|
|
24
24
|
),
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -49,14 +49,19 @@ export function createRemoveCustomClaims() {
|
|
|
49
49
|
removeCustomClaimsSchema,
|
|
50
50
|
async (data, ctx) => {
|
|
51
51
|
// Get current app_metadata
|
|
52
|
-
const {
|
|
53
|
-
|
|
52
|
+
const {
|
|
53
|
+
data: { user },
|
|
54
|
+
error: getUserError,
|
|
55
|
+
} = await ctx.supabaseAdmin.auth.admin.getUserById(ctx.uid);
|
|
54
56
|
if (getUserError || !user) {
|
|
55
57
|
throw new Error('User not found');
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
// Remove specified keys
|
|
59
|
-
const existingClaims = { ...(user.app_metadata ?? {}) } as Record<
|
|
61
|
+
const existingClaims = { ...(user.app_metadata ?? {}) } as Record<
|
|
62
|
+
string,
|
|
63
|
+
unknown
|
|
64
|
+
>;
|
|
60
65
|
for (const key of data.claimsToRemove) {
|
|
61
66
|
delete existingClaims[key];
|
|
62
67
|
}
|
|
@@ -70,6 +75,6 @@ export function createRemoveCustomClaims() {
|
|
|
70
75
|
|
|
71
76
|
return { success: true, customClaims: existingClaims };
|
|
72
77
|
},
|
|
73
|
-
'user'
|
|
78
|
+
'user'
|
|
74
79
|
);
|
|
75
80
|
}
|
|
@@ -49,14 +49,19 @@ export function createSetCustomClaims() {
|
|
|
49
49
|
setCustomClaimsSchema,
|
|
50
50
|
async (data, ctx) => {
|
|
51
51
|
// Get current app_metadata
|
|
52
|
-
const {
|
|
53
|
-
|
|
52
|
+
const {
|
|
53
|
+
data: { user },
|
|
54
|
+
error: getUserError,
|
|
55
|
+
} = await ctx.supabaseAdmin.auth.admin.getUserById(ctx.uid);
|
|
54
56
|
if (getUserError || !user) {
|
|
55
57
|
throw new Error('User not found');
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
// Merge new claims with existing app_metadata
|
|
59
|
-
const existingClaims = (user.app_metadata ?? {}) as Record<
|
|
61
|
+
const existingClaims = (user.app_metadata ?? {}) as Record<
|
|
62
|
+
string,
|
|
63
|
+
unknown
|
|
64
|
+
>;
|
|
60
65
|
const updatedClaims = { ...existingClaims, ...data.customClaims };
|
|
61
66
|
|
|
62
67
|
// Update app_metadata
|
|
@@ -68,6 +73,6 @@ export function createSetCustomClaims() {
|
|
|
68
73
|
|
|
69
74
|
return { success: true, customClaims: updatedClaims };
|
|
70
75
|
},
|
|
71
|
-
'user'
|
|
76
|
+
'user'
|
|
72
77
|
);
|
|
73
78
|
}
|
|
@@ -103,7 +103,8 @@ export function createSupabaseHandler<TReq, TRes>(
|
|
|
103
103
|
headers: {
|
|
104
104
|
'Access-Control-Allow-Origin': allowedOrigin,
|
|
105
105
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
106
|
-
'Access-Control-Allow-Headers':
|
|
106
|
+
'Access-Control-Allow-Headers':
|
|
107
|
+
'authorization, content-type, x-client-info, apikey',
|
|
107
108
|
},
|
|
108
109
|
});
|
|
109
110
|
}
|
|
@@ -116,19 +117,32 @@ export function createSupabaseHandler<TReq, TRes>(
|
|
|
116
117
|
// Extract and verify auth token
|
|
117
118
|
const authHeader = req.headers.get('authorization');
|
|
118
119
|
if (!authHeader?.startsWith('Bearer ')) {
|
|
119
|
-
return respond(
|
|
120
|
+
return respond(
|
|
121
|
+
{ error: 'Missing or invalid authorization header' },
|
|
122
|
+
401
|
|
123
|
+
);
|
|
120
124
|
}
|
|
121
125
|
const token = authHeader.slice(7);
|
|
122
126
|
|
|
123
127
|
// Create admin client
|
|
124
128
|
const supabaseUrl = getEnvOrThrow('SUPABASE_URL');
|
|
125
|
-
|
|
129
|
+
// Try new env var first, fall back to legacy name
|
|
130
|
+
const secretKey =
|
|
131
|
+
getEnv('SUPABASE_SECRET_KEY') || getEnv('SUPABASE_SERVICE_ROLE_KEY');
|
|
132
|
+
if (!secretKey) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
'Missing environment variable: SUPABASE_SECRET_KEY or SUPABASE_SERVICE_ROLE_KEY'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
126
137
|
const supabaseAdmin = createClient(supabaseUrl, secretKey, {
|
|
127
138
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
128
139
|
});
|
|
129
140
|
|
|
130
141
|
// Verify JWT and extract user
|
|
131
|
-
const {
|
|
142
|
+
const {
|
|
143
|
+
data: { user },
|
|
144
|
+
error: authError,
|
|
145
|
+
} = await supabaseAdmin.auth.getUser(token);
|
|
132
146
|
if (authError || !user) {
|
|
133
147
|
return respond({ error: 'Invalid or expired token' }, 401);
|
|
134
148
|
}
|
|
@@ -144,7 +158,9 @@ export function createSupabaseHandler<TReq, TRes>(
|
|
|
144
158
|
// Non-guest access: verify user has required role level
|
|
145
159
|
if (!hasRoleAccess(userRole, requiredRole)) {
|
|
146
160
|
return respond(
|
|
147
|
-
{
|
|
161
|
+
{
|
|
162
|
+
error: `Access denied. Required role: ${requiredRole}, your role: ${userRole}`,
|
|
163
|
+
},
|
|
148
164
|
403
|
|
149
165
|
);
|
|
150
166
|
}
|
|
@@ -160,8 +176,9 @@ export function createSupabaseHandler<TReq, TRes>(
|
|
|
160
176
|
: `uid_${user.id}`;
|
|
161
177
|
const rateLimitKey = `${operationName}_${rateLimitIdentifier}`;
|
|
162
178
|
const rateLimitConfig: RateLimitConfig =
|
|
163
|
-
(DEFAULT_RATE_LIMITS as Record<string, RateLimitConfig>)[
|
|
164
|
-
|
|
179
|
+
(DEFAULT_RATE_LIMITS as Record<string, RateLimitConfig>)[
|
|
180
|
+
operationName
|
|
181
|
+
] || DEFAULT_RATE_LIMITS.api;
|
|
165
182
|
|
|
166
183
|
const rateLimitResult = await checkRateLimitWithPostgres(
|
|
167
184
|
supabaseAdmin,
|
|
@@ -206,7 +223,10 @@ export function createSupabaseHandler<TReq, TRes>(
|
|
|
206
223
|
supabaseAdmin,
|
|
207
224
|
});
|
|
208
225
|
} catch (handlerError) {
|
|
209
|
-
error =
|
|
226
|
+
error =
|
|
227
|
+
handlerError instanceof Error
|
|
228
|
+
? handlerError
|
|
229
|
+
: new Error(String(handlerError));
|
|
210
230
|
throw handlerError;
|
|
211
231
|
} finally {
|
|
212
232
|
// Record metrics if enabled
|
|
@@ -229,7 +249,8 @@ export function createSupabaseHandler<TReq, TRes>(
|
|
|
229
249
|
return respond(result, 200);
|
|
230
250
|
} catch (error) {
|
|
231
251
|
console.error(`[${operationName}] Error:`, error);
|
|
232
|
-
const message =
|
|
252
|
+
const message =
|
|
253
|
+
error instanceof Error ? error.message : 'Internal server error';
|
|
233
254
|
const status = getErrorStatus(message);
|
|
234
255
|
// allowedOrigin may not be set if error occurred before that line; fall back to '*'
|
|
235
256
|
const origin = getEnv('ALLOWED_ORIGIN', '*');
|
|
@@ -242,7 +263,11 @@ export function createSupabaseHandler<TReq, TRes>(
|
|
|
242
263
|
// Helpers
|
|
243
264
|
// =============================================================================
|
|
244
265
|
|
|
245
|
-
function jsonResponse(
|
|
266
|
+
function jsonResponse(
|
|
267
|
+
data: unknown,
|
|
268
|
+
status: number,
|
|
269
|
+
allowedOrigin = '*'
|
|
270
|
+
): Response {
|
|
246
271
|
return new Response(JSON.stringify(data), {
|
|
247
272
|
status,
|
|
248
273
|
headers: {
|
|
@@ -253,13 +278,19 @@ function jsonResponse(data: unknown, status: number, allowedOrigin = '*'): Respo
|
|
|
253
278
|
}
|
|
254
279
|
|
|
255
280
|
function getEnvOrThrow(key: string): string {
|
|
256
|
-
const value = (
|
|
281
|
+
const value = (
|
|
282
|
+
typeof Deno !== 'undefined' ? Deno.env.get(key) : process.env[key]
|
|
283
|
+
) as string | undefined;
|
|
257
284
|
if (!value) throw new Error(`Missing environment variable: ${key}`);
|
|
258
285
|
return value;
|
|
259
286
|
}
|
|
260
287
|
|
|
261
288
|
function getEnv(key: string, defaultValue: string = ''): string {
|
|
262
|
-
return (
|
|
289
|
+
return (
|
|
290
|
+
((typeof Deno !== 'undefined' ? Deno.env.get(key) : process.env[key]) as
|
|
291
|
+
| string
|
|
292
|
+
| undefined) || defaultValue
|
|
293
|
+
);
|
|
263
294
|
}
|
|
264
295
|
|
|
265
296
|
function getClientIp(req: Request): string {
|
|
@@ -269,7 +300,10 @@ function getClientIp(req: Request): string {
|
|
|
269
300
|
// W19: Take the rightmost (last) IP — the leftmost entry is client-supplied
|
|
270
301
|
// and trivially spoofable. The last entry is appended by the nearest trusted
|
|
271
302
|
// reverse proxy and is the most reliable.
|
|
272
|
-
const ips = forwardedFor
|
|
303
|
+
const ips = forwardedFor
|
|
304
|
+
.split(',')
|
|
305
|
+
.map((ip) => ip.trim())
|
|
306
|
+
.filter(Boolean);
|
|
273
307
|
const lastIp = ips[ips.length - 1];
|
|
274
308
|
if (lastIp) return lastIp;
|
|
275
309
|
}
|
|
@@ -283,20 +317,45 @@ function getClientIp(req: Request): string {
|
|
|
283
317
|
|
|
284
318
|
function getErrorCode(message: string): string {
|
|
285
319
|
if (message.includes('Rate limit')) return 'rate-limit-exceeded';
|
|
286
|
-
if (message.includes('not found') || message.includes('No active'))
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
320
|
+
if (message.includes('not found') || message.includes('No active'))
|
|
321
|
+
return 'not-found';
|
|
322
|
+
if (
|
|
323
|
+
message.includes('permission') ||
|
|
324
|
+
message.includes('denied') ||
|
|
325
|
+
message.includes('Forbidden')
|
|
326
|
+
)
|
|
327
|
+
return 'permission-denied';
|
|
328
|
+
if (
|
|
329
|
+
message.includes('mismatch') ||
|
|
330
|
+
message.includes('Invalid') ||
|
|
331
|
+
message.includes('Missing')
|
|
332
|
+
)
|
|
333
|
+
return 'invalid-argument';
|
|
334
|
+
if (message.includes('already-exists') || message.includes('Duplicate'))
|
|
335
|
+
return 'already-exists';
|
|
290
336
|
return 'internal';
|
|
291
337
|
}
|
|
292
338
|
|
|
293
339
|
function getErrorStatus(message: string): number {
|
|
294
340
|
if (message.includes('Rate limit')) return 429;
|
|
295
|
-
if (message.includes('not found') || message.includes('No active'))
|
|
296
|
-
|
|
297
|
-
if (
|
|
341
|
+
if (message.includes('not found') || message.includes('No active'))
|
|
342
|
+
return 404;
|
|
343
|
+
if (
|
|
344
|
+
message.includes('permission') ||
|
|
345
|
+
message.includes('denied') ||
|
|
346
|
+
message.includes('Forbidden')
|
|
347
|
+
)
|
|
348
|
+
return 403;
|
|
349
|
+
if (
|
|
350
|
+
message.includes('mismatch') ||
|
|
351
|
+
message.includes('Invalid') ||
|
|
352
|
+
message.includes('Missing')
|
|
353
|
+
)
|
|
354
|
+
return 400;
|
|
298
355
|
return 500;
|
|
299
356
|
}
|
|
300
357
|
|
|
301
358
|
// Deno global type declaration for env access
|
|
302
|
-
declare const Deno:
|
|
359
|
+
declare const Deno:
|
|
360
|
+
| { env: { get(key: string): string | undefined } }
|
|
361
|
+
| undefined;
|
|
@@ -44,14 +44,20 @@ export function createCancelSubscription() {
|
|
|
44
44
|
initStripe(getStripeKey());
|
|
45
45
|
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
46
46
|
return cancelUserSubscription(data.userId, authProvider);
|
|
47
|
-
}
|
|
47
|
+
}
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function getStripeKey(): string {
|
|
52
|
-
const key = (
|
|
52
|
+
const key = (
|
|
53
|
+
typeof Deno !== 'undefined'
|
|
54
|
+
? Deno.env.get('STRIPE_SECRET_KEY')
|
|
55
|
+
: process.env.STRIPE_SECRET_KEY
|
|
56
|
+
) as string | undefined;
|
|
53
57
|
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
54
58
|
return key;
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
declare const Deno:
|
|
61
|
+
declare const Deno:
|
|
62
|
+
| { env: { get(key: string): string | undefined } }
|
|
63
|
+
| undefined;
|