@donotdev/functions 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +31 -7
  2. package/src/firebase/auth/setCustomClaims.ts +26 -4
  3. package/src/firebase/baseFunction.ts +43 -20
  4. package/src/firebase/billing/cancelSubscription.ts +9 -1
  5. package/src/firebase/billing/changePlan.ts +8 -2
  6. package/src/firebase/billing/createCustomerPortal.ts +16 -2
  7. package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
  8. package/src/firebase/billing/webhookHandler.ts +13 -1
  9. package/src/firebase/crud/aggregate.ts +20 -5
  10. package/src/firebase/crud/create.ts +31 -7
  11. package/src/firebase/crud/list.ts +7 -22
  12. package/src/firebase/crud/update.ts +29 -7
  13. package/src/firebase/oauth/exchangeToken.ts +30 -4
  14. package/src/firebase/oauth/githubAccess.ts +8 -3
  15. package/src/firebase/registerCrudFunctions.ts +2 -2
  16. package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
  17. package/src/shared/__tests__/detectFirestore.test.ts +52 -0
  18. package/src/shared/__tests__/errorHandling.test.ts +144 -0
  19. package/src/shared/__tests__/idempotency.test.ts +95 -0
  20. package/src/shared/__tests__/rateLimiter.test.ts +142 -0
  21. package/src/shared/__tests__/validation.test.ts +172 -0
  22. package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
  23. package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
  24. package/src/shared/billing/webhookHandler.ts +16 -7
  25. package/src/shared/errorHandling.ts +16 -54
  26. package/src/shared/firebase.ts +1 -25
  27. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  28. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  29. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  30. package/src/shared/utils/external/subscription.ts +10 -0
  31. package/src/shared/utils/internal/auth.ts +140 -16
  32. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  33. package/src/shared/utils/internal/validation.ts +47 -3
  34. package/src/shared/utils.ts +154 -39
  35. package/src/supabase/auth/deleteAccount.ts +59 -0
  36. package/src/supabase/auth/getCustomClaims.ts +56 -0
  37. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  38. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  39. package/src/supabase/auth/setCustomClaims.ts +73 -0
  40. package/src/supabase/baseFunction.ts +302 -0
  41. package/src/supabase/billing/cancelSubscription.ts +57 -0
  42. package/src/supabase/billing/changePlan.ts +62 -0
  43. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  44. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  45. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  46. package/src/supabase/crud/aggregate.ts +169 -0
  47. package/src/supabase/crud/create.ts +225 -0
  48. package/src/supabase/crud/delete.ts +154 -0
  49. package/src/supabase/crud/get.ts +89 -0
  50. package/src/supabase/crud/index.ts +24 -0
  51. package/src/supabase/crud/list.ts +357 -0
  52. package/src/supabase/crud/update.ts +199 -0
  53. package/src/supabase/helpers/authProvider.ts +45 -0
  54. package/src/supabase/index.ts +73 -0
  55. package/src/supabase/registerCrudFunctions.ts +180 -0
  56. package/src/supabase/utils/idempotency.ts +141 -0
  57. package/src/supabase/utils/monitoring.ts +187 -0
  58. package/src/supabase/utils/rateLimiter.ts +216 -0
  59. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  60. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  61. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  62. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  63. package/src/vercel/api/billing/cancel.ts +2 -1
  64. package/src/vercel/api/billing/change-plan.ts +3 -1
  65. package/src/vercel/api/billing/customer-portal.ts +4 -1
  66. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  67. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  68. package/src/vercel/api/crud/create.ts +14 -8
  69. package/src/vercel/api/crud/delete.ts +15 -6
  70. package/src/vercel/api/crud/get.ts +16 -8
  71. package/src/vercel/api/crud/list.ts +22 -10
  72. package/src/vercel/api/crud/update.ts +16 -10
  73. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  74. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  75. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  76. package/src/vercel/api/utils/cors.ts +13 -2
  77. 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;