@clovnet/plugin-cli 0.2.2
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/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/bin/cwe-plugin.mjs +14 -0
- package/dist/cli.js +10400 -0
- package/package.json +62 -0
- package/templates/blank/README.md +60 -0
- package/templates/blank/_gitignore +3 -0
- package/templates/blank/cwe-plugin.json +9 -0
- package/templates/blank/package.json +31 -0
- package/templates/blank/settings.dev.json +8 -0
- package/templates/blank/src/handlers/hooks.ts +29 -0
- package/templates/blank/src/handlers/jobs.ts +9 -0
- package/templates/blank/src/handlers/routes.ts +44 -0
- package/templates/blank/src/index.ts +68 -0
- package/templates/blank/src/settings.ts +23 -0
- package/templates/blank/test/hooks.test.ts +43 -0
- package/templates/blank/tsconfig.json +14 -0
- package/templates/cashback/README.md +61 -0
- package/templates/cashback/_gitignore +3 -0
- package/templates/cashback/cwe-plugin.json +9 -0
- package/templates/cashback/package.json +31 -0
- package/templates/cashback/settings.dev.json +9 -0
- package/templates/cashback/src/handlers/hooks.ts +19 -0
- package/templates/cashback/src/handlers/jobs.ts +23 -0
- package/templates/cashback/src/handlers/routes.ts +188 -0
- package/templates/cashback/src/index.ts +128 -0
- package/templates/cashback/src/settings.ts +29 -0
- package/templates/cashback/test/hooks.test.ts +83 -0
- package/templates/cashback/tsconfig.json +14 -0
- package/templates/provider-skeleton/README.md +63 -0
- package/templates/provider-skeleton/_gitignore +3 -0
- package/templates/provider-skeleton/cwe-plugin.json +9 -0
- package/templates/provider-skeleton/package.json +31 -0
- package/templates/provider-skeleton/settings.dev.json +10 -0
- package/templates/provider-skeleton/src/adapter.ts +164 -0
- package/templates/provider-skeleton/src/fake-backend.ts +56 -0
- package/templates/provider-skeleton/src/handlers/hooks.ts +30 -0
- package/templates/provider-skeleton/src/handlers/jobs.ts +16 -0
- package/templates/provider-skeleton/src/handlers/routes.ts +128 -0
- package/templates/provider-skeleton/src/index.ts +83 -0
- package/templates/provider-skeleton/src/settings.ts +37 -0
- package/templates/provider-skeleton/test/hooks.test.ts +83 -0
- package/templates/provider-skeleton/tsconfig.json +14 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PluginContext, PluginRequest } from "@clovnet/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Player-facing cashback surface (mirrors the platform's reference cashback
|
|
6
|
+
* plugin): a dataset of per-player accrual periods, two query actions and an
|
|
7
|
+
* idempotent `claim` mutation that credits the BONUS bucket via the
|
|
8
|
+
* allowlisted `wallet.bonus_award` command — the only way a plugin may award
|
|
9
|
+
* money, with every wallet invariant (idempotency, immutable ledger, audit,
|
|
10
|
+
* outbox) applied by the platform.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// `wallet.bonus_award` — referenced by string: an external plugin depends
|
|
14
|
+
// only on the SDK + zod, never on platform packages.
|
|
15
|
+
export const AWARD_BONUS_COMMAND = "wallet.bonus_award";
|
|
16
|
+
|
|
17
|
+
// ── Dataset shape: one accrual period per player, per cycle ──────────────────
|
|
18
|
+
export const accrualPeriodSchema = z.object({
|
|
19
|
+
periodId: z.string(),
|
|
20
|
+
playerId: z.string(),
|
|
21
|
+
/** accruing → claimable → claimed. */
|
|
22
|
+
status: z.enum(["accruing", "claimable", "claimed"]),
|
|
23
|
+
/** Decimal major units (e.g. 5 = €5.00); the wallet normalizes to 4dp. */
|
|
24
|
+
amount: z.number(),
|
|
25
|
+
currency: z.string(),
|
|
26
|
+
accruedAt: z.string(),
|
|
27
|
+
claimedAt: z.string().optional(),
|
|
28
|
+
});
|
|
29
|
+
export type AccrualPeriod = z.infer<typeof accrualPeriodSchema>;
|
|
30
|
+
|
|
31
|
+
const periods = (ctx: PluginContext) => ctx.datasets.collection<AccrualPeriod>("accrual_periods");
|
|
32
|
+
|
|
33
|
+
async function playerPeriods(ctx: PluginContext, playerId: string): Promise<AccrualPeriod[]> {
|
|
34
|
+
const result = await periods(ctx).query({ where: { playerId }, limit: 200 });
|
|
35
|
+
return result.records.map((r) => r.value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Action I/O schemas (drive the catalog descriptors + generated client) ────
|
|
39
|
+
export const summaryOutputSchema = z.object({
|
|
40
|
+
currency: z.string(),
|
|
41
|
+
claimableAmount: z.number(),
|
|
42
|
+
claimedAmount: z.number(),
|
|
43
|
+
accruingAmount: z.number(),
|
|
44
|
+
periodCount: z.number(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const listPeriodsQuerySchema = z.object({
|
|
48
|
+
status: z.enum(["accruing", "claimable", "claimed"]).optional(),
|
|
49
|
+
});
|
|
50
|
+
export const listPeriodsOutputSchema = z.object({
|
|
51
|
+
periods: z.array(
|
|
52
|
+
z.object({
|
|
53
|
+
periodId: z.string(),
|
|
54
|
+
status: z.enum(["accruing", "claimable", "claimed"]),
|
|
55
|
+
amount: z.number(),
|
|
56
|
+
currency: z.string(),
|
|
57
|
+
accruedAt: z.string(),
|
|
58
|
+
claimedAt: z.string().optional(),
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const claimBodySchema = z.object({ periodId: z.string().min(1).max(191) }).strict();
|
|
64
|
+
export const claimOutputSchema = z.object({
|
|
65
|
+
claimed: z.literal(true),
|
|
66
|
+
periodId: z.string(),
|
|
67
|
+
amount: z.number(),
|
|
68
|
+
currency: z.string(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const accrueBodySchema = z
|
|
72
|
+
.object({
|
|
73
|
+
playerId: z.string().min(1).max(191),
|
|
74
|
+
amount: z.number().positive(),
|
|
75
|
+
currency: z.string().length(3).optional(),
|
|
76
|
+
})
|
|
77
|
+
.strict();
|
|
78
|
+
|
|
79
|
+
// ── Handlers ──────────────────────────────────────────────────────────────────
|
|
80
|
+
export async function getSummary(req: PluginRequest, ctx: PluginContext) {
|
|
81
|
+
const playerId = req.player?.id;
|
|
82
|
+
if (!playerId) return { status: 401, body: { error: { code: "UNAUTHORIZED" } } };
|
|
83
|
+
const { payoutCurrency } = ctx.settings.get<{ payoutCurrency?: string }>();
|
|
84
|
+
const all = await playerPeriods(ctx, playerId);
|
|
85
|
+
const sum = (status: AccrualPeriod["status"]): number =>
|
|
86
|
+
all.filter((p) => p.status === status).reduce((n, p) => n + p.amount, 0);
|
|
87
|
+
return {
|
|
88
|
+
body: {
|
|
89
|
+
currency: payoutCurrency ?? "EUR",
|
|
90
|
+
claimableAmount: sum("claimable"),
|
|
91
|
+
claimedAmount: sum("claimed"),
|
|
92
|
+
accruingAmount: sum("accruing"),
|
|
93
|
+
periodCount: all.length,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function listPeriods(req: PluginRequest, ctx: PluginContext) {
|
|
99
|
+
const playerId = req.player?.id;
|
|
100
|
+
if (!playerId) return { status: 401, body: { error: { code: "UNAUTHORIZED" } } };
|
|
101
|
+
const query = req.query as z.infer<typeof listPeriodsQuerySchema>;
|
|
102
|
+
let all = await playerPeriods(ctx, playerId);
|
|
103
|
+
if (query.status) all = all.filter((p) => p.status === query.status);
|
|
104
|
+
all.sort((a, b) => a.accruedAt.localeCompare(b.accruedAt));
|
|
105
|
+
return {
|
|
106
|
+
body: {
|
|
107
|
+
periods: all.map((p) => ({
|
|
108
|
+
periodId: p.periodId,
|
|
109
|
+
status: p.status,
|
|
110
|
+
amount: p.amount,
|
|
111
|
+
currency: p.currency,
|
|
112
|
+
accruedAt: p.accruedAt,
|
|
113
|
+
...(p.claimedAt ? { claimedAt: p.claimedAt } : {}),
|
|
114
|
+
})),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function claim(req: PluginRequest, ctx: PluginContext) {
|
|
120
|
+
const playerId = req.player?.id;
|
|
121
|
+
if (!playerId) return { status: 401, body: { error: { code: "UNAUTHORIZED" } } };
|
|
122
|
+
const body = req.body as z.infer<typeof claimBodySchema>;
|
|
123
|
+
const period = await periods(ctx).get(body.periodId);
|
|
124
|
+
// Not-found and not-owned look identical — never leak another player's data.
|
|
125
|
+
if (!period || period.playerId !== playerId) {
|
|
126
|
+
return { status: 404, body: { error: { code: "CASHBACK_PERIOD_NOT_FOUND" } } };
|
|
127
|
+
}
|
|
128
|
+
if (period.status !== "claimable") {
|
|
129
|
+
return {
|
|
130
|
+
status: 409,
|
|
131
|
+
body: { error: { code: "CASHBACK_NOT_CLAIMABLE", details: { status: period.status } } },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The wallet idempotency key is PERIOD-SCOPED and deterministic, so
|
|
136
|
+
// concurrent/replayed claims of one period credit exactly once;
|
|
137
|
+
// `externalTransactionId = periodId` is the cross-key backstop.
|
|
138
|
+
const award = await ctx.commands.execute<{ idempotent: boolean }>({
|
|
139
|
+
name: AWARD_BONUS_COMMAND,
|
|
140
|
+
input: {
|
|
141
|
+
playerId,
|
|
142
|
+
amount: period.amount,
|
|
143
|
+
currency: period.currency,
|
|
144
|
+
idempotencyKey: `__PLUGIN_KEY__:claim:${period.periodId}`,
|
|
145
|
+
source: "__PLUGIN_KEY__",
|
|
146
|
+
externalTransactionId: period.periodId,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await periods(ctx).put(period.periodId, {
|
|
151
|
+
...period,
|
|
152
|
+
status: "claimed",
|
|
153
|
+
claimedAt: new Date().toISOString(),
|
|
154
|
+
});
|
|
155
|
+
if (!award.idempotent) {
|
|
156
|
+
await ctx.events.emit({
|
|
157
|
+
name: "plugin.__PLUGIN_KEY__.claimed",
|
|
158
|
+
playerId,
|
|
159
|
+
payload: { periodId: period.periodId, amount: period.amount, currency: period.currency },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
body: { claimed: true as const, periodId: period.periodId, amount: period.amount, currency: period.currency },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Backoffice/ops route: mint a claimable cashback period for a player. */
|
|
168
|
+
export async function accrue(req: PluginRequest, ctx: PluginContext) {
|
|
169
|
+
const body = req.body as z.infer<typeof accrueBodySchema>;
|
|
170
|
+
const { payoutCurrency } = ctx.settings.get<{ payoutCurrency?: string }>();
|
|
171
|
+
const now = new Date();
|
|
172
|
+
const periodId = `cb_${body.playerId}_${now.getTime().toString(36)}`;
|
|
173
|
+
const currency = body.currency ?? payoutCurrency ?? "EUR";
|
|
174
|
+
await periods(ctx).put(periodId, {
|
|
175
|
+
periodId,
|
|
176
|
+
playerId: body.playerId,
|
|
177
|
+
status: "claimable",
|
|
178
|
+
amount: body.amount,
|
|
179
|
+
currency,
|
|
180
|
+
accruedAt: now.toISOString(),
|
|
181
|
+
});
|
|
182
|
+
await ctx.events.emit({
|
|
183
|
+
name: "plugin.__PLUGIN_KEY__.accrued",
|
|
184
|
+
playerId: body.playerId,
|
|
185
|
+
payload: { periodId, amount: body.amount, currency },
|
|
186
|
+
});
|
|
187
|
+
return { status: 201, body: { periodId } };
|
|
188
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { definePlugin } from "@clovnet/plugin-sdk";
|
|
2
|
+
import { settings } from "./settings.js";
|
|
3
|
+
import { onConfigure, onInstall, onUninstall } from "./handlers/hooks.js";
|
|
4
|
+
import {
|
|
5
|
+
AWARD_BONUS_COMMAND,
|
|
6
|
+
accrualPeriodSchema,
|
|
7
|
+
accrue,
|
|
8
|
+
accrueBodySchema,
|
|
9
|
+
claim,
|
|
10
|
+
claimBodySchema,
|
|
11
|
+
claimOutputSchema,
|
|
12
|
+
getSummary,
|
|
13
|
+
listPeriods,
|
|
14
|
+
listPeriodsOutputSchema,
|
|
15
|
+
listPeriodsQuerySchema,
|
|
16
|
+
summaryOutputSchema,
|
|
17
|
+
} from "./handlers/routes.js";
|
|
18
|
+
import { nightlyAccrual } from "./handlers/jobs.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* __PLUGIN_NAME__ — scaffolded from the `cashback` template (the platform's
|
|
22
|
+
* reference plugin): a plugin-private dataset, three player actions
|
|
23
|
+
* (`getSummary`/`listPeriods` queries + the idempotent `claim` mutation), an
|
|
24
|
+
* admin accrue route, a nightly cron and an account-slot frontend widget.
|
|
25
|
+
*/
|
|
26
|
+
export default definePlugin({
|
|
27
|
+
manifest: {
|
|
28
|
+
key: "__PLUGIN_KEY__",
|
|
29
|
+
name: "__PLUGIN_NAME__",
|
|
30
|
+
author: "you",
|
|
31
|
+
version: "0.1.0",
|
|
32
|
+
kind: "integration",
|
|
33
|
+
runtimeCompat: ">=0.1.0 <0.2.0",
|
|
34
|
+
description: "Player cashback accrual periods, claimable to the bonus bucket.",
|
|
35
|
+
permissions: {
|
|
36
|
+
// The ONLY wallet command a plugin may hold for awards — wallet.credit
|
|
37
|
+
// is hard-denied to plugins by the platform.
|
|
38
|
+
commands: [AWARD_BONUS_COMMAND],
|
|
39
|
+
events: {
|
|
40
|
+
subscribe: [],
|
|
41
|
+
emit: ["plugin.__PLUGIN_KEY__.accrued", "plugin.__PLUGIN_KEY__.claimed"],
|
|
42
|
+
},
|
|
43
|
+
// Short event types allowed to fan out to player sockets.
|
|
44
|
+
frontendEvents: ["__PLUGIN_KEY__.accrued", "__PLUGIN_KEY__.claimed"],
|
|
45
|
+
},
|
|
46
|
+
settings,
|
|
47
|
+
datasets: {
|
|
48
|
+
accrual_periods: {
|
|
49
|
+
schema: accrualPeriodSchema,
|
|
50
|
+
keyField: "periodId",
|
|
51
|
+
indexes: ["playerId", "status"],
|
|
52
|
+
maxRecords: 200_000,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
routes: [
|
|
56
|
+
{
|
|
57
|
+
method: "GET",
|
|
58
|
+
path: "/summary",
|
|
59
|
+
surface: "player",
|
|
60
|
+
handler: "getSummary",
|
|
61
|
+
output: summaryOutputSchema,
|
|
62
|
+
rateLimit: { windowSec: 60, max: 120 },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
method: "GET",
|
|
66
|
+
path: "/periods",
|
|
67
|
+
surface: "player",
|
|
68
|
+
handler: "listPeriods",
|
|
69
|
+
input: { query: listPeriodsQuerySchema },
|
|
70
|
+
output: listPeriodsOutputSchema,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
method: "POST",
|
|
74
|
+
path: "/claim",
|
|
75
|
+
surface: "player",
|
|
76
|
+
handler: "claim",
|
|
77
|
+
input: { body: claimBodySchema },
|
|
78
|
+
output: claimOutputSchema,
|
|
79
|
+
idempotent: true,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
method: "POST",
|
|
83
|
+
path: "/accrue",
|
|
84
|
+
surface: "admin",
|
|
85
|
+
handler: "accrue",
|
|
86
|
+
input: { body: accrueBodySchema },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
actions: [
|
|
90
|
+
{
|
|
91
|
+
key: "getSummary",
|
|
92
|
+
route: "GET /summary",
|
|
93
|
+
title: "Cashback summary",
|
|
94
|
+
description: "Claimable, claimed and accruing cashback totals for the player.",
|
|
95
|
+
kind: "query",
|
|
96
|
+
},
|
|
97
|
+
{ key: "listPeriods", route: "GET /periods", title: "List cashback periods", kind: "query" },
|
|
98
|
+
{
|
|
99
|
+
key: "claim",
|
|
100
|
+
route: "POST /claim",
|
|
101
|
+
title: "Claim cashback",
|
|
102
|
+
description: "Credit a claimable period's cashback to the bonus balance.",
|
|
103
|
+
kind: "mutation",
|
|
104
|
+
idempotent: true,
|
|
105
|
+
emits: ["__PLUGIN_KEY__.claimed"],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
frontend: {
|
|
109
|
+
widgets: [
|
|
110
|
+
{
|
|
111
|
+
key: "__PLUGIN_KEY__-card",
|
|
112
|
+
title: "__PLUGIN_NAME__",
|
|
113
|
+
slot: "account",
|
|
114
|
+
dataAction: "getSummary",
|
|
115
|
+
actions: ["claim"],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
jobs: {
|
|
120
|
+
"nightly-accrual": { schedule: "0 3 * * *", handler: "nightly-accrual", timeoutSec: 300 },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
hooks: { onInstall, onConfigure, onUninstall },
|
|
124
|
+
handlers: {
|
|
125
|
+
routes: { getSummary, listPeriods, claim, accrue },
|
|
126
|
+
jobs: { "nightly-accrual": nightlyAccrual },
|
|
127
|
+
},
|
|
128
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { settingsField, type PluginSettingsSchema } from "@clovnet/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tenant settings for the cashback program. `cashbackPercent` drives the
|
|
6
|
+
* nightly accrual sweep; the secret is an example write-only field (stored
|
|
7
|
+
* encrypted, never returned, never logged).
|
|
8
|
+
*/
|
|
9
|
+
export const settings: PluginSettingsSchema = {
|
|
10
|
+
fields: {
|
|
11
|
+
cashbackPercent: settingsField.number({
|
|
12
|
+
label: "Cashback percent",
|
|
13
|
+
description: "Percent of tracked losses accrued as claimable cashback.",
|
|
14
|
+
default: 5,
|
|
15
|
+
zod: z.number().min(0).max(25),
|
|
16
|
+
}),
|
|
17
|
+
payoutCurrency: settingsField.string({
|
|
18
|
+
label: "Payout currency",
|
|
19
|
+
description: "ISO currency the cashback is credited in.",
|
|
20
|
+
default: "EUR",
|
|
21
|
+
zod: z.string().length(3),
|
|
22
|
+
}),
|
|
23
|
+
reportingApiKey: settingsField.string({
|
|
24
|
+
label: "Reporting API key",
|
|
25
|
+
description: "Example write-only secret — read with ctx.secrets.get('reportingApiKey').",
|
|
26
|
+
secret: true,
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTestContext } from "@clovnet/plugin-sdk/testing";
|
|
3
|
+
import type { PluginRequest } from "@clovnet/plugin-sdk";
|
|
4
|
+
import plugin from "../src/index.js";
|
|
5
|
+
import { accrue, claim, getSummary } from "../src/handlers/routes.js";
|
|
6
|
+
import { nightlyAccrual } from "../src/handlers/jobs.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unit tests on the in-memory test context: real permission enforcement,
|
|
10
|
+
* real dataset semantics, canned wallet results — no runtime, no docker,
|
|
11
|
+
* breakpoints work under `vitest --inspect`.
|
|
12
|
+
*/
|
|
13
|
+
const asRequest = (partial: Partial<PluginRequest>): PluginRequest =>
|
|
14
|
+
({ params: {}, query: {}, headers: {}, ...partial }) as PluginRequest;
|
|
15
|
+
|
|
16
|
+
function makeContext() {
|
|
17
|
+
return createTestContext(plugin, {
|
|
18
|
+
tenantId: "t-test",
|
|
19
|
+
settings: { cashbackPercent: 10, payoutCurrency: "EUR" },
|
|
20
|
+
secrets: { reportingApiKey: "test-key" },
|
|
21
|
+
commandResults: {
|
|
22
|
+
// Money commands NEVER execute in tests — canned result + recording.
|
|
23
|
+
"wallet.bonus_award": { idempotent: false, balances: { bonus: "5.0000" } },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("__PLUGIN_KEY__", () => {
|
|
29
|
+
it("accrues then claims exactly once through wallet.bonus_award", async () => {
|
|
30
|
+
const t = makeContext();
|
|
31
|
+
await t.runHook("onInstall");
|
|
32
|
+
|
|
33
|
+
const accrued = await accrue(
|
|
34
|
+
asRequest({ body: { playerId: "p1", amount: 5 }, actor: { type: "staff", id: "admin" } as never }),
|
|
35
|
+
t.ctx,
|
|
36
|
+
);
|
|
37
|
+
expect(accrued.status).toBe(201);
|
|
38
|
+
const periodId = (accrued.body as { periodId: string }).periodId;
|
|
39
|
+
|
|
40
|
+
const claimed = await claim(asRequest({ player: { id: "p1" }, body: { periodId } }), t.ctx);
|
|
41
|
+
expect(claimed.body).toMatchObject({ claimed: true, amount: 5, currency: "EUR" });
|
|
42
|
+
|
|
43
|
+
// The wallet command was recorded with a deterministic, period-scoped key.
|
|
44
|
+
expect(t.recorder.commands).toHaveLength(1);
|
|
45
|
+
expect(t.recorder.commands[0]).toMatchObject({ name: "wallet.bonus_award" });
|
|
46
|
+
expect(t.recorder.events.map((e) => e.name)).toEqual([
|
|
47
|
+
"plugin.__PLUGIN_KEY__.accrued",
|
|
48
|
+
"plugin.__PLUGIN_KEY__.claimed",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// A second claim of the SAME period is a 409, not a second credit.
|
|
52
|
+
const again = await claim(asRequest({ player: { id: "p1" }, body: { periodId } }), t.ctx);
|
|
53
|
+
expect(again.status).toBe(409);
|
|
54
|
+
expect(t.recorder.commands).toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("never leaks another player's period", async () => {
|
|
58
|
+
const t = makeContext();
|
|
59
|
+
await accrue(asRequest({ body: { playerId: "p1", amount: 5 } }), t.ctx);
|
|
60
|
+
const summary = await getSummary(asRequest({ player: { id: "p2" } }), t.ctx);
|
|
61
|
+
expect(summary.body).toMatchObject({ claimableAmount: 0, periodCount: 0 });
|
|
62
|
+
|
|
63
|
+
const stolen = await claim(asRequest({ player: { id: "p2" }, body: { periodId: "cb_p1_x" } }), t.ctx);
|
|
64
|
+
expect(stolen.status).toBe(404);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("nightly sweep flips accruing periods to claimable, idempotently", async () => {
|
|
68
|
+
const t = makeContext();
|
|
69
|
+
const periods = t.ctx.datasets.collection("accrual_periods");
|
|
70
|
+
await periods.put("cb_1", {
|
|
71
|
+
periodId: "cb_1",
|
|
72
|
+
playerId: "p1",
|
|
73
|
+
status: "accruing",
|
|
74
|
+
amount: 2.5,
|
|
75
|
+
currency: "EUR",
|
|
76
|
+
accruedAt: new Date().toISOString(),
|
|
77
|
+
});
|
|
78
|
+
await nightlyAccrual(t.ctx);
|
|
79
|
+
await nightlyAccrual(t.ctx); // idempotent
|
|
80
|
+
const record = await periods.get("cb_1");
|
|
81
|
+
expect(record).toMatchObject({ status: "claimable" });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noUncheckedIndexedAccess": true,
|
|
8
|
+
"verbatimModuleSyntax": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"types": ["node"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src", "test"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# __PLUGIN_NAME__
|
|
2
|
+
|
|
3
|
+
A CasinoWebEngine game-provider plugin skeleton, scaffolded with `cwe-plugin create`.
|
|
4
|
+
|
|
5
|
+
## The golden path
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm create cwe-plugin my-cashback # scaffold: manifest, handlers, tests, config
|
|
9
|
+
cd my-cashback && pnpm install
|
|
10
|
+
|
|
11
|
+
pnpm dev # = cwe-plugin dev
|
|
12
|
+
# → first contact with the scaffold's preset `local` environment: the handshake verifies
|
|
13
|
+
# class=local + devHarness=true and pins its fingerprint → green banner
|
|
14
|
+
# → doctor passes → sandbox tenant resolved → plugin installed+enabled (prompted)
|
|
15
|
+
# → esbuild watch starts, bundle hot-loaded, log stream attached
|
|
16
|
+
# → edit src/handlers/routes.ts, save → rebuilt + hot-reloaded in <2s, logs streaming live
|
|
17
|
+
|
|
18
|
+
# in the dev session (keyboard): i install · e enable · c configure · x disable · u uninstall
|
|
19
|
+
# r reload · d doctor · l clear · q quit
|
|
20
|
+
# each lifecycle key prints a full hook trace: capability calls, events, audit refs, state
|
|
21
|
+
|
|
22
|
+
pnpm test # unit tests on hooks/handlers via the SDK test kit
|
|
23
|
+
|
|
24
|
+
cwe-plugin lifecycle cycle # conformance sweep: install→enable→configure→disable→
|
|
25
|
+
# uninstall→reinstall, asserting idempotency + clean teardown
|
|
26
|
+
|
|
27
|
+
cwe-plugin login # only needed for shared dev clusters / publishing
|
|
28
|
+
cwe-plugin env add dev-eu https://dev-eu.cwe.dev && cwe-plugin env use dev-eu
|
|
29
|
+
pnpm dev # same loop against the shared cluster, per-dev sandbox
|
|
30
|
+
|
|
31
|
+
pnpm publish:dev # = cwe-plugin publish — always the dev channel:
|
|
32
|
+
# doctor + codegen-diff gate + publish
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What's in this template
|
|
36
|
+
|
|
37
|
+
| File | Purpose |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `cwe-plugin.json` | project config: plugin key, entry, environments (committed — no secrets, ever) |
|
|
40
|
+
| `src/index.ts` | `definePlugin({ manifest, hooks, handlers })` — the whole platform contract |
|
|
41
|
+
| `src/settings.ts` | Zod-typed tenant settings (one secret field included) |
|
|
42
|
+
| `src/adapter.ts` | the provider adapter — bets/settlements/rollbacks normalized into wallet commands |
|
|
43
|
+
| `src/fake-backend.ts` | in-memory provider API stand-in (swap for real `ctx.http` calls) |
|
|
44
|
+
| `src/handlers/hooks.ts` | lifecycle hooks (`onEnable` demands the apiKey secret) |
|
|
45
|
+
| `src/handlers/routes.ts` | signature-verified `/tx` callback + public `/status` |
|
|
46
|
+
| `src/handlers/jobs.ts` | nightly reconcile placeholder (dry-run with `cwe-plugin jobs run reconcile`) |
|
|
47
|
+
| `settings.dev.json` | sandbox settings applied by `cwe-plugin lifecycle configure` / the `c` key |
|
|
48
|
+
| `test/hooks.test.ts` | in-memory test-context examples — breakpoints work under `vitest --inspect` |
|
|
49
|
+
|
|
50
|
+
## Useful commands
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cwe-plugin env status # am I pointed at a dev environment? auth? sandbox state?
|
|
54
|
+
cwe-plugin invoke POST /tx --surface callback \
|
|
55
|
+
--body '{"type":"wager","playerId":"p1","roundId":"r1","amount":"5.0000","currency":"EUR","providerTxId":"ptx1"}'
|
|
56
|
+
cwe-plugin settings set greeting=hi # validated locally against the schema before it is sent
|
|
57
|
+
cwe-plugin logs --level warn # standalone log tail for a second terminal
|
|
58
|
+
cwe-plugin doctor --remote # full server-side validation
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Machine-local state (pinned environment fingerprints, log cursors) lives in `.cwe/` — gitignored
|
|
62
|
+
and always safe to delete. Credentials never touch the repo: `cwe-plugin login` stores them in the
|
|
63
|
+
OS keychain.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cwe-plugin-__PLUGIN_KEY__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "__PLUGIN_NAME__ \u2014 a CasinoWebEngine plugin",
|
|
5
|
+
"private": true,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "cwe-plugin dev",
|
|
12
|
+
"doctor": "cwe-plugin doctor",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"build": "cwe-plugin build",
|
|
15
|
+
"codegen": "cwe-plugin codegen client",
|
|
16
|
+
"publish:dev": "cwe-plugin publish"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@clovnet/plugin-sdk": "^0.1.4"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"zod": "^3.23.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@clovnet/plugin-cli": "^0.2.0",
|
|
26
|
+
"@types/node": "^20.16.5",
|
|
27
|
+
"typescript": "^5.6.2",
|
|
28
|
+
"vitest": "^2.1.1",
|
|
29
|
+
"zod": "^3.23.8"
|
|
30
|
+
}
|
|
31
|
+
}
|