@donotdev/functions 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) 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/config/constants.ts +0 -3
  10. package/src/firebase/crud/aggregate.ts +20 -5
  11. package/src/firebase/crud/create.ts +31 -7
  12. package/src/firebase/crud/get.ts +16 -8
  13. package/src/firebase/crud/list.ts +70 -29
  14. package/src/firebase/crud/update.ts +29 -7
  15. package/src/firebase/oauth/exchangeToken.ts +30 -4
  16. package/src/firebase/oauth/githubAccess.ts +8 -3
  17. package/src/firebase/registerCrudFunctions.ts +15 -4
  18. package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
  19. package/src/shared/__tests__/detectFirestore.test.ts +52 -0
  20. package/src/shared/__tests__/errorHandling.test.ts +144 -0
  21. package/src/shared/__tests__/idempotency.test.ts +95 -0
  22. package/src/shared/__tests__/rateLimiter.test.ts +142 -0
  23. package/src/shared/__tests__/validation.test.ts +172 -0
  24. package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
  25. package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
  26. package/src/shared/billing/webhookHandler.ts +16 -7
  27. package/src/shared/errorHandling.ts +16 -54
  28. package/src/shared/firebase.ts +1 -25
  29. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  30. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  31. package/src/shared/schema.ts +7 -1
  32. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  33. package/src/shared/utils/external/subscription.ts +10 -0
  34. package/src/shared/utils/internal/auth.ts +140 -16
  35. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  36. package/src/shared/utils/internal/validation.ts +47 -3
  37. package/src/shared/utils.ts +154 -39
  38. package/src/supabase/auth/deleteAccount.ts +59 -0
  39. package/src/supabase/auth/getCustomClaims.ts +56 -0
  40. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  41. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  42. package/src/supabase/auth/setCustomClaims.ts +73 -0
  43. package/src/supabase/baseFunction.ts +302 -0
  44. package/src/supabase/billing/cancelSubscription.ts +57 -0
  45. package/src/supabase/billing/changePlan.ts +62 -0
  46. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  47. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  48. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  49. package/src/supabase/crud/aggregate.ts +169 -0
  50. package/src/supabase/crud/create.ts +225 -0
  51. package/src/supabase/crud/delete.ts +154 -0
  52. package/src/supabase/crud/get.ts +89 -0
  53. package/src/supabase/crud/index.ts +24 -0
  54. package/src/supabase/crud/list.ts +357 -0
  55. package/src/supabase/crud/update.ts +199 -0
  56. package/src/supabase/helpers/authProvider.ts +45 -0
  57. package/src/supabase/index.ts +73 -0
  58. package/src/supabase/registerCrudFunctions.ts +180 -0
  59. package/src/supabase/utils/idempotency.ts +141 -0
  60. package/src/supabase/utils/monitoring.ts +187 -0
  61. package/src/supabase/utils/rateLimiter.ts +216 -0
  62. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  63. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  64. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  65. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  66. package/src/vercel/api/billing/cancel.ts +2 -1
  67. package/src/vercel/api/billing/change-plan.ts +3 -1
  68. package/src/vercel/api/billing/customer-portal.ts +4 -1
  69. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  70. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  71. package/src/vercel/api/crud/create.ts +14 -8
  72. package/src/vercel/api/crud/delete.ts +15 -6
  73. package/src/vercel/api/crud/get.ts +16 -8
  74. package/src/vercel/api/crud/list.ts +22 -10
  75. package/src/vercel/api/crud/update.ts +16 -10
  76. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  77. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  78. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  79. package/src/vercel/api/utils/cors.ts +13 -2
  80. package/src/vercel/baseFunction.ts +40 -25
@@ -89,8 +89,13 @@ export async function updateUserSubscription(
89
89
  const auth = getFirebaseAdminAuth();
90
90
  const db = getFirebaseAdminFirestore();
91
91
 
92
+ // Read existing claims and merge to avoid overwriting other claims
93
+ const user = await auth.getUser(firebaseUid);
94
+ const currentClaims = user.customClaims || {};
95
+
92
96
  // Update Firebase Auth custom claims
93
97
  await auth.setCustomUserClaims(firebaseUid, {
98
+ ...currentClaims,
94
99
  subscription: subscriptionClaims,
95
100
  });
96
101
 
@@ -150,8 +155,13 @@ export async function cancelUserSubscription(
150
155
  const auth = getFirebaseAdminAuth();
151
156
  const db = getFirebaseAdminFirestore();
152
157
 
158
+ // Read existing claims and merge to avoid overwriting other claims
159
+ const user = await auth.getUser(firebaseUid);
160
+ const currentClaims = user.customClaims || {};
161
+
153
162
  // Update Firebase Auth custom claims
154
163
  await auth.setCustomUserClaims(firebaseUid, {
164
+ ...currentClaims,
155
165
  subscription: subscriptionClaims,
156
166
  });
157
167
 
@@ -2,19 +2,23 @@
2
2
 
3
3
  /**
4
4
  * @fileoverview Authentication utility functions
5
- * @description Functions for user authentication and authorization
5
+ * @description Provider-agnostic functions for user authentication and authorization.
6
+ * Uses IServerAuthAdapter from provider registry when configured, falls back to Firebase Admin SDK.
6
7
  *
7
- * @version 0.0.1
8
+ * @version 0.0.2
8
9
  * @since 0.0.1
9
10
  * @author AMBROISE PARK Consulting
10
11
  */
11
12
 
12
13
  import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
14
+ import { hasProvider, getProvider } from '@donotdev/core/server';
13
15
 
14
16
  import type { NextApiRequest } from 'next';
15
17
 
18
+ /** Auth provider type for explicit configuration */
19
+ export type AuthProvider = 'firebase' | 'supabase';
20
+
16
21
  // IMPORTANT: Don't call getAuth() at module load - breaks Firebase deployment
17
- // const auth = getAuth(); // REMOVED - use getAuth() directly in functions instead
18
22
 
19
23
  /**
20
24
  * Validates that user is authenticated
@@ -31,9 +35,10 @@ export function assertAuthenticated(uid: string): string {
31
35
  }
32
36
 
33
37
  /**
34
- * Validates that user has admin privileges
38
+ * Validates that user has admin privileges.
39
+ * Uses IServerAuthAdapter when configured, falls back to Firebase Admin SDK.
35
40
  *
36
- * @version 0.0.1
41
+ * @version 0.0.2
37
42
  * @since 0.0.1
38
43
  * @author AMBROISE PARK Consulting
39
44
  */
@@ -41,8 +46,25 @@ export async function assertAdmin(uid: string): Promise<string> {
41
46
  assertAuthenticated(uid);
42
47
 
43
48
  try {
44
- const user = await getFirebaseAdminAuth().getUser(uid); // Lazy initialization
45
- const isAdmin = user.customClaims?.isAdmin === true;
49
+ let claims: Record<string, unknown> = {};
50
+
51
+ if (hasProvider('serverAuth')) {
52
+ const user = await getProvider('serverAuth').getUser(uid);
53
+ claims = (user?.customClaims as Record<string, unknown>) ?? {};
54
+ } else {
55
+ // Legacy Firebase path
56
+ const user = await getFirebaseAdminAuth().getUser(uid);
57
+ claims = user.customClaims ?? {};
58
+ }
59
+
60
+ // W8: Unified role-check logic consistent with shared/utils.ts:assertAdmin.
61
+ // Check role string first (standard), then legacy boolean flags.
62
+ const role = claims.role;
63
+ const isAdmin =
64
+ role === 'admin' ||
65
+ role === 'super' ||
66
+ claims.isAdmin === true ||
67
+ claims.isSuper === true;
46
68
 
47
69
  if (!isAdmin) {
48
70
  throw new Error('Admin privileges required');
@@ -50,22 +72,23 @@ export async function assertAdmin(uid: string): Promise<string> {
50
72
 
51
73
  return uid;
52
74
  } catch (error) {
75
+ // C4: Re-throw permission-denied as-is so callers can distinguish it from
76
+ // infrastructure failures. Only wrap genuine unexpected errors.
77
+ if (error instanceof Error && error.message === 'Admin privileges required') {
78
+ throw error;
79
+ }
53
80
  throw new Error('Failed to verify admin status');
54
81
  }
55
82
  }
56
83
 
57
84
  /**
58
- * Verifies Firebase auth token from Vercel request
85
+ * Extract Bearer token from authorization header.
59
86
  *
60
87
  * @version 0.0.1
61
- * @since 0.0.1
88
+ * @since 0.5.0
62
89
  * @author AMBROISE PARK Consulting
63
90
  */
64
- export async function verifyFirebaseAuthToken(
65
- req: NextApiRequest
66
- ): Promise<string> {
67
- const authHeader = req.headers.authorization;
68
-
91
+ function extractBearerToken(authHeader: string | undefined): string {
69
92
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
70
93
  throw new Error('Missing or invalid authorization header');
71
94
  }
@@ -76,10 +99,111 @@ export async function verifyFirebaseAuthToken(
76
99
  throw new Error('Missing token in authorization header');
77
100
  }
78
101
 
102
+ return token;
103
+ }
104
+
105
+ /**
106
+ * Verify a Bearer token using the specified auth provider.
107
+ * Returns `{ uid: string }` on success, throws on failure.
108
+ *
109
+ * Provider resolution order:
110
+ * 1. Explicit `provider` parameter ('firebase' | 'supabase')
111
+ * 2. Provider registry (IServerAuthAdapter via `hasProvider('serverAuth')`)
112
+ * 3. Firebase Admin SDK fallback
113
+ *
114
+ * @param token - Raw JWT token (without "Bearer " prefix)
115
+ * @param provider - Optional explicit auth provider
116
+ * @returns Object with verified user's uid
117
+ *
118
+ * @version 0.0.1
119
+ * @since 0.5.0
120
+ * @author AMBROISE PARK Consulting
121
+ */
122
+ export async function verifyToken(
123
+ token: string,
124
+ provider?: AuthProvider
125
+ ): Promise<{ uid: string }> {
79
126
  try {
80
- const decodedToken = await getFirebaseAdminAuth().verifyIdToken(token); // Lazy initialization
81
- return decodedToken.uid;
127
+ // Explicit provider
128
+ if (provider === 'supabase') {
129
+ return await verifySupabaseToken(token);
130
+ }
131
+
132
+ if (provider === 'firebase') {
133
+ const decodedToken = await getFirebaseAdminAuth().verifyIdToken(token);
134
+ return { uid: decodedToken.uid };
135
+ }
136
+
137
+ // Auto-detect: provider registry first, then Firebase fallback
138
+ if (hasProvider('serverAuth')) {
139
+ const verified = await getProvider('serverAuth').verifyToken(token);
140
+ return { uid: verified.uid };
141
+ }
142
+
143
+ // Legacy Firebase path
144
+ const decodedToken = await getFirebaseAdminAuth().verifyIdToken(token);
145
+ return { uid: decodedToken.uid };
82
146
  } catch (error) {
83
147
  throw new Error('Invalid or expired token');
84
148
  }
85
149
  }
150
+
151
+ /**
152
+ * Verify a Supabase JWT token using the Supabase admin client.
153
+ * Requires SUPABASE_URL and SUPABASE_SECRET_KEY (or SUPABASE_SERVICE_ROLE_KEY) env vars.
154
+ *
155
+ * @version 0.0.1
156
+ * @since 0.5.0
157
+ * @author AMBROISE PARK Consulting
158
+ */
159
+ async function verifySupabaseToken(token: string): Promise<{ uid: string }> {
160
+ // Lazy import to avoid pulling in @supabase/supabase-js when using Firebase
161
+ const { createClient } = await import('@supabase/supabase-js');
162
+
163
+ const supabaseUrl = process.env.SUPABASE_URL;
164
+ const secretKey = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
165
+
166
+ if (!supabaseUrl || !secretKey) {
167
+ throw new Error(
168
+ 'Missing SUPABASE_URL or SUPABASE_SECRET_KEY/SUPABASE_SERVICE_ROLE_KEY environment variables'
169
+ );
170
+ }
171
+
172
+ const supabaseAdmin = createClient(supabaseUrl, secretKey, {
173
+ auth: { autoRefreshToken: false, persistSession: false },
174
+ });
175
+
176
+ const { data: { user }, error } = await supabaseAdmin.auth.getUser(token);
177
+
178
+ if (error || !user) {
179
+ throw new Error('Invalid or expired token');
180
+ }
181
+
182
+ return { uid: user.id };
183
+ }
184
+
185
+ /**
186
+ * Verifies auth token from request.
187
+ * Uses IServerAuthAdapter when configured, falls back to Firebase Admin SDK.
188
+ *
189
+ * @param req - Next.js API request
190
+ * @param provider - Optional explicit auth provider ('firebase' | 'supabase')
191
+ * @returns Verified user's uid
192
+ *
193
+ * @version 0.0.3
194
+ * @since 0.0.1
195
+ * @author AMBROISE PARK Consulting
196
+ */
197
+ export async function verifyAuthToken(
198
+ req: NextApiRequest,
199
+ provider?: AuthProvider
200
+ ): Promise<string> {
201
+ const token = extractBearerToken(req.headers.authorization);
202
+ const { uid } = await verifyToken(token, provider);
203
+ return uid;
204
+ }
205
+
206
+ /**
207
+ * @deprecated Use verifyAuthToken instead. Kept for backwards compatibility.
208
+ */
209
+ export const verifyFirebaseAuthToken = verifyAuthToken;
@@ -12,19 +12,9 @@
12
12
  import { logger } from 'firebase-functions/v2';
13
13
 
14
14
  import { getFirebaseAdminFirestore } from '@donotdev/firebase/server';
15
+ import type { ServerRateLimitConfig as RateLimitConfig, ServerRateLimitResult as RateLimitResult } from '@donotdev/core';
15
16
 
16
- interface RateLimitConfig {
17
- maxAttempts: number;
18
- windowMs: number;
19
- blockDurationMs: number;
20
- }
21
-
22
- interface RateLimitResult {
23
- allowed: boolean;
24
- remaining: number;
25
- resetAt: Date | null;
26
- blockRemainingSeconds: number | null;
27
- }
17
+ export type { RateLimitConfig, RateLimitResult };
28
18
 
29
19
  interface RateLimitEntry {
30
20
  attempts: number;
@@ -104,8 +94,21 @@ export async function checkRateLimit(
104
94
  }
105
95
 
106
96
  /**
107
- * Check rate limit using Firestore for persistent storage
108
- * This is the recommended approach for production
97
+ * Check rate limit using Firestore for persistent storage.
98
+ * This is the recommended approach for production.
99
+ *
100
+ * **Architecture decision — Firestore transaction for rate limiting:**
101
+ * The read-modify-write cycle is wrapped in a Firestore transaction to
102
+ * eliminate TOCTOU races. While transactions add some latency, rate limiting
103
+ * is a best-effort abuse-prevention mechanism, not a hard security boundary.
104
+ * The window-based approach (maxAttempts per windowMs) tolerates minor
105
+ * timing variations. If transaction contention becomes an issue under extreme
106
+ * load, the catch block fails open (allows the request) to avoid blocking
107
+ * legitimate traffic — this is an intentional trade-off favoring availability
108
+ * over strict enforcement.
109
+ *
110
+ * For stricter rate limiting (e.g. payment endpoints), consumers can use
111
+ * Redis-backed rate limiters or API gateway-level throttling.
109
112
  *
110
113
  * @version 0.0.1
111
114
  * @since 0.0.1
@@ -120,89 +123,97 @@ export async function checkRateLimitWithFirestore(
120
123
 
121
124
  try {
122
125
  const now = Date.now();
123
- const doc = await rateLimitRef.get();
124
-
125
- if (!doc.exists) {
126
- // First attempt
127
- await rateLimitRef.set({
128
- attempts: 1,
129
- windowStart: now,
130
- blockUntil: null,
131
- lastUpdated: now,
132
- });
133
-
134
- return {
135
- allowed: true,
136
- remaining: config.maxAttempts - 1,
137
- resetAt: new Date(now + config.windowMs),
138
- blockRemainingSeconds: null,
139
- };
140
- }
141
126
 
142
- const data = doc.data() as RateLimitEntry & { lastUpdated: number };
127
+ // W2: Wrap the entire read-modify-write in a Firestore transaction to
128
+ // eliminate the TOCTOU race where two concurrent requests both read the
129
+ // same counter and both increment it independently.
130
+ let result: RateLimitResult = {
131
+ allowed: true,
132
+ remaining: config.maxAttempts - 1,
133
+ resetAt: new Date(now + config.windowMs),
134
+ blockRemainingSeconds: null,
135
+ };
143
136
 
144
- // Check if currently blocked
145
- if (data.blockUntil && now < data.blockUntil) {
146
- return {
147
- allowed: false,
148
- remaining: 0,
149
- resetAt: new Date(data.blockUntil),
150
- blockRemainingSeconds: Math.ceil((data.blockUntil - now) / 1000),
151
- };
152
- }
153
-
154
- // Check if window expired
155
- if (now >= data.windowStart + config.windowMs) {
156
- // Reset window
157
- await rateLimitRef.set({
158
- attempts: 1,
159
- windowStart: now,
160
- blockUntil: null,
161
- lastUpdated: now,
162
- });
163
-
164
- return {
137
+ await db.runTransaction(async (tx) => {
138
+ const doc = await tx.get(rateLimitRef);
139
+
140
+ if (!doc.exists) {
141
+ tx.set(rateLimitRef, {
142
+ attempts: 1,
143
+ windowStart: now,
144
+ blockUntil: null,
145
+ lastUpdated: now,
146
+ });
147
+ result = {
148
+ allowed: true,
149
+ remaining: config.maxAttempts - 1,
150
+ resetAt: new Date(now + config.windowMs),
151
+ blockRemainingSeconds: null,
152
+ };
153
+ return;
154
+ }
155
+
156
+ const data = doc.data() as RateLimitEntry & { lastUpdated: number };
157
+
158
+ // Check if currently blocked
159
+ if (data.blockUntil && now < data.blockUntil) {
160
+ result = {
161
+ allowed: false,
162
+ remaining: 0,
163
+ resetAt: new Date(data.blockUntil),
164
+ blockRemainingSeconds: Math.ceil((data.blockUntil - now) / 1000),
165
+ };
166
+ return;
167
+ }
168
+
169
+ // Check if window expired — reset
170
+ if (now >= data.windowStart + config.windowMs) {
171
+ tx.set(rateLimitRef, {
172
+ attempts: 1,
173
+ windowStart: now,
174
+ blockUntil: null,
175
+ lastUpdated: now,
176
+ });
177
+ result = {
178
+ allowed: true,
179
+ remaining: config.maxAttempts - 1,
180
+ resetAt: new Date(now + config.windowMs),
181
+ blockRemainingSeconds: null,
182
+ };
183
+ return;
184
+ }
185
+
186
+ // Check if limit exceeded
187
+ if (data.attempts >= config.maxAttempts) {
188
+ const blockUntil = now + config.blockDurationMs;
189
+ tx.update(rateLimitRef, { blockUntil, lastUpdated: now });
190
+
191
+ logger.warn('Rate limit exceeded (Firestore)', {
192
+ key,
193
+ attempts: data.attempts,
194
+ blockUntil: new Date(blockUntil),
195
+ });
196
+
197
+ result = {
198
+ allowed: false,
199
+ remaining: 0,
200
+ resetAt: new Date(blockUntil),
201
+ blockRemainingSeconds: Math.ceil(config.blockDurationMs / 1000),
202
+ };
203
+ return;
204
+ }
205
+
206
+ // Increment attempts
207
+ tx.update(rateLimitRef, { attempts: data.attempts + 1, lastUpdated: now });
208
+ result = {
165
209
  allowed: true,
166
- remaining: config.maxAttempts - 1,
167
- resetAt: new Date(now + config.windowMs),
210
+ remaining: config.maxAttempts - (data.attempts + 1),
211
+ resetAt: new Date(data.windowStart + config.windowMs),
168
212
  blockRemainingSeconds: null,
169
213
  };
170
- }
171
-
172
- // Check if limit exceeded
173
- if (data.attempts >= config.maxAttempts) {
174
- const blockUntil = now + config.blockDurationMs;
175
- await rateLimitRef.update({
176
- blockUntil,
177
- lastUpdated: now,
178
- });
179
-
180
- logger.warn('Rate limit exceeded (Firestore)', {
181
- key,
182
- attempts: data.attempts,
183
- blockUntil: new Date(blockUntil),
184
- });
185
-
186
- return {
187
- allowed: false,
188
- remaining: 0,
189
- resetAt: new Date(blockUntil),
190
- blockRemainingSeconds: Math.ceil(config.blockDurationMs / 1000),
191
- };
192
- }
193
-
194
- // Increment attempts
195
- await rateLimitRef.update({
196
- attempts: data.attempts + 1,
197
- lastUpdated: now,
198
214
  });
199
215
 
200
- return {
201
- allowed: true,
202
- remaining: config.maxAttempts - (data.attempts + 1),
203
- resetAt: new Date(data.windowStart + config.windowMs),
204
- blockRemainingSeconds: null,
205
- };
216
+ return result;
206
217
  } catch (error) {
207
218
  logger.error('Rate limit check failed', {
208
219
  key,
@@ -4,11 +4,13 @@
4
4
  * @fileoverview Validation utility functions
5
5
  * @description Functions for validating environment and data
6
6
  *
7
- * @version 0.0.1
7
+ * @version 0.0.2
8
8
  * @since 0.0.1
9
9
  * @author AMBROISE PARK Consulting
10
10
  */
11
11
 
12
+ import * as v from 'valibot';
13
+
12
14
  /**
13
15
  * Validates environment variables required for Stripe
14
16
  *
@@ -43,19 +45,61 @@ export function safeJsonParse<T = any>(json: string): T | null {
43
45
  }
44
46
 
45
47
  /**
46
- * Validates document data against schema
48
+ * Validates a Firestore collection name from client-supplied schema.
49
+ *
50
+ * W22: Vercel CRUD handlers accept `schema` (including collection name) from
51
+ * the client. This is a known design limitation. As a defense-in-depth measure,
52
+ * reject collection names that could be used for path traversal or access to
53
+ * internal collections.
54
+ *
55
+ * @param name - Collection name to validate
56
+ * @throws Error if the name is unsafe
47
57
  *
48
58
  * @version 0.0.1
49
59
  * @since 0.0.1
50
60
  * @author AMBROISE PARK Consulting
51
61
  */
62
+ export function validateCollectionName(name: string): void {
63
+ if (!name || typeof name !== 'string') {
64
+ throw new Error('Collection name is required');
65
+ }
66
+ if (name.includes('/') || name.includes('..') || name.startsWith('_')) {
67
+ throw new Error(
68
+ 'Invalid collection name: must not contain "/", "..", or start with "_"'
69
+ );
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Validates document data against an optional Valibot schema.
75
+ *
76
+ * W1: Previous stub ignored the schema parameter entirely. Now performs
77
+ * actual schema validation when a schema is provided.
78
+ *
79
+ * @version 0.0.2
80
+ * @since 0.0.1
81
+ * @author AMBROISE PARK Consulting
82
+ */
52
83
  export function validateDocument(data: any, schema?: any): void {
53
84
  if (!data || typeof data !== 'object') {
54
85
  throw new Error('Invalid document data');
55
86
  }
56
87
 
57
- // Basic validation - can be extended with schema validation
58
88
  if (Array.isArray(data)) {
59
89
  throw new Error('Document data cannot be an array');
60
90
  }
91
+
92
+ // W1: Perform schema validation when a Valibot schema is supplied.
93
+ if (schema) {
94
+ const result = v.safeParse(schema, data);
95
+ if (!result.success) {
96
+ const messages = result.issues
97
+ .map(
98
+ (issue) =>
99
+ `${issue.path?.map((p) => (p as any).key).join('.') || 'root'}: ${issue.message}`
100
+ )
101
+ .join('; ');
102
+ throw new Error(`Validation failed: ${messages}`);
103
+ }
104
+ }
61
105
  }