@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
|
@@ -25,7 +25,10 @@ import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
|
25
25
|
const changePlanSchema = v.object({
|
|
26
26
|
userId: v.pipe(v.string(), v.minLength(1, 'User ID is required')),
|
|
27
27
|
newPriceId: v.pipe(v.string(), v.minLength(1, 'Price ID is required')),
|
|
28
|
-
billingConfigKey: v.pipe(
|
|
28
|
+
billingConfigKey: v.pipe(
|
|
29
|
+
v.string(),
|
|
30
|
+
v.minLength(1, 'Billing config key is required')
|
|
31
|
+
),
|
|
29
32
|
});
|
|
30
33
|
|
|
31
34
|
// =============================================================================
|
|
@@ -48,15 +51,27 @@ export function createChangePlan(billingConfig: StripeBackConfig) {
|
|
|
48
51
|
async (data, ctx) => {
|
|
49
52
|
initStripe(getStripeKey());
|
|
50
53
|
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
51
|
-
return changeUserPlan(
|
|
52
|
-
|
|
54
|
+
return changeUserPlan(
|
|
55
|
+
data.userId,
|
|
56
|
+
data.newPriceId,
|
|
57
|
+
data.billingConfigKey,
|
|
58
|
+
billingConfig,
|
|
59
|
+
authProvider
|
|
60
|
+
);
|
|
61
|
+
}
|
|
53
62
|
);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
function getStripeKey(): string {
|
|
57
|
-
const key = (
|
|
66
|
+
const key = (
|
|
67
|
+
typeof Deno !== 'undefined'
|
|
68
|
+
? Deno.env.get('STRIPE_SECRET_KEY')
|
|
69
|
+
: process.env.STRIPE_SECRET_KEY
|
|
70
|
+
) as string | undefined;
|
|
58
71
|
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
59
72
|
return key;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
declare const Deno:
|
|
75
|
+
declare const Deno:
|
|
76
|
+
| { env: { get(key: string): string | undefined } }
|
|
77
|
+
| undefined;
|
|
@@ -13,7 +13,11 @@ import type { StripeBackConfig } from '@donotdev/core/server';
|
|
|
13
13
|
import { CreateCheckoutSessionRequestSchema } from '@donotdev/core/server';
|
|
14
14
|
|
|
15
15
|
import { createCheckoutAlgorithm } from '../../shared/billing/createCheckout.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
initStripe,
|
|
18
|
+
stripe,
|
|
19
|
+
validateStripeEnvironment,
|
|
20
|
+
} from '../../shared/utils.js';
|
|
17
21
|
import { createSupabaseHandler } from '../baseFunction.js';
|
|
18
22
|
import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
19
23
|
|
|
@@ -68,15 +72,26 @@ export function createCheckoutSession(billingConfig: StripeBackConfig) {
|
|
|
68
72
|
},
|
|
69
73
|
};
|
|
70
74
|
|
|
71
|
-
return createCheckoutAlgorithm(
|
|
72
|
-
|
|
75
|
+
return createCheckoutAlgorithm(
|
|
76
|
+
data,
|
|
77
|
+
stripeProvider,
|
|
78
|
+
authProvider,
|
|
79
|
+
billingConfig
|
|
80
|
+
);
|
|
81
|
+
}
|
|
73
82
|
);
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
function getStripeKey(): string {
|
|
77
|
-
const key = (
|
|
86
|
+
const key = (
|
|
87
|
+
typeof Deno !== 'undefined'
|
|
88
|
+
? Deno.env.get('STRIPE_SECRET_KEY')
|
|
89
|
+
: process.env.STRIPE_SECRET_KEY
|
|
90
|
+
) as string | undefined;
|
|
78
91
|
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
79
92
|
return key;
|
|
80
93
|
}
|
|
81
94
|
|
|
82
|
-
declare const Deno:
|
|
95
|
+
declare const Deno:
|
|
96
|
+
| { env: { get(key: string): string | undefined } }
|
|
97
|
+
| undefined;
|
|
@@ -44,15 +44,25 @@ export function createCustomerPortal() {
|
|
|
44
44
|
async (data, ctx) => {
|
|
45
45
|
initStripe(getStripeKey());
|
|
46
46
|
const authProvider = createSupabaseAuthProvider(ctx.supabaseAdmin);
|
|
47
|
-
return createCustomerPortalSession(
|
|
48
|
-
|
|
47
|
+
return createCustomerPortalSession(
|
|
48
|
+
data.userId,
|
|
49
|
+
authProvider,
|
|
50
|
+
data.returnUrl
|
|
51
|
+
);
|
|
52
|
+
}
|
|
49
53
|
);
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
function getStripeKey(): string {
|
|
53
|
-
const key = (
|
|
57
|
+
const key = (
|
|
58
|
+
typeof Deno !== 'undefined'
|
|
59
|
+
? Deno.env.get('STRIPE_SECRET_KEY')
|
|
60
|
+
: process.env.STRIPE_SECRET_KEY
|
|
61
|
+
) as string | undefined;
|
|
54
62
|
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
55
63
|
return key;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
declare const Deno:
|
|
66
|
+
declare const Deno:
|
|
67
|
+
| { env: { get(key: string): string | undefined } }
|
|
68
|
+
| undefined;
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
import Stripe from 'stripe';
|
|
13
13
|
import * as v from 'valibot';
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
initStripe,
|
|
17
|
+
stripe,
|
|
18
|
+
validateStripeEnvironment,
|
|
19
|
+
} from '../../shared/utils.js';
|
|
16
20
|
import { createSupabaseHandler } from '../baseFunction.js';
|
|
17
21
|
import { createSupabaseAuthProvider } from '../helpers/authProvider.js';
|
|
18
22
|
|
|
@@ -48,12 +52,16 @@ export function createRefreshSubscriptionStatus() {
|
|
|
48
52
|
const user = await authProvider.getUser(data.userId);
|
|
49
53
|
const currentClaims = user.customClaims || {};
|
|
50
54
|
|
|
51
|
-
const subscriptionId = (
|
|
55
|
+
const subscriptionId = (
|
|
56
|
+
currentClaims.subscription as { subscriptionId?: string } | undefined
|
|
57
|
+
)?.subscriptionId;
|
|
52
58
|
if (!subscriptionId) {
|
|
53
59
|
throw new Error('No active subscription found');
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
const subscription = await stripe.subscriptions.retrieve(
|
|
62
|
+
const subscription = (await stripe.subscriptions.retrieve(
|
|
63
|
+
subscriptionId
|
|
64
|
+
)) as Stripe.Subscription;
|
|
57
65
|
|
|
58
66
|
// Update claims via auth provider
|
|
59
67
|
const updatedClaims = {
|
|
@@ -61,8 +69,12 @@ export function createRefreshSubscriptionStatus() {
|
|
|
61
69
|
subscription: {
|
|
62
70
|
...(currentClaims.subscription as Record<string, unknown>),
|
|
63
71
|
status: subscription.status,
|
|
64
|
-
currentPeriodStart: new Date(
|
|
65
|
-
|
|
72
|
+
currentPeriodStart: new Date(
|
|
73
|
+
(subscription as any).current_period_start * 1000
|
|
74
|
+
).toISOString(),
|
|
75
|
+
currentPeriodEnd: new Date(
|
|
76
|
+
(subscription as any).current_period_end * 1000
|
|
77
|
+
).toISOString(),
|
|
66
78
|
cancelAtPeriodEnd: (subscription as any).cancel_at_period_end,
|
|
67
79
|
},
|
|
68
80
|
};
|
|
@@ -73,17 +85,25 @@ export function createRefreshSubscriptionStatus() {
|
|
|
73
85
|
success: true,
|
|
74
86
|
userId: data.userId,
|
|
75
87
|
status: subscription.status,
|
|
76
|
-
currentPeriodEnd: new Date(
|
|
88
|
+
currentPeriodEnd: new Date(
|
|
89
|
+
(subscription as any).current_period_end * 1000
|
|
90
|
+
).toISOString(),
|
|
77
91
|
cancelAtPeriodEnd: (subscription as any).cancel_at_period_end,
|
|
78
92
|
};
|
|
79
|
-
}
|
|
93
|
+
}
|
|
80
94
|
);
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
function getStripeKey(): string {
|
|
84
|
-
const key = (
|
|
98
|
+
const key = (
|
|
99
|
+
typeof Deno !== 'undefined'
|
|
100
|
+
? Deno.env.get('STRIPE_SECRET_KEY')
|
|
101
|
+
: process.env.STRIPE_SECRET_KEY
|
|
102
|
+
) as string | undefined;
|
|
85
103
|
if (!key) throw new Error('Missing STRIPE_SECRET_KEY environment variable');
|
|
86
104
|
return key;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
declare const Deno:
|
|
107
|
+
declare const Deno:
|
|
108
|
+
| { env: { get(key: string): string | undefined } }
|
|
109
|
+
| undefined;
|
|
@@ -92,11 +92,18 @@ export function createSupabaseAggregateEntities(
|
|
|
92
92
|
let query = supabaseAdmin.from(collection);
|
|
93
93
|
|
|
94
94
|
for (const [field, operator, value] of where) {
|
|
95
|
-
query = applyOperator(
|
|
95
|
+
query = applyOperator(
|
|
96
|
+
query,
|
|
97
|
+
mapper.toBackendField(field),
|
|
98
|
+
operator,
|
|
99
|
+
value
|
|
100
|
+
);
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
if (groupBy && groupBy.length > 0) {
|
|
99
|
-
const groupByColumns = groupBy
|
|
104
|
+
const groupByColumns = groupBy
|
|
105
|
+
.map((f) => mapper.toBackendField(f))
|
|
106
|
+
.join(', ');
|
|
100
107
|
const selectFields = `${groupByColumns}, ${aggregate.operation}(${aggregateColumn})`;
|
|
101
108
|
query = query.select(selectFields);
|
|
102
109
|
for (const field of groupBy) {
|
|
@@ -114,7 +121,10 @@ export function createSupabaseAggregateEntities(
|
|
|
114
121
|
const { data: rows, error, count } = await query;
|
|
115
122
|
|
|
116
123
|
if (error) {
|
|
117
|
-
throw new DoNotDevError(
|
|
124
|
+
throw new DoNotDevError(
|
|
125
|
+
`Failed to aggregate entities: ${error.message}`,
|
|
126
|
+
'internal'
|
|
127
|
+
);
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
// Process results
|
|
@@ -133,7 +143,7 @@ export function createSupabaseAggregateEntities(
|
|
|
133
143
|
return { value: count ?? 0 };
|
|
134
144
|
} else {
|
|
135
145
|
// Calculate aggregate from rows
|
|
136
|
-
const values = (rows || [] as any[])
|
|
146
|
+
const values = (rows || ([] as any[]))
|
|
137
147
|
.map((row) => row[aggregateColumn])
|
|
138
148
|
.filter((v) => v != null && !isNaN(Number(v)))
|
|
139
149
|
.map((v) => Number(v));
|
|
@@ -19,10 +19,7 @@ import { defaultFieldMapper } from '@donotdev/supabase';
|
|
|
19
19
|
import { createMetadata } from '../../shared/index.js';
|
|
20
20
|
import { DoNotDevError, validateDocument } from '../../shared/utils.js';
|
|
21
21
|
import { createSupabaseHandler } from '../baseFunction.js';
|
|
22
|
-
import {
|
|
23
|
-
checkIdempotency,
|
|
24
|
-
storeIdempotency,
|
|
25
|
-
} from '../utils/idempotency.js';
|
|
22
|
+
import { checkIdempotency, storeIdempotency } from '../utils/idempotency.js';
|
|
26
23
|
|
|
27
24
|
const mapper = defaultFieldMapper;
|
|
28
25
|
|
|
@@ -68,7 +65,10 @@ async function checkUniqueKeys(
|
|
|
68
65
|
uniqueKeys: UniqueKeyDefinition[],
|
|
69
66
|
isDraft: boolean,
|
|
70
67
|
supabaseAdmin: any
|
|
71
|
-
): Promise<
|
|
68
|
+
): Promise<
|
|
69
|
+
| { found: false }
|
|
70
|
+
| { found: true; existingDoc: Record<string, any>; findOrCreate: boolean }
|
|
71
|
+
> {
|
|
72
72
|
for (const uniqueKey of uniqueKeys) {
|
|
73
73
|
// Skip validation for drafts only if explicitly opted in
|
|
74
74
|
if (isDraft && uniqueKey.skipForDrafts === true) continue;
|
|
@@ -81,13 +81,19 @@ async function checkUniqueKeys(
|
|
|
81
81
|
|
|
82
82
|
let query = supabaseAdmin.from(collection).select('*');
|
|
83
83
|
for (const field of uniqueKey.fields) {
|
|
84
|
-
query = query.eq(
|
|
84
|
+
query = query.eq(
|
|
85
|
+
mapper.toBackendField(field),
|
|
86
|
+
normalizeValue(payload[field])
|
|
87
|
+
);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
const { data: existing, error } = await query.limit(1);
|
|
88
91
|
|
|
89
92
|
if (!error && existing && existing.length > 0) {
|
|
90
|
-
const existingDoc = mapper.fromBackendRow(existing[0]) as Record<
|
|
93
|
+
const existingDoc = mapper.fromBackendRow(existing[0]) as Record<
|
|
94
|
+
string,
|
|
95
|
+
any
|
|
96
|
+
>;
|
|
91
97
|
|
|
92
98
|
if (uniqueKey.findOrCreate) {
|
|
93
99
|
return { found: true, existingDoc, findOrCreate: true };
|
|
@@ -185,9 +191,17 @@ export function createSupabaseCreateEntity(
|
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
const metadata = createMetadata(uid);
|
|
188
|
-
const snakeMetadata = mapper.toBackendKeys(
|
|
194
|
+
const snakeMetadata = mapper.toBackendKeys(
|
|
195
|
+
metadata as Record<string, unknown>
|
|
196
|
+
);
|
|
189
197
|
|
|
190
|
-
const {
|
|
198
|
+
const {
|
|
199
|
+
createdAt,
|
|
200
|
+
updatedAt,
|
|
201
|
+
created_at,
|
|
202
|
+
updated_at,
|
|
203
|
+
...payloadWithoutTimestamps
|
|
204
|
+
} = normalizedPayload;
|
|
191
205
|
const snakePayload = mapper.toBackendKeys(payloadWithoutTimestamps);
|
|
192
206
|
|
|
193
207
|
// Insert document (DB sets created_at/updated_at via triggers)
|
|
@@ -202,10 +216,15 @@ export function createSupabaseCreateEntity(
|
|
|
202
216
|
.single();
|
|
203
217
|
|
|
204
218
|
if (error) {
|
|
205
|
-
throw new DoNotDevError(
|
|
219
|
+
throw new DoNotDevError(
|
|
220
|
+
`Failed to create entity: ${error.message}`,
|
|
221
|
+
'internal'
|
|
222
|
+
);
|
|
206
223
|
}
|
|
207
224
|
|
|
208
|
-
const result = mapper.fromBackendRow(
|
|
225
|
+
const result = mapper.fromBackendRow(
|
|
226
|
+
inserted as Record<string, unknown>
|
|
227
|
+
) as Record<string, any>;
|
|
209
228
|
|
|
210
229
|
// Store result for idempotency if key provided
|
|
211
230
|
if (idempotencyKey) {
|
|
@@ -51,7 +51,11 @@ async function findReferences(
|
|
|
51
51
|
docId: string,
|
|
52
52
|
referenceMetadata?: ReferenceMetadata
|
|
53
53
|
): Promise<Array<{ collection: string; field: string; count: number }>> {
|
|
54
|
-
const references: Array<{
|
|
54
|
+
const references: Array<{
|
|
55
|
+
collection: string;
|
|
56
|
+
field: string;
|
|
57
|
+
count: number;
|
|
58
|
+
}> = [];
|
|
55
59
|
|
|
56
60
|
if (!referenceMetadata) {
|
|
57
61
|
return references;
|
|
@@ -120,7 +124,8 @@ export function createSupabaseDeleteEntity(
|
|
|
120
124
|
// Prevent deletion if required references exist
|
|
121
125
|
const requiredReferences = references.filter((ref) => {
|
|
122
126
|
const metadata = referenceMetadata?.incoming?.find(
|
|
123
|
-
(r) =>
|
|
127
|
+
(r) =>
|
|
128
|
+
r.sourceCollection === ref.collection && r.sourceField === ref.field
|
|
124
129
|
);
|
|
125
130
|
return metadata?.required === true;
|
|
126
131
|
});
|
|
@@ -144,7 +149,10 @@ export function createSupabaseDeleteEntity(
|
|
|
144
149
|
.eq('id', id);
|
|
145
150
|
|
|
146
151
|
if (error) {
|
|
147
|
-
throw new DoNotDevError(
|
|
152
|
+
throw new DoNotDevError(
|
|
153
|
+
`Failed to delete entity: ${error.message}`,
|
|
154
|
+
'internal'
|
|
155
|
+
);
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
return { success: true };
|
package/src/supabase/crud/get.ts
CHANGED
|
@@ -18,6 +18,12 @@ import {
|
|
|
18
18
|
} from '@donotdev/core/server';
|
|
19
19
|
import type { EntityOwnershipConfig, UserRole } from '@donotdev/core/server';
|
|
20
20
|
|
|
21
|
+
import {
|
|
22
|
+
createEntityAwareMapper,
|
|
23
|
+
defaultFieldMapper,
|
|
24
|
+
getEntityFieldNames,
|
|
25
|
+
} from '@donotdev/supabase';
|
|
26
|
+
|
|
21
27
|
import { DoNotDevError } from '../../shared/utils.js';
|
|
22
28
|
import { createSupabaseHandler } from '../baseFunction.js';
|
|
23
29
|
|
|
@@ -63,16 +69,32 @@ export function createSupabaseGetEntity(
|
|
|
63
69
|
const isAdmin = hasRoleAccess(userRole, 'admin');
|
|
64
70
|
|
|
65
71
|
// Hide drafts/deleted from non-admin users (security: hidden statuses never reach public)
|
|
66
|
-
if (
|
|
72
|
+
if (
|
|
73
|
+
!isAdmin &&
|
|
74
|
+
(HIDDEN_STATUSES as readonly string[]).includes(row.status)
|
|
75
|
+
) {
|
|
67
76
|
throw new DoNotDevError('Entity not found', 'not-found');
|
|
68
77
|
}
|
|
69
78
|
|
|
79
|
+
// Normalize DB row to entity field names before filtering.
|
|
80
|
+
const entityFieldNames = getEntityFieldNames(documentSchema);
|
|
81
|
+
const entityMapper =
|
|
82
|
+
entityFieldNames.length > 0
|
|
83
|
+
? createEntityAwareMapper(entityFieldNames)
|
|
84
|
+
: defaultFieldMapper;
|
|
85
|
+
const normalized = entityMapper.fromBackendRow(row) as Record<
|
|
86
|
+
string,
|
|
87
|
+
any
|
|
88
|
+
>;
|
|
89
|
+
|
|
70
90
|
const visibilityOptions =
|
|
71
|
-
ownership && uid
|
|
91
|
+
ownership && uid
|
|
92
|
+
? { documentData: normalized, uid, ownership }
|
|
93
|
+
: undefined;
|
|
72
94
|
|
|
73
95
|
// Filter fields based on visibility and user role (and ownership for visibility: 'owner')
|
|
74
96
|
const filteredData = filterVisibleFields(
|
|
75
|
-
|
|
97
|
+
normalized,
|
|
76
98
|
documentSchema,
|
|
77
99
|
userRole,
|
|
78
100
|
visibilityOptions
|
|
@@ -22,7 +22,11 @@ import type {
|
|
|
22
22
|
UserRole,
|
|
23
23
|
} from '@donotdev/core/server';
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
createEntityAwareMapper,
|
|
27
|
+
defaultFieldMapper,
|
|
28
|
+
getEntityFieldNames,
|
|
29
|
+
} from '@donotdev/supabase';
|
|
26
30
|
|
|
27
31
|
import { DoNotDevError } from '../../shared/utils.js';
|
|
28
32
|
import { createSupabaseHandler } from '../baseFunction.js';
|
|
@@ -32,7 +36,9 @@ const mapper = defaultFieldMapper;
|
|
|
32
36
|
|
|
33
37
|
/** Ensure we only pass strings to the mapper (entity listFields/ownership can be mis-typed at runtime). */
|
|
34
38
|
function toBackendColumn(field: unknown): string {
|
|
35
|
-
return mapper.toBackendField(
|
|
39
|
+
return mapper.toBackendField(
|
|
40
|
+
typeof field === 'string' ? field : String(field)
|
|
41
|
+
);
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface ListEntityRequest {
|
|
@@ -58,7 +64,10 @@ function encodeCursor(id: string, orderBy: Record<string, any>): string {
|
|
|
58
64
|
/**
|
|
59
65
|
* Decode cursor for keyset pagination
|
|
60
66
|
*/
|
|
61
|
-
function decodeCursor(cursor: string): {
|
|
67
|
+
function decodeCursor(cursor: string): {
|
|
68
|
+
id: string;
|
|
69
|
+
orderBy: Record<string, any>;
|
|
70
|
+
} {
|
|
62
71
|
try {
|
|
63
72
|
return JSON.parse(Buffer.from(cursor, 'base64').toString());
|
|
64
73
|
} catch (error) {
|
|
@@ -132,7 +141,12 @@ export function createSupabaseListEntities(
|
|
|
132
141
|
const requestSchema = v.object({
|
|
133
142
|
where: v.optional(v.array(v.tuple([v.string(), v.any(), v.any()]))),
|
|
134
143
|
orderBy: v.optional(
|
|
135
|
-
v.array(
|
|
144
|
+
v.array(
|
|
145
|
+
v.tuple([
|
|
146
|
+
v.pipe(v.string(), v.minLength(1)),
|
|
147
|
+
v.picklist(['asc', 'desc']),
|
|
148
|
+
])
|
|
149
|
+
)
|
|
136
150
|
),
|
|
137
151
|
limit: v.optional(v.pipe(v.number(), v.minValue(1))),
|
|
138
152
|
startAfterId: v.optional(v.string()), // Offset-based (legacy)
|
|
@@ -149,18 +163,31 @@ export function createSupabaseListEntities(
|
|
|
149
163
|
isListCard ? `listCard_${collection}` : `list_${collection}`,
|
|
150
164
|
requestSchema,
|
|
151
165
|
async (data: ListEntityRequest, ctx) => {
|
|
152
|
-
const {
|
|
166
|
+
const {
|
|
167
|
+
where = [],
|
|
168
|
+
orderBy = [],
|
|
169
|
+
limit = 50,
|
|
170
|
+
startAfterId,
|
|
171
|
+
startAfterCursor,
|
|
172
|
+
search,
|
|
173
|
+
} = data;
|
|
153
174
|
const { userRole, uid, supabaseAdmin } = ctx;
|
|
154
175
|
|
|
155
176
|
const isAdmin = hasRoleAccess(userRole, 'admin');
|
|
156
177
|
|
|
157
178
|
// Build query - select fields (listFields + id + status, or *). Only use string entries (entity may be mis-typed).
|
|
158
|
-
const safeListFields = listFields?.filter(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
const safeListFields = listFields?.filter(
|
|
180
|
+
(f): f is string => typeof f === 'string'
|
|
181
|
+
);
|
|
182
|
+
const selectFields =
|
|
183
|
+
safeListFields && safeListFields.length > 0
|
|
184
|
+
? safeListFields.map((f) => toBackendColumn(f)).join(', ') +
|
|
185
|
+
', id, status'
|
|
186
|
+
: '*';
|
|
187
|
+
|
|
188
|
+
let query = supabaseAdmin
|
|
189
|
+
.from(collection)
|
|
190
|
+
.select(selectFields, { count: 'exact' });
|
|
164
191
|
|
|
165
192
|
// Filter out hidden statuses for non-admin users
|
|
166
193
|
if (!isAdmin) {
|
|
@@ -182,16 +209,57 @@ export function createSupabaseListEntities(
|
|
|
182
209
|
}
|
|
183
210
|
|
|
184
211
|
if (search) {
|
|
185
|
-
|
|
212
|
+
// Validate search.field against entity schema (listFields as allowlist)
|
|
213
|
+
if (safeListFields && safeListFields.length > 0) {
|
|
214
|
+
if (!safeListFields.includes(search.field)) {
|
|
215
|
+
throw new DoNotDevError(
|
|
216
|
+
`Search field '${search.field}' is not allowed`,
|
|
217
|
+
'invalid-argument'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} else if (search.field.startsWith('_') || search.field.includes('.')) {
|
|
221
|
+
// No schema available — reject obviously unsafe field names
|
|
222
|
+
throw new DoNotDevError(
|
|
223
|
+
`Search field '${search.field}' is not allowed`,
|
|
224
|
+
'invalid-argument'
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
// Escape SQL ILIKE wildcards to prevent wildcard injection
|
|
228
|
+
const escapedQuery = search.query
|
|
229
|
+
.replace(/%/g, '\\%')
|
|
230
|
+
.replace(/_/g, '\\_');
|
|
231
|
+
query = query.ilike(toBackendColumn(search.field), `%${escapedQuery}%`);
|
|
186
232
|
}
|
|
187
233
|
|
|
234
|
+
// Validate where clause fields against entity schema
|
|
188
235
|
for (const [field, operator, value] of where) {
|
|
236
|
+
if (safeListFields && safeListFields.length > 0) {
|
|
237
|
+
if (
|
|
238
|
+
!safeListFields.includes(field) &&
|
|
239
|
+
field !== 'status' &&
|
|
240
|
+
field !== 'id'
|
|
241
|
+
) {
|
|
242
|
+
throw new DoNotDevError(
|
|
243
|
+
`Where field '${field}' is not allowed`,
|
|
244
|
+
'invalid-argument'
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
} else if (field.startsWith('_') || field.includes('.')) {
|
|
248
|
+
throw new DoNotDevError(
|
|
249
|
+
`Where field '${field}' is not allowed`,
|
|
250
|
+
'invalid-argument'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
189
253
|
query = applyOperator(query, toBackendColumn(field), operator, value);
|
|
190
254
|
}
|
|
191
255
|
|
|
192
|
-
const hasIdInOrderBy = orderBy.some(
|
|
256
|
+
const hasIdInOrderBy = orderBy.some(
|
|
257
|
+
([field]) => toBackendColumn(field) === 'id'
|
|
258
|
+
);
|
|
193
259
|
for (const [field, direction] of orderBy) {
|
|
194
|
-
query = query.order(toBackendColumn(field), {
|
|
260
|
+
query = query.order(toBackendColumn(field), {
|
|
261
|
+
ascending: direction === 'asc',
|
|
262
|
+
});
|
|
195
263
|
}
|
|
196
264
|
// Add id as tiebreaker if not already in orderBy
|
|
197
265
|
if (!hasIdInOrderBy && orderBy.length > 0) {
|
|
@@ -205,7 +273,7 @@ export function createSupabaseListEntities(
|
|
|
205
273
|
useKeysetPagination = true;
|
|
206
274
|
try {
|
|
207
275
|
const cursor = decodeCursor(startAfterCursor);
|
|
208
|
-
|
|
276
|
+
|
|
209
277
|
if (orderBy.length === 0) {
|
|
210
278
|
query = query.gt('id', cursor.id);
|
|
211
279
|
} else {
|
|
@@ -240,12 +308,17 @@ export function createSupabaseListEntities(
|
|
|
240
308
|
const { data: rows, error, count } = await query;
|
|
241
309
|
|
|
242
310
|
if (error) {
|
|
243
|
-
throw new DoNotDevError(
|
|
311
|
+
throw new DoNotDevError(
|
|
312
|
+
`Failed to list entities: ${error.message}`,
|
|
313
|
+
'internal'
|
|
314
|
+
);
|
|
244
315
|
}
|
|
245
316
|
|
|
246
317
|
let items = (rows || []) as Record<string, any>[];
|
|
247
|
-
|
|
248
|
-
// Filter out cursor item for keyset pagination
|
|
318
|
+
|
|
319
|
+
// Filter out cursor item for keyset pagination.
|
|
320
|
+
// Note: items are still raw DB rows here (pre-normalization), so mapper.toBackendField
|
|
321
|
+
// is correct for accessing backend column values. entityMapper is used later for normalization.
|
|
249
322
|
if (useKeysetPagination && startAfterCursor && items.length > 0) {
|
|
250
323
|
try {
|
|
251
324
|
const cursor = decodeCursor(startAfterCursor);
|
|
@@ -289,11 +362,23 @@ export function createSupabaseListEntities(
|
|
|
289
362
|
return value;
|
|
290
363
|
};
|
|
291
364
|
|
|
292
|
-
const visibilityOptions =
|
|
365
|
+
const visibilityOptions =
|
|
366
|
+
ownership && uid ? { uid, ownership } : undefined;
|
|
367
|
+
|
|
368
|
+
// Build entity-aware mapper from schema entries so fromBackendRow returns
|
|
369
|
+
// entity field names (not blind camelCase), matching filterVisibleFields expectations.
|
|
370
|
+
const entityFieldNames = getEntityFieldNames(documentSchema);
|
|
371
|
+
const entityMapper =
|
|
372
|
+
entityFieldNames.length > 0
|
|
373
|
+
? createEntityAwareMapper(entityFieldNames)
|
|
374
|
+
: mapper;
|
|
293
375
|
|
|
294
376
|
// Filter document fields based on visibility and user role
|
|
295
377
|
const filteredItems = items.map((row) => {
|
|
296
|
-
const camelRow =
|
|
378
|
+
const camelRow = entityMapper.fromBackendRow(row) as Record<
|
|
379
|
+
string,
|
|
380
|
+
any
|
|
381
|
+
>;
|
|
297
382
|
const visibleData = filterVisibleFields(
|
|
298
383
|
camelRow,
|
|
299
384
|
documentSchema,
|
|
@@ -324,7 +409,7 @@ export function createSupabaseListEntities(
|
|
|
324
409
|
});
|
|
325
410
|
|
|
326
411
|
const hasMore = items.length === limit;
|
|
327
|
-
|
|
412
|
+
|
|
328
413
|
// Generate cursor for keyset pagination or offset for legacy
|
|
329
414
|
let lastVisible: string | null = null;
|
|
330
415
|
if (hasMore && filteredItems.length > 0) {
|