@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
@@ -23,14 +23,22 @@ import { logger } from '../logger.js';
23
23
  import { createIdempotencyStore } from './idempotency.js';
24
24
  import { calculateSubscriptionEndDate } from '../utils/external/date.js';
25
25
 
26
- // ✅ CREATE: Singleton idempotency store (auto-detects Firestore)
27
- const idempotencyStore = createIdempotencyStore();
28
-
29
26
  import type {
30
27
  SubscriptionData,
31
28
  AuthProvider,
32
29
  } from './helpers/updateUserSubscription.js';
33
30
 
31
+ // W7: Lazy singleton — resolving at import time reads env vars / Firebase Admin
32
+ // before they are fully injected (e.g., defineSecret values on Firebase Functions v2).
33
+ // Resolved on first use instead.
34
+ let _idempotencyStore: ReturnType<typeof createIdempotencyStore> | null = null;
35
+ function getIdempotencyStore() {
36
+ if (!_idempotencyStore) {
37
+ _idempotencyStore = createIdempotencyStore();
38
+ }
39
+ return _idempotencyStore;
40
+ }
41
+
34
42
  /**
35
43
  * Platform-agnostic auth provider interface for webhook handlers
36
44
  */
@@ -94,8 +102,8 @@ export async function processWebhook(
94
102
  operation: 'webhook_processing',
95
103
  });
96
104
 
97
- // ✅ CHECK: Idempotency (auto-detects storage)
98
- if (await idempotencyStore.isProcessed(event.id)) {
105
+ // ✅ CHECK: Idempotency (auto-detects storage, lazy init per W7)
106
+ if (await getIdempotencyStore().isProcessed(event.id)) {
99
107
  logger.info('[Webhook] Already processed', { eventId: event.id });
100
108
  return { success: true, message: 'Event already processed' };
101
109
  }
@@ -104,7 +112,7 @@ export async function processWebhook(
104
112
  await routeEvent(event, config, updateSubscription, stripe, authProvider);
105
113
 
106
114
  // ✅ MARK: Event as processed
107
- await idempotencyStore.markProcessed(event.id);
115
+ await getIdempotencyStore().markProcessed(event.id);
108
116
 
109
117
  return { success: true, message: 'Webhook processed' };
110
118
  } catch (error) {
@@ -160,7 +168,8 @@ async function routeEvent(
160
168
  break;
161
169
 
162
170
  default:
163
- console.log('[Webhook] Unhandled event type', { type: event.type });
171
+ // W23: Use structured logger instead of console.log in production
172
+ logger.debug('[Webhook] Unhandled event type', { type: event.type });
164
173
  }
165
174
  }
166
175
 
@@ -12,49 +12,10 @@
12
12
  import { HttpsError } from 'firebase-functions/v2/https';
13
13
  import * as v from 'valibot';
14
14
 
15
- import type { ErrorCode } from '@donotdev/core/server';
16
- import { EntityHookError } from '@donotdev/core/server';
15
+ import { DoNotDevError, EntityHookError } from '@donotdev/core/server';
17
16
 
18
- /**
19
- * Custom error class for application-specific errors in functions.
20
- *
21
- * @version 0.0.1
22
- * @since 0.0.1
23
- * @author AMBROISE PARK Consulting
24
- */
25
- export class DoNotDevError extends Error {
26
- public readonly code: ErrorCode;
27
- public readonly details?: Record<string, any>;
28
-
29
- constructor(
30
- message: string,
31
- code: ErrorCode = 'unknown',
32
- details?: Record<string, any>
33
- ) {
34
- super(message);
35
- Object.setPrototypeOf(this, DoNotDevError.prototype);
36
- this.code = code;
37
- this.details = details;
38
- this.name = this.constructor.name;
39
-
40
- if (Error.captureStackTrace) {
41
- Error.captureStackTrace(this, this.constructor);
42
- }
43
- }
44
-
45
- public toString(): string {
46
- return `${this.name} [${this.code}]: ${this.message}`;
47
- }
48
-
49
- public toJSON(): object {
50
- return {
51
- name: this.name,
52
- code: this.code,
53
- message: this.message,
54
- details: this.details,
55
- };
56
- }
57
- }
17
+ // Re-export DoNotDevError so existing imports from this module continue to work
18
+ export { DoNotDevError };
58
19
 
59
20
  /**
60
21
  * Platform-aware error handling that throws in the correct format
@@ -66,7 +27,16 @@ export class DoNotDevError extends Error {
66
27
  * @author AMBROISE PARK Consulting
67
28
  */
68
29
  export function handleError(error: unknown): never {
69
- console.error('Function error:', error);
30
+ // W11: Log only in development to avoid double-logging with platform loggers.
31
+ // Known DoNotDevError, ValiError, and EntityHookError are handled by callers.
32
+ if (
33
+ process.env.NODE_ENV === 'development' &&
34
+ !(error instanceof DoNotDevError) &&
35
+ !(error instanceof Error && error.name === 'ValiError') &&
36
+ !(error instanceof Error && error.name === 'EntityHookError')
37
+ ) {
38
+ console.error('Function error:', error);
39
+ }
70
40
 
71
41
  // Error classification logic (same for all platforms)
72
42
  let code: string;
@@ -117,20 +87,12 @@ export function handleError(error: unknown): never {
117
87
  message = entityError.message;
118
88
  details = undefined;
119
89
  } else {
120
- // Preserve original error message for debugging
121
90
  code = 'internal';
91
+ // W10: Never expose error.stack in details — it leaks internal file paths
92
+ // and implementation details to clients. Log server-side only.
122
93
  message =
123
94
  error instanceof Error ? error.message : 'An unexpected error occurred';
124
- details = {
125
- originalError:
126
- error instanceof Error
127
- ? {
128
- name: error.name,
129
- message: error.message,
130
- stack: error.stack,
131
- }
132
- : String(error),
133
- };
95
+ details = undefined;
134
96
  }
135
97
 
136
98
  // Platform-specific error throwing
@@ -212,28 +212,4 @@ export function prepareForFirestore<T = any>(
212
212
 
213
213
  // Return primitive values unchanged
214
214
  return data;
215
- }
216
-
217
- /**
218
- * Checks if a string is an ISO date string
219
- * @param str - The string to check
220
- * @returns True if the string is an ISO date
221
- *
222
- * @version 0.0.1
223
- * @since 0.0.1
224
- * @author AMBROISE PARK Consulting
225
- */
226
- function isISODateString(str: string): boolean {
227
- if (typeof str !== 'string') return false;
228
-
229
- // Basic ISO date pattern check
230
- const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
231
- if (!isoDatePattern.test(str)) return false;
232
-
233
- // Try to parse it as a date
234
- const date = new Date(str);
235
- return (
236
- !isNaN(date.getTime()) &&
237
- date.toISOString().startsWith(str.substring(0, 19))
238
- );
239
- }
215
+ }
@@ -0,0 +1,116 @@
1
+ // packages/functions/src/shared/oauth/__tests__/exchangeToken.test.ts
2
+
3
+ /**
4
+ * @fileoverview Tests for exchangeTokenAlgorithm
5
+ * @description Unit tests using dependency injection — no real OAuth or network calls.
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.0.1
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest';
13
+
14
+ import {
15
+ exchangeTokenAlgorithm,
16
+ type OAuthProvider,
17
+ } from '../exchangeToken';
18
+ import type { ExchangeTokenRequest, TokenResponse } from '@donotdev/core/server';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function makeProvider(result: TokenResponse): OAuthProvider {
25
+ return {
26
+ exchangeCodeForToken: vi.fn().mockResolvedValue(result),
27
+ };
28
+ }
29
+
30
+ function makeRejectingProvider(error: Error): OAuthProvider {
31
+ return {
32
+ exchangeCodeForToken: vi.fn().mockRejectedValue(error),
33
+ };
34
+ }
35
+
36
+ const BASE_REQUEST: ExchangeTokenRequest = {
37
+ provider: 'github',
38
+ purpose: 'login',
39
+ code: 'auth-code-abc',
40
+ redirectUri: 'https://app.example.com/oauth/callback',
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Tests
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('exchangeTokenAlgorithm', () => {
48
+ describe('successful exchange', () => {
49
+ it('returns token response from provider', async () => {
50
+ const tokenResponse: TokenResponse = {
51
+ access_token: 'access-token-xyz',
52
+ token_type: 'Bearer',
53
+ expires_in: 3600,
54
+ refresh_token: 'refresh-token-123',
55
+ };
56
+ const oauthProvider = makeProvider(tokenResponse);
57
+
58
+ const result = await exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider);
59
+
60
+ expect(result).toEqual(tokenResponse);
61
+ });
62
+
63
+ it('forwards code, redirectUri, and codeVerifier to provider', async () => {
64
+ const oauthProvider = makeProvider({
65
+ access_token: 'tok',
66
+ });
67
+ const request: ExchangeTokenRequest = {
68
+ ...BASE_REQUEST,
69
+ codeVerifier: 'pkce-verifier-xyz',
70
+ };
71
+
72
+ await exchangeTokenAlgorithm(request, oauthProvider);
73
+
74
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledOnce();
75
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledWith({
76
+ code: request.code,
77
+ redirectUri: request.redirectUri,
78
+ codeVerifier: 'pkce-verifier-xyz',
79
+ });
80
+ });
81
+
82
+ it('forwards without codeVerifier when omitted', async () => {
83
+ const oauthProvider = makeProvider({ access_token: 'tok' });
84
+
85
+ await exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider);
86
+
87
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledWith({
88
+ code: BASE_REQUEST.code,
89
+ redirectUri: BASE_REQUEST.redirectUri,
90
+ codeVerifier: undefined,
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('error scenarios', () => {
96
+ it('re-throws errors from provider', async () => {
97
+ const oauthProvider = makeRejectingProvider(
98
+ new Error('invalid_grant')
99
+ );
100
+
101
+ await expect(
102
+ exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider)
103
+ ).rejects.toThrow('invalid_grant');
104
+ });
105
+
106
+ it('calls provider exactly once on error', async () => {
107
+ const oauthProvider = makeRejectingProvider(new Error('network error'));
108
+
109
+ await expect(
110
+ exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider)
111
+ ).rejects.toThrow('network error');
112
+
113
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledOnce();
114
+ });
115
+ });
116
+ });
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ import {
4
+ grantAccessAlgorithm,
5
+ type OAuthGrantProvider,
6
+ } from '../grantAccess';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function makeProvider(
13
+ result: { success: boolean; message: string }
14
+ ): OAuthGrantProvider {
15
+ return { grantAccess: vi.fn().mockResolvedValue(result) };
16
+ }
17
+
18
+ function makeRejectingProvider(error: Error): OAuthGrantProvider {
19
+ return { grantAccess: vi.fn().mockRejectedValue(error) };
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Tests
24
+ // ---------------------------------------------------------------------------
25
+
26
+ describe('grantAccessAlgorithm', () => {
27
+ const userId = 'user-123';
28
+ const provider = 'github';
29
+ const accessToken = 'access-token-abc';
30
+ const refreshToken = 'refresh-token-xyz';
31
+
32
+ // -------------------------------------------------------------------------
33
+ // Successful grant
34
+ // -------------------------------------------------------------------------
35
+
36
+ describe('successful grant', () => {
37
+ it('returns success result from provider', async () => {
38
+ const oauthProvider = makeProvider({ success: true, message: 'OK' });
39
+
40
+ const result = await grantAccessAlgorithm(
41
+ userId,
42
+ provider,
43
+ accessToken,
44
+ refreshToken,
45
+ oauthProvider
46
+ );
47
+
48
+ expect(result).toEqual({ success: true, message: 'OK' });
49
+ });
50
+
51
+ it('forwards all parameters to the provider', async () => {
52
+ const oauthProvider = makeProvider({ success: true, message: 'granted' });
53
+
54
+ await grantAccessAlgorithm(
55
+ userId,
56
+ provider,
57
+ accessToken,
58
+ refreshToken,
59
+ oauthProvider
60
+ );
61
+
62
+ expect(oauthProvider.grantAccess).toHaveBeenCalledOnce();
63
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith({
64
+ userId,
65
+ provider,
66
+ accessToken,
67
+ refreshToken,
68
+ });
69
+ });
70
+
71
+ it('works without a refreshToken (undefined)', async () => {
72
+ const oauthProvider = makeProvider({ success: true, message: 'granted' });
73
+
74
+ const result = await grantAccessAlgorithm(
75
+ userId,
76
+ provider,
77
+ accessToken,
78
+ undefined,
79
+ oauthProvider
80
+ );
81
+
82
+ expect(result.success).toBe(true);
83
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith({
84
+ userId,
85
+ provider,
86
+ accessToken,
87
+ refreshToken: undefined,
88
+ });
89
+ });
90
+ });
91
+
92
+ // -------------------------------------------------------------------------
93
+ // Invalid / missing user
94
+ // -------------------------------------------------------------------------
95
+
96
+ describe('invalid / missing user', () => {
97
+ it('passes empty userId to provider (provider owns validation)', async () => {
98
+ const oauthProvider = makeProvider({
99
+ success: false,
100
+ message: 'user not found',
101
+ });
102
+
103
+ const result = await grantAccessAlgorithm(
104
+ '',
105
+ provider,
106
+ accessToken,
107
+ undefined,
108
+ oauthProvider
109
+ );
110
+
111
+ expect(result.success).toBe(false);
112
+ expect(result.message).toBe('user not found');
113
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith({
114
+ userId: '',
115
+ provider,
116
+ accessToken,
117
+ refreshToken: undefined,
118
+ });
119
+ });
120
+ });
121
+
122
+ // -------------------------------------------------------------------------
123
+ // Permission checks (provider denies access)
124
+ // -------------------------------------------------------------------------
125
+
126
+ describe('permission checks', () => {
127
+ it('returns provider failure when access is denied', async () => {
128
+ const oauthProvider = makeProvider({
129
+ success: false,
130
+ message: 'permission denied',
131
+ });
132
+
133
+ const result = await grantAccessAlgorithm(
134
+ userId,
135
+ provider,
136
+ accessToken,
137
+ refreshToken,
138
+ oauthProvider
139
+ );
140
+
141
+ expect(result.success).toBe(false);
142
+ expect(result.message).toBe('permission denied');
143
+ });
144
+
145
+ it('propagates the exact message returned by the provider', async () => {
146
+ const expectedMessage = 'scope insufficient for this resource';
147
+ const oauthProvider = makeProvider({
148
+ success: false,
149
+ message: expectedMessage,
150
+ });
151
+
152
+ const result = await grantAccessAlgorithm(
153
+ userId,
154
+ provider,
155
+ accessToken,
156
+ refreshToken,
157
+ oauthProvider
158
+ );
159
+
160
+ expect(result.message).toBe(expectedMessage);
161
+ });
162
+ });
163
+
164
+ // -------------------------------------------------------------------------
165
+ // Provider-specific grant logic
166
+ // -------------------------------------------------------------------------
167
+
168
+ describe('provider-specific grant logic', () => {
169
+ it.each([
170
+ ['github', 'gh-token'],
171
+ ['google', 'goog-token'],
172
+ ['stripe', 'stripe-token'],
173
+ ])(
174
+ 'correctly forwards provider "%s" with its access token',
175
+ async (providerName, token) => {
176
+ const oauthProvider = makeProvider({
177
+ success: true,
178
+ message: 'granted',
179
+ });
180
+
181
+ await grantAccessAlgorithm(
182
+ userId,
183
+ providerName,
184
+ token,
185
+ undefined,
186
+ oauthProvider
187
+ );
188
+
189
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith(
190
+ expect.objectContaining({ provider: providerName, accessToken: token })
191
+ );
192
+ }
193
+ );
194
+ });
195
+
196
+ // -------------------------------------------------------------------------
197
+ // Error scenarios
198
+ // -------------------------------------------------------------------------
199
+
200
+ describe('error scenarios', () => {
201
+ it('re-throws synchronous errors from provider', async () => {
202
+ const oauthProvider = makeRejectingProvider(
203
+ new Error('network failure')
204
+ );
205
+
206
+ await expect(
207
+ grantAccessAlgorithm(userId, provider, accessToken, refreshToken, oauthProvider)
208
+ ).rejects.toThrow('network failure');
209
+ });
210
+
211
+ it('re-throws custom provider errors without wrapping', async () => {
212
+ class ProviderError extends Error {
213
+ constructor(msg: string) {
214
+ super(msg);
215
+ this.name = 'ProviderError';
216
+ }
217
+ }
218
+
219
+ const originalError = new ProviderError('token expired');
220
+ const oauthProvider = makeRejectingProvider(originalError);
221
+
222
+ await expect(
223
+ grantAccessAlgorithm(userId, provider, accessToken, refreshToken, oauthProvider)
224
+ ).rejects.toThrow('token expired');
225
+ });
226
+
227
+ it('calls provider exactly once even on error', async () => {
228
+ const oauthProvider = makeRejectingProvider(new Error('boom'));
229
+
230
+ await expect(
231
+ grantAccessAlgorithm(userId, provider, accessToken, refreshToken, oauthProvider)
232
+ ).rejects.toThrow('boom');
233
+
234
+ expect(oauthProvider.grantAccess).toHaveBeenCalledOnce();
235
+ });
236
+ });
237
+ });
@@ -12,7 +12,7 @@
12
12
  import * as v from 'valibot';
13
13
 
14
14
  /** Visibility levels matching @donotdev/core */
15
- type Visibility = 'guest' | 'user' | 'admin' | 'technical' | 'hidden';
15
+ type Visibility = 'guest' | 'user' | 'admin' | 'technical' | 'hidden' | 'owner';
16
16
 
17
17
  // Define a type for the custom visibility property we might add to Valibot schemas
18
18
  interface ValibotSchemaWithVisibility extends v.BaseSchema<
@@ -52,6 +52,7 @@ function getFieldVisibility(field: any): Visibility | undefined {
52
52
  * - 'admin': Visible only to admins
53
53
  * - 'technical': Visible to admins only (shown as read-only in edit forms)
54
54
  * - 'hidden': Never visible (passwords, tokens, API keys - only in DB)
55
+ * - 'owner': Visible only when uid matches one of entity.ownership.ownerFields (requires document context; here treated as not visible for aggregate)
55
56
  * - undefined: Defaults to 'user' behavior (visible to authenticated users)
56
57
  *
57
58
  * @param key - The name (key) of the field.
@@ -90,6 +91,11 @@ export function isFieldVisible(
90
91
  return isAdmin;
91
92
  }
92
93
 
94
+ // Owner: requires per-document check (documentData, uid, ownership); not available in aggregate context
95
+ if (visibility === 'owner') {
96
+ return false;
97
+ }
98
+
93
99
  // User fields (or undefined/default) are visible to authenticated users
94
100
  if (visibility === 'user' || visibility === undefined) {
95
101
  return isAuthenticated;
@@ -0,0 +1,122 @@
1
+ // packages/functions/src/shared/utils/__tests__/functionWrapper.test.ts
2
+
3
+ /**
4
+ * @fileoverview Tests for withValidation, firebaseFunction, vercelFunction
5
+ * @description Unit tests for schema validation wrappers.
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.0.1
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+ import * as v from 'valibot';
14
+
15
+ import {
16
+ withValidation,
17
+ firebaseFunction,
18
+ vercelFunction,
19
+ } from '../functionWrapper';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Schemas and fixtures
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const TestSchema = v.object({
26
+ name: v.pipe(v.string(), v.minLength(1)),
27
+ count: v.optional(v.pipe(v.number(), v.minValue(0))),
28
+ });
29
+
30
+ type TestData = v.InferOutput<typeof TestSchema>;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // withValidation
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('withValidation', () => {
37
+ it('returns handler result when data is valid', async () => {
38
+ const handler = vi.fn().mockResolvedValue({ id: '123' });
39
+ const wrapped = withValidation(TestSchema, handler);
40
+ const data = { name: 'foo', count: 2 };
41
+
42
+ const result = await wrapped(data);
43
+
44
+ expect(result).toEqual({ id: '123' });
45
+ expect(handler).toHaveBeenCalledWith({ name: 'foo', count: 2 });
46
+ });
47
+
48
+ it('throws when data fails validation', async () => {
49
+ const handler = vi.fn();
50
+ const wrapped = withValidation(TestSchema, handler);
51
+
52
+ await expect(wrapped({ name: '' })).rejects.toThrow();
53
+ expect(handler).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it('passes validated (parsed) data to handler', async () => {
57
+ const handler = vi.fn().mockResolvedValue(undefined);
58
+ const wrapped = withValidation(TestSchema, handler);
59
+ await wrapped({ name: 'a', count: 0 });
60
+
61
+ expect(handler).toHaveBeenCalledWith(
62
+ expect.objectContaining({ name: 'a', count: 0 })
63
+ );
64
+ });
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // firebaseFunction
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe('firebaseFunction', () => {
72
+ it('calls handler with parsed request.data', async () => {
73
+ const handler = vi.fn().mockResolvedValue({ success: true });
74
+ const fn = firebaseFunction(TestSchema, handler);
75
+ const request = {
76
+ data: { name: 'test', count: 1 },
77
+ rawRequest: {} as any,
78
+ };
79
+
80
+ const result = await fn(request);
81
+
82
+ expect(result).toEqual({ success: true });
83
+ expect(handler).toHaveBeenCalledWith({ name: 'test', count: 1 });
84
+ });
85
+
86
+ it('throws when request.data is invalid', async () => {
87
+ const handler = vi.fn();
88
+ const fn = firebaseFunction(TestSchema, handler);
89
+ const request = { data: { name: '' }, rawRequest: {} as any };
90
+
91
+ await expect(fn(request)).rejects.toThrow();
92
+ expect(handler).not.toHaveBeenCalled();
93
+ });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // vercelFunction
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe('vercelFunction', () => {
101
+ it('parses req.body and calls handler with (req, res, data)', async () => {
102
+ const handler = vi.fn().mockResolvedValue({ ok: true });
103
+ const fn = vercelFunction(TestSchema, handler);
104
+ const req = { body: { name: 'v', count: 0 } } as any;
105
+ const res = {} as any;
106
+
107
+ const result = await fn(req, res);
108
+
109
+ expect(result).toEqual({ ok: true });
110
+ expect(handler).toHaveBeenCalledWith(req, res, { name: 'v', count: 0 });
111
+ });
112
+
113
+ it('throws when req.body is invalid', async () => {
114
+ const handler = vi.fn();
115
+ const fn = vercelFunction(TestSchema, handler);
116
+ const req = { body: {} } as any;
117
+ const res = {} as any;
118
+
119
+ await expect(fn(req, res)).rejects.toThrow();
120
+ expect(handler).not.toHaveBeenCalled();
121
+ });
122
+ });