@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.
- 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/config/constants.ts +0 -3
- package/src/firebase/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/get.ts +16 -8
- package/src/firebase/crud/list.ts +70 -29
- 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 +15 -4
- 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/schema.ts +7 -1
- 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,302 @@
|
|
|
1
|
+
// packages/functions/src/supabase/baseFunction.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Base Supabase Edge Function handler
|
|
5
|
+
* @description Handles auth verification, request validation, and error responses
|
|
6
|
+
* for Supabase Edge Functions. Mirrors the Firebase/Vercel base function pattern.
|
|
7
|
+
*
|
|
8
|
+
* @version 0.0.1
|
|
9
|
+
* @since 0.5.0
|
|
10
|
+
* @author AMBROISE PARK Consulting
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createClient } from '@supabase/supabase-js';
|
|
14
|
+
import * as v from 'valibot';
|
|
15
|
+
|
|
16
|
+
import { hasRoleAccess } from '@donotdev/core/server';
|
|
17
|
+
import type { UserRole, SecurityContext } from '@donotdev/core/server';
|
|
18
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
checkRateLimitWithPostgres,
|
|
22
|
+
DEFAULT_RATE_LIMITS,
|
|
23
|
+
} from './utils/rateLimiter.js';
|
|
24
|
+
import type { RateLimitConfig } from './utils/rateLimiter.js';
|
|
25
|
+
import { recordOperationMetrics } from './utils/monitoring.js';
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Types
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Context passed to Supabase Edge Function business logic
|
|
33
|
+
*
|
|
34
|
+
* @version 0.0.1
|
|
35
|
+
* @since 0.5.0
|
|
36
|
+
*/
|
|
37
|
+
export interface SupabaseHandlerContext {
|
|
38
|
+
/** Authenticated user ID (from JWT verification) */
|
|
39
|
+
uid: string;
|
|
40
|
+
/** User role extracted from app_metadata.role */
|
|
41
|
+
userRole: UserRole;
|
|
42
|
+
/** Supabase admin client (service role — full access) */
|
|
43
|
+
supabaseAdmin: SupabaseClient;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Base Handler
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a Supabase Edge Function handler with built-in auth, validation, role checking, and error handling.
|
|
52
|
+
*
|
|
53
|
+
* @param operationName - Operation name for logging
|
|
54
|
+
* @param schema - Valibot schema for request body validation
|
|
55
|
+
* @param handler - Business logic function
|
|
56
|
+
* @param requiredRole - Minimum role required (default: 'user')
|
|
57
|
+
* @returns A `(req: Request) => Promise<Response>` handler for Deno.serve
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* import * as v from 'valibot';
|
|
62
|
+
* import { createSupabaseHandler } from '@donotdev/functions/supabase';
|
|
63
|
+
*
|
|
64
|
+
* const schema = v.object({ userId: v.string() });
|
|
65
|
+
*
|
|
66
|
+
* export default createSupabaseHandler('delete-account', schema, async (data, ctx) => {
|
|
67
|
+
* await ctx.supabaseAdmin.auth.admin.deleteUser(data.userId);
|
|
68
|
+
* return { success: true };
|
|
69
|
+
* }, 'admin');
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @version 0.0.1
|
|
73
|
+
* @since 0.5.0
|
|
74
|
+
*/
|
|
75
|
+
export function createSupabaseHandler<TReq, TRes>(
|
|
76
|
+
operationName: string,
|
|
77
|
+
schema: v.BaseSchema<unknown, TReq, v.BaseIssue<unknown>>,
|
|
78
|
+
handler: (data: TReq, context: SupabaseHandlerContext) => Promise<TRes>,
|
|
79
|
+
requiredRole: UserRole = 'user',
|
|
80
|
+
security?: SecurityContext
|
|
81
|
+
): (req: Request) => Promise<Response> {
|
|
82
|
+
return async (req: Request): Promise<Response> => {
|
|
83
|
+
try {
|
|
84
|
+
// CORS preflight
|
|
85
|
+
// C3: CORS origin is configurable via ALLOWED_ORIGIN env var.
|
|
86
|
+
// Default '*' is acceptable for pure-token-auth edge functions where credentials
|
|
87
|
+
// (cookies) are never used. Consumers that need credentialed requests must set
|
|
88
|
+
// ALLOWED_ORIGIN to their specific frontend origin.
|
|
89
|
+
//
|
|
90
|
+
// Architecture decision — CORS wildcard (`*`) as framework default:
|
|
91
|
+
// This is an intentional development-convenience default, not a security oversight.
|
|
92
|
+
// Consumer apps override it by setting the ALLOWED_ORIGIN environment variable
|
|
93
|
+
// (e.g. ALLOWED_ORIGIN=https://myapp.example) in their Supabase Edge Function config.
|
|
94
|
+
// The framework documents this in the deployment guide.
|
|
95
|
+
const allowedOrigin = getEnv('ALLOWED_ORIGIN', '*');
|
|
96
|
+
// Local wrapper so all responses in this closure carry the correct origin
|
|
97
|
+
const respond = (data: unknown, status: number) =>
|
|
98
|
+
jsonResponse(data, status, allowedOrigin);
|
|
99
|
+
|
|
100
|
+
if (req.method === 'OPTIONS') {
|
|
101
|
+
return new Response(null, {
|
|
102
|
+
status: 204,
|
|
103
|
+
headers: {
|
|
104
|
+
'Access-Control-Allow-Origin': allowedOrigin,
|
|
105
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
106
|
+
'Access-Control-Allow-Headers': 'authorization, content-type, x-client-info, apikey',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Method check
|
|
112
|
+
if (req.method !== 'POST') {
|
|
113
|
+
return respond({ error: 'Method not allowed' }, 405);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Extract and verify auth token
|
|
117
|
+
const authHeader = req.headers.get('authorization');
|
|
118
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
119
|
+
return respond({ error: 'Missing or invalid authorization header' }, 401);
|
|
120
|
+
}
|
|
121
|
+
const token = authHeader.slice(7);
|
|
122
|
+
|
|
123
|
+
// Create admin client
|
|
124
|
+
const supabaseUrl = getEnvOrThrow('SUPABASE_URL');
|
|
125
|
+
const secretKey = getEnvOrThrow('SUPABASE_SECRET_KEY') || getEnvOrThrow('SUPABASE_SERVICE_ROLE_KEY'); // New: sb_secret_..., Legacy: service_role
|
|
126
|
+
const supabaseAdmin = createClient(supabaseUrl, secretKey, {
|
|
127
|
+
auth: { autoRefreshToken: false, persistSession: false },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Verify JWT and extract user
|
|
131
|
+
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(token);
|
|
132
|
+
if (authError || !user) {
|
|
133
|
+
return respond({ error: 'Invalid or expired token' }, 401);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Extract user role from app_metadata (Supabase stores custom claims here)
|
|
137
|
+
const appMetadata = (user.app_metadata ?? {}) as Record<string, unknown>;
|
|
138
|
+
const userRole: UserRole = (appMetadata.role as UserRole) || 'user';
|
|
139
|
+
|
|
140
|
+
// Role-based access control
|
|
141
|
+
if (requiredRole === 'guest') {
|
|
142
|
+
// Guest access: no additional check needed
|
|
143
|
+
} else {
|
|
144
|
+
// Non-guest access: verify user has required role level
|
|
145
|
+
if (!hasRoleAccess(userRole, requiredRole)) {
|
|
146
|
+
return respond(
|
|
147
|
+
{ error: `Access denied. Required role: ${requiredRole}, your role: ${userRole}` },
|
|
148
|
+
403
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Rate limiting (on by default, set DISABLE_RATE_LIMITING=true to opt out)
|
|
154
|
+
const disableRateLimiting = getEnv('DISABLE_RATE_LIMITING') === 'true';
|
|
155
|
+
if (!disableRateLimiting) {
|
|
156
|
+
// Use IP-based key for guest operations, UID-based for authenticated
|
|
157
|
+
const rateLimitIdentifier =
|
|
158
|
+
requiredRole === 'guest' && user.id === 'guest'
|
|
159
|
+
? `ip_${getClientIp(req)}`
|
|
160
|
+
: `uid_${user.id}`;
|
|
161
|
+
const rateLimitKey = `${operationName}_${rateLimitIdentifier}`;
|
|
162
|
+
const rateLimitConfig: RateLimitConfig =
|
|
163
|
+
(DEFAULT_RATE_LIMITS as Record<string, RateLimitConfig>)[operationName] ||
|
|
164
|
+
DEFAULT_RATE_LIMITS.api;
|
|
165
|
+
|
|
166
|
+
const rateLimitResult = await checkRateLimitWithPostgres(
|
|
167
|
+
supabaseAdmin,
|
|
168
|
+
rateLimitKey,
|
|
169
|
+
rateLimitConfig
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (!rateLimitResult.allowed) {
|
|
173
|
+
security?.audit({
|
|
174
|
+
type: 'rate_limit.exceeded',
|
|
175
|
+
userId: user.id,
|
|
176
|
+
metadata: { operation: operationName, remaining: 0 },
|
|
177
|
+
});
|
|
178
|
+
return respond(
|
|
179
|
+
{
|
|
180
|
+
error: `Rate limit exceeded. Try again in ${rateLimitResult.blockRemainingSeconds} seconds.`,
|
|
181
|
+
},
|
|
182
|
+
429
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Parse and validate request body
|
|
188
|
+
const body = await req.json().catch(() => ({}));
|
|
189
|
+
const validationResult = v.safeParse(schema, body);
|
|
190
|
+
if (!validationResult.success) {
|
|
191
|
+
const issues = validationResult.issues.map((i) => i.message).join(', ');
|
|
192
|
+
return respond({ error: `Validation failed: ${issues}` }, 400);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Record metrics (only if enabled via ENABLE_METRICS env var)
|
|
196
|
+
const enableMetrics = getEnv('ENABLE_METRICS') === 'true';
|
|
197
|
+
const startTime = enableMetrics ? Date.now() : 0;
|
|
198
|
+
|
|
199
|
+
// Execute business logic
|
|
200
|
+
let result: TRes;
|
|
201
|
+
let error: Error | null = null;
|
|
202
|
+
try {
|
|
203
|
+
result = await handler(validationResult.output, {
|
|
204
|
+
uid: user.id,
|
|
205
|
+
userRole,
|
|
206
|
+
supabaseAdmin,
|
|
207
|
+
});
|
|
208
|
+
} catch (handlerError) {
|
|
209
|
+
error = handlerError instanceof Error ? handlerError : new Error(String(handlerError));
|
|
210
|
+
throw handlerError;
|
|
211
|
+
} finally {
|
|
212
|
+
// Record metrics if enabled
|
|
213
|
+
if (enableMetrics) {
|
|
214
|
+
const durationMs = Date.now() - startTime;
|
|
215
|
+
await recordOperationMetrics(supabaseAdmin, {
|
|
216
|
+
operation: operationName,
|
|
217
|
+
userId: user.id,
|
|
218
|
+
status: error ? 'failed' : 'success',
|
|
219
|
+
durationMs,
|
|
220
|
+
metadata: {
|
|
221
|
+
requestId: req.headers.get('x-request-id') || undefined,
|
|
222
|
+
},
|
|
223
|
+
errorCode: error ? getErrorCode(error.message) : undefined,
|
|
224
|
+
errorMessage: error ? error.message : undefined,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return respond(result, 200);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error(`[${operationName}] Error:`, error);
|
|
232
|
+
const message = error instanceof Error ? error.message : 'Internal server error';
|
|
233
|
+
const status = getErrorStatus(message);
|
|
234
|
+
// allowedOrigin may not be set if error occurred before that line; fall back to '*'
|
|
235
|
+
const origin = getEnv('ALLOWED_ORIGIN', '*');
|
|
236
|
+
return jsonResponse({ error: message }, status, origin);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// =============================================================================
|
|
242
|
+
// Helpers
|
|
243
|
+
// =============================================================================
|
|
244
|
+
|
|
245
|
+
function jsonResponse(data: unknown, status: number, allowedOrigin = '*'): Response {
|
|
246
|
+
return new Response(JSON.stringify(data), {
|
|
247
|
+
status,
|
|
248
|
+
headers: {
|
|
249
|
+
'Content-Type': 'application/json',
|
|
250
|
+
'Access-Control-Allow-Origin': allowedOrigin,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getEnvOrThrow(key: string): string {
|
|
256
|
+
const value = (typeof Deno !== 'undefined' ? Deno.env.get(key) : process.env[key]) as string | undefined;
|
|
257
|
+
if (!value) throw new Error(`Missing environment variable: ${key}`);
|
|
258
|
+
return value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getEnv(key: string, defaultValue: string = ''): string {
|
|
262
|
+
return (typeof Deno !== 'undefined' ? Deno.env.get(key) : process.env[key]) as string | undefined || defaultValue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getClientIp(req: Request): string {
|
|
266
|
+
// Try X-Forwarded-For first (common for proxied requests)
|
|
267
|
+
const forwardedFor = req.headers.get('x-forwarded-for');
|
|
268
|
+
if (forwardedFor) {
|
|
269
|
+
// W19: Take the rightmost (last) IP — the leftmost entry is client-supplied
|
|
270
|
+
// and trivially spoofable. The last entry is appended by the nearest trusted
|
|
271
|
+
// reverse proxy and is the most reliable.
|
|
272
|
+
const ips = forwardedFor.split(',').map((ip) => ip.trim()).filter(Boolean);
|
|
273
|
+
const lastIp = ips[ips.length - 1];
|
|
274
|
+
if (lastIp) return lastIp;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Fallback to CF-Connecting-IP (Cloudflare) or other headers
|
|
278
|
+
const cfIp = req.headers.get('cf-connecting-ip');
|
|
279
|
+
if (cfIp) return cfIp;
|
|
280
|
+
|
|
281
|
+
return 'unknown';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getErrorCode(message: string): string {
|
|
285
|
+
if (message.includes('Rate limit')) return 'rate-limit-exceeded';
|
|
286
|
+
if (message.includes('not found') || message.includes('No active')) return 'not-found';
|
|
287
|
+
if (message.includes('permission') || message.includes('denied') || message.includes('Forbidden')) return 'permission-denied';
|
|
288
|
+
if (message.includes('mismatch') || message.includes('Invalid') || message.includes('Missing')) return 'invalid-argument';
|
|
289
|
+
if (message.includes('already-exists') || message.includes('Duplicate')) return 'already-exists';
|
|
290
|
+
return 'internal';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getErrorStatus(message: string): number {
|
|
294
|
+
if (message.includes('Rate limit')) return 429;
|
|
295
|
+
if (message.includes('not found') || message.includes('No active')) return 404;
|
|
296
|
+
if (message.includes('permission') || message.includes('denied') || message.includes('Forbidden')) return 403;
|
|
297
|
+
if (message.includes('mismatch') || message.includes('Invalid') || message.includes('Missing')) return 400;
|
|
298
|
+
return 500;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Deno global type declaration for env access
|
|
302
|
+
declare const Deno: { env: { get(key: string): string | undefined } } | undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// packages/functions/src/supabase/billing/cancelSubscription.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Cancel Subscription — Supabase Edge Function
|
|
5
|
+
* @description Wraps the shared `cancelUserSubscription` for Supabase.
|
|
6
|
+
*
|
|
7
|
+
* @version 0.0.1
|
|
8
|
+
* @since 0.5.0
|
|
9
|
+
* @author AMBROISE PARK Consulting
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as v from 'valibot';
|
|
13
|
+
|
|
14
|
+
import { cancelUserSubscription } from '../../shared/billing/helpers/subscriptionManagement.js';
|
|
15
|
+
import { initStripe } from '../../shared/utils.js';
|
|
16
|
+
import { createSupabaseHandler } from '../baseFunction.js';
|
|
17
|
+
import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Schema
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
const cancelSubscriptionSchema = v.object({
|
|
24
|
+
userId: v.pipe(v.string(), v.minLength(1, 'User ID is required')),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Factory
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a Supabase Edge Function handler for subscription cancellation.
|
|
33
|
+
*
|
|
34
|
+
* @returns `(req: Request) => Promise<Response>` handler
|
|
35
|
+
*
|
|
36
|
+
* @version 0.0.1
|
|
37
|
+
* @since 0.5.0
|
|
38
|
+
*/
|
|
39
|
+
export function createCancelSubscription() {
|
|
40
|
+
return createSupabaseHandler(
|
|
41
|
+
'cancel-subscription',
|
|
42
|
+
cancelSubscriptionSchema,
|
|
43
|
+
async (data, ctx) => {
|
|
44
|
+
initStripe(getStripeKey());
|
|
45
|
+
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
46
|
+
return cancelUserSubscription(data.userId, authProvider);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getStripeKey(): string {
|
|
52
|
+
const key = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
|
|
53
|
+
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare const Deno: { env: { get(key: string): string | undefined } } | undefined;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// packages/functions/src/supabase/billing/changePlan.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Change Plan — Supabase Edge Function
|
|
5
|
+
* @description Wraps the shared `changeUserPlan` for Supabase.
|
|
6
|
+
*
|
|
7
|
+
* @version 0.0.1
|
|
8
|
+
* @since 0.5.0
|
|
9
|
+
* @author AMBROISE PARK Consulting
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as v from 'valibot';
|
|
13
|
+
|
|
14
|
+
import type { StripeBackConfig } from '@donotdev/core/server';
|
|
15
|
+
|
|
16
|
+
import { changeUserPlan } from '../../shared/billing/helpers/subscriptionManagement.js';
|
|
17
|
+
import { initStripe } from '../../shared/utils.js';
|
|
18
|
+
import { createSupabaseHandler } from '../baseFunction.js';
|
|
19
|
+
import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Schema
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
const changePlanSchema = v.object({
|
|
26
|
+
userId: v.pipe(v.string(), v.minLength(1, 'User ID is required')),
|
|
27
|
+
newPriceId: v.pipe(v.string(), v.minLength(1, 'Price ID is required')),
|
|
28
|
+
billingConfigKey: v.pipe(v.string(), v.minLength(1, 'Billing config key is required')),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Factory
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a Supabase Edge Function handler for plan changes.
|
|
37
|
+
*
|
|
38
|
+
* @param billingConfig - Billing configuration with product definitions
|
|
39
|
+
* @returns `(req: Request) => Promise<Response>` handler
|
|
40
|
+
*
|
|
41
|
+
* @version 0.0.1
|
|
42
|
+
* @since 0.5.0
|
|
43
|
+
*/
|
|
44
|
+
export function createChangePlan(billingConfig: StripeBackConfig) {
|
|
45
|
+
return createSupabaseHandler(
|
|
46
|
+
'change-plan',
|
|
47
|
+
changePlanSchema,
|
|
48
|
+
async (data, ctx) => {
|
|
49
|
+
initStripe(getStripeKey());
|
|
50
|
+
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
51
|
+
return changeUserPlan(data.userId, data.newPriceId, data.billingConfigKey, billingConfig, authProvider);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getStripeKey(): string {
|
|
57
|
+
const key = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
|
|
58
|
+
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
59
|
+
return key;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
declare const Deno: { env: { get(key: string): string | undefined } } | undefined;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// packages/functions/src/supabase/billing/createCheckoutSession.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Create Checkout Session — Supabase Edge Function
|
|
5
|
+
* @description Wraps the shared `createCheckoutAlgorithm` for Supabase.
|
|
6
|
+
*
|
|
7
|
+
* @version 0.0.1
|
|
8
|
+
* @since 0.5.0
|
|
9
|
+
* @author AMBROISE PARK Consulting
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StripeBackConfig } from '@donotdev/core/server';
|
|
13
|
+
import { CreateCheckoutSessionRequestSchema } from '@donotdev/core/server';
|
|
14
|
+
|
|
15
|
+
import { createCheckoutAlgorithm } from '../../shared/billing/createCheckout.js';
|
|
16
|
+
import { initStripe, stripe, validateStripeEnvironment } from '../../shared/utils.js';
|
|
17
|
+
import { createSupabaseHandler } from '../baseFunction.js';
|
|
18
|
+
import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
19
|
+
|
|
20
|
+
import type { CreateCheckoutSessionRequest } from '@donotdev/core/server';
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Factory
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create a Supabase Edge Function handler for Stripe checkout session creation.
|
|
28
|
+
*
|
|
29
|
+
* @param billingConfig - Billing configuration with product definitions
|
|
30
|
+
* @returns `(req: Request) => Promise<Response>` handler
|
|
31
|
+
*
|
|
32
|
+
* @version 0.0.1
|
|
33
|
+
* @since 0.5.0
|
|
34
|
+
*/
|
|
35
|
+
export function createCheckoutSession(billingConfig: StripeBackConfig) {
|
|
36
|
+
return createSupabaseHandler(
|
|
37
|
+
'create-checkout-session',
|
|
38
|
+
CreateCheckoutSessionRequestSchema,
|
|
39
|
+
async (data: CreateCheckoutSessionRequest, ctx) => {
|
|
40
|
+
initStripe(getStripeKey());
|
|
41
|
+
validateStripeEnvironment();
|
|
42
|
+
|
|
43
|
+
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
44
|
+
|
|
45
|
+
const stripeProvider = {
|
|
46
|
+
async createCheckoutSession(params: {
|
|
47
|
+
priceId: string;
|
|
48
|
+
customerEmail?: string;
|
|
49
|
+
metadata: Record<string, string>;
|
|
50
|
+
allowPromotionCodes: boolean;
|
|
51
|
+
successUrl: string;
|
|
52
|
+
cancelUrl: string;
|
|
53
|
+
mode?: 'payment' | 'subscription';
|
|
54
|
+
}) {
|
|
55
|
+
const session = await stripe.checkout.sessions.create({
|
|
56
|
+
mode: params.mode || 'payment',
|
|
57
|
+
line_items: [{ price: params.priceId, quantity: 1 }],
|
|
58
|
+
customer_email: params.customerEmail || undefined,
|
|
59
|
+
success_url: params.successUrl,
|
|
60
|
+
cancel_url: params.cancelUrl,
|
|
61
|
+
allow_promotion_codes: params.allowPromotionCodes,
|
|
62
|
+
metadata: params.metadata,
|
|
63
|
+
...(params.mode === 'subscription' && {
|
|
64
|
+
subscription_data: { metadata: params.metadata },
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
return { id: session.id, url: session.url };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return createCheckoutAlgorithm(data, stripeProvider, authProvider, billingConfig);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getStripeKey(): string {
|
|
77
|
+
const key = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
|
|
78
|
+
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
79
|
+
return key;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
declare const Deno: { env: { get(key: string): string | undefined } } | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// packages/functions/src/supabase/billing/createCustomerPortal.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Create Customer Portal — Supabase Edge Function
|
|
5
|
+
* @description Wraps the shared `createCustomerPortalSession` for Supabase.
|
|
6
|
+
*
|
|
7
|
+
* @version 0.0.1
|
|
8
|
+
* @since 0.5.0
|
|
9
|
+
* @author AMBROISE PARK Consulting
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as v from 'valibot';
|
|
13
|
+
|
|
14
|
+
import { createCustomerPortalSession } from '../../shared/billing/helpers/subscriptionManagement.js';
|
|
15
|
+
import { initStripe } from '../../shared/utils.js';
|
|
16
|
+
import { createSupabaseHandler } from '../baseFunction.js';
|
|
17
|
+
import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Schema
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
const customerPortalSchema = v.object({
|
|
24
|
+
userId: v.pipe(v.string(), v.minLength(1, 'User ID is required')),
|
|
25
|
+
returnUrl: v.optional(v.pipe(v.string(), v.url())),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Factory
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a Supabase Edge Function handler for Stripe Customer Portal.
|
|
34
|
+
*
|
|
35
|
+
* @returns `(req: Request) => Promise<Response>` handler
|
|
36
|
+
*
|
|
37
|
+
* @version 0.0.1
|
|
38
|
+
* @since 0.5.0
|
|
39
|
+
*/
|
|
40
|
+
export function createCustomerPortal() {
|
|
41
|
+
return createSupabaseHandler(
|
|
42
|
+
'create-customer-portal',
|
|
43
|
+
customerPortalSchema,
|
|
44
|
+
async (data, ctx) => {
|
|
45
|
+
initStripe(getStripeKey());
|
|
46
|
+
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
47
|
+
return createCustomerPortalSession(data.userId, authProvider, data.returnUrl);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getStripeKey(): string {
|
|
53
|
+
const key = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
|
|
54
|
+
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
declare const Deno: { env: { get(key: string): string | undefined } } | undefined;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// packages/functions/src/supabase/billing/refreshSubscriptionStatus.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Refresh Subscription Status — Supabase Edge Function
|
|
5
|
+
* @description Direct Stripe lookup + claim update via Supabase Admin.
|
|
6
|
+
*
|
|
7
|
+
* @version 0.0.1
|
|
8
|
+
* @since 0.5.0
|
|
9
|
+
* @author AMBROISE PARK Consulting
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import Stripe from 'stripe';
|
|
13
|
+
import * as v from 'valibot';
|
|
14
|
+
|
|
15
|
+
import { initStripe, stripe, validateStripeEnvironment } from '../../shared/utils.js';
|
|
16
|
+
import { createSupabaseHandler } from '../baseFunction.js';
|
|
17
|
+
import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Schema
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
const refreshSubscriptionSchema = v.object({
|
|
24
|
+
userId: v.pipe(v.string(), v.minLength(1, 'User ID is required')),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Factory
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a Supabase Edge Function handler for refreshing subscription status.
|
|
33
|
+
*
|
|
34
|
+
* @returns `(req: Request) => Promise<Response>` handler
|
|
35
|
+
*
|
|
36
|
+
* @version 0.0.1
|
|
37
|
+
* @since 0.5.0
|
|
38
|
+
*/
|
|
39
|
+
export function createRefreshSubscriptionStatus() {
|
|
40
|
+
return createSupabaseHandler(
|
|
41
|
+
'refresh-subscription-status',
|
|
42
|
+
refreshSubscriptionSchema,
|
|
43
|
+
async (data, ctx) => {
|
|
44
|
+
initStripe(getStripeKey());
|
|
45
|
+
validateStripeEnvironment();
|
|
46
|
+
|
|
47
|
+
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
48
|
+
const user = await authProvider.getUser(data.userId);
|
|
49
|
+
const currentClaims = user.customClaims || {};
|
|
50
|
+
|
|
51
|
+
const subscriptionId = (currentClaims.subscription as { subscriptionId?: string } | undefined)?.subscriptionId;
|
|
52
|
+
if (!subscriptionId) {
|
|
53
|
+
throw new Error('No active subscription found');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const subscription = await stripe.subscriptions.retrieve(subscriptionId) as Stripe.Subscription;
|
|
57
|
+
|
|
58
|
+
// Update claims via auth provider
|
|
59
|
+
const updatedClaims = {
|
|
60
|
+
...currentClaims,
|
|
61
|
+
subscription: {
|
|
62
|
+
...(currentClaims.subscription as Record<string, unknown>),
|
|
63
|
+
status: subscription.status,
|
|
64
|
+
currentPeriodStart: new Date((subscription as any).current_period_start * 1000).toISOString(),
|
|
65
|
+
currentPeriodEnd: new Date((subscription as any).current_period_end * 1000).toISOString(),
|
|
66
|
+
cancelAtPeriodEnd: (subscription as any).cancel_at_period_end,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await authProvider.setCustomUserClaims(data.userId, updatedClaims);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
success: true,
|
|
74
|
+
userId: data.userId,
|
|
75
|
+
status: subscription.status,
|
|
76
|
+
currentPeriodEnd: new Date((subscription as any).current_period_end * 1000).toISOString(),
|
|
77
|
+
cancelAtPeriodEnd: (subscription as any).cancel_at_period_end,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getStripeKey(): string {
|
|
84
|
+
const key = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
|
|
85
|
+
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
86
|
+
return key;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
declare const Deno: { env: { get(key: string): string | undefined } } | undefined;
|