@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,566 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
import { simplifyJsonApiDocument } from "@jskit-ai/http-runtime/shared";
|
|
4
|
+
|
|
5
|
+
const REWARDED_SURFACE = "app";
|
|
6
|
+
|
|
7
|
+
function resolveActionUser(context, input) {
|
|
8
|
+
const payload = normalizeObject(input);
|
|
9
|
+
const request = context?.requestMeta?.request || null;
|
|
10
|
+
return payload.user || request?.user || context?.actor || null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeRecordList(document = null) {
|
|
14
|
+
const simplified = simplifyJsonApiDocument(document);
|
|
15
|
+
return Array.isArray(simplified) ? simplified.filter(Boolean) : [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeRecord(document = null) {
|
|
19
|
+
const simplified = simplifyJsonApiDocument(document);
|
|
20
|
+
return simplified && typeof simplified === "object" && !Array.isArray(simplified) ? simplified : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeGateKey(value = "") {
|
|
24
|
+
return normalizeText(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeSurface(value = "", { fallback = REWARDED_SURFACE } = {}) {
|
|
28
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
29
|
+
return normalized || fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toIsoOrNull(value = null) {
|
|
33
|
+
if (!value) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
37
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseDate(value = null) {
|
|
41
|
+
if (!value) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
45
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function addMinutes(date, minutes) {
|
|
49
|
+
const amount = Number(minutes || 0);
|
|
50
|
+
return new Date(date.getTime() + Math.max(0, amount) * 60 * 1000);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function startOfUtcDay(date) {
|
|
54
|
+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requireActor(context, input) {
|
|
58
|
+
const actor = resolveActionUser(context, input);
|
|
59
|
+
const actorId = actor?.id == null ? "" : String(actor.id).trim();
|
|
60
|
+
if (!actorId) {
|
|
61
|
+
throw new AppError(401, "Authentication required.");
|
|
62
|
+
}
|
|
63
|
+
return actor;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function requireWorkspaceSlug(input = {}) {
|
|
67
|
+
const workspaceSlug = normalizeText(input?.workspaceSlug).toLowerCase();
|
|
68
|
+
if (!workspaceSlug) {
|
|
69
|
+
throw new AppError(400, "workspaceSlug is required.");
|
|
70
|
+
}
|
|
71
|
+
return workspaceSlug;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requireGateKey(input = {}) {
|
|
75
|
+
const gateKey = normalizeGateKey(input?.gateKey);
|
|
76
|
+
if (!gateKey) {
|
|
77
|
+
throw new AppError(400, "gateKey is required.");
|
|
78
|
+
}
|
|
79
|
+
return gateKey;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatRule(rule = null) {
|
|
83
|
+
if (!rule) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: rule.id || null,
|
|
89
|
+
gateKey: normalizeGateKey(rule.gateKey),
|
|
90
|
+
surface: normalizeSurface(rule.surface, { fallback: REWARDED_SURFACE }),
|
|
91
|
+
enabled: rule.enabled === true,
|
|
92
|
+
unlockMinutes: Number(rule.unlockMinutes || 0),
|
|
93
|
+
cooldownMinutes: Number(rule.cooldownMinutes || 0),
|
|
94
|
+
dailyLimit: rule.dailyLimit == null ? null : Number(rule.dailyLimit),
|
|
95
|
+
title: normalizeText(rule.title),
|
|
96
|
+
description: normalizeText(rule.description)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatProviderConfig(record = null) {
|
|
101
|
+
if (!record) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: record.id || null,
|
|
107
|
+
surface: normalizeSurface(record.surface, { fallback: REWARDED_SURFACE }),
|
|
108
|
+
enabled: record.enabled === true,
|
|
109
|
+
placement: normalizeText(record.placement),
|
|
110
|
+
provider: normalizeText(record.provider)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatWatchSession(record = null) {
|
|
115
|
+
if (!record) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
id: record.id || null,
|
|
121
|
+
gateKey: normalizeGateKey(record.gateKey),
|
|
122
|
+
providerConfigId: record.providerConfigId || null,
|
|
123
|
+
status: normalizeSurface(record.status, { fallback: "started" }),
|
|
124
|
+
startedAt: toIsoOrNull(record.startedAt),
|
|
125
|
+
rewardedAt: toIsoOrNull(record.rewardedAt),
|
|
126
|
+
completedAt: toIsoOrNull(record.completedAt),
|
|
127
|
+
closedAt: toIsoOrNull(record.closedAt)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatUnlockReceipt(record = null) {
|
|
132
|
+
if (!record) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
id: record.id || null,
|
|
138
|
+
gateKey: normalizeGateKey(record.gateKey),
|
|
139
|
+
providerConfigId: record.providerConfigId || null,
|
|
140
|
+
watchSessionId: record.watchSessionId || null,
|
|
141
|
+
grantedAt: toIsoOrNull(record.grantedAt),
|
|
142
|
+
unlockedUntil: toIsoOrNull(record.unlockedUntil)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isFutureDate(value = null, now = new Date()) {
|
|
147
|
+
const date = parseDate(value);
|
|
148
|
+
return !!date && date.getTime() > now.getTime();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createService({
|
|
152
|
+
authorizeGrant,
|
|
153
|
+
rewardedRulesRepository,
|
|
154
|
+
rewardedProviderConfigsRepository,
|
|
155
|
+
rewardedWatchSessionsRepository,
|
|
156
|
+
rewardedUnlockReceiptsRepository
|
|
157
|
+
} = {}) {
|
|
158
|
+
if (typeof authorizeGrant !== "function") {
|
|
159
|
+
throw new TypeError("createService requires application authorizeGrant policy.");
|
|
160
|
+
}
|
|
161
|
+
if (!rewardedRulesRepository) {
|
|
162
|
+
throw new TypeError("createService requires rewardedRulesRepository.");
|
|
163
|
+
}
|
|
164
|
+
if (!rewardedProviderConfigsRepository) {
|
|
165
|
+
throw new TypeError("createService requires rewardedProviderConfigsRepository.");
|
|
166
|
+
}
|
|
167
|
+
if (!rewardedWatchSessionsRepository) {
|
|
168
|
+
throw new TypeError("createService requires rewardedWatchSessionsRepository.");
|
|
169
|
+
}
|
|
170
|
+
if (!rewardedUnlockReceiptsRepository) {
|
|
171
|
+
throw new TypeError("createService requires rewardedUnlockReceiptsRepository.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function queryFirst(repository, query = {}, options = {}) {
|
|
175
|
+
const rows = normalizeRecordList(await repository.queryDocuments(query, options));
|
|
176
|
+
return rows[0] || null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function listRecords(repository, query = {}, options = {}) {
|
|
180
|
+
return normalizeRecordList(await repository.queryDocuments(query, options));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function resolveRule({ gateKey, surface, context, trx }) {
|
|
184
|
+
const filters = {
|
|
185
|
+
gateKey,
|
|
186
|
+
enabled: "true"
|
|
187
|
+
};
|
|
188
|
+
if (surface) {
|
|
189
|
+
filters.surface = surface;
|
|
190
|
+
}
|
|
191
|
+
return queryFirst(rewardedRulesRepository, {
|
|
192
|
+
...filters,
|
|
193
|
+
sort: ["-updatedAt"],
|
|
194
|
+
limit: 1
|
|
195
|
+
}, {
|
|
196
|
+
context,
|
|
197
|
+
trx
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function resolveProviderConfig({ surface, context, trx }) {
|
|
202
|
+
return queryFirst(rewardedProviderConfigsRepository, {
|
|
203
|
+
surface,
|
|
204
|
+
enabled: "true",
|
|
205
|
+
sort: ["-updatedAt"],
|
|
206
|
+
limit: 1
|
|
207
|
+
}, {
|
|
208
|
+
context,
|
|
209
|
+
trx
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function resolveUnlockReceipts({ gateKey, context, trx, limit = 25 }) {
|
|
214
|
+
return listRecords(rewardedUnlockReceiptsRepository, {
|
|
215
|
+
gateKey,
|
|
216
|
+
sort: ["-grantedAt"],
|
|
217
|
+
limit
|
|
218
|
+
}, {
|
|
219
|
+
context,
|
|
220
|
+
trx
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function evaluateGate(input = {}, options = {}) {
|
|
225
|
+
requireActor(options?.context || null, input);
|
|
226
|
+
const workspaceSlug = requireWorkspaceSlug(input);
|
|
227
|
+
const gateKey = requireGateKey(input);
|
|
228
|
+
const surface = REWARDED_SURFACE;
|
|
229
|
+
const now = new Date();
|
|
230
|
+
|
|
231
|
+
const ruleRecord = await resolveRule({
|
|
232
|
+
gateKey,
|
|
233
|
+
surface,
|
|
234
|
+
context: options?.context || null,
|
|
235
|
+
trx: options?.trx || null
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!ruleRecord) {
|
|
239
|
+
return {
|
|
240
|
+
gateKey,
|
|
241
|
+
workspaceSlug,
|
|
242
|
+
surface,
|
|
243
|
+
enabled: false,
|
|
244
|
+
available: false,
|
|
245
|
+
blocked: false,
|
|
246
|
+
reason: "rule-not-configured",
|
|
247
|
+
rule: null,
|
|
248
|
+
providerConfig: null,
|
|
249
|
+
unlock: null,
|
|
250
|
+
cooldownUntil: null,
|
|
251
|
+
dailyLimitRemaining: null
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const providerConfigRecord = await resolveProviderConfig({
|
|
256
|
+
surface,
|
|
257
|
+
context: options?.context || null,
|
|
258
|
+
trx: options?.trx || null
|
|
259
|
+
});
|
|
260
|
+
if (!providerConfigRecord) {
|
|
261
|
+
return {
|
|
262
|
+
gateKey,
|
|
263
|
+
workspaceSlug,
|
|
264
|
+
surface,
|
|
265
|
+
enabled: false,
|
|
266
|
+
available: false,
|
|
267
|
+
blocked: false,
|
|
268
|
+
reason: "provider-not-configured",
|
|
269
|
+
rule: formatRule(ruleRecord),
|
|
270
|
+
providerConfig: null,
|
|
271
|
+
unlock: null,
|
|
272
|
+
cooldownUntil: null,
|
|
273
|
+
dailyLimitRemaining: null
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const rule = formatRule(ruleRecord);
|
|
278
|
+
const providerConfig = formatProviderConfig(providerConfigRecord);
|
|
279
|
+
const receipts = await resolveUnlockReceipts({
|
|
280
|
+
gateKey,
|
|
281
|
+
context: options?.context || null,
|
|
282
|
+
trx: options?.trx || null
|
|
283
|
+
});
|
|
284
|
+
const activeReceiptRecord = receipts.find((entry) => isFutureDate(entry?.unlockedUntil, now)) || null;
|
|
285
|
+
const activeUnlock = formatUnlockReceipt(activeReceiptRecord);
|
|
286
|
+
|
|
287
|
+
if (activeUnlock) {
|
|
288
|
+
return {
|
|
289
|
+
gateKey,
|
|
290
|
+
workspaceSlug,
|
|
291
|
+
surface,
|
|
292
|
+
enabled: true,
|
|
293
|
+
available: true,
|
|
294
|
+
blocked: false,
|
|
295
|
+
reason: "already-unlocked",
|
|
296
|
+
rule,
|
|
297
|
+
providerConfig,
|
|
298
|
+
unlock: activeUnlock,
|
|
299
|
+
cooldownUntil: null,
|
|
300
|
+
dailyLimitRemaining: rule.dailyLimit
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const latestReceipt = receipts[0] || null;
|
|
305
|
+
let cooldownUntil = null;
|
|
306
|
+
if (latestReceipt && Number(rule.cooldownMinutes || 0) > 0) {
|
|
307
|
+
cooldownUntil = addMinutes(parseDate(latestReceipt.grantedAt) || now, rule.cooldownMinutes);
|
|
308
|
+
if (cooldownUntil.getTime() > now.getTime()) {
|
|
309
|
+
return {
|
|
310
|
+
gateKey,
|
|
311
|
+
workspaceSlug,
|
|
312
|
+
surface,
|
|
313
|
+
enabled: true,
|
|
314
|
+
available: false,
|
|
315
|
+
blocked: false,
|
|
316
|
+
reason: "cooldown-active",
|
|
317
|
+
rule,
|
|
318
|
+
providerConfig,
|
|
319
|
+
unlock: null,
|
|
320
|
+
cooldownUntil: cooldownUntil.toISOString(),
|
|
321
|
+
dailyLimitRemaining: rule.dailyLimit
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (rule.dailyLimit != null) {
|
|
327
|
+
const dayStart = startOfUtcDay(now);
|
|
328
|
+
const todaysCount = receipts.filter((entry) => {
|
|
329
|
+
const grantedAt = parseDate(entry?.grantedAt);
|
|
330
|
+
return grantedAt && grantedAt.getTime() >= dayStart.getTime();
|
|
331
|
+
}).length;
|
|
332
|
+
const remaining = Math.max(0, Number(rule.dailyLimit) - todaysCount);
|
|
333
|
+
if (remaining < 1) {
|
|
334
|
+
return {
|
|
335
|
+
gateKey,
|
|
336
|
+
workspaceSlug,
|
|
337
|
+
surface,
|
|
338
|
+
enabled: true,
|
|
339
|
+
available: false,
|
|
340
|
+
blocked: false,
|
|
341
|
+
reason: "daily-limit-reached",
|
|
342
|
+
rule,
|
|
343
|
+
providerConfig,
|
|
344
|
+
unlock: null,
|
|
345
|
+
cooldownUntil: null,
|
|
346
|
+
dailyLimitRemaining: 0
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
gateKey,
|
|
352
|
+
workspaceSlug,
|
|
353
|
+
surface,
|
|
354
|
+
enabled: true,
|
|
355
|
+
available: true,
|
|
356
|
+
blocked: true,
|
|
357
|
+
reason: "reward-required",
|
|
358
|
+
rule,
|
|
359
|
+
providerConfig,
|
|
360
|
+
unlock: null,
|
|
361
|
+
cooldownUntil: null,
|
|
362
|
+
dailyLimitRemaining: remaining
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
gateKey,
|
|
368
|
+
workspaceSlug,
|
|
369
|
+
surface,
|
|
370
|
+
enabled: true,
|
|
371
|
+
available: true,
|
|
372
|
+
blocked: true,
|
|
373
|
+
reason: "reward-required",
|
|
374
|
+
rule,
|
|
375
|
+
providerConfig,
|
|
376
|
+
unlock: null,
|
|
377
|
+
cooldownUntil: null,
|
|
378
|
+
dailyLimitRemaining: null
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function getCurrentState(input = {}, options = {}) {
|
|
383
|
+
return evaluateGate(input, options);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function startGate(input = {}, options = {}) {
|
|
387
|
+
const state = await evaluateGate(input, options);
|
|
388
|
+
if (!state.enabled || !state.available || !state.blocked) {
|
|
389
|
+
return {
|
|
390
|
+
...state,
|
|
391
|
+
session: null
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const createdSession = normalizeRecord(await rewardedWatchSessionsRepository.createDocument(
|
|
396
|
+
{
|
|
397
|
+
gateKey: state.gateKey,
|
|
398
|
+
providerConfigId: state.providerConfig?.id || null,
|
|
399
|
+
status: "started",
|
|
400
|
+
startedAt: new Date().toISOString()
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
context: options?.context || null,
|
|
404
|
+
trx: options?.trx || null
|
|
405
|
+
}
|
|
406
|
+
));
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
...state,
|
|
410
|
+
session: formatWatchSession(createdSession)
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function grantReward(input = {}, options = {}) {
|
|
415
|
+
requireActor(options?.context || null, input);
|
|
416
|
+
const workspaceSlug = requireWorkspaceSlug(input);
|
|
417
|
+
const sessionId = input?.sessionId == null ? "" : String(input.sessionId).trim();
|
|
418
|
+
if (!sessionId) {
|
|
419
|
+
throw new AppError(400, "sessionId is required.");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return rewardedWatchSessionsRepository.withTransaction(async (trx) => {
|
|
423
|
+
const sessionRecord = normalizeRecord(await rewardedWatchSessionsRepository.getDocumentById(sessionId, {}, {
|
|
424
|
+
context: options?.context || null,
|
|
425
|
+
trx
|
|
426
|
+
}));
|
|
427
|
+
if (!sessionRecord) {
|
|
428
|
+
throw new AppError(404, "Watch session not found.");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const authorized = await authorizeGrant({
|
|
432
|
+
session: formatWatchSession(sessionRecord),
|
|
433
|
+
context: options?.context || null,
|
|
434
|
+
trx
|
|
435
|
+
});
|
|
436
|
+
if (authorized !== true) {
|
|
437
|
+
throw new AppError(403, "Reward grant is not authorized.");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (normalizeText(sessionRecord.status).toLowerCase() === "closed") {
|
|
441
|
+
throw new AppError(409, "Watch session is already closed.");
|
|
442
|
+
}
|
|
443
|
+
if (sessionRecord.rewardedAt || normalizeText(sessionRecord.status).toLowerCase() === "rewarded") {
|
|
444
|
+
const receiptRecord = await queryFirst(rewardedUnlockReceiptsRepository, {
|
|
445
|
+
watchSessionId: sessionId,
|
|
446
|
+
sort: ["-grantedAt"],
|
|
447
|
+
limit: 1
|
|
448
|
+
}, {
|
|
449
|
+
context: options?.context || null,
|
|
450
|
+
trx
|
|
451
|
+
});
|
|
452
|
+
return {
|
|
453
|
+
unlocked: true,
|
|
454
|
+
workspaceSlug,
|
|
455
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
456
|
+
unlock: formatUnlockReceipt(receiptRecord),
|
|
457
|
+
session: formatWatchSession(sessionRecord)
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const ruleRecord = await resolveRule({
|
|
462
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
463
|
+
surface: REWARDED_SURFACE,
|
|
464
|
+
context: options?.context || null,
|
|
465
|
+
trx
|
|
466
|
+
});
|
|
467
|
+
if (!ruleRecord) {
|
|
468
|
+
throw new AppError(409, "No rewarded rule is configured for this session.");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const now = new Date();
|
|
472
|
+
const nowIso = now.toISOString();
|
|
473
|
+
const unlockedUntil = addMinutes(now, Number(ruleRecord.unlockMinutes || 0));
|
|
474
|
+
|
|
475
|
+
const updatedSession = normalizeRecord(await rewardedWatchSessionsRepository.patchDocumentById(
|
|
476
|
+
sessionId,
|
|
477
|
+
{
|
|
478
|
+
status: "rewarded",
|
|
479
|
+
rewardedAt: nowIso,
|
|
480
|
+
completedAt: nowIso
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
context: options?.context || null,
|
|
484
|
+
trx
|
|
485
|
+
}
|
|
486
|
+
));
|
|
487
|
+
|
|
488
|
+
const createdReceipt = normalizeRecord(await rewardedUnlockReceiptsRepository.createDocument(
|
|
489
|
+
{
|
|
490
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
491
|
+
providerConfigId: sessionRecord.providerConfigId || null,
|
|
492
|
+
watchSessionId: sessionId,
|
|
493
|
+
grantedAt: nowIso,
|
|
494
|
+
unlockedUntil: unlockedUntil.toISOString()
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
context: options?.context || null,
|
|
498
|
+
trx
|
|
499
|
+
}
|
|
500
|
+
));
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
unlocked: true,
|
|
504
|
+
workspaceSlug,
|
|
505
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
506
|
+
unlock: formatUnlockReceipt(createdReceipt),
|
|
507
|
+
session: formatWatchSession(updatedSession)
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function closeSession(input = {}, options = {}) {
|
|
513
|
+
requireActor(options?.context || null, input);
|
|
514
|
+
const workspaceSlug = requireWorkspaceSlug(input);
|
|
515
|
+
const sessionId = input?.sessionId == null ? "" : String(input.sessionId).trim();
|
|
516
|
+
if (!sessionId) {
|
|
517
|
+
throw new AppError(400, "sessionId is required.");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const sessionRecord = normalizeRecord(await rewardedWatchSessionsRepository.getDocumentById(sessionId, {}, {
|
|
521
|
+
context: options?.context || null,
|
|
522
|
+
trx: options?.trx || null
|
|
523
|
+
}));
|
|
524
|
+
if (!sessionRecord) {
|
|
525
|
+
throw new AppError(404, "Watch session not found.");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (sessionRecord.rewardedAt || normalizeText(sessionRecord.status).toLowerCase() === "rewarded") {
|
|
529
|
+
return {
|
|
530
|
+
closed: false,
|
|
531
|
+
workspaceSlug,
|
|
532
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
533
|
+
session: formatWatchSession(sessionRecord),
|
|
534
|
+
reason: "already-rewarded"
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const closedSession = normalizeRecord(await rewardedWatchSessionsRepository.patchDocumentById(
|
|
539
|
+
sessionId,
|
|
540
|
+
{
|
|
541
|
+
status: "closed",
|
|
542
|
+
closedAt: new Date().toISOString()
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
context: options?.context || null,
|
|
546
|
+
trx: options?.trx || null
|
|
547
|
+
}
|
|
548
|
+
));
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
closed: true,
|
|
552
|
+
workspaceSlug,
|
|
553
|
+
gateKey: normalizeGateKey(closedSession?.gateKey || sessionRecord.gateKey),
|
|
554
|
+
session: formatWatchSession(closedSession)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return Object.freeze({
|
|
559
|
+
getCurrentState,
|
|
560
|
+
startGate,
|
|
561
|
+
grantReward,
|
|
562
|
+
closeSession
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export { createService };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
|
|
4
|
+
const REWARDED_SURFACE = "app";
|
|
5
|
+
|
|
6
|
+
const REWARDED_GATE_CONFIGURATION_REASONS = new Set([
|
|
7
|
+
"rule-not-configured",
|
|
8
|
+
"provider-not-configured"
|
|
9
|
+
]);
|
|
10
|
+
const REWARDED_GATE_NON_BLOCKING_REASONS = new Set([
|
|
11
|
+
"already-unlocked",
|
|
12
|
+
"cooldown-active",
|
|
13
|
+
"daily-limit-reached",
|
|
14
|
+
...REWARDED_GATE_CONFIGURATION_REASONS
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const REWARDED_GATE_REASON_ERROR_MAP = Object.freeze({
|
|
18
|
+
"reward-required": {
|
|
19
|
+
status: 423,
|
|
20
|
+
code: "rewarded_unlock_required",
|
|
21
|
+
message: "Rewarded unlock required."
|
|
22
|
+
},
|
|
23
|
+
"cooldown-active": {
|
|
24
|
+
status: 423,
|
|
25
|
+
code: "rewarded_cooldown_active",
|
|
26
|
+
message: "Rewarded unlock is cooling down."
|
|
27
|
+
},
|
|
28
|
+
"daily-limit-reached": {
|
|
29
|
+
status: 423,
|
|
30
|
+
code: "rewarded_daily_limit_reached",
|
|
31
|
+
message: "Rewarded unlock limit reached."
|
|
32
|
+
},
|
|
33
|
+
"rule-not-configured": {
|
|
34
|
+
status: 503,
|
|
35
|
+
code: "rewarded_not_configured",
|
|
36
|
+
message: "Rewarded gate is not configured."
|
|
37
|
+
},
|
|
38
|
+
"provider-not-configured": {
|
|
39
|
+
status: 503,
|
|
40
|
+
code: "rewarded_not_configured",
|
|
41
|
+
message: "Rewarded gate is not configured."
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function normalizeReason(value = "") {
|
|
46
|
+
return normalizeText(value).toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isUnlockSatisfied(gateState = null) {
|
|
50
|
+
const reason = normalizeReason(gateState?.reason);
|
|
51
|
+
return Boolean(gateState?.unlock) || reason === "already-unlocked";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasBooleanGateFlag(gateState, key) {
|
|
55
|
+
return gateState && typeof gateState === "object" && typeof gateState[key] === "boolean";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function assertWellFormedGateState(gateState = null) {
|
|
59
|
+
if (hasBooleanGateFlag(gateState, "enabled") && hasBooleanGateFlag(gateState, "blocked")) {
|
|
60
|
+
if (
|
|
61
|
+
gateState.blocked === true ||
|
|
62
|
+
isUnlockSatisfied(gateState) ||
|
|
63
|
+
REWARDED_GATE_NON_BLOCKING_REASONS.has(normalizeReason(gateState?.reason))
|
|
64
|
+
) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new AppError(503, "Rewarded gate returned an invalid state.", {
|
|
70
|
+
code: "rewarded_gate_state_invalid",
|
|
71
|
+
details: {
|
|
72
|
+
rewardedGate: gateState
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isConfigurationBypassAllowed(gateState = null, requireConfigured = false) {
|
|
78
|
+
if (requireConfigured === true) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (gateState?.enabled === true) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const reason = normalizeReason(gateState?.reason);
|
|
87
|
+
return REWARDED_GATE_CONFIGURATION_REASONS.has(reason) || !reason;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createRewardedGateError(gateState = null, {
|
|
91
|
+
errorCode = "",
|
|
92
|
+
errorMessage = ""
|
|
93
|
+
} = {}) {
|
|
94
|
+
const reason = normalizeReason(gateState?.reason);
|
|
95
|
+
const defaults = REWARDED_GATE_REASON_ERROR_MAP[reason] || {
|
|
96
|
+
status: 423,
|
|
97
|
+
code: "rewarded_unlock_blocked",
|
|
98
|
+
message: "Rewarded unlock is required before this action can continue."
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return new AppError(defaults.status, errorMessage || defaults.message, {
|
|
102
|
+
code: errorCode || defaults.code,
|
|
103
|
+
details: {
|
|
104
|
+
rewardedGate: gateState
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function requireRewardedUnlock(rewardedService, input = {}, {
|
|
110
|
+
context = null,
|
|
111
|
+
requireConfigured = false,
|
|
112
|
+
errorCode = "",
|
|
113
|
+
errorMessage = ""
|
|
114
|
+
} = {}) {
|
|
115
|
+
if (!rewardedService || typeof rewardedService.getCurrentState !== "function") {
|
|
116
|
+
throw new TypeError("requireRewardedUnlock requires rewardedService.getCurrentState().");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const gateState = await rewardedService.getCurrentState(
|
|
120
|
+
{
|
|
121
|
+
...input,
|
|
122
|
+
surface: REWARDED_SURFACE
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
context
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
assertWellFormedGateState(gateState);
|
|
129
|
+
|
|
130
|
+
if (isUnlockSatisfied(gateState) || isConfigurationBypassAllowed(gateState, requireConfigured)) {
|
|
131
|
+
return gateState;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw createRewardedGateError(gateState, {
|
|
135
|
+
errorCode,
|
|
136
|
+
errorMessage
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export {
|
|
141
|
+
requireRewardedUnlock
|
|
142
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
resource as rewardedRuleResource
|
|
3
|
+
} from "./rewardedRuleResource.js";
|
|
4
|
+
export {
|
|
5
|
+
resource as rewardedProviderConfigResource
|
|
6
|
+
} from "./rewardedProviderConfigResource.js";
|
|
7
|
+
export {
|
|
8
|
+
resource as rewardedWatchSessionResource
|
|
9
|
+
} from "./rewardedWatchSessionResource.js";
|
|
10
|
+
export {
|
|
11
|
+
resource as rewardedUnlockReceiptResource
|
|
12
|
+
} from "./rewardedUnlockReceiptResource.js";
|