@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,180 @@
1
+ // packages/functions/src/supabase/registerCrudFunctions.ts
2
+
3
+ /**
4
+ * @fileoverview Auto-register CRUD handlers from entities for Supabase Edge Functions
5
+ * @description Utility to automatically generate CRUD Edge Function handlers for all entities
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.5.0
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import { createSchemas, getListCardFieldNames } from '@donotdev/core/server';
13
+ import type { Entity } from '@donotdev/core/server';
14
+
15
+ import {
16
+ createSupabaseGetEntity,
17
+ createSupabaseCreateEntity,
18
+ createSupabaseUpdateEntity,
19
+ createSupabaseDeleteEntity,
20
+ createSupabaseListEntities,
21
+ createSupabaseAggregateEntities,
22
+ } from './crud/index.js';
23
+
24
+ type SupabaseHandler = (req: Request) => Promise<Response>;
25
+
26
+ interface CrudHandlers {
27
+ handlers: Record<string, SupabaseHandler>;
28
+ serve: (req: Request) => Promise<Response>;
29
+ }
30
+
31
+ /**
32
+ * Create CRUD handlers for all entities (Supabase Edge Functions)
33
+ * Returns handlers object + serve dispatcher function
34
+ *
35
+ * @param entities - Object of { key: Entity } (from `import * as entities from 'entities'`)
36
+ * @returns Object with handlers and serve dispatcher
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * import * as entities from '../_shared/entities.ts';
41
+ * import { createSupabaseCrudFunctions } from '@donotdev/functions/supabase';
42
+ *
43
+ * const { serve } = createSupabaseCrudFunctions(entities);
44
+ * Deno.serve(serve);
45
+ * ```
46
+ */
47
+ export function createSupabaseCrudFunctions(
48
+ entities: Record<string, Entity | unknown>
49
+ ): CrudHandlers {
50
+ const handlers: Record<string, SupabaseHandler> = {};
51
+
52
+ for (const [key, value] of Object.entries(entities)) {
53
+ if (!isEntity(value)) continue;
54
+
55
+ const entity = value as Entity;
56
+ const col = entity.collection;
57
+ const schemas = createSchemas(entity);
58
+ const access = entity.access;
59
+
60
+ handlers[`create_${col}`] = createSupabaseCreateEntity(
61
+ col,
62
+ schemas.create,
63
+ access.create
64
+ );
65
+ handlers[`get_${col}`] = createSupabaseGetEntity(
66
+ col,
67
+ schemas.get,
68
+ access.read,
69
+ entity.ownership
70
+ );
71
+ handlers[`list_${col}`] = createSupabaseListEntities(
72
+ col,
73
+ schemas.get,
74
+ access.read,
75
+ entity.listFields,
76
+ entity.ownership,
77
+ false
78
+ );
79
+ handlers[`listCard_${col}`] = createSupabaseListEntities(
80
+ col,
81
+ schemas.get,
82
+ access.read,
83
+ getListCardFieldNames(entity),
84
+ entity.ownership,
85
+ true
86
+ );
87
+ handlers[`update_${col}`] = createSupabaseUpdateEntity(
88
+ col,
89
+ schemas.update,
90
+ access.update
91
+ );
92
+
93
+ // Extract reference metadata from entity if available
94
+ const schemaWithMeta = schemas.get as {
95
+ metadata?: {
96
+ references?: {
97
+ outgoing?: Array<{
98
+ field: string;
99
+ targetCollection: string;
100
+ required?: boolean;
101
+ }>;
102
+ incoming?: Array<{
103
+ sourceCollection: string;
104
+ sourceField: string;
105
+ required?: boolean;
106
+ }>;
107
+ };
108
+ };
109
+ };
110
+ const referenceMetadata = schemaWithMeta.metadata?.references;
111
+
112
+ handlers[`delete_${col}`] = createSupabaseDeleteEntity(
113
+ col,
114
+ access.delete,
115
+ referenceMetadata
116
+ );
117
+ handlers[`aggregate_${col}`] = createSupabaseAggregateEntities(
118
+ col,
119
+ schemas.get,
120
+ access.read
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Serve dispatcher: reads _functionName from request body, routes to correct handler
126
+ */
127
+ const serve = async (req: Request): Promise<Response> => {
128
+ try {
129
+ const body = await req.json().catch(() => ({}));
130
+ const functionName = (body as Record<string, unknown>)._functionName as string;
131
+
132
+ if (!functionName) {
133
+ return new Response(
134
+ JSON.stringify({ error: 'Missing _functionName in request body' }),
135
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
136
+ );
137
+ }
138
+
139
+ const handler = handlers[functionName];
140
+ if (!handler) {
141
+ return new Response(
142
+ JSON.stringify({ error: `Unknown function: ${functionName}` }),
143
+ { status: 404, headers: { 'Content-Type': 'application/json' } }
144
+ );
145
+ }
146
+
147
+ // Remove _functionName from body before passing to handler
148
+ const { _functionName, ...handlerData } = body as Record<string, unknown>;
149
+
150
+ // Create new request with cleaned body
151
+ const handlerReq = new Request(req.url, {
152
+ method: req.method,
153
+ headers: req.headers,
154
+ body: JSON.stringify(handlerData),
155
+ });
156
+
157
+ return handler(handlerReq);
158
+ } catch (error) {
159
+ const message = error instanceof Error ? error.message : 'Internal server error';
160
+ return new Response(
161
+ JSON.stringify({ error: message }),
162
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
163
+ );
164
+ }
165
+ };
166
+
167
+ return { handlers, serve };
168
+ }
169
+
170
+ /**
171
+ * Type guard to check if a value is an Entity
172
+ */
173
+ function isEntity(value: unknown): value is Entity {
174
+ return (
175
+ typeof value === 'object' &&
176
+ value !== null &&
177
+ 'collection' in value &&
178
+ 'fields' in value
179
+ );
180
+ }
@@ -0,0 +1,141 @@
1
+ // packages/functions/src/supabase/utils/idempotency.ts
2
+
3
+ /**
4
+ * @fileoverview Idempotency utilities for Supabase Edge Functions
5
+ * @description Provides idempotency checking and storage using Postgres table
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.5.0
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import type { SupabaseClient } from '@supabase/supabase-js';
13
+
14
+ /**
15
+ * Default TTL for idempotency records (24 hours)
16
+ */
17
+ const DEFAULT_TTL_HOURS = 24;
18
+
19
+ /**
20
+ * TTL per operation type (hours)
21
+ */
22
+ const OPERATION_TTL: Record<string, number> = {
23
+ create: 24,
24
+ update: 12,
25
+ delete: 1,
26
+ default: 24,
27
+ };
28
+
29
+ /**
30
+ * Check if an operation has already been processed (idempotency check)
31
+ *
32
+ * @param supabaseAdmin - Supabase admin client
33
+ * @param idempotencyKey - Client-provided idempotency key
34
+ * @param operation - Operation name (e.g., 'create', 'update')
35
+ * @returns Cached result if found, null otherwise
36
+ */
37
+ export async function checkIdempotency<T>(
38
+ supabaseAdmin: SupabaseClient,
39
+ idempotencyKey: string,
40
+ operation: string
41
+ ): Promise<T | null> {
42
+ try {
43
+ const id = `${operation}_${idempotencyKey}`;
44
+
45
+ const { data, error } = await supabaseAdmin
46
+ .from('idempotency')
47
+ .select('result')
48
+ .eq('idempotency_key', idempotencyKey)
49
+ .eq('operation', operation)
50
+ .gt('expires_at', new Date().toISOString())
51
+ .single();
52
+
53
+ if (error || !data) {
54
+ return null;
55
+ }
56
+
57
+ return data.result as T;
58
+ } catch (error) {
59
+ // Fail open - allow the operation if idempotency check fails
60
+ console.error('[idempotency] Check failed:', error);
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Store the result of an operation for idempotency
67
+ *
68
+ * @param supabaseAdmin - Supabase admin client
69
+ * @param idempotencyKey - Client-provided idempotency key
70
+ * @param operation - Operation name (e.g., 'create', 'update')
71
+ * @param result - Operation result to cache
72
+ * @param uid - User ID who processed the operation
73
+ * @param ttlHours - Optional TTL override (defaults to operation-specific TTL)
74
+ */
75
+ export async function storeIdempotency<T>(
76
+ supabaseAdmin: SupabaseClient,
77
+ idempotencyKey: string,
78
+ operation: string,
79
+ result: T,
80
+ uid: string,
81
+ ttlHours?: number
82
+ ): Promise<void> {
83
+ try {
84
+ const id = `${operation}_${idempotencyKey}`;
85
+ const ttl = ttlHours ?? OPERATION_TTL[operation] ?? OPERATION_TTL.default;
86
+ const expiresAt = new Date();
87
+ expiresAt.setHours(expiresAt.getHours() + ttl);
88
+
89
+ const { error } = await supabaseAdmin
90
+ .from('idempotency')
91
+ .upsert(
92
+ {
93
+ id,
94
+ operation,
95
+ idempotency_key: idempotencyKey,
96
+ result: result as any,
97
+ processed_by: uid,
98
+ expires_at: expiresAt.toISOString(),
99
+ },
100
+ {
101
+ onConflict: 'idempotency_key',
102
+ }
103
+ );
104
+
105
+ if (error) {
106
+ console.error('[idempotency] Store failed:', error);
107
+ // Don't throw - idempotency storage failure shouldn't break the operation
108
+ }
109
+ } catch (error) {
110
+ console.error('[idempotency] Store failed:', error);
111
+ // Don't throw - idempotency storage failure shouldn't break the operation
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Clean up expired idempotency records
117
+ *
118
+ * @param supabaseAdmin - Supabase admin client
119
+ * @returns Number of deleted records
120
+ */
121
+ export async function cleanupExpiredIdempotency(
122
+ supabaseAdmin: SupabaseClient
123
+ ): Promise<number> {
124
+ try {
125
+ const { data, error } = await supabaseAdmin
126
+ .from('idempotency')
127
+ .delete()
128
+ .lt('expires_at', new Date().toISOString())
129
+ .select('id');
130
+
131
+ if (error) {
132
+ console.error('[idempotency] Cleanup failed:', error);
133
+ return 0;
134
+ }
135
+
136
+ return data?.length ?? 0;
137
+ } catch (error) {
138
+ console.error('[idempotency] Cleanup failed:', error);
139
+ return 0;
140
+ }
141
+ }
@@ -0,0 +1,187 @@
1
+ // packages/functions/src/supabase/utils/monitoring.ts
2
+
3
+ /**
4
+ * @fileoverview Monitoring and metrics utilities for Supabase Edge Functions
5
+ * @description Provides metrics collection and analytics queries using Postgres table
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.5.0
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import type { SupabaseClient } from '@supabase/supabase-js';
13
+
14
+ /**
15
+ * Operation metrics data structure
16
+ */
17
+ export interface OperationMetrics {
18
+ operation: string;
19
+ userId?: string;
20
+ status: 'success' | 'failed' | 'pending';
21
+ durationMs?: number;
22
+ metadata?: Record<string, any>;
23
+ errorCode?: string;
24
+ errorMessage?: string;
25
+ }
26
+
27
+ /**
28
+ * Record operation metrics for monitoring
29
+ *
30
+ * @param supabaseAdmin - Supabase admin client
31
+ * @param metrics - Operation metrics to record
32
+ */
33
+ export async function recordOperationMetrics(
34
+ supabaseAdmin: SupabaseClient,
35
+ metrics: OperationMetrics
36
+ ): Promise<void> {
37
+ try {
38
+ const { error } = await supabaseAdmin.from('operation_metrics').insert({
39
+ operation: metrics.operation,
40
+ user_id: metrics.userId || null,
41
+ status: metrics.status,
42
+ duration_ms: metrics.durationMs || null,
43
+ metadata: metrics.metadata || null,
44
+ error_code: metrics.errorCode || null,
45
+ error_message: metrics.errorMessage || null,
46
+ });
47
+
48
+ if (error) {
49
+ console.error('[monitoring] Record failed:', error);
50
+ // Don't throw - metrics failure shouldn't break the operation
51
+ }
52
+ } catch (error) {
53
+ console.error('[monitoring] Record failed:', error);
54
+ // Don't throw - metrics failure shouldn't break the operation
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Get failure rate for an operation
60
+ *
61
+ * @param supabaseAdmin - Supabase admin client
62
+ * @param operation - Operation name
63
+ * @param hours - Time window in hours (default: 24)
64
+ * @returns Failure rate (0-100)
65
+ */
66
+ export async function getFailureRate(
67
+ supabaseAdmin: SupabaseClient,
68
+ operation: string,
69
+ hours: number = 24
70
+ ): Promise<number> {
71
+ try {
72
+ const cutoffTime = new Date();
73
+ cutoffTime.setHours(cutoffTime.getHours() - hours);
74
+
75
+ const { data, error } = await supabaseAdmin
76
+ .from('operation_metrics')
77
+ .select('status')
78
+ .eq('operation', operation)
79
+ .gte('timestamp', cutoffTime.toISOString());
80
+
81
+ if (error || !data || data.length === 0) {
82
+ return 0;
83
+ }
84
+
85
+ const failed = data.filter((m) => m.status === 'failed').length;
86
+ return (failed / data.length) * 100;
87
+ } catch (error) {
88
+ console.error('[monitoring] Get failure rate failed:', error);
89
+ return 0;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Get operation counts by user
95
+ *
96
+ * @param supabaseAdmin - Supabase admin client
97
+ * @param userId - User ID
98
+ * @param hours - Time window in hours (default: 24)
99
+ * @returns Record of operation names to counts
100
+ */
101
+ export async function getOperationCounts(
102
+ supabaseAdmin: SupabaseClient,
103
+ userId: string,
104
+ hours: number = 24
105
+ ): Promise<Record<string, number>> {
106
+ try {
107
+ const cutoffTime = new Date();
108
+ cutoffTime.setHours(cutoffTime.getHours() - hours);
109
+
110
+ const { data, error } = await supabaseAdmin
111
+ .from('operation_metrics')
112
+ .select('operation')
113
+ .eq('user_id', userId)
114
+ .gte('timestamp', cutoffTime.toISOString());
115
+
116
+ if (error || !data) {
117
+ return {};
118
+ }
119
+
120
+ const counts: Record<string, number> = {};
121
+ for (const metric of data) {
122
+ counts[metric.operation] = (counts[metric.operation] || 0) + 1;
123
+ }
124
+
125
+ return counts;
126
+ } catch (error) {
127
+ console.error('[monitoring] Get operation counts failed:', error);
128
+ return {};
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get slow operations (above threshold)
134
+ *
135
+ * @param supabaseAdmin - Supabase admin client
136
+ * @param thresholdMs - Duration threshold in milliseconds
137
+ * @param hours - Time window in hours (default: 24)
138
+ * @returns Array of operations with average duration
139
+ */
140
+ export async function getSlowOperations(
141
+ supabaseAdmin: SupabaseClient,
142
+ thresholdMs: number,
143
+ hours: number = 24
144
+ ): Promise<Array<{ operation: string; avgDuration: number; count: number }>> {
145
+ try {
146
+ const cutoffTime = new Date();
147
+ cutoffTime.setHours(cutoffTime.getHours() - hours);
148
+
149
+ const { data, error } = await supabaseAdmin
150
+ .from('operation_metrics')
151
+ .select('operation, duration_ms')
152
+ .gte('timestamp', cutoffTime.toISOString())
153
+ .not('duration_ms', 'is', null);
154
+
155
+ if (error || !data) {
156
+ return [];
157
+ }
158
+
159
+ // Group by operation and calculate average
160
+ const grouped: Record<string, { sum: number; count: number }> = {};
161
+ for (const metric of data) {
162
+ if (!metric.duration_ms) continue;
163
+ if (!grouped[metric.operation]) {
164
+ grouped[metric.operation] = { sum: 0, count: 0 };
165
+ }
166
+ grouped[metric.operation].sum += metric.duration_ms;
167
+ grouped[metric.operation].count += 1;
168
+ }
169
+
170
+ const results: Array<{ operation: string; avgDuration: number; count: number }> = [];
171
+ for (const [operation, stats] of Object.entries(grouped)) {
172
+ const avgDuration = stats.sum / stats.count;
173
+ if (avgDuration >= thresholdMs) {
174
+ results.push({
175
+ operation,
176
+ avgDuration: Math.round(avgDuration),
177
+ count: stats.count,
178
+ });
179
+ }
180
+ }
181
+
182
+ return results.sort((a, b) => b.avgDuration - a.avgDuration);
183
+ } catch (error) {
184
+ console.error('[monitoring] Get slow operations failed:', error);
185
+ return [];
186
+ }
187
+ }