@jskit-ai/rewarded-core 0.1.120
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 +149 -0
- package/docs/protecting-server-actions.md +190 -0
- package/migrations/rewarded_provider_configs_initial.cjs +27 -0
- package/migrations/rewarded_rules_initial.cjs +31 -0
- package/migrations/rewarded_unlock_receipts_initial.cjs +35 -0
- package/migrations/rewarded_watch_sessions_initial.cjs +36 -0
- package/package.json +130 -0
- package/src/server/RewardedCoreProvider.js +44 -0
- package/src/server/RewardedResources.js +69 -0
- package/src/server/actions.js +152 -0
- package/src/server/inputSchemas.js +394 -0
- package/src/server/registerRoutes.js +167 -0
- package/src/server/service.js +566 -0
- package/src/server/support/requireRewardedUnlock.js +142 -0
- package/src/shared/index.js +12 -0
- package/src/shared/rewardedProviderConfigResource.js +95 -0
- package/src/shared/rewardedRuleResource.js +136 -0
- package/src/shared/rewardedUnlockReceiptResource.js +113 -0
- package/src/shared/rewardedWatchSessionResource.js +139 -0
- package/test/featureRuntime.test.js +91 -0
- package/test/requireRewardedUnlock.test.js +274 -0
- package/test/routes.test.js +239 -0
- package/test/service.test.js +357 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { requireRewardedUnlock } from "../src/server/support/requireRewardedUnlock.js";
|
|
5
|
+
|
|
6
|
+
function createRewardedService(gateState) {
|
|
7
|
+
const calls = [];
|
|
8
|
+
return {
|
|
9
|
+
calls,
|
|
10
|
+
async getCurrentState(input, options = {}) {
|
|
11
|
+
calls.push({
|
|
12
|
+
input,
|
|
13
|
+
options
|
|
14
|
+
});
|
|
15
|
+
return gateState;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const REQUEST_CONTEXT = Object.freeze({
|
|
21
|
+
actor: {
|
|
22
|
+
id: "7"
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("requireRewardedUnlock allows an already unlocked gate", async () => {
|
|
27
|
+
const gateState = {
|
|
28
|
+
gateKey: "progress-logging",
|
|
29
|
+
workspaceSlug: "alpha",
|
|
30
|
+
surface: "app",
|
|
31
|
+
enabled: true,
|
|
32
|
+
available: true,
|
|
33
|
+
blocked: false,
|
|
34
|
+
reason: "already-unlocked",
|
|
35
|
+
unlock: {
|
|
36
|
+
id: "31"
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const rewardedService = createRewardedService(gateState);
|
|
40
|
+
|
|
41
|
+
const result = await requireRewardedUnlock(
|
|
42
|
+
rewardedService,
|
|
43
|
+
{
|
|
44
|
+
gateKey: "progress-logging",
|
|
45
|
+
workspaceSlug: "alpha"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
context: REQUEST_CONTEXT
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.equal(result, gateState);
|
|
53
|
+
assert.deepEqual(rewardedService.calls[0], {
|
|
54
|
+
input: {
|
|
55
|
+
gateKey: "progress-logging",
|
|
56
|
+
workspaceSlug: "alpha",
|
|
57
|
+
surface: "app"
|
|
58
|
+
},
|
|
59
|
+
options: {
|
|
60
|
+
context: REQUEST_CONTEXT
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("requireRewardedUnlock bypasses missing configuration by default", async () => {
|
|
66
|
+
const gateState = {
|
|
67
|
+
gateKey: "progress-logging",
|
|
68
|
+
workspaceSlug: "alpha",
|
|
69
|
+
surface: "app",
|
|
70
|
+
enabled: false,
|
|
71
|
+
available: false,
|
|
72
|
+
blocked: false,
|
|
73
|
+
reason: "rule-not-configured",
|
|
74
|
+
unlock: null
|
|
75
|
+
};
|
|
76
|
+
const rewardedService = createRewardedService(gateState);
|
|
77
|
+
|
|
78
|
+
const result = await requireRewardedUnlock(
|
|
79
|
+
rewardedService,
|
|
80
|
+
{
|
|
81
|
+
gateKey: "progress-logging",
|
|
82
|
+
workspaceSlug: "alpha"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
context: REQUEST_CONTEXT
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
assert.equal(result, gateState);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("requireRewardedUnlock can fail closed when configuration is required", async () => {
|
|
93
|
+
const gateState = {
|
|
94
|
+
gateKey: "progress-logging",
|
|
95
|
+
workspaceSlug: "alpha",
|
|
96
|
+
surface: "app",
|
|
97
|
+
enabled: false,
|
|
98
|
+
available: false,
|
|
99
|
+
blocked: false,
|
|
100
|
+
reason: "provider-not-configured",
|
|
101
|
+
unlock: null
|
|
102
|
+
};
|
|
103
|
+
const rewardedService = createRewardedService(gateState);
|
|
104
|
+
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
() => requireRewardedUnlock(
|
|
107
|
+
rewardedService,
|
|
108
|
+
{
|
|
109
|
+
gateKey: "progress-logging",
|
|
110
|
+
workspaceSlug: "alpha"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
context: REQUEST_CONTEXT,
|
|
114
|
+
requireConfigured: true
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
(error) => {
|
|
118
|
+
assert.equal(error.statusCode, 503);
|
|
119
|
+
assert.equal(error.code, "rewarded_not_configured");
|
|
120
|
+
assert.equal(error.details.rewardedGate, gateState);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("requireRewardedUnlock rejects when a reward is still required", async () => {
|
|
127
|
+
const gateState = {
|
|
128
|
+
gateKey: "progress-logging",
|
|
129
|
+
workspaceSlug: "alpha",
|
|
130
|
+
surface: "app",
|
|
131
|
+
enabled: true,
|
|
132
|
+
available: true,
|
|
133
|
+
blocked: true,
|
|
134
|
+
reason: "reward-required",
|
|
135
|
+
unlock: null
|
|
136
|
+
};
|
|
137
|
+
const rewardedService = createRewardedService(gateState);
|
|
138
|
+
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
() => requireRewardedUnlock(
|
|
141
|
+
rewardedService,
|
|
142
|
+
{
|
|
143
|
+
gateKey: "progress-logging",
|
|
144
|
+
workspaceSlug: "alpha"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
context: REQUEST_CONTEXT,
|
|
148
|
+
errorMessage: "Watch a rewarded ad before logging progress."
|
|
149
|
+
}
|
|
150
|
+
),
|
|
151
|
+
(error) => {
|
|
152
|
+
assert.equal(error.statusCode, 423);
|
|
153
|
+
assert.equal(error.code, "rewarded_unlock_required");
|
|
154
|
+
assert.equal(error.message, "Watch a rewarded ad before logging progress.");
|
|
155
|
+
assert.equal(error.details.rewardedGate, gateState);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("requireRewardedUnlock rejects cooldown and daily-limit states", async () => {
|
|
162
|
+
const cooldownService = createRewardedService({
|
|
163
|
+
gateKey: "progress-logging",
|
|
164
|
+
workspaceSlug: "alpha",
|
|
165
|
+
surface: "app",
|
|
166
|
+
enabled: true,
|
|
167
|
+
available: false,
|
|
168
|
+
blocked: false,
|
|
169
|
+
reason: "cooldown-active",
|
|
170
|
+
unlock: null
|
|
171
|
+
});
|
|
172
|
+
const dailyLimitService = createRewardedService({
|
|
173
|
+
gateKey: "progress-logging",
|
|
174
|
+
workspaceSlug: "alpha",
|
|
175
|
+
surface: "app",
|
|
176
|
+
enabled: true,
|
|
177
|
+
available: false,
|
|
178
|
+
blocked: false,
|
|
179
|
+
reason: "daily-limit-reached",
|
|
180
|
+
unlock: null
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await assert.rejects(
|
|
184
|
+
() => requireRewardedUnlock(
|
|
185
|
+
cooldownService,
|
|
186
|
+
{
|
|
187
|
+
gateKey: "progress-logging",
|
|
188
|
+
workspaceSlug: "alpha"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
context: REQUEST_CONTEXT
|
|
192
|
+
}
|
|
193
|
+
),
|
|
194
|
+
(error) => {
|
|
195
|
+
assert.equal(error.statusCode, 423);
|
|
196
|
+
assert.equal(error.code, "rewarded_cooldown_active");
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await assert.rejects(
|
|
202
|
+
() => requireRewardedUnlock(
|
|
203
|
+
dailyLimitService,
|
|
204
|
+
{
|
|
205
|
+
gateKey: "progress-logging",
|
|
206
|
+
workspaceSlug: "alpha"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
context: REQUEST_CONTEXT
|
|
210
|
+
}
|
|
211
|
+
),
|
|
212
|
+
(error) => {
|
|
213
|
+
assert.equal(error.statusCode, 423);
|
|
214
|
+
assert.equal(error.code, "rewarded_daily_limit_reached");
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("requireRewardedUnlock fails closed when the rewarded service returns an invalid gate state", async () => {
|
|
221
|
+
const rewardedService = createRewardedService({
|
|
222
|
+
gateKey: "progress-logging",
|
|
223
|
+
workspaceSlug: "alpha"
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await assert.rejects(
|
|
227
|
+
() => requireRewardedUnlock(
|
|
228
|
+
rewardedService,
|
|
229
|
+
{
|
|
230
|
+
gateKey: "progress-logging",
|
|
231
|
+
workspaceSlug: "alpha"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
context: REQUEST_CONTEXT
|
|
235
|
+
}
|
|
236
|
+
),
|
|
237
|
+
(error) => {
|
|
238
|
+
assert.equal(error.statusCode, 503);
|
|
239
|
+
assert.equal(error.code, "rewarded_gate_state_invalid");
|
|
240
|
+
assert.deepEqual(error.details.rewardedGate, {
|
|
241
|
+
gateKey: "progress-logging",
|
|
242
|
+
workspaceSlug: "alpha"
|
|
243
|
+
});
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("requireRewardedUnlock fails closed when a non-blocking gate state has no explicit reason", async () => {
|
|
250
|
+
const rewardedService = createRewardedService({
|
|
251
|
+
gateKey: "progress-logging",
|
|
252
|
+
workspaceSlug: "alpha",
|
|
253
|
+
enabled: false,
|
|
254
|
+
blocked: false
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await assert.rejects(
|
|
258
|
+
() => requireRewardedUnlock(
|
|
259
|
+
rewardedService,
|
|
260
|
+
{
|
|
261
|
+
gateKey: "progress-logging",
|
|
262
|
+
workspaceSlug: "alpha"
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
context: REQUEST_CONTEXT
|
|
266
|
+
}
|
|
267
|
+
),
|
|
268
|
+
(error) => {
|
|
269
|
+
assert.equal(error.statusCode, 503);
|
|
270
|
+
assert.equal(error.code, "rewarded_gate_state_invalid");
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { registerRoutes } from "../src/server/registerRoutes.js";
|
|
5
|
+
|
|
6
|
+
function createReplyDouble() {
|
|
7
|
+
return {
|
|
8
|
+
statusCode: 200,
|
|
9
|
+
payload: null,
|
|
10
|
+
code(value) {
|
|
11
|
+
this.statusCode = value;
|
|
12
|
+
return this;
|
|
13
|
+
},
|
|
14
|
+
send(value) {
|
|
15
|
+
this.payload = value;
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findRoute(routes, { method, path }) {
|
|
22
|
+
return routes.find((route) => route.method === method && route.path === path) || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mountRoutes() {
|
|
26
|
+
const registeredRoutes = [];
|
|
27
|
+
const router = {
|
|
28
|
+
register(method, path, route, handler) {
|
|
29
|
+
registeredRoutes.push({
|
|
30
|
+
...route,
|
|
31
|
+
method,
|
|
32
|
+
path,
|
|
33
|
+
handler
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
registerRoutes(router);
|
|
39
|
+
return registeredRoutes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createActionRequest({ input = {}, executeAction }) {
|
|
43
|
+
return {
|
|
44
|
+
input,
|
|
45
|
+
executeAction
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("google rewarded workflow routes mount with explicit request and response contracts", () => {
|
|
50
|
+
const routes = mountRoutes();
|
|
51
|
+
const currentRoute = findRoute(routes, {
|
|
52
|
+
method: "GET",
|
|
53
|
+
path: "/api/w/:workspaceSlug/rewarded/current"
|
|
54
|
+
});
|
|
55
|
+
const startRoute = findRoute(routes, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
path: "/api/w/:workspaceSlug/rewarded/start"
|
|
58
|
+
});
|
|
59
|
+
const grantRoute = findRoute(routes, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
path: "/api/w/:workspaceSlug/rewarded/grant"
|
|
62
|
+
});
|
|
63
|
+
const closeRoute = findRoute(routes, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
path: "/api/w/:workspaceSlug/rewarded/close"
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.equal(currentRoute?.auth, "required");
|
|
69
|
+
assert.equal(currentRoute?.visibility, "workspace_user");
|
|
70
|
+
assert.equal(currentRoute?.surface, "app");
|
|
71
|
+
assert.equal(typeof currentRoute?.query?.schema, "object");
|
|
72
|
+
assert.equal(currentRoute?.responses?.[200]?.transportSchema?.properties?.blocked?.["x-json-rest-schema"]?.castType, "boolean");
|
|
73
|
+
|
|
74
|
+
assert.equal(startRoute?.auth, "required");
|
|
75
|
+
assert.equal(startRoute?.visibility, "workspace_user");
|
|
76
|
+
assert.equal(typeof startRoute?.body?.schema, "object");
|
|
77
|
+
assert.equal(startRoute?.responses?.[200]?.transportSchema?.properties?.session?.anyOf?.[1]?.type, "null");
|
|
78
|
+
|
|
79
|
+
assert.equal(grantRoute?.responses?.[200]?.transportSchema?.properties?.unlocked?.["x-json-rest-schema"]?.castType, "boolean");
|
|
80
|
+
assert.equal(closeRoute?.responses?.[200]?.transportSchema?.properties?.closed?.["x-json-rest-schema"]?.castType, "boolean");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("google rewarded workflow route handlers build action input from request.input", async () => {
|
|
84
|
+
const routes = mountRoutes();
|
|
85
|
+
const calls = [];
|
|
86
|
+
const executeAction = async (payload) => {
|
|
87
|
+
calls.push(payload);
|
|
88
|
+
if (payload.actionId === "rewarded.current.read") {
|
|
89
|
+
return {
|
|
90
|
+
gateKey: "progress-logging",
|
|
91
|
+
workspaceSlug: "alpha",
|
|
92
|
+
surface: "app",
|
|
93
|
+
enabled: true,
|
|
94
|
+
available: true,
|
|
95
|
+
blocked: true,
|
|
96
|
+
reason: "reward-required",
|
|
97
|
+
rule: null,
|
|
98
|
+
providerConfig: null,
|
|
99
|
+
unlock: null,
|
|
100
|
+
cooldownUntil: null,
|
|
101
|
+
dailyLimitRemaining: null
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (payload.actionId === "rewarded.start") {
|
|
105
|
+
return {
|
|
106
|
+
gateKey: "progress-logging",
|
|
107
|
+
workspaceSlug: "alpha",
|
|
108
|
+
surface: "app",
|
|
109
|
+
enabled: true,
|
|
110
|
+
available: true,
|
|
111
|
+
blocked: true,
|
|
112
|
+
reason: "reward-required",
|
|
113
|
+
rule: null,
|
|
114
|
+
providerConfig: null,
|
|
115
|
+
unlock: null,
|
|
116
|
+
cooldownUntil: null,
|
|
117
|
+
dailyLimitRemaining: null,
|
|
118
|
+
session: null
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (payload.actionId === "rewarded.grant") {
|
|
122
|
+
return {
|
|
123
|
+
unlocked: true,
|
|
124
|
+
workspaceSlug: "alpha",
|
|
125
|
+
gateKey: "progress-logging",
|
|
126
|
+
unlock: null,
|
|
127
|
+
session: null
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
closed: true,
|
|
132
|
+
workspaceSlug: "alpha",
|
|
133
|
+
gateKey: "progress-logging",
|
|
134
|
+
session: null,
|
|
135
|
+
reason: null
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
await findRoute(routes, {
|
|
140
|
+
method: "GET",
|
|
141
|
+
path: "/api/w/:workspaceSlug/rewarded/current"
|
|
142
|
+
}).handler(
|
|
143
|
+
createActionRequest({
|
|
144
|
+
input: {
|
|
145
|
+
params: {
|
|
146
|
+
workspaceSlug: "Alpha"
|
|
147
|
+
},
|
|
148
|
+
query: {
|
|
149
|
+
gateKey: "progress-logging"
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
executeAction
|
|
153
|
+
}),
|
|
154
|
+
createReplyDouble()
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await findRoute(routes, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
path: "/api/w/:workspaceSlug/rewarded/start"
|
|
160
|
+
}).handler(
|
|
161
|
+
createActionRequest({
|
|
162
|
+
input: {
|
|
163
|
+
params: {
|
|
164
|
+
workspaceSlug: "Alpha"
|
|
165
|
+
},
|
|
166
|
+
body: {
|
|
167
|
+
gateKey: "progress-logging"
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
executeAction
|
|
171
|
+
}),
|
|
172
|
+
createReplyDouble()
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await findRoute(routes, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
path: "/api/w/:workspaceSlug/rewarded/grant"
|
|
178
|
+
}).handler(
|
|
179
|
+
createActionRequest({
|
|
180
|
+
input: {
|
|
181
|
+
params: {
|
|
182
|
+
workspaceSlug: "Alpha"
|
|
183
|
+
},
|
|
184
|
+
body: {
|
|
185
|
+
sessionId: "41"
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
executeAction
|
|
189
|
+
}),
|
|
190
|
+
createReplyDouble()
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
await findRoute(routes, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
path: "/api/w/:workspaceSlug/rewarded/close"
|
|
196
|
+
}).handler(
|
|
197
|
+
createActionRequest({
|
|
198
|
+
input: {
|
|
199
|
+
params: {
|
|
200
|
+
workspaceSlug: "Alpha"
|
|
201
|
+
},
|
|
202
|
+
body: {
|
|
203
|
+
sessionId: "41"
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
executeAction
|
|
207
|
+
}),
|
|
208
|
+
createReplyDouble()
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.deepEqual(calls[0], {
|
|
212
|
+
actionId: "rewarded.current.read",
|
|
213
|
+
input: {
|
|
214
|
+
workspaceSlug: "alpha",
|
|
215
|
+
gateKey: "progress-logging"
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
assert.deepEqual(calls[1], {
|
|
219
|
+
actionId: "rewarded.start",
|
|
220
|
+
input: {
|
|
221
|
+
workspaceSlug: "alpha",
|
|
222
|
+
gateKey: "progress-logging"
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
assert.deepEqual(calls[2], {
|
|
226
|
+
actionId: "rewarded.grant",
|
|
227
|
+
input: {
|
|
228
|
+
workspaceSlug: "alpha",
|
|
229
|
+
sessionId: "41"
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
assert.deepEqual(calls[3], {
|
|
233
|
+
actionId: "rewarded.close",
|
|
234
|
+
input: {
|
|
235
|
+
workspaceSlug: "alpha",
|
|
236
|
+
sessionId: "41"
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|