@donotdev/functions 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +9 -9
- package/src/firebase/auth/setCustomClaims.ts +19 -5
- package/src/firebase/baseFunction.ts +11 -3
- package/src/firebase/billing/changePlan.ts +5 -1
- package/src/firebase/billing/createCheckoutSession.ts +3 -1
- package/src/firebase/billing/createCustomerPortal.ts +6 -2
- package/src/firebase/billing/webhookHandler.ts +4 -1
- package/src/firebase/crud/aggregate.ts +5 -1
- package/src/firebase/crud/create.ts +17 -4
- package/src/firebase/crud/list.ts +37 -5
- package/src/firebase/crud/update.ts +17 -4
- package/src/firebase/oauth/exchangeToken.ts +17 -4
- package/src/shared/__tests__/validation.test.ts +5 -3
- package/src/shared/billing/__tests__/createCheckout.test.ts +123 -22
- package/src/shared/billing/__tests__/webhookHandler.test.ts +121 -30
- package/src/shared/errorHandling.ts +6 -6
- package/src/shared/firebase.ts +1 -1
- package/src/shared/index.ts +2 -1
- package/src/shared/logger.ts +9 -7
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +6 -8
- package/src/shared/oauth/__tests__/grantAccess.test.ts +31 -14
- package/src/shared/utils/external/subscription.ts +2 -2
- package/src/shared/utils/internal/auth.ts +10 -3
- package/src/shared/utils/internal/rateLimiter.ts +8 -2
- package/src/shared/utils.ts +23 -30
- package/src/supabase/auth/deleteAccount.ts +4 -11
- package/src/supabase/auth/getCustomClaims.ts +5 -3
- package/src/supabase/auth/getUserAuthStatus.ts +5 -3
- package/src/supabase/auth/removeCustomClaims.ts +10 -5
- package/src/supabase/auth/setCustomClaims.ts +9 -4
- package/src/supabase/baseFunction.ts +80 -21
- package/src/supabase/billing/cancelSubscription.ts +9 -3
- package/src/supabase/billing/changePlan.ts +20 -5
- package/src/supabase/billing/createCheckoutSession.ts +20 -5
- package/src/supabase/billing/createCustomerPortal.ts +14 -4
- package/src/supabase/billing/refreshSubscriptionStatus.ts +29 -9
- package/src/supabase/crud/aggregate.ts +14 -4
- package/src/supabase/crud/create.ts +30 -11
- package/src/supabase/crud/delete.ts +11 -3
- package/src/supabase/crud/get.ts +25 -3
- package/src/supabase/crud/list.ts +106 -21
- package/src/supabase/crud/update.ts +32 -10
- package/src/supabase/helpers/authProvider.ts +5 -2
- package/src/supabase/index.ts +1 -4
- package/src/supabase/registerCrudFunctions.ts +11 -9
- package/src/supabase/utils/idempotency.ts +13 -15
- package/src/supabase/utils/monitoring.ts +5 -1
- package/src/supabase/utils/rateLimiter.ts +13 -3
- package/src/vercel/api/billing/webhook-handler.ts +6 -2
- package/src/vercel/api/crud/create.ts +7 -2
- package/src/vercel/api/crud/delete.ts +3 -1
- package/src/vercel/api/crud/get.ts +3 -1
- package/src/vercel/api/crud/list.ts +3 -1
- package/src/vercel/api/crud/update.ts +7 -2
package/README.md
CHANGED
|
@@ -482,7 +482,7 @@ bun run typecheck
|
|
|
482
482
|
bun run dev:firebase
|
|
483
483
|
|
|
484
484
|
# Terminal 2: Forward webhooks
|
|
485
|
-
stripe listen --forward-to localhost:5001/your-project/
|
|
485
|
+
stripe listen --forward-to localhost:5001/your-project/europe-west1/stripeWebhook
|
|
486
486
|
```
|
|
487
487
|
|
|
488
488
|
#### Vercel
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donotdev/functions",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Backend functions for DoNotDev Framework - Firebase, Vercel, and platform-agnostic implementations for auth, billing, CRUD, and OAuth",
|
|
6
6
|
"main": "./lib/firebase/index.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"node": ">=18.0.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"type-check": "tsc --noEmit",
|
|
42
|
+
"type-check": "bunx tsc --noEmit",
|
|
43
43
|
"build": "node build.mjs && tsc -p tsconfig.json",
|
|
44
44
|
"build:types": "tsc -p tsconfig.json",
|
|
45
45
|
"prepublishOnly": "bun run build",
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
"serve": "firebase emulators:start --only functions"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@donotdev/core": "^0.0.
|
|
51
|
-
"@donotdev/firebase": "^0.0.
|
|
52
|
-
"@donotdev/supabase": "^0.0.
|
|
50
|
+
"@donotdev/core": "^0.0.26",
|
|
51
|
+
"@donotdev/firebase": "^0.0.13",
|
|
52
|
+
"@donotdev/supabase": "^0.0.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@sentry/node": "^10.39.0",
|
|
56
|
-
"@supabase/supabase-js": "^2.
|
|
56
|
+
"@supabase/supabase-js": "^2.76.11",
|
|
57
57
|
"firebase-admin": "^13.6.1",
|
|
58
58
|
"firebase-functions": "^7.0.5",
|
|
59
59
|
"next": "^16.1.6",
|
|
@@ -85,6 +85,9 @@
|
|
|
85
85
|
"access": "public"
|
|
86
86
|
},
|
|
87
87
|
"peerDependenciesMeta": {
|
|
88
|
+
"@sentry/node": {
|
|
89
|
+
"optional": true
|
|
90
|
+
},
|
|
88
91
|
"@supabase/supabase-js": {
|
|
89
92
|
"optional": true
|
|
90
93
|
},
|
|
@@ -96,9 +99,6 @@
|
|
|
96
99
|
},
|
|
97
100
|
"next": {
|
|
98
101
|
"optional": true
|
|
99
|
-
},
|
|
100
|
-
"@sentry/node": {
|
|
101
|
-
"optional": true
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
}
|
|
@@ -60,11 +60,19 @@ async function setCustomClaimsLogic(
|
|
|
60
60
|
|
|
61
61
|
// W17: Validate idempotency key to prevent oversized or malformed inputs.
|
|
62
62
|
if (idempotencyKey !== undefined) {
|
|
63
|
-
if (
|
|
64
|
-
|
|
63
|
+
if (
|
|
64
|
+
typeof idempotencyKey !== 'string' ||
|
|
65
|
+
idempotencyKey.length === 0 ||
|
|
66
|
+
idempotencyKey.length > 256
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'idempotencyKey must be a non-empty string of at most 256 characters'
|
|
70
|
+
);
|
|
65
71
|
}
|
|
66
72
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
67
|
-
throw new Error(
|
|
73
|
+
throw new Error(
|
|
74
|
+
'idempotencyKey contains invalid characters (allowed: alphanumeric, -, _, ., :, @)'
|
|
75
|
+
);
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
|
|
@@ -85,11 +93,17 @@ async function setCustomClaimsLogic(
|
|
|
85
93
|
alreadyProcessed = true;
|
|
86
94
|
return;
|
|
87
95
|
}
|
|
88
|
-
tx.set(idempotencyRef, {
|
|
96
|
+
tx.set(idempotencyRef, {
|
|
97
|
+
processing: true,
|
|
98
|
+
reservedAt: new Date().toISOString(),
|
|
99
|
+
});
|
|
89
100
|
});
|
|
90
101
|
|
|
91
102
|
if (alreadyProcessed) {
|
|
92
|
-
return existingResult as {
|
|
103
|
+
return existingResult as {
|
|
104
|
+
success: boolean;
|
|
105
|
+
customClaims: Record<string, any>;
|
|
106
|
+
};
|
|
93
107
|
}
|
|
94
108
|
}
|
|
95
109
|
|
|
@@ -21,7 +21,10 @@ import { FUNCTION_CONFIG } from './config/constants.js';
|
|
|
21
21
|
import { handleError } from '../shared/errorHandling.js';
|
|
22
22
|
import { assertAuthenticated, getUserRole } from '../shared/utils.js';
|
|
23
23
|
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
CallableRequest,
|
|
26
|
+
CallableOptions,
|
|
27
|
+
} from 'firebase-functions/v2/https';
|
|
25
28
|
|
|
26
29
|
// Optional monitoring imports - only used when enabled
|
|
27
30
|
// Lazy loaded to avoid unnecessary Firestore operations
|
|
@@ -76,9 +79,14 @@ async function loadMonitoring() {
|
|
|
76
79
|
function getClientIp(request: CallableRequest<unknown>): string {
|
|
77
80
|
const forwardedFor = request.rawRequest.headers['x-forwarded-for'];
|
|
78
81
|
if (forwardedFor) {
|
|
79
|
-
const raw = Array.isArray(forwardedFor)
|
|
82
|
+
const raw = Array.isArray(forwardedFor)
|
|
83
|
+
? forwardedFor.join(',')
|
|
84
|
+
: forwardedFor;
|
|
80
85
|
// Split and take the RIGHTMOST entry (last untrusted / first-to-be-trusted)
|
|
81
|
-
const ips = raw
|
|
86
|
+
const ips = raw
|
|
87
|
+
.split(',')
|
|
88
|
+
.map((s) => s.trim())
|
|
89
|
+
.filter(Boolean);
|
|
82
90
|
if (ips.length > 0) {
|
|
83
91
|
return ips[ips.length - 1]!;
|
|
84
92
|
}
|
|
@@ -17,7 +17,11 @@ import { getFirebaseAdminAuth } from '@donotdev/firebase/server';
|
|
|
17
17
|
|
|
18
18
|
import { updateUserSubscription } from '../../shared/billing/helpers/updateUserSubscription.js';
|
|
19
19
|
import { handleError } from '../../shared/errorHandling.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
stripe,
|
|
22
|
+
validateStripeEnvironment,
|
|
23
|
+
initStripe,
|
|
24
|
+
} from '../../shared/utils.js';
|
|
21
25
|
import { createBaseFunction } from '../baseFunction.js';
|
|
22
26
|
import { STRIPE_CONFIG } from '../config/constants.js';
|
|
23
27
|
import { stripeSecretKey } from '../config/secrets.js';
|
|
@@ -63,7 +63,6 @@ async function createCheckoutSessionLogic(
|
|
|
63
63
|
|
|
64
64
|
const {
|
|
65
65
|
priceId,
|
|
66
|
-
userId,
|
|
67
66
|
customerEmail,
|
|
68
67
|
metadata = {},
|
|
69
68
|
successUrl,
|
|
@@ -72,6 +71,9 @@ async function createCheckoutSessionLogic(
|
|
|
72
71
|
mode = 'payment',
|
|
73
72
|
} = data;
|
|
74
73
|
|
|
74
|
+
// Use authenticated uid from context — client no longer sends userId
|
|
75
|
+
const userId = context.uid;
|
|
76
|
+
|
|
75
77
|
logger.debug('[createCheckoutSession] Processing request', {
|
|
76
78
|
priceId,
|
|
77
79
|
userId,
|
|
@@ -70,10 +70,14 @@ async function createCustomerPortalLogic(
|
|
|
70
70
|
// Use caller-supplied returnUrl or derive from FRONTEND_URL env var.
|
|
71
71
|
const resolvedReturnUrl =
|
|
72
72
|
returnUrl ??
|
|
73
|
-
(process.env.FRONTEND_URL
|
|
73
|
+
(process.env.FRONTEND_URL
|
|
74
|
+
? `${process.env.FRONTEND_URL}/dashboard`
|
|
75
|
+
: undefined);
|
|
74
76
|
|
|
75
77
|
if (!resolvedReturnUrl) {
|
|
76
|
-
throw handleError(
|
|
78
|
+
throw handleError(
|
|
79
|
+
new Error('returnUrl is required (or set FRONTEND_URL env var)')
|
|
80
|
+
);
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
// Create portal session
|
|
@@ -102,7 +102,10 @@ export function createStripeWebhook(
|
|
|
102
102
|
async getUser(userId: string) {
|
|
103
103
|
return getFirebaseAdminAuth().getUser(userId);
|
|
104
104
|
},
|
|
105
|
-
async setCustomUserClaims(
|
|
105
|
+
async setCustomUserClaims(
|
|
106
|
+
userId: string,
|
|
107
|
+
claims: Record<string, unknown>
|
|
108
|
+
) {
|
|
106
109
|
await getFirebaseAdminAuth().setCustomUserClaims(userId, claims);
|
|
107
110
|
},
|
|
108
111
|
};
|
|
@@ -220,7 +220,11 @@ function aggregateEntitiesLogicFactory(
|
|
|
220
220
|
) {
|
|
221
221
|
return async function aggregateEntitiesLogic(
|
|
222
222
|
data: AggregateRequest,
|
|
223
|
-
context: {
|
|
223
|
+
context: {
|
|
224
|
+
uid: string;
|
|
225
|
+
userRole: UserRole;
|
|
226
|
+
request: CallableRequest<AggregateRequest>;
|
|
227
|
+
}
|
|
224
228
|
) {
|
|
225
229
|
const db = getFirebaseAdminFirestore();
|
|
226
230
|
const { userRole } = context;
|
|
@@ -160,11 +160,21 @@ function createEntityLogicFactory(
|
|
|
160
160
|
|
|
161
161
|
// W17: Validate idempotency key length and content.
|
|
162
162
|
if (idempotencyKey !== undefined) {
|
|
163
|
-
if (
|
|
164
|
-
|
|
163
|
+
if (
|
|
164
|
+
typeof idempotencyKey !== 'string' ||
|
|
165
|
+
idempotencyKey.length === 0 ||
|
|
166
|
+
idempotencyKey.length > 256
|
|
167
|
+
) {
|
|
168
|
+
throw new DoNotDevError(
|
|
169
|
+
'idempotencyKey must be a non-empty string of at most 256 characters',
|
|
170
|
+
'invalid-argument'
|
|
171
|
+
);
|
|
165
172
|
}
|
|
166
173
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
167
|
-
throw new DoNotDevError(
|
|
174
|
+
throw new DoNotDevError(
|
|
175
|
+
'idempotencyKey contains invalid characters',
|
|
176
|
+
'invalid-argument'
|
|
177
|
+
);
|
|
168
178
|
}
|
|
169
179
|
}
|
|
170
180
|
|
|
@@ -187,7 +197,10 @@ function createEntityLogicFactory(
|
|
|
187
197
|
alreadyProcessed = true;
|
|
188
198
|
return;
|
|
189
199
|
}
|
|
190
|
-
tx.set(idempotencyRef, {
|
|
200
|
+
tx.set(idempotencyRef, {
|
|
201
|
+
processing: true,
|
|
202
|
+
reservedAt: new Date().toISOString(),
|
|
203
|
+
});
|
|
191
204
|
});
|
|
192
205
|
|
|
193
206
|
if (alreadyProcessed) {
|
|
@@ -115,13 +115,44 @@ function listEntitiesLogicFactory(
|
|
|
115
115
|
// Apply search if provided
|
|
116
116
|
if (search) {
|
|
117
117
|
const { field, query: searchQuery } = search;
|
|
118
|
+
// Validate search.field against entity schema (listFields as allowlist)
|
|
119
|
+
if (listFields && listFields.length > 0) {
|
|
120
|
+
if (!listFields.includes(field)) {
|
|
121
|
+
throw new DoNotDevError(
|
|
122
|
+
`Search field '${field}' is not allowed`,
|
|
123
|
+
'invalid-argument'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} else if (field.startsWith('_') || field.includes('.')) {
|
|
127
|
+
throw new DoNotDevError(
|
|
128
|
+
`Search field '${field}' is not allowed`,
|
|
129
|
+
'invalid-argument'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
118
132
|
query = query
|
|
119
133
|
.where(field, '>=', searchQuery)
|
|
120
134
|
.where(field, '<=', searchQuery + '\uf8ff');
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
// Apply where clauses for filtering
|
|
137
|
+
// Apply where clauses for filtering — validate field names against entity schema
|
|
124
138
|
for (const [field, operator, value] of where) {
|
|
139
|
+
if (listFields && listFields.length > 0) {
|
|
140
|
+
if (
|
|
141
|
+
!listFields.includes(field) &&
|
|
142
|
+
field !== 'status' &&
|
|
143
|
+
field !== 'id'
|
|
144
|
+
) {
|
|
145
|
+
throw new DoNotDevError(
|
|
146
|
+
`Where field '${field}' is not allowed`,
|
|
147
|
+
'invalid-argument'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} else if (field.startsWith('_') || field.includes('.')) {
|
|
151
|
+
throw new DoNotDevError(
|
|
152
|
+
`Where field '${field}' is not allowed`,
|
|
153
|
+
'invalid-argument'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
125
156
|
query = query.where(field, operator, value);
|
|
126
157
|
}
|
|
127
158
|
|
|
@@ -146,9 +177,10 @@ function listEntitiesLogicFactory(
|
|
|
146
177
|
|
|
147
178
|
// W13: Cap at MAX_LIST_LIMIT to prevent unbounded reads (DoS via cost amplification).
|
|
148
179
|
const MAX_LIST_LIMIT = 500;
|
|
149
|
-
const effectiveLimit =
|
|
150
|
-
|
|
151
|
-
|
|
180
|
+
const effectiveLimit =
|
|
181
|
+
limit !== undefined && limit > 0
|
|
182
|
+
? Math.min(limit, MAX_LIST_LIMIT)
|
|
183
|
+
: MAX_LIST_LIMIT;
|
|
152
184
|
query = query.limit(effectiveLimit);
|
|
153
185
|
|
|
154
186
|
// Execute the query
|
|
@@ -225,7 +257,7 @@ function listEntitiesLogicFactory(
|
|
|
225
257
|
items: transformFirestoreData(docs),
|
|
226
258
|
lastVisible: snapshot.docs[snapshot.docs.length - 1]?.id || null,
|
|
227
259
|
count: snapshot.docs.length,
|
|
228
|
-
hasMore: snapshot.docs.length ===
|
|
260
|
+
hasMore: snapshot.docs.length === effectiveLimit,
|
|
229
261
|
};
|
|
230
262
|
};
|
|
231
263
|
}
|
|
@@ -153,11 +153,21 @@ function updateEntityLogicFactory(
|
|
|
153
153
|
|
|
154
154
|
// W17: Validate idempotency key length and content.
|
|
155
155
|
if (idempotencyKey !== undefined) {
|
|
156
|
-
if (
|
|
157
|
-
|
|
156
|
+
if (
|
|
157
|
+
typeof idempotencyKey !== 'string' ||
|
|
158
|
+
idempotencyKey.length === 0 ||
|
|
159
|
+
idempotencyKey.length > 256
|
|
160
|
+
) {
|
|
161
|
+
throw new DoNotDevError(
|
|
162
|
+
'idempotencyKey must be a non-empty string of at most 256 characters',
|
|
163
|
+
'invalid-argument'
|
|
164
|
+
);
|
|
158
165
|
}
|
|
159
166
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
160
|
-
throw new DoNotDevError(
|
|
167
|
+
throw new DoNotDevError(
|
|
168
|
+
'idempotencyKey contains invalid characters',
|
|
169
|
+
'invalid-argument'
|
|
170
|
+
);
|
|
161
171
|
}
|
|
162
172
|
}
|
|
163
173
|
|
|
@@ -178,7 +188,10 @@ function updateEntityLogicFactory(
|
|
|
178
188
|
alreadyProcessed = true;
|
|
179
189
|
return;
|
|
180
190
|
}
|
|
181
|
-
tx.set(idempotencyRef, {
|
|
191
|
+
tx.set(idempotencyRef, {
|
|
192
|
+
processing: true,
|
|
193
|
+
reservedAt: new Date().toISOString(),
|
|
194
|
+
});
|
|
182
195
|
});
|
|
183
196
|
|
|
184
197
|
if (alreadyProcessed) {
|
|
@@ -39,11 +39,21 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
|
|
|
39
39
|
|
|
40
40
|
// W17: Validate idempotency key length and content.
|
|
41
41
|
if (idempotencyKey !== undefined) {
|
|
42
|
-
if (
|
|
43
|
-
|
|
42
|
+
if (
|
|
43
|
+
typeof idempotencyKey !== 'string' ||
|
|
44
|
+
idempotencyKey.length === 0 ||
|
|
45
|
+
idempotencyKey.length > 256
|
|
46
|
+
) {
|
|
47
|
+
throw new HttpsError(
|
|
48
|
+
'invalid-argument',
|
|
49
|
+
'idempotencyKey must be a non-empty string of at most 256 characters'
|
|
50
|
+
);
|
|
44
51
|
}
|
|
45
52
|
if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
|
|
46
|
-
throw new HttpsError(
|
|
53
|
+
throw new HttpsError(
|
|
54
|
+
'invalid-argument',
|
|
55
|
+
'idempotencyKey contains invalid characters'
|
|
56
|
+
);
|
|
47
57
|
}
|
|
48
58
|
}
|
|
49
59
|
|
|
@@ -67,7 +77,10 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
|
|
|
67
77
|
return;
|
|
68
78
|
}
|
|
69
79
|
// Reserve the key before executing business logic
|
|
70
|
-
tx.set(idempotencyRef, {
|
|
80
|
+
tx.set(idempotencyRef, {
|
|
81
|
+
processing: true,
|
|
82
|
+
reservedAt: new Date().toISOString(),
|
|
83
|
+
});
|
|
71
84
|
});
|
|
72
85
|
|
|
73
86
|
if (alreadyProcessed) {
|
|
@@ -108,7 +108,9 @@ describe('validateUrl', () => {
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
it('includes custom name in error message', () => {
|
|
111
|
-
expect(() => validateUrl('bad', 'Success URL')).toThrow(
|
|
111
|
+
expect(() => validateUrl('bad', 'Success URL')).toThrow(
|
|
112
|
+
'Invalid Success URL'
|
|
113
|
+
);
|
|
112
114
|
});
|
|
113
115
|
});
|
|
114
116
|
|
|
@@ -121,10 +123,10 @@ describe('validateMetadata', () => {
|
|
|
121
123
|
|
|
122
124
|
it('rejects non-string values', () => {
|
|
123
125
|
expect(() => validateMetadata({ key: 123 as any })).toThrow(
|
|
124
|
-
|
|
126
|
+
'must be a string'
|
|
125
127
|
);
|
|
126
128
|
expect(() => validateMetadata({ key: true as any })).toThrow(
|
|
127
|
-
|
|
129
|
+
'must be a string'
|
|
128
130
|
);
|
|
129
131
|
});
|
|
130
132
|
|