@jskit-ai/google-rewarded-core 0.1.1
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 +98 -0
- package/docs/protecting-server-actions.md +190 -0
- package/package.descriptor.mjs +186 -0
- package/package.json +10 -0
- package/src/server/GoogleRewardedCoreProvider.js +51 -0
- package/src/server/actions.js +102 -0
- package/src/server/inputSchemas.js +394 -0
- package/src/server/providerConfigs/GoogleRewardedProviderConfigsProvider.js +98 -0
- package/src/server/providerConfigs/actions.js +157 -0
- package/src/server/providerConfigs/registerRoutes.js +176 -0
- package/src/server/providerConfigs/repository.js +107 -0
- package/src/server/providerConfigs/service.js +61 -0
- package/src/server/registerRoutes.js +169 -0
- package/src/server/rules/GoogleRewardedRulesProvider.js +98 -0
- package/src/server/rules/actions.js +157 -0
- package/src/server/rules/registerRoutes.js +176 -0
- package/src/server/rules/repository.js +107 -0
- package/src/server/rules/service.js +61 -0
- package/src/server/service.js +552 -0
- package/src/server/support/requireGoogleRewardedUnlock.js +142 -0
- package/src/server/unlockReceipts/GoogleRewardedUnlockReceiptsProvider.js +98 -0
- package/src/server/unlockReceipts/actions.js +157 -0
- package/src/server/unlockReceipts/registerRoutes.js +176 -0
- package/src/server/unlockReceipts/repository.js +107 -0
- package/src/server/unlockReceipts/service.js +61 -0
- package/src/server/watchSessions/GoogleRewardedWatchSessionsProvider.js +98 -0
- package/src/server/watchSessions/actions.js +157 -0
- package/src/server/watchSessions/registerRoutes.js +176 -0
- package/src/server/watchSessions/repository.js +107 -0
- package/src/server/watchSessions/service.js +61 -0
- package/src/shared/googleRewardedProviderConfigResource.js +94 -0
- package/src/shared/googleRewardedRuleResource.js +136 -0
- package/src/shared/googleRewardedUnlockReceiptResource.js +113 -0
- package/src/shared/googleRewardedWatchSessionResource.js +139 -0
- package/src/shared/index.js +12 -0
- package/templates/migrations/google_rewarded_provider_configs_initial.cjs +27 -0
- package/templates/migrations/google_rewarded_rules_initial.cjs +31 -0
- package/templates/migrations/google_rewarded_unlock_receipts_initial.cjs +35 -0
- package/templates/migrations/google_rewarded_watch_sessions_initial.cjs +36 -0
- package/test/requireGoogleRewardedUnlock.test.js +274 -0
- package/test/routes.test.js +248 -0
- package/test/service.test.js +315 -0
|
@@ -0,0 +1,552 @@
|
|
|
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 GOOGLE_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 = GOOGLE_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: GOOGLE_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: GOOGLE_REWARDED_SURFACE }),
|
|
108
|
+
enabled: record.enabled === true,
|
|
109
|
+
adUnitPath: normalizeText(record.adUnitPath),
|
|
110
|
+
scriptMode: normalizeText(record.scriptMode) || "gpt_rewarded"
|
|
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
|
+
googleRewardedRulesRepository,
|
|
153
|
+
googleRewardedProviderConfigsRepository,
|
|
154
|
+
googleRewardedWatchSessionsRepository,
|
|
155
|
+
googleRewardedUnlockReceiptsRepository
|
|
156
|
+
} = {}) {
|
|
157
|
+
if (!googleRewardedRulesRepository) {
|
|
158
|
+
throw new TypeError("createService requires googleRewardedRulesRepository.");
|
|
159
|
+
}
|
|
160
|
+
if (!googleRewardedProviderConfigsRepository) {
|
|
161
|
+
throw new TypeError("createService requires googleRewardedProviderConfigsRepository.");
|
|
162
|
+
}
|
|
163
|
+
if (!googleRewardedWatchSessionsRepository) {
|
|
164
|
+
throw new TypeError("createService requires googleRewardedWatchSessionsRepository.");
|
|
165
|
+
}
|
|
166
|
+
if (!googleRewardedUnlockReceiptsRepository) {
|
|
167
|
+
throw new TypeError("createService requires googleRewardedUnlockReceiptsRepository.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function queryFirst(repository, query = {}, options = {}) {
|
|
171
|
+
const rows = normalizeRecordList(await repository.queryDocuments(query, options));
|
|
172
|
+
return rows[0] || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function listRecords(repository, query = {}, options = {}) {
|
|
176
|
+
return normalizeRecordList(await repository.queryDocuments(query, options));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function resolveRule({ gateKey, surface, context, trx }) {
|
|
180
|
+
const filters = {
|
|
181
|
+
gateKey,
|
|
182
|
+
enabled: "true"
|
|
183
|
+
};
|
|
184
|
+
if (surface) {
|
|
185
|
+
filters.surface = surface;
|
|
186
|
+
}
|
|
187
|
+
return queryFirst(googleRewardedRulesRepository, {
|
|
188
|
+
...filters,
|
|
189
|
+
sort: ["-updatedAt"],
|
|
190
|
+
limit: 1
|
|
191
|
+
}, {
|
|
192
|
+
context,
|
|
193
|
+
trx
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function resolveProviderConfig({ surface, context, trx }) {
|
|
198
|
+
return queryFirst(googleRewardedProviderConfigsRepository, {
|
|
199
|
+
surface,
|
|
200
|
+
enabled: "true",
|
|
201
|
+
sort: ["-updatedAt"],
|
|
202
|
+
limit: 1
|
|
203
|
+
}, {
|
|
204
|
+
context,
|
|
205
|
+
trx
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function resolveUnlockReceipts({ gateKey, context, trx, limit = 25 }) {
|
|
210
|
+
return listRecords(googleRewardedUnlockReceiptsRepository, {
|
|
211
|
+
gateKey,
|
|
212
|
+
sort: ["-grantedAt"],
|
|
213
|
+
limit
|
|
214
|
+
}, {
|
|
215
|
+
context,
|
|
216
|
+
trx
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function evaluateGate(input = {}, options = {}) {
|
|
221
|
+
requireActor(options?.context || null, input);
|
|
222
|
+
const workspaceSlug = requireWorkspaceSlug(input);
|
|
223
|
+
const gateKey = requireGateKey(input);
|
|
224
|
+
const surface = GOOGLE_REWARDED_SURFACE;
|
|
225
|
+
const now = new Date();
|
|
226
|
+
|
|
227
|
+
const ruleRecord = await resolveRule({
|
|
228
|
+
gateKey,
|
|
229
|
+
surface,
|
|
230
|
+
context: options?.context || null,
|
|
231
|
+
trx: options?.trx || null
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (!ruleRecord) {
|
|
235
|
+
return {
|
|
236
|
+
gateKey,
|
|
237
|
+
workspaceSlug,
|
|
238
|
+
surface,
|
|
239
|
+
enabled: false,
|
|
240
|
+
available: false,
|
|
241
|
+
blocked: false,
|
|
242
|
+
reason: "rule-not-configured",
|
|
243
|
+
rule: null,
|
|
244
|
+
providerConfig: null,
|
|
245
|
+
unlock: null,
|
|
246
|
+
cooldownUntil: null,
|
|
247
|
+
dailyLimitRemaining: null
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const providerConfigRecord = await resolveProviderConfig({
|
|
252
|
+
surface,
|
|
253
|
+
context: options?.context || null,
|
|
254
|
+
trx: options?.trx || null
|
|
255
|
+
});
|
|
256
|
+
if (!providerConfigRecord) {
|
|
257
|
+
return {
|
|
258
|
+
gateKey,
|
|
259
|
+
workspaceSlug,
|
|
260
|
+
surface,
|
|
261
|
+
enabled: false,
|
|
262
|
+
available: false,
|
|
263
|
+
blocked: false,
|
|
264
|
+
reason: "provider-not-configured",
|
|
265
|
+
rule: formatRule(ruleRecord),
|
|
266
|
+
providerConfig: null,
|
|
267
|
+
unlock: null,
|
|
268
|
+
cooldownUntil: null,
|
|
269
|
+
dailyLimitRemaining: null
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const rule = formatRule(ruleRecord);
|
|
274
|
+
const providerConfig = formatProviderConfig(providerConfigRecord);
|
|
275
|
+
const receipts = await resolveUnlockReceipts({
|
|
276
|
+
gateKey,
|
|
277
|
+
context: options?.context || null,
|
|
278
|
+
trx: options?.trx || null
|
|
279
|
+
});
|
|
280
|
+
const activeReceiptRecord = receipts.find((entry) => isFutureDate(entry?.unlockedUntil, now)) || null;
|
|
281
|
+
const activeUnlock = formatUnlockReceipt(activeReceiptRecord);
|
|
282
|
+
|
|
283
|
+
if (activeUnlock) {
|
|
284
|
+
return {
|
|
285
|
+
gateKey,
|
|
286
|
+
workspaceSlug,
|
|
287
|
+
surface,
|
|
288
|
+
enabled: true,
|
|
289
|
+
available: true,
|
|
290
|
+
blocked: false,
|
|
291
|
+
reason: "already-unlocked",
|
|
292
|
+
rule,
|
|
293
|
+
providerConfig,
|
|
294
|
+
unlock: activeUnlock,
|
|
295
|
+
cooldownUntil: null,
|
|
296
|
+
dailyLimitRemaining: rule.dailyLimit
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const latestReceipt = receipts[0] || null;
|
|
301
|
+
let cooldownUntil = null;
|
|
302
|
+
if (latestReceipt && Number(rule.cooldownMinutes || 0) > 0) {
|
|
303
|
+
cooldownUntil = addMinutes(parseDate(latestReceipt.grantedAt) || now, rule.cooldownMinutes);
|
|
304
|
+
if (cooldownUntil.getTime() > now.getTime()) {
|
|
305
|
+
return {
|
|
306
|
+
gateKey,
|
|
307
|
+
workspaceSlug,
|
|
308
|
+
surface,
|
|
309
|
+
enabled: true,
|
|
310
|
+
available: false,
|
|
311
|
+
blocked: false,
|
|
312
|
+
reason: "cooldown-active",
|
|
313
|
+
rule,
|
|
314
|
+
providerConfig,
|
|
315
|
+
unlock: null,
|
|
316
|
+
cooldownUntil: cooldownUntil.toISOString(),
|
|
317
|
+
dailyLimitRemaining: rule.dailyLimit
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (rule.dailyLimit != null) {
|
|
323
|
+
const dayStart = startOfUtcDay(now);
|
|
324
|
+
const todaysCount = receipts.filter((entry) => {
|
|
325
|
+
const grantedAt = parseDate(entry?.grantedAt);
|
|
326
|
+
return grantedAt && grantedAt.getTime() >= dayStart.getTime();
|
|
327
|
+
}).length;
|
|
328
|
+
const remaining = Math.max(0, Number(rule.dailyLimit) - todaysCount);
|
|
329
|
+
if (remaining < 1) {
|
|
330
|
+
return {
|
|
331
|
+
gateKey,
|
|
332
|
+
workspaceSlug,
|
|
333
|
+
surface,
|
|
334
|
+
enabled: true,
|
|
335
|
+
available: false,
|
|
336
|
+
blocked: false,
|
|
337
|
+
reason: "daily-limit-reached",
|
|
338
|
+
rule,
|
|
339
|
+
providerConfig,
|
|
340
|
+
unlock: null,
|
|
341
|
+
cooldownUntil: null,
|
|
342
|
+
dailyLimitRemaining: 0
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
gateKey,
|
|
348
|
+
workspaceSlug,
|
|
349
|
+
surface,
|
|
350
|
+
enabled: true,
|
|
351
|
+
available: true,
|
|
352
|
+
blocked: true,
|
|
353
|
+
reason: "reward-required",
|
|
354
|
+
rule,
|
|
355
|
+
providerConfig,
|
|
356
|
+
unlock: null,
|
|
357
|
+
cooldownUntil: null,
|
|
358
|
+
dailyLimitRemaining: remaining
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
gateKey,
|
|
364
|
+
workspaceSlug,
|
|
365
|
+
surface,
|
|
366
|
+
enabled: true,
|
|
367
|
+
available: true,
|
|
368
|
+
blocked: true,
|
|
369
|
+
reason: "reward-required",
|
|
370
|
+
rule,
|
|
371
|
+
providerConfig,
|
|
372
|
+
unlock: null,
|
|
373
|
+
cooldownUntil: null,
|
|
374
|
+
dailyLimitRemaining: null
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function getCurrentState(input = {}, options = {}) {
|
|
379
|
+
return evaluateGate(input, options);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function startGate(input = {}, options = {}) {
|
|
383
|
+
const state = await evaluateGate(input, options);
|
|
384
|
+
if (!state.enabled || !state.available || !state.blocked) {
|
|
385
|
+
return {
|
|
386
|
+
...state,
|
|
387
|
+
session: null
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const createdSession = normalizeRecord(await googleRewardedWatchSessionsRepository.createDocument(
|
|
392
|
+
{
|
|
393
|
+
gateKey: state.gateKey,
|
|
394
|
+
providerConfigId: state.providerConfig?.id || null,
|
|
395
|
+
status: "started",
|
|
396
|
+
startedAt: new Date()
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
context: options?.context || null,
|
|
400
|
+
trx: options?.trx || null
|
|
401
|
+
}
|
|
402
|
+
));
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
...state,
|
|
406
|
+
session: formatWatchSession(createdSession)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function grantReward(input = {}, options = {}) {
|
|
411
|
+
requireActor(options?.context || null, input);
|
|
412
|
+
const workspaceSlug = requireWorkspaceSlug(input);
|
|
413
|
+
const sessionId = input?.sessionId == null ? "" : String(input.sessionId).trim();
|
|
414
|
+
if (!sessionId) {
|
|
415
|
+
throw new AppError(400, "sessionId is required.");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return googleRewardedWatchSessionsRepository.withTransaction(async (trx) => {
|
|
419
|
+
const sessionRecord = normalizeRecord(await googleRewardedWatchSessionsRepository.getDocumentById(sessionId, {
|
|
420
|
+
context: options?.context || null,
|
|
421
|
+
trx
|
|
422
|
+
}));
|
|
423
|
+
if (!sessionRecord) {
|
|
424
|
+
throw new AppError(404, "Watch session not found.");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (normalizeText(sessionRecord.status).toLowerCase() === "closed") {
|
|
428
|
+
throw new AppError(409, "Watch session is already closed.");
|
|
429
|
+
}
|
|
430
|
+
if (sessionRecord.rewardedAt || normalizeText(sessionRecord.status).toLowerCase() === "rewarded") {
|
|
431
|
+
const receiptRecord = await queryFirst(googleRewardedUnlockReceiptsRepository, {
|
|
432
|
+
watchSessionId: sessionId,
|
|
433
|
+
sort: ["-grantedAt"],
|
|
434
|
+
limit: 1
|
|
435
|
+
}, {
|
|
436
|
+
context: options?.context || null,
|
|
437
|
+
trx
|
|
438
|
+
});
|
|
439
|
+
return {
|
|
440
|
+
unlocked: true,
|
|
441
|
+
workspaceSlug,
|
|
442
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
443
|
+
unlock: formatUnlockReceipt(receiptRecord),
|
|
444
|
+
session: formatWatchSession(sessionRecord)
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const ruleRecord = await resolveRule({
|
|
449
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
450
|
+
surface: GOOGLE_REWARDED_SURFACE,
|
|
451
|
+
context: options?.context || null,
|
|
452
|
+
trx
|
|
453
|
+
});
|
|
454
|
+
if (!ruleRecord) {
|
|
455
|
+
throw new AppError(409, "No rewarded rule is configured for this session.");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const now = new Date();
|
|
459
|
+
const unlockedUntil = addMinutes(now, Number(ruleRecord.unlockMinutes || 0));
|
|
460
|
+
|
|
461
|
+
const updatedSession = normalizeRecord(await googleRewardedWatchSessionsRepository.patchDocumentById(
|
|
462
|
+
sessionId,
|
|
463
|
+
{
|
|
464
|
+
status: "rewarded",
|
|
465
|
+
rewardedAt: now,
|
|
466
|
+
completedAt: now
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
context: options?.context || null,
|
|
470
|
+
trx
|
|
471
|
+
}
|
|
472
|
+
));
|
|
473
|
+
|
|
474
|
+
const createdReceipt = normalizeRecord(await googleRewardedUnlockReceiptsRepository.createDocument(
|
|
475
|
+
{
|
|
476
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
477
|
+
providerConfigId: sessionRecord.providerConfigId || null,
|
|
478
|
+
watchSessionId: sessionId,
|
|
479
|
+
grantedAt: now,
|
|
480
|
+
unlockedUntil
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
context: options?.context || null,
|
|
484
|
+
trx
|
|
485
|
+
}
|
|
486
|
+
));
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
unlocked: true,
|
|
490
|
+
workspaceSlug,
|
|
491
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
492
|
+
unlock: formatUnlockReceipt(createdReceipt),
|
|
493
|
+
session: formatWatchSession(updatedSession)
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function closeSession(input = {}, options = {}) {
|
|
499
|
+
requireActor(options?.context || null, input);
|
|
500
|
+
const workspaceSlug = requireWorkspaceSlug(input);
|
|
501
|
+
const sessionId = input?.sessionId == null ? "" : String(input.sessionId).trim();
|
|
502
|
+
if (!sessionId) {
|
|
503
|
+
throw new AppError(400, "sessionId is required.");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const sessionRecord = normalizeRecord(await googleRewardedWatchSessionsRepository.getDocumentById(sessionId, {
|
|
507
|
+
context: options?.context || null,
|
|
508
|
+
trx: options?.trx || null
|
|
509
|
+
}));
|
|
510
|
+
if (!sessionRecord) {
|
|
511
|
+
throw new AppError(404, "Watch session not found.");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (sessionRecord.rewardedAt || normalizeText(sessionRecord.status).toLowerCase() === "rewarded") {
|
|
515
|
+
return {
|
|
516
|
+
closed: false,
|
|
517
|
+
workspaceSlug,
|
|
518
|
+
gateKey: normalizeGateKey(sessionRecord.gateKey),
|
|
519
|
+
session: formatWatchSession(sessionRecord),
|
|
520
|
+
reason: "already-rewarded"
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const closedSession = normalizeRecord(await googleRewardedWatchSessionsRepository.patchDocumentById(
|
|
525
|
+
sessionId,
|
|
526
|
+
{
|
|
527
|
+
status: "closed",
|
|
528
|
+
closedAt: new Date()
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
context: options?.context || null,
|
|
532
|
+
trx: options?.trx || null
|
|
533
|
+
}
|
|
534
|
+
));
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
closed: true,
|
|
538
|
+
workspaceSlug,
|
|
539
|
+
gateKey: normalizeGateKey(closedSession?.gateKey || sessionRecord.gateKey),
|
|
540
|
+
session: formatWatchSession(closedSession)
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return Object.freeze({
|
|
545
|
+
getCurrentState,
|
|
546
|
+
startGate,
|
|
547
|
+
grantReward,
|
|
548
|
+
closeSession
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
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 GOOGLE_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: "google_rewarded_unlock_required",
|
|
21
|
+
message: "Rewarded unlock required."
|
|
22
|
+
},
|
|
23
|
+
"cooldown-active": {
|
|
24
|
+
status: 423,
|
|
25
|
+
code: "google_rewarded_cooldown_active",
|
|
26
|
+
message: "Rewarded unlock is cooling down."
|
|
27
|
+
},
|
|
28
|
+
"daily-limit-reached": {
|
|
29
|
+
status: 423,
|
|
30
|
+
code: "google_rewarded_daily_limit_reached",
|
|
31
|
+
message: "Rewarded unlock limit reached."
|
|
32
|
+
},
|
|
33
|
+
"rule-not-configured": {
|
|
34
|
+
status: 503,
|
|
35
|
+
code: "google_rewarded_not_configured",
|
|
36
|
+
message: "Rewarded gate is not configured."
|
|
37
|
+
},
|
|
38
|
+
"provider-not-configured": {
|
|
39
|
+
status: 503,
|
|
40
|
+
code: "google_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: "google_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: "google_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 requireGoogleRewardedUnlock(googleRewardedService, input = {}, {
|
|
110
|
+
context = null,
|
|
111
|
+
requireConfigured = false,
|
|
112
|
+
errorCode = "",
|
|
113
|
+
errorMessage = ""
|
|
114
|
+
} = {}) {
|
|
115
|
+
if (!googleRewardedService || typeof googleRewardedService.getCurrentState !== "function") {
|
|
116
|
+
throw new TypeError("requireGoogleRewardedUnlock requires googleRewardedService.getCurrentState().");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const gateState = await googleRewardedService.getCurrentState(
|
|
120
|
+
{
|
|
121
|
+
...input,
|
|
122
|
+
surface: GOOGLE_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
|
+
requireGoogleRewardedUnlock
|
|
142
|
+
};
|