@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.
- package/package.json +31 -7
- package/src/firebase/auth/setCustomClaims.ts +26 -4
- package/src/firebase/baseFunction.ts +43 -20
- package/src/firebase/billing/cancelSubscription.ts +9 -1
- package/src/firebase/billing/changePlan.ts +8 -2
- package/src/firebase/billing/createCustomerPortal.ts +16 -2
- package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
- package/src/firebase/billing/webhookHandler.ts +13 -1
- package/src/firebase/config/constants.ts +0 -3
- package/src/firebase/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/get.ts +16 -8
- package/src/firebase/crud/list.ts +70 -29
- package/src/firebase/crud/update.ts +29 -7
- package/src/firebase/oauth/exchangeToken.ts +30 -4
- package/src/firebase/oauth/githubAccess.ts +8 -3
- package/src/firebase/registerCrudFunctions.ts +15 -4
- package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
- package/src/shared/__tests__/detectFirestore.test.ts +52 -0
- package/src/shared/__tests__/errorHandling.test.ts +144 -0
- package/src/shared/__tests__/idempotency.test.ts +95 -0
- package/src/shared/__tests__/rateLimiter.test.ts +142 -0
- package/src/shared/__tests__/validation.test.ts +172 -0
- package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
- package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
- package/src/shared/billing/webhookHandler.ts +16 -7
- package/src/shared/errorHandling.ts +16 -54
- package/src/shared/firebase.ts +1 -25
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
- package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
- package/src/shared/schema.ts +7 -1
- package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
- package/src/shared/utils/external/subscription.ts +10 -0
- package/src/shared/utils/internal/auth.ts +140 -16
- package/src/shared/utils/internal/rateLimiter.ts +101 -90
- package/src/shared/utils/internal/validation.ts +47 -3
- package/src/shared/utils.ts +154 -39
- package/src/supabase/auth/deleteAccount.ts +59 -0
- package/src/supabase/auth/getCustomClaims.ts +56 -0
- package/src/supabase/auth/getUserAuthStatus.ts +64 -0
- package/src/supabase/auth/removeCustomClaims.ts +75 -0
- package/src/supabase/auth/setCustomClaims.ts +73 -0
- package/src/supabase/baseFunction.ts +302 -0
- package/src/supabase/billing/cancelSubscription.ts +57 -0
- package/src/supabase/billing/changePlan.ts +62 -0
- package/src/supabase/billing/createCheckoutSession.ts +82 -0
- package/src/supabase/billing/createCustomerPortal.ts +58 -0
- package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
- package/src/supabase/crud/aggregate.ts +169 -0
- package/src/supabase/crud/create.ts +225 -0
- package/src/supabase/crud/delete.ts +154 -0
- package/src/supabase/crud/get.ts +89 -0
- package/src/supabase/crud/index.ts +24 -0
- package/src/supabase/crud/list.ts +357 -0
- package/src/supabase/crud/update.ts +199 -0
- package/src/supabase/helpers/authProvider.ts +45 -0
- package/src/supabase/index.ts +73 -0
- package/src/supabase/registerCrudFunctions.ts +180 -0
- package/src/supabase/utils/idempotency.ts +141 -0
- package/src/supabase/utils/monitoring.ts +187 -0
- package/src/supabase/utils/rateLimiter.ts +216 -0
- package/src/vercel/api/auth/get-custom-claims.ts +3 -2
- package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
- package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
- package/src/vercel/api/auth/set-custom-claims.ts +5 -2
- package/src/vercel/api/billing/cancel.ts +2 -1
- package/src/vercel/api/billing/change-plan.ts +3 -1
- package/src/vercel/api/billing/customer-portal.ts +4 -1
- package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
- package/src/vercel/api/billing/webhook-handler.ts +24 -4
- package/src/vercel/api/crud/create.ts +14 -8
- package/src/vercel/api/crud/delete.ts +15 -6
- package/src/vercel/api/crud/get.ts +16 -8
- package/src/vercel/api/crud/list.ts +22 -10
- package/src/vercel/api/crud/update.ts +16 -10
- package/src/vercel/api/oauth/check-github-access.ts +2 -5
- package/src/vercel/api/oauth/grant-github-access.ts +1 -5
- package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
- package/src/vercel/api/utils/cors.ts +13 -2
- package/src/vercel/baseFunction.ts +40 -25
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
hasRoleAccess,
|
|
17
17
|
HIDDEN_STATUSES,
|
|
18
18
|
} from '@donotdev/core/server';
|
|
19
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
EntityOwnershipConfig,
|
|
21
|
+
EntityOwnershipPublicCondition,
|
|
22
|
+
UserRole,
|
|
23
|
+
} from '@donotdev/core/server';
|
|
20
24
|
import { getFirebaseAdminFirestore, Query } from '@donotdev/firebase/server';
|
|
21
25
|
|
|
22
26
|
import { transformFirestoreData } from '../../shared/index.js';
|
|
@@ -40,6 +44,20 @@ export interface ListEntityRequest {
|
|
|
40
44
|
};
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Apply public-condition where clauses to a query (for listCard when ownership is set).
|
|
49
|
+
*/
|
|
50
|
+
function applyPublicCondition(
|
|
51
|
+
query: Query,
|
|
52
|
+
publicCondition: EntityOwnershipPublicCondition[]
|
|
53
|
+
): Query {
|
|
54
|
+
let q = query;
|
|
55
|
+
for (const c of publicCondition) {
|
|
56
|
+
q = q.where(c.field, c.op as any, c.value);
|
|
57
|
+
}
|
|
58
|
+
return q;
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
/**
|
|
44
62
|
* Generic business logic for listing entities
|
|
45
63
|
* Base function handles: validation, auth, rate limiting, monitoring
|
|
@@ -47,7 +65,9 @@ export interface ListEntityRequest {
|
|
|
47
65
|
function listEntitiesLogicFactory(
|
|
48
66
|
collection: string,
|
|
49
67
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
50
|
-
listFields?: string[]
|
|
68
|
+
listFields?: string[],
|
|
69
|
+
ownership?: EntityOwnershipConfig,
|
|
70
|
+
isListCard?: boolean
|
|
51
71
|
) {
|
|
52
72
|
return async function listEntitiesLogic(
|
|
53
73
|
data: ListEntityRequest,
|
|
@@ -58,7 +78,8 @@ function listEntitiesLogicFactory(
|
|
|
58
78
|
}
|
|
59
79
|
) {
|
|
60
80
|
const { where = [], orderBy = [], limit, startAfterId, search } = data;
|
|
61
|
-
|
|
81
|
+
// Extract uid and userRole once (reused for all document masking)
|
|
82
|
+
const { userRole, uid } = context;
|
|
62
83
|
|
|
63
84
|
const isAdmin = hasRoleAccess(userRole, 'admin'); // Uses role hierarchy
|
|
64
85
|
|
|
@@ -71,6 +92,26 @@ function listEntitiesLogicFactory(
|
|
|
71
92
|
query = query.where('status', 'not-in', [...HIDDEN_STATUSES]);
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
// Ownership: when set and not admin, listCard = public condition, list = mine filter
|
|
96
|
+
if (ownership && !isAdmin) {
|
|
97
|
+
if (
|
|
98
|
+
isListCard &&
|
|
99
|
+
ownership.publicCondition &&
|
|
100
|
+
ownership.publicCondition.length > 0
|
|
101
|
+
) {
|
|
102
|
+
query = applyPublicCondition(query, ownership.publicCondition);
|
|
103
|
+
} else if (!isListCard && ownership.ownerFields.length > 0) {
|
|
104
|
+
// "Mine" filter: Firestore does not support OR across different fields in one query.
|
|
105
|
+
// Use first owner field only for single query; recommend ownerIds array-contains for multiple.
|
|
106
|
+
const firstOwnerField = ownership.ownerFields[0];
|
|
107
|
+
query = query.where(firstOwnerField, '==', uid);
|
|
108
|
+
if (ownership.ownerFields.length > 1) {
|
|
109
|
+
// Optional: run second query and merge (complex pagination). For now, single field only.
|
|
110
|
+
// Document ownerIds pattern for multiple stakeholders.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
74
115
|
// Apply search if provided
|
|
75
116
|
if (search) {
|
|
76
117
|
const { field, query: searchQuery } = search;
|
|
@@ -103,31 +144,16 @@ function listEntitiesLogicFactory(
|
|
|
103
144
|
query = query.startAfter(startAfterDoc);
|
|
104
145
|
}
|
|
105
146
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
console.log(
|
|
113
|
-
`[listEntities] Collection: ${collection}, UserRole: ${userRole}, IsAdmin: ${isAdmin}`
|
|
114
|
-
);
|
|
115
|
-
console.log(
|
|
116
|
-
`[listEntities] Filters - where: ${JSON.stringify(where)}, orderBy: ${JSON.stringify(orderBy)}, limit: ${limit}`
|
|
117
|
-
);
|
|
147
|
+
// W13: Cap at MAX_LIST_LIMIT to prevent unbounded reads (DoS via cost amplification).
|
|
148
|
+
const MAX_LIST_LIMIT = 500;
|
|
149
|
+
const effectiveLimit = limit !== undefined && limit > 0
|
|
150
|
+
? Math.min(limit, MAX_LIST_LIMIT)
|
|
151
|
+
: MAX_LIST_LIMIT;
|
|
152
|
+
query = query.limit(effectiveLimit);
|
|
118
153
|
|
|
119
154
|
// Execute the query
|
|
120
155
|
const snapshot = await query.get();
|
|
121
156
|
|
|
122
|
-
// DEBUG: Log result count
|
|
123
|
-
console.log(
|
|
124
|
-
`[listEntities] Query returned ${snapshot.docs.length} documents`
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
// DEBUG: Log schema info
|
|
128
|
-
const schemaHasEntries = !!(documentSchema as any)?.entries;
|
|
129
|
-
console.log(`[listEntities] Schema has entries: ${schemaHasEntries}`);
|
|
130
|
-
|
|
131
157
|
// Helper: Check if value is a Picture object (has thumbUrl and fullUrl)
|
|
132
158
|
const isPictureObject = (value: any): boolean => {
|
|
133
159
|
return (
|
|
@@ -158,13 +184,18 @@ function listEntitiesLogicFactory(
|
|
|
158
184
|
return value;
|
|
159
185
|
};
|
|
160
186
|
|
|
161
|
-
|
|
187
|
+
const visibilityOptions = ownership && uid ? { uid, ownership } : undefined;
|
|
188
|
+
|
|
189
|
+
// Filter document fields based on visibility and user role (uid/userRole from context, once)
|
|
162
190
|
const docs = snapshot.docs.map((doc: any) => {
|
|
163
191
|
const rawData = doc.data() || {};
|
|
164
192
|
const visibleData = filterVisibleFields(
|
|
165
193
|
rawData,
|
|
166
194
|
documentSchema,
|
|
167
|
-
userRole
|
|
195
|
+
userRole,
|
|
196
|
+
visibilityOptions
|
|
197
|
+
? { ...visibilityOptions, documentData: rawData }
|
|
198
|
+
: undefined
|
|
168
199
|
);
|
|
169
200
|
|
|
170
201
|
// If listFields specified, filter to only those fields (plus id always)
|
|
@@ -206,6 +237,8 @@ function listEntitiesLogicFactory(
|
|
|
206
237
|
* @param requiredRole - Minimum role required for this operation
|
|
207
238
|
* @param customSchema - Optional custom request schema
|
|
208
239
|
* @param listFields - Optional array of field names to include (plus id). If not provided, all visible fields are returned.
|
|
240
|
+
* @param ownership - Optional ownership config for list constraints and visibility: 'owner' masking
|
|
241
|
+
* @param isListCard - When true and ownership is set, applies public condition; when false, applies "mine" filter
|
|
209
242
|
* @returns Firebase callable function
|
|
210
243
|
*/
|
|
211
244
|
export const listEntities = (
|
|
@@ -213,7 +246,9 @@ export const listEntities = (
|
|
|
213
246
|
documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
214
247
|
requiredRole: UserRole,
|
|
215
248
|
customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
|
|
216
|
-
listFields?: string[]
|
|
249
|
+
listFields?: string[],
|
|
250
|
+
ownership?: EntityOwnershipConfig,
|
|
251
|
+
isListCard?: boolean
|
|
217
252
|
): CallableFunction<ListEntityRequest, Promise<any>> => {
|
|
218
253
|
const requestSchema =
|
|
219
254
|
customSchema ||
|
|
@@ -222,7 +257,7 @@ export const listEntities = (
|
|
|
222
257
|
orderBy: v.optional(
|
|
223
258
|
v.array(v.tuple([v.string(), v.picklist(['asc', 'desc'])]))
|
|
224
259
|
),
|
|
225
|
-
limit: v.optional(v.pipe(v.number(), v.minValue(1))),
|
|
260
|
+
limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(500))),
|
|
226
261
|
startAfterId: v.optional(v.string()),
|
|
227
262
|
search: v.optional(
|
|
228
263
|
v.object({
|
|
@@ -236,7 +271,13 @@ export const listEntities = (
|
|
|
236
271
|
CRUD_READ_CONFIG,
|
|
237
272
|
requestSchema,
|
|
238
273
|
'list_entities',
|
|
239
|
-
listEntitiesLogicFactory(
|
|
274
|
+
listEntitiesLogicFactory(
|
|
275
|
+
collection,
|
|
276
|
+
documentSchema,
|
|
277
|
+
listFields,
|
|
278
|
+
ownership,
|
|
279
|
+
isListCard
|
|
280
|
+
),
|
|
240
281
|
requiredRole
|
|
241
282
|
);
|
|
242
283
|
};
|
|
@@ -89,9 +89,8 @@ async function checkUniqueKeysForUpdate(
|
|
|
89
89
|
const db = getFirebaseAdminFirestore();
|
|
90
90
|
|
|
91
91
|
for (const uniqueKey of uniqueKeys) {
|
|
92
|
-
// Skip validation for drafts if
|
|
93
|
-
|
|
94
|
-
if (isDraft && skipForDrafts) continue;
|
|
92
|
+
// Skip validation for drafts only if explicitly opted in (default: false)
|
|
93
|
+
if (isDraft && uniqueKey.skipForDrafts === true) continue;
|
|
95
94
|
|
|
96
95
|
// Check if any of the unique key fields are being updated
|
|
97
96
|
const isUpdatingUniqueKeyField = uniqueKey.fields.some(
|
|
@@ -152,15 +151,38 @@ function updateEntityLogicFactory(
|
|
|
152
151
|
const { id, payload, idempotencyKey } = data;
|
|
153
152
|
const { uid } = context;
|
|
154
153
|
|
|
155
|
-
//
|
|
154
|
+
// W17: Validate idempotency key length and content.
|
|
155
|
+
if (idempotencyKey !== undefined) {
|
|
156
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
|
|
157
|
+
throw new DoNotDevError('idempotencyKey must be a non-empty string of at most 256 characters', 'invalid-argument');
|
|
158
|
+
}
|
|
159
|
+
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
160
|
+
throw new DoNotDevError('idempotencyKey contains invalid characters', 'invalid-argument');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// C9: Atomic idempotency check — reserve key in a transaction to eliminate TOCTOU race.
|
|
156
165
|
if (idempotencyKey) {
|
|
157
166
|
const db = getFirebaseAdminFirestore();
|
|
158
167
|
const idempotencyRef = db
|
|
159
168
|
.collection('idempotency')
|
|
160
169
|
.doc(`update_${idempotencyKey}`);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
|
|
171
|
+
let existingResult: unknown = undefined;
|
|
172
|
+
let alreadyProcessed = false;
|
|
173
|
+
|
|
174
|
+
await db.runTransaction(async (tx) => {
|
|
175
|
+
const idempotencyDoc = await tx.get(idempotencyRef);
|
|
176
|
+
if (idempotencyDoc.exists) {
|
|
177
|
+
existingResult = idempotencyDoc.data()?.result;
|
|
178
|
+
alreadyProcessed = true;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (alreadyProcessed) {
|
|
185
|
+
return existingResult;
|
|
164
186
|
}
|
|
165
187
|
}
|
|
166
188
|
|
|
@@ -37,15 +37,41 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
|
|
|
37
37
|
const { provider, purpose, code, redirectUri, codeVerifier, idempotencyKey } =
|
|
38
38
|
request.data;
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// W17: Validate idempotency key length and content.
|
|
41
|
+
if (idempotencyKey !== undefined) {
|
|
42
|
+
if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
|
|
43
|
+
throw new HttpsError('invalid-argument', 'idempotencyKey must be a non-empty string of at most 256 characters');
|
|
44
|
+
}
|
|
45
|
+
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
46
|
+
throw new HttpsError('invalid-argument', 'idempotencyKey contains invalid characters');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// C9: Atomic idempotency check using Firestore transaction to prevent TOCTOU race.
|
|
51
|
+
// A concurrent request with the same key would see the 'pending' sentinel and wait
|
|
52
|
+
// or return early instead of executing duplicate business logic.
|
|
41
53
|
if (idempotencyKey) {
|
|
42
54
|
const db = getFirebaseAdminFirestore();
|
|
43
55
|
const idempotencyRef = db
|
|
44
56
|
.collection('idempotency')
|
|
45
57
|
.doc(`oauth_${idempotencyKey}`);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
|
|
59
|
+
let existingResult: unknown = undefined;
|
|
60
|
+
let alreadyProcessed = false;
|
|
61
|
+
|
|
62
|
+
await db.runTransaction(async (tx) => {
|
|
63
|
+
const idempotencyDoc = await tx.get(idempotencyRef);
|
|
64
|
+
if (idempotencyDoc.exists) {
|
|
65
|
+
existingResult = idempotencyDoc.data()?.result;
|
|
66
|
+
alreadyProcessed = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Reserve the key before executing business logic
|
|
70
|
+
tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (alreadyProcessed) {
|
|
74
|
+
return existingResult;
|
|
49
75
|
}
|
|
50
76
|
}
|
|
51
77
|
|
|
@@ -68,12 +68,13 @@ async function grantGitHubAccessInternal(
|
|
|
68
68
|
|
|
69
69
|
const validatedData = validationResult.output;
|
|
70
70
|
const {
|
|
71
|
-
userId,
|
|
72
71
|
githubUsername,
|
|
73
72
|
repoConfig,
|
|
74
73
|
permission = 'push',
|
|
75
74
|
customClaims,
|
|
76
75
|
} = validatedData;
|
|
76
|
+
// W20: Enforce authenticated user ID — never trust client-supplied userId
|
|
77
|
+
const userId = uid;
|
|
77
78
|
|
|
78
79
|
// Get GitHub token
|
|
79
80
|
const githubToken = githubPersonalAccessToken.value();
|
|
@@ -206,7 +207,9 @@ async function revokeGitHubAccessInternal(
|
|
|
206
207
|
);
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
const {
|
|
210
|
+
const { githubUsername, repoConfig } = validationResult.output;
|
|
211
|
+
// W20: Enforce authenticated user ID — never trust client-supplied userId
|
|
212
|
+
const userId = uid;
|
|
210
213
|
|
|
211
214
|
// Get GitHub token
|
|
212
215
|
const githubToken = githubPersonalAccessToken.value();
|
|
@@ -335,7 +338,9 @@ async function checkGitHubAccessInternal(
|
|
|
335
338
|
);
|
|
336
339
|
}
|
|
337
340
|
|
|
338
|
-
const {
|
|
341
|
+
const { githubUsername, repoConfig } = validationResult.output;
|
|
342
|
+
// W20: Enforce authenticated user ID — never trust client-supplied userId
|
|
343
|
+
const userId = uid;
|
|
339
344
|
|
|
340
345
|
// Get GitHub token
|
|
341
346
|
const githubToken = githubPersonalAccessToken.value();
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @author AMBROISE PARK Consulting
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createSchemas } from '@donotdev/core/server';
|
|
12
|
+
import { createSchemas, getListCardFieldNames } from '@donotdev/core/server';
|
|
13
13
|
import type { Entity } from '@donotdev/core/server';
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -67,14 +67,23 @@ export function createCrudFunctions(
|
|
|
67
67
|
schemas.create,
|
|
68
68
|
access.create
|
|
69
69
|
);
|
|
70
|
-
functions[`${prefix}get_${col}`] = getEntity(
|
|
70
|
+
functions[`${prefix}get_${col}`] = getEntity(
|
|
71
|
+
col,
|
|
72
|
+
schemas.get,
|
|
73
|
+
access.read,
|
|
74
|
+
undefined,
|
|
75
|
+
entity.ownership
|
|
76
|
+
);
|
|
71
77
|
// Use schemas.get for visibility filtering, entity.listFields for field selection
|
|
78
|
+
// When ownership is set: list = "mine" filter, listCard = public condition
|
|
72
79
|
functions[`${prefix}list_${col}`] = listEntities(
|
|
73
80
|
col,
|
|
74
81
|
schemas.get,
|
|
75
82
|
access.read,
|
|
76
83
|
undefined,
|
|
77
|
-
entity.listFields
|
|
84
|
+
entity.listFields,
|
|
85
|
+
entity.ownership,
|
|
86
|
+
false
|
|
78
87
|
);
|
|
79
88
|
// Always create listCard - uses same schemas.get, field selection via listCardFields ?? listFields ?? undefined
|
|
80
89
|
functions[`${prefix}listCard_${col}`] = listEntities(
|
|
@@ -82,7 +91,9 @@ export function createCrudFunctions(
|
|
|
82
91
|
schemas.get,
|
|
83
92
|
access.read,
|
|
84
93
|
undefined,
|
|
85
|
-
entity
|
|
94
|
+
getListCardFieldNames(entity),
|
|
95
|
+
entity.ownership,
|
|
96
|
+
true
|
|
86
97
|
);
|
|
87
98
|
functions[`${prefix}update_${col}`] = updateEntity(
|
|
88
99
|
col,
|
|
@@ -21,7 +21,16 @@ import { handleError } from '../../shared/errorHandling.js';
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Scheduled function that runs daily to check for expired subscriptions
|
|
24
|
-
* and update custom claims with expired status
|
|
24
|
+
* and update custom claims with expired status.
|
|
25
|
+
*
|
|
26
|
+
* **Architecture decision — opt-in scheduled function:**
|
|
27
|
+
* This function is not automatically deployed. Consumer apps opt in by
|
|
28
|
+
* exporting it from their functions entry point (e.g. `index.ts`). If not
|
|
29
|
+
* exported, Firebase never schedules it.
|
|
30
|
+
*
|
|
31
|
+
* The `users` collection path defaults to `'users'` but is configurable via
|
|
32
|
+
* the `USERS_COLLECTION_PATH` environment variable, allowing consumers to
|
|
33
|
+
* use their own collection structure (e.g. `'app_users'`, `'tenants/{id}/users'`).
|
|
25
34
|
*/
|
|
26
35
|
export const checkExpiredSubscriptions = onSchedule(
|
|
27
36
|
{
|
|
@@ -36,9 +45,17 @@ export const checkExpiredSubscriptions = onSchedule(
|
|
|
36
45
|
const db = getFirebaseAdminFirestore();
|
|
37
46
|
const now = new Date();
|
|
38
47
|
|
|
48
|
+
// W18: The 'users' collection name is configurable. Consumer apps may use
|
|
49
|
+
// a different collection. Default to 'users' but respect
|
|
50
|
+
// USERS_COLLECTION_PATH env var if set.
|
|
51
|
+
const usersCollection = process.env.USERS_COLLECTION_PATH || 'users';
|
|
52
|
+
|
|
53
|
+
// Guard: if the collection doesn't exist the query returns empty, which
|
|
54
|
+
// is safe. Log a warning so operators know if misconfigured.
|
|
55
|
+
const collectionRef = db.collection(usersCollection);
|
|
56
|
+
|
|
39
57
|
// Get all users with active subscriptions
|
|
40
|
-
const usersSnapshot = await
|
|
41
|
-
.collection('users')
|
|
58
|
+
const usersSnapshot = await collectionRef
|
|
42
59
|
.where('subscription.status', '==', 'active')
|
|
43
60
|
.get();
|
|
44
61
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { isFirestoreConfigured } from '../utils/detectFirestore';
|
|
4
|
+
|
|
5
|
+
describe('isFirestoreConfigured', () => {
|
|
6
|
+
const originalEnv = { ...process.env };
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Clear all relevant env vars
|
|
10
|
+
delete process.env.FUNCTION_NAME;
|
|
11
|
+
delete process.env.FIREBASE_CONFIG;
|
|
12
|
+
delete process.env.FIREBASE_PROJECT_ID;
|
|
13
|
+
delete process.env.FIREBASE_CLIENT_EMAIL;
|
|
14
|
+
delete process.env.FIREBASE_PRIVATE_KEY;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
// Restore original env
|
|
19
|
+
process.env = { ...originalEnv };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns true when FUNCTION_NAME is set (Firebase Functions runtime)', () => {
|
|
23
|
+
process.env.FUNCTION_NAME = 'myFunction';
|
|
24
|
+
|
|
25
|
+
expect(isFirestoreConfigured()).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns true when FIREBASE_CONFIG is set', () => {
|
|
29
|
+
process.env.FIREBASE_CONFIG = '{"projectId":"test"}';
|
|
30
|
+
|
|
31
|
+
expect(isFirestoreConfigured()).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns true when all manual credentials are set', () => {
|
|
35
|
+
process.env.FIREBASE_PROJECT_ID = 'my-project';
|
|
36
|
+
process.env.FIREBASE_CLIENT_EMAIL = 'sa@project.iam.gserviceaccount.com';
|
|
37
|
+
process.env.FIREBASE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\n...';
|
|
38
|
+
|
|
39
|
+
expect(isFirestoreConfigured()).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns false when only partial credentials are set', () => {
|
|
43
|
+
process.env.FIREBASE_PROJECT_ID = 'my-project';
|
|
44
|
+
// Missing CLIENT_EMAIL and PRIVATE_KEY
|
|
45
|
+
|
|
46
|
+
expect(isFirestoreConfigured()).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns false when no env vars are set', () => {
|
|
50
|
+
expect(isFirestoreConfigured()).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock firebase-functions
|
|
4
|
+
vi.mock('firebase-functions/v2/https', () => ({
|
|
5
|
+
HttpsError: class HttpsError extends Error {
|
|
6
|
+
code: string;
|
|
7
|
+
details: any;
|
|
8
|
+
constructor(code: string, message: string, details?: any) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.details = details;
|
|
12
|
+
this.name = 'HttpsError';
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('firebase-functions/v2', () => ({
|
|
18
|
+
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), log: vi.fn() },
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('@donotdev/firebase/server', () => ({
|
|
22
|
+
getFirebaseAdminFirestore: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { handleError, DoNotDevError } from '../errorHandling';
|
|
26
|
+
|
|
27
|
+
describe('DoNotDevError (functions)', () => {
|
|
28
|
+
it('creates with default code "unknown"', () => {
|
|
29
|
+
const error = new DoNotDevError('test');
|
|
30
|
+
|
|
31
|
+
expect(error.code).toBe('unknown');
|
|
32
|
+
expect(error.message).toBe('test');
|
|
33
|
+
expect(error.name).toBe('DoNotDevError');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('creates with explicit code and details', () => {
|
|
37
|
+
const error = new DoNotDevError('bad input', 'invalid-argument', {
|
|
38
|
+
field: 'email',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(error.code).toBe('invalid-argument');
|
|
42
|
+
expect(error.details).toEqual({ field: 'email' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('toString formats correctly', () => {
|
|
46
|
+
const error = new DoNotDevError('test', 'not-found');
|
|
47
|
+
|
|
48
|
+
expect(error.toString()).toBe('DoNotDevError [not-found]: test');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('toJSON includes all fields', () => {
|
|
52
|
+
const error = new DoNotDevError('test', 'internal', { key: 'val' });
|
|
53
|
+
const json = error.toJSON();
|
|
54
|
+
|
|
55
|
+
expect(json).toEqual({
|
|
56
|
+
name: 'DoNotDevError',
|
|
57
|
+
code: 'internal',
|
|
58
|
+
message: 'test',
|
|
59
|
+
details: { key: 'val' },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('instanceof chain works', () => {
|
|
64
|
+
const error = new DoNotDevError('test');
|
|
65
|
+
|
|
66
|
+
expect(error instanceof DoNotDevError).toBe(true);
|
|
67
|
+
expect(error instanceof Error).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('handleError', () => {
|
|
72
|
+
const originalEnv = { ...process.env };
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
76
|
+
// Clear env to force fallback (no Firebase, no Vercel)
|
|
77
|
+
delete process.env.FUNCTIONS_EMULATOR;
|
|
78
|
+
delete process.env.FIREBASE_CONFIG;
|
|
79
|
+
delete process.env.GCLOUD_PROJECT;
|
|
80
|
+
delete process.env.VERCEL;
|
|
81
|
+
delete process.env.VERCEL_ENV;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
process.env = { ...originalEnv };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws for DoNotDevError (fallback env)', () => {
|
|
89
|
+
const original = new DoNotDevError('Bad', 'invalid-argument');
|
|
90
|
+
|
|
91
|
+
expect(() => handleError(original)).toThrow('invalid-argument: Bad');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throws for generic Error (fallback env)', () => {
|
|
95
|
+
const original = new Error('Something broke');
|
|
96
|
+
|
|
97
|
+
expect(() => handleError(original)).toThrow('internal: Something broke');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('throws for string error (fallback env)', () => {
|
|
101
|
+
expect(() => handleError('random string')).toThrow(
|
|
102
|
+
'internal: An unexpected error occurred'
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('throws HttpsError in Firebase environment', () => {
|
|
107
|
+
process.env.FIREBASE_CONFIG = '{}';
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
handleError(new DoNotDevError('test', 'not-found'));
|
|
111
|
+
expect.fail('should have thrown');
|
|
112
|
+
} catch (error: any) {
|
|
113
|
+
expect(error.name).toBe('HttpsError');
|
|
114
|
+
expect(error.code).toBe('not-found');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('maps EntityHookError types correctly', () => {
|
|
119
|
+
// Simulate EntityHookError (name-based detection)
|
|
120
|
+
const entityError = new Error('Permission denied');
|
|
121
|
+
(entityError as any).name = 'EntityHookError';
|
|
122
|
+
(entityError as any).type = 'PERMISSION_DENIED';
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
handleError(entityError);
|
|
126
|
+
expect.fail('should have thrown');
|
|
127
|
+
} catch (error: any) {
|
|
128
|
+
expect(error.message).toContain('permission-denied');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('maps ValiError to invalid-argument', () => {
|
|
133
|
+
const valiError = new Error('Validation failed');
|
|
134
|
+
(valiError as any).name = 'ValiError';
|
|
135
|
+
(valiError as any).issues = [{ path: [], message: 'required' }];
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
handleError(valiError);
|
|
139
|
+
expect.fail('should have thrown');
|
|
140
|
+
} catch (error: any) {
|
|
141
|
+
expect(error.message).toContain('invalid-argument');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|