@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.
Files changed (55) hide show
  1. package/README.md +1 -1
  2. package/package.json +9 -9
  3. package/src/firebase/auth/setCustomClaims.ts +19 -5
  4. package/src/firebase/baseFunction.ts +11 -3
  5. package/src/firebase/billing/changePlan.ts +5 -1
  6. package/src/firebase/billing/createCheckoutSession.ts +3 -1
  7. package/src/firebase/billing/createCustomerPortal.ts +6 -2
  8. package/src/firebase/billing/webhookHandler.ts +4 -1
  9. package/src/firebase/crud/aggregate.ts +5 -1
  10. package/src/firebase/crud/create.ts +17 -4
  11. package/src/firebase/crud/list.ts +37 -5
  12. package/src/firebase/crud/update.ts +17 -4
  13. package/src/firebase/oauth/exchangeToken.ts +17 -4
  14. package/src/shared/__tests__/validation.test.ts +5 -3
  15. package/src/shared/billing/__tests__/createCheckout.test.ts +123 -22
  16. package/src/shared/billing/__tests__/webhookHandler.test.ts +121 -30
  17. package/src/shared/errorHandling.ts +6 -6
  18. package/src/shared/firebase.ts +1 -1
  19. package/src/shared/index.ts +2 -1
  20. package/src/shared/logger.ts +9 -7
  21. package/src/shared/oauth/__tests__/exchangeToken.test.ts +6 -8
  22. package/src/shared/oauth/__tests__/grantAccess.test.ts +31 -14
  23. package/src/shared/utils/external/subscription.ts +2 -2
  24. package/src/shared/utils/internal/auth.ts +10 -3
  25. package/src/shared/utils/internal/rateLimiter.ts +8 -2
  26. package/src/shared/utils.ts +23 -30
  27. package/src/supabase/auth/deleteAccount.ts +4 -11
  28. package/src/supabase/auth/getCustomClaims.ts +5 -3
  29. package/src/supabase/auth/getUserAuthStatus.ts +5 -3
  30. package/src/supabase/auth/removeCustomClaims.ts +10 -5
  31. package/src/supabase/auth/setCustomClaims.ts +9 -4
  32. package/src/supabase/baseFunction.ts +80 -21
  33. package/src/supabase/billing/cancelSubscription.ts +9 -3
  34. package/src/supabase/billing/changePlan.ts +20 -5
  35. package/src/supabase/billing/createCheckoutSession.ts +20 -5
  36. package/src/supabase/billing/createCustomerPortal.ts +14 -4
  37. package/src/supabase/billing/refreshSubscriptionStatus.ts +29 -9
  38. package/src/supabase/crud/aggregate.ts +14 -4
  39. package/src/supabase/crud/create.ts +30 -11
  40. package/src/supabase/crud/delete.ts +11 -3
  41. package/src/supabase/crud/get.ts +25 -3
  42. package/src/supabase/crud/list.ts +106 -21
  43. package/src/supabase/crud/update.ts +32 -10
  44. package/src/supabase/helpers/authProvider.ts +5 -2
  45. package/src/supabase/index.ts +1 -4
  46. package/src/supabase/registerCrudFunctions.ts +11 -9
  47. package/src/supabase/utils/idempotency.ts +13 -15
  48. package/src/supabase/utils/monitoring.ts +5 -1
  49. package/src/supabase/utils/rateLimiter.ts +13 -3
  50. package/src/vercel/api/billing/webhook-handler.ts +6 -2
  51. package/src/vercel/api/crud/create.ts +7 -2
  52. package/src/vercel/api/crud/delete.ts +3 -1
  53. package/src/vercel/api/crud/get.ts +3 -1
  54. package/src/vercel/api/crud/list.ts +3 -1
  55. 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(v.string(), v.minLength(1, 'Billing config key is required')),
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(data.userId, data.newPriceId, data.billingConfigKey, billingConfig, authProvider);
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 = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
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: { env: { get(key: string): string | undefined } } | undefined;
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 { initStripe, stripe, validateStripeEnvironment } from '../../shared/utils.js';
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(data, stripeProvider, authProvider, billingConfig);
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 = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
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: { env: { get(key: string): string | undefined } } | undefined;
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(data.userId, authProvider, data.returnUrl);
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 = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
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: { env: { get(key: string): string | undefined } } | undefined;
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 { initStripe, stripe, validateStripeEnvironment } from '../../shared/utils.js';
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 = (currentClaims.subscription as { subscriptionId?: string } | undefined)?.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(subscriptionId) as Stripe.Subscription;
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((subscription as any).current_period_start * 1000).toISOString(),
65
- currentPeriodEnd: new Date((subscription as any).current_period_end * 1000).toISOString(),
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((subscription as any).current_period_end * 1000).toISOString(),
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 = (typeof Deno !== 'undefined' ? Deno.env.get('STRIPE_SECRET_KEY') : process.env.STRIPE_SECRET_KEY) as string | undefined;
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: { env: { get(key: string): string | undefined } } | undefined;
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(query, mapper.toBackendField(field), operator, value);
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.map((f) => mapper.toBackendField(f)).join(', ');
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(`Failed to aggregate entities: ${error.message}`, 'internal');
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<{ found: false } | { found: true; existingDoc: Record<string, any>; findOrCreate: boolean }> {
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(mapper.toBackendField(field), normalizeValue(payload[field]));
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<string, any>;
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(metadata as Record<string, unknown>);
194
+ const snakeMetadata = mapper.toBackendKeys(
195
+ metadata as Record<string, unknown>
196
+ );
189
197
 
190
- const { createdAt, updatedAt, created_at, updated_at, ...payloadWithoutTimestamps } = normalizedPayload;
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(`Failed to create entity: ${error.message}`, 'internal');
219
+ throw new DoNotDevError(
220
+ `Failed to create entity: ${error.message}`,
221
+ 'internal'
222
+ );
206
223
  }
207
224
 
208
- const result = mapper.fromBackendRow(inserted as Record<string, unknown>) as Record<string, any>;
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<{ collection: string; field: string; count: number }> = [];
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) => r.sourceCollection === ref.collection && r.sourceField === ref.field
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(`Failed to delete entity: ${error.message}`, 'internal');
152
+ throw new DoNotDevError(
153
+ `Failed to delete entity: ${error.message}`,
154
+ 'internal'
155
+ );
148
156
  }
149
157
 
150
158
  return { success: true };
@@ -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 (!isAdmin && (HIDDEN_STATUSES as readonly string[]).includes(row.status)) {
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 ? { documentData: row, uid, ownership } : undefined;
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
- row,
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 { defaultFieldMapper } from '@donotdev/supabase';
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(typeof field === 'string' ? field : String(field));
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): { id: string; orderBy: Record<string, any> } {
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(v.tuple([v.pipe(v.string(), v.minLength(1)), v.picklist(['asc', 'desc'])]))
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 { where = [], orderBy = [], limit = 50, startAfterId, startAfterCursor, search } = data;
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((f): f is string => typeof f === 'string');
159
- const selectFields = safeListFields && safeListFields.length > 0
160
- ? safeListFields.map((f) => toBackendColumn(f)).join(', ') + ', id, status'
161
- : '*';
162
-
163
- let query = supabaseAdmin.from(collection).select(selectFields, { count: 'exact' });
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
- query = query.ilike(toBackendColumn(search.field), `%${search.query}%`);
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(([field]) => toBackendColumn(field) === 'id');
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), { ascending: direction === 'asc' });
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(`Failed to list entities: ${error.message}`, 'internal');
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 = ownership && uid ? { uid, ownership } : undefined;
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 = mapper.fromBackendRow(row) as Record<string, any>;
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) {