@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,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BalanceResult,
|
|
3
|
+
BetResult,
|
|
4
|
+
CloseRoundInput,
|
|
5
|
+
CreateSessionInput,
|
|
6
|
+
CreateSessionResult,
|
|
7
|
+
PlaceBetInput,
|
|
8
|
+
PluginContext,
|
|
9
|
+
ProviderAdapter,
|
|
10
|
+
ProviderContext,
|
|
11
|
+
RollbackInput,
|
|
12
|
+
RoundResult,
|
|
13
|
+
SettleBetInput,
|
|
14
|
+
} from "@clovnet/plugin-sdk";
|
|
15
|
+
import { FakeProviderBackend } from "./fake-backend.js";
|
|
16
|
+
|
|
17
|
+
export const PROVIDER_KEY = "__PLUGIN_KEY__";
|
|
18
|
+
|
|
19
|
+
/** Wallet command names, referenced by string — an external plugin depends
|
|
20
|
+
* only on the SDK. The manifest allowlists exactly these. */
|
|
21
|
+
export const WALLET_PLACE_BET = "wallet.bet.place";
|
|
22
|
+
export const WALLET_SETTLE_BET = "wallet.bet.settle";
|
|
23
|
+
export const WALLET_ROLLBACK_BET = "wallet.bet.rollback";
|
|
24
|
+
|
|
25
|
+
/** Shape of the wallet MovementResult fields the adapter reads. */
|
|
26
|
+
interface MovementLike {
|
|
27
|
+
transactionId: string;
|
|
28
|
+
legs: Array<{ balanceAfter: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The provider adapter NORMALIZES provider API shapes into internal wallet
|
|
33
|
+
* commands — it never mutates wallets directly: `placeBet` →
|
|
34
|
+
* `wallet.bet.place`, `settleBet` → `wallet.bet.settle`, `rollback` →
|
|
35
|
+
* `wallet.bet.rollback`, all through `ctx.commands.execute`
|
|
36
|
+
* (allowlist-enforced). Provider transaction ids are the idempotency keys,
|
|
37
|
+
* so a replayed provider callback can never double-move money.
|
|
38
|
+
*/
|
|
39
|
+
export class ProviderSkeletonAdapter implements ProviderAdapter {
|
|
40
|
+
readonly name = PROVIDER_KEY;
|
|
41
|
+
|
|
42
|
+
private readonly sessions = new Map<string, { playerId: string; gameId: string; currency: string }>();
|
|
43
|
+
/** roundId → wallet transactionId of the bet (rollback needs the original). */
|
|
44
|
+
private readonly betTransactions = new Map<string, string>();
|
|
45
|
+
private readonly lastBalances = new Map<string, string>();
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly ctx: PluginContext,
|
|
49
|
+
private readonly backend: FakeProviderBackend,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
async createSession(_pctx: ProviderContext, input: CreateSessionInput): Promise<CreateSessionResult> {
|
|
53
|
+
const { sessionId, launchUrl } = this.backend.createSession(input);
|
|
54
|
+
this.sessions.set(sessionId, {
|
|
55
|
+
playerId: input.playerId,
|
|
56
|
+
gameId: input.gameId,
|
|
57
|
+
currency: input.currency,
|
|
58
|
+
});
|
|
59
|
+
this.ctx.logger.info("__PLUGIN_KEY__.session_created", { sessionId, gameId: input.gameId });
|
|
60
|
+
return { sessionId, launchUrl };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getBalance(_pctx: ProviderContext, playerId: string, currency: string): Promise<BalanceResult> {
|
|
64
|
+
// Plugins hold no wallet-read capability: report the balance the wallet
|
|
65
|
+
// returned on the last movement (what a real aggregator caches).
|
|
66
|
+
return { currency, balance: this.lastBalances.get(`${playerId}:${currency}`) ?? "0.0000" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private session(sessionId: string): { playerId: string; gameId: string; currency: string } {
|
|
70
|
+
const session = this.sessions.get(sessionId);
|
|
71
|
+
if (!session) throw new Error(`unknown session '${sessionId}'`);
|
|
72
|
+
return session;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async placeBet(_pctx: ProviderContext, input: PlaceBetInput): Promise<BetResult> {
|
|
76
|
+
const session = this.session(input.sessionId);
|
|
77
|
+
const { providerTxId } = this.backend.wager({ sessionId: input.sessionId, roundId: input.roundId });
|
|
78
|
+
|
|
79
|
+
const result = await this.ctx.commands.execute<MovementLike>({
|
|
80
|
+
name: WALLET_PLACE_BET,
|
|
81
|
+
input: {
|
|
82
|
+
playerId: session.playerId,
|
|
83
|
+
amount: Number(input.amount),
|
|
84
|
+
currency: input.currency,
|
|
85
|
+
provider: PROVIDER_KEY,
|
|
86
|
+
roundId: input.roundId,
|
|
87
|
+
gameId: session.gameId,
|
|
88
|
+
externalTransactionId: providerTxId,
|
|
89
|
+
idempotencyKey: `${PROVIDER_KEY}:${providerTxId}`,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
this.betTransactions.set(input.roundId, result.transactionId);
|
|
94
|
+
const balance = result.legs[0]?.balanceAfter ?? "0.0000";
|
|
95
|
+
this.lastBalances.set(`${session.playerId}:${input.currency}`, balance);
|
|
96
|
+
return { roundId: input.roundId, balance, status: "ok" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async settleBet(_pctx: ProviderContext, input: SettleBetInput): Promise<BetResult> {
|
|
100
|
+
const session = this.session(input.sessionId);
|
|
101
|
+
const { providerTxId } = this.backend.result({ sessionId: input.sessionId, roundId: input.roundId });
|
|
102
|
+
|
|
103
|
+
const result = await this.ctx.commands.execute<MovementLike>({
|
|
104
|
+
name: WALLET_SETTLE_BET,
|
|
105
|
+
input: {
|
|
106
|
+
playerId: session.playerId,
|
|
107
|
+
amount: Number(input.payout),
|
|
108
|
+
currency: input.currency,
|
|
109
|
+
provider: PROVIDER_KEY,
|
|
110
|
+
roundId: input.roundId,
|
|
111
|
+
gameId: session.gameId,
|
|
112
|
+
externalTransactionId: providerTxId,
|
|
113
|
+
idempotencyKey: `${PROVIDER_KEY}:${providerTxId}`,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const balance =
|
|
118
|
+
result.legs[0]?.balanceAfter ?? this.lastBalances.get(`${session.playerId}:${input.currency}`) ?? "0.0000";
|
|
119
|
+
this.lastBalances.set(`${session.playerId}:${input.currency}`, balance);
|
|
120
|
+
|
|
121
|
+
await this.ctx.events.emit({
|
|
122
|
+
name: "plugin.__PLUGIN_KEY__.round_completed",
|
|
123
|
+
payload: { roundId: input.roundId, playerId: session.playerId, payout: input.payout },
|
|
124
|
+
});
|
|
125
|
+
return { roundId: input.roundId, balance, status: "ok" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async rollback(_pctx: ProviderContext, input: RollbackInput): Promise<BetResult> {
|
|
129
|
+
const session = this.session(input.sessionId);
|
|
130
|
+
this.backend.rollback({ sessionId: input.sessionId, roundId: input.roundId });
|
|
131
|
+
|
|
132
|
+
const betTxId = this.betTransactions.get(input.roundId);
|
|
133
|
+
if (!betTxId) throw new Error(`no bet on record for round '${input.roundId}'`);
|
|
134
|
+
|
|
135
|
+
// Deterministic idempotency (`rollback:<txId>`): a duplicate rollback
|
|
136
|
+
// replays — it never reverses twice.
|
|
137
|
+
const result = await this.ctx.commands.execute<MovementLike>({
|
|
138
|
+
name: WALLET_ROLLBACK_BET,
|
|
139
|
+
input: {
|
|
140
|
+
transactionId: betTxId,
|
|
141
|
+
reason: "provider_rollback",
|
|
142
|
+
idempotencyKey: `rollback:${betTxId}`,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const balance = result.legs[0]?.balanceAfter ?? "0.0000";
|
|
147
|
+
this.lastBalances.set(`${session.playerId}:${session.currency}`, balance);
|
|
148
|
+
return { roundId: input.roundId, balance, status: "ok" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async closeRound(_pctx: ProviderContext, input: CloseRoundInput): Promise<RoundResult> {
|
|
152
|
+
this.backend.closeRound({ sessionId: input.sessionId, roundId: input.roundId });
|
|
153
|
+
this.betTransactions.delete(input.roundId);
|
|
154
|
+
return { roundId: input.roundId, status: "closed" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async freeSpin(): Promise<BetResult> {
|
|
158
|
+
return { roundId: "", balance: "0.0000", status: "rejected" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async bonusWin(): Promise<BetResult> {
|
|
162
|
+
return { roundId: "", balance: "0.0000", status: "rejected" };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory stand-in for the real provider's API. Replace every method
|
|
3
|
+
* with real HTTP calls (`ctx.http.fetch` against `settings.baseUrl` with
|
|
4
|
+
* `secretHeaders: { authorization: "apiKey" }`) when integrating — until
|
|
5
|
+
* then the whole bet lifecycle is exercisable end-to-end offline.
|
|
6
|
+
*/
|
|
7
|
+
export interface FakeSession {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
playerId: string;
|
|
10
|
+
gameId: string;
|
|
11
|
+
currency: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class FakeProviderBackend {
|
|
15
|
+
private counter = 0;
|
|
16
|
+
private readonly sessions = new Map<string, FakeSession>();
|
|
17
|
+
|
|
18
|
+
constructor(private readonly opts: { baseUrl: string; operatorId: string }) {}
|
|
19
|
+
|
|
20
|
+
createSession(input: { playerId: string; gameId: string; currency: string }): {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
launchUrl: string;
|
|
23
|
+
} {
|
|
24
|
+
const sessionId = `sess_${++this.counter}`;
|
|
25
|
+
this.sessions.set(sessionId, { sessionId, ...input });
|
|
26
|
+
return {
|
|
27
|
+
sessionId,
|
|
28
|
+
launchUrl: `${this.opts.baseUrl}/launch/${input.gameId}?session=${sessionId}&op=${this.opts.operatorId}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The fake mints provider transaction ids the way a real aggregator would. */
|
|
33
|
+
wager(input: { sessionId: string; roundId: string }): { providerTxId: string } {
|
|
34
|
+
this.assertSession(input.sessionId);
|
|
35
|
+
return { providerTxId: `wager_${input.roundId}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
result(input: { sessionId: string; roundId: string }): { providerTxId: string } {
|
|
39
|
+
this.assertSession(input.sessionId);
|
|
40
|
+
return { providerTxId: `result_${input.roundId}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
rollback(input: { sessionId: string; roundId: string }): void {
|
|
44
|
+
this.assertSession(input.sessionId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
closeRound(input: { sessionId: string; roundId: string }): void {
|
|
48
|
+
this.assertSession(input.sessionId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private assertSession(sessionId: string): FakeSession {
|
|
52
|
+
const session = this.sessions.get(sessionId);
|
|
53
|
+
if (!session) throw new Error(`unknown provider session '${sessionId}'`);
|
|
54
|
+
return session;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PluginContext } from "@clovnet/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle hooks. `onEnable` demands the provider credentials: enabling a
|
|
5
|
+
* provider without its secrets is a misconfiguration the platform surfaces
|
|
6
|
+
* as `state=errored` — better at enable time than on the first real bet.
|
|
7
|
+
*/
|
|
8
|
+
export async function onInstall(ctx: PluginContext): Promise<void> {
|
|
9
|
+
ctx.logger.info("__PLUGIN_KEY__.installed");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function onEnable(ctx: PluginContext): Promise<void> {
|
|
13
|
+
const apiKey = await ctx.secrets.get("apiKey");
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
throw new Error("__PLUGIN_KEY__ requires the 'apiKey' secret — save settings before enabling");
|
|
16
|
+
}
|
|
17
|
+
ctx.logger.info("__PLUGIN_KEY__.enabled");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function onConfigure(ctx: PluginContext): Promise<void> {
|
|
21
|
+
ctx.logger.info("__PLUGIN_KEY__.configured");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function onDisable(ctx: PluginContext): Promise<void> {
|
|
25
|
+
ctx.logger.info("__PLUGIN_KEY__.disabled");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function onUninstall(ctx: PluginContext): Promise<void> {
|
|
29
|
+
ctx.logger.info("__PLUGIN_KEY__.uninstalled");
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PluginContext } from "@clovnet/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Nightly reconciliation placeholder: a real provider integration re-fetches
|
|
5
|
+
* open rounds / the game catalog here (via `ctx.http.fetch` against the
|
|
6
|
+
* allowlisted provider host). Dry-run: `cwe-plugin jobs run reconcile`.
|
|
7
|
+
*/
|
|
8
|
+
export async function reconcile(ctx: PluginContext): Promise<void> {
|
|
9
|
+
const { baseUrl } = ctx.settings.get<{ baseUrl?: string }>();
|
|
10
|
+
ctx.logger.info("__PLUGIN_KEY__.reconcile_started", { baseUrl });
|
|
11
|
+
// Example allowlisted outbound call with host-side secret injection:
|
|
12
|
+
// const res = await ctx.http.fetch(`${baseUrl}/v1/rounds/open`, {
|
|
13
|
+
// secretHeaders: { authorization: "apiKey" },
|
|
14
|
+
// });
|
|
15
|
+
ctx.logger.info("__PLUGIN_KEY__.reconcile_completed");
|
|
16
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PluginContext, PluginRequest } from "@clovnet/plugin-sdk";
|
|
3
|
+
import { PROVIDER_KEY, WALLET_PLACE_BET, WALLET_SETTLE_BET, WALLET_ROLLBACK_BET } from "../adapter.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The inbound provider webhook — the latency-sensitive money path.
|
|
7
|
+
* Signature verification comes FIRST, then wallet commands with provider tx
|
|
8
|
+
* ids as idempotency keys: a replayed callback can never double-move money.
|
|
9
|
+
* Exercise it locally with:
|
|
10
|
+
* cwe-plugin invoke POST /tx --surface callback --body '{"type":"wager",...}'
|
|
11
|
+
* (the route console skips the signature in the sandbox).
|
|
12
|
+
*/
|
|
13
|
+
export const txCallbackBodySchema = z
|
|
14
|
+
.object({
|
|
15
|
+
type: z.enum(["wager", "result", "rollback"]),
|
|
16
|
+
playerId: z.string().min(1).max(64),
|
|
17
|
+
roundId: z.string().min(1).max(128),
|
|
18
|
+
gameKey: z.string().min(1).max(128).optional(),
|
|
19
|
+
amount: z
|
|
20
|
+
.string()
|
|
21
|
+
.regex(/^\d+(\.\d{1,4})?$/)
|
|
22
|
+
.optional(),
|
|
23
|
+
currency: z.string().length(3).optional(),
|
|
24
|
+
providerTxId: z.string().min(1).max(128),
|
|
25
|
+
/** rollback: the providerTxId of the wager being reversed. */
|
|
26
|
+
refTxId: z.string().min(1).max(128).optional(),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
|
|
30
|
+
interface MovementLike {
|
|
31
|
+
transactionId: string;
|
|
32
|
+
legs: Array<{ balanceAfter: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function txCallback(req: PluginRequest, ctx: PluginContext) {
|
|
36
|
+
await req.verifySignature("callbackSigningSecret");
|
|
37
|
+
const body = req.body as z.infer<typeof txCallbackBodySchema>;
|
|
38
|
+
|
|
39
|
+
switch (body.type) {
|
|
40
|
+
case "wager": {
|
|
41
|
+
if (!body.amount || !body.currency) {
|
|
42
|
+
return { status: 400, body: { error: { code: "MISSING_AMOUNT" } } };
|
|
43
|
+
}
|
|
44
|
+
const result = await ctx.commands.execute<MovementLike>({
|
|
45
|
+
name: WALLET_PLACE_BET,
|
|
46
|
+
input: {
|
|
47
|
+
playerId: body.playerId,
|
|
48
|
+
amount: Number(body.amount),
|
|
49
|
+
currency: body.currency,
|
|
50
|
+
provider: PROVIDER_KEY,
|
|
51
|
+
roundId: body.roundId,
|
|
52
|
+
externalTransactionId: body.providerTxId,
|
|
53
|
+
idempotencyKey: `${PROVIDER_KEY}:${body.providerTxId}`,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
body: {
|
|
58
|
+
status: "ok",
|
|
59
|
+
transactionId: result.transactionId,
|
|
60
|
+
balance: result.legs[0]?.balanceAfter ?? "0.0000",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case "result": {
|
|
66
|
+
if (!body.amount || !body.currency) {
|
|
67
|
+
return { status: 400, body: { error: { code: "MISSING_AMOUNT" } } };
|
|
68
|
+
}
|
|
69
|
+
const result = await ctx.commands.execute<MovementLike>({
|
|
70
|
+
name: WALLET_SETTLE_BET,
|
|
71
|
+
input: {
|
|
72
|
+
playerId: body.playerId,
|
|
73
|
+
amount: Number(body.amount),
|
|
74
|
+
currency: body.currency,
|
|
75
|
+
provider: PROVIDER_KEY,
|
|
76
|
+
roundId: body.roundId,
|
|
77
|
+
externalTransactionId: body.providerTxId,
|
|
78
|
+
idempotencyKey: `${PROVIDER_KEY}:${body.providerTxId}`,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await ctx.events.emit({
|
|
82
|
+
name: "plugin.__PLUGIN_KEY__.round_completed",
|
|
83
|
+
payload: { roundId: body.roundId, playerId: body.playerId, payout: body.amount },
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
body: {
|
|
87
|
+
status: "ok",
|
|
88
|
+
transactionId: result.transactionId,
|
|
89
|
+
balance: result.legs[0]?.balanceAfter ?? "0.0000",
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case "rollback": {
|
|
95
|
+
if (!body.refTxId) return { status: 400, body: { error: { code: "MISSING_REF_TX" } } };
|
|
96
|
+
// Resolve the original bet through the scoped read model (bets:read).
|
|
97
|
+
const round = await ctx.data.query("bets.byRound", {
|
|
98
|
+
source: PROVIDER_KEY,
|
|
99
|
+
externalTransactionId: body.refTxId,
|
|
100
|
+
});
|
|
101
|
+
const bet = round.bets.find((b) => b.type === "bet");
|
|
102
|
+
if (!bet) return { status: 404, body: { error: { code: "BET_NOT_FOUND" } } };
|
|
103
|
+
const result = await ctx.commands.execute<MovementLike>({
|
|
104
|
+
name: WALLET_ROLLBACK_BET,
|
|
105
|
+
input: {
|
|
106
|
+
transactionId: bet.transactionId,
|
|
107
|
+
reason: "provider_rollback",
|
|
108
|
+
idempotencyKey: `rollback:${bet.transactionId}`,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
body: {
|
|
113
|
+
status: "ok",
|
|
114
|
+
transactionId: result.transactionId,
|
|
115
|
+
balance: result.legs[0]?.balanceAfter ?? "0.0000",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Public health/lobby placeholder — replace with your game catalog surface. */
|
|
123
|
+
export const statusOutputSchema = z.object({ provider: z.string(), operational: z.boolean() });
|
|
124
|
+
|
|
125
|
+
export async function providerStatus(_req: PluginRequest, ctx: PluginContext) {
|
|
126
|
+
const { baseUrl } = ctx.settings.get<{ baseUrl?: string }>();
|
|
127
|
+
return { body: { provider: PROVIDER_KEY, operational: Boolean(baseUrl) } };
|
|
128
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { definePlugin } from "@clovnet/plugin-sdk";
|
|
2
|
+
import { settings } from "./settings.js";
|
|
3
|
+
import { PROVIDER_KEY, ProviderSkeletonAdapter, WALLET_PLACE_BET, WALLET_ROLLBACK_BET, WALLET_SETTLE_BET } from "./adapter.js";
|
|
4
|
+
import { FakeProviderBackend } from "./fake-backend.js";
|
|
5
|
+
import { onConfigure, onDisable, onEnable, onInstall, onUninstall } from "./handlers/hooks.js";
|
|
6
|
+
import { providerStatus, statusOutputSchema, txCallback, txCallbackBodySchema } from "./handlers/routes.js";
|
|
7
|
+
import { reconcile } from "./handlers/jobs.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* __PLUGIN_NAME__ — scaffolded from the `provider-skeleton` template: a game
|
|
11
|
+
* provider adapter (launch/bet/settle/rollback normalized into wallet
|
|
12
|
+
* commands), a signature-verified transaction callback and a fake backend so
|
|
13
|
+
* the whole money path is exercisable before any real API exists.
|
|
14
|
+
*/
|
|
15
|
+
export default definePlugin({
|
|
16
|
+
manifest: {
|
|
17
|
+
key: "__PLUGIN_KEY__",
|
|
18
|
+
name: "__PLUGIN_NAME__",
|
|
19
|
+
author: "you",
|
|
20
|
+
version: "0.1.0",
|
|
21
|
+
kind: "provider",
|
|
22
|
+
runtimeCompat: ">=0.1.0 <0.2.0",
|
|
23
|
+
description:
|
|
24
|
+
"Provider integration skeleton: game launch, bets, settlements and rollbacks — money moves only via wallet commands.",
|
|
25
|
+
permissions: {
|
|
26
|
+
commands: [WALLET_PLACE_BET, WALLET_SETTLE_BET, WALLET_ROLLBACK_BET],
|
|
27
|
+
events: {
|
|
28
|
+
subscribe: [],
|
|
29
|
+
emit: ["plugin.__PLUGIN_KEY__.round_completed"],
|
|
30
|
+
},
|
|
31
|
+
// bets:read powers the rollback path (resolve the original bet).
|
|
32
|
+
dataScopes: ["bets:read"],
|
|
33
|
+
providerKeys: [PROVIDER_KEY],
|
|
34
|
+
},
|
|
35
|
+
settings,
|
|
36
|
+
provider: {
|
|
37
|
+
providerKey: PROVIDER_KEY,
|
|
38
|
+
capabilities: ["launch", "balance", "bet", "settle", "rollback", "closeRound"],
|
|
39
|
+
},
|
|
40
|
+
hooks: {
|
|
41
|
+
onInstall: true,
|
|
42
|
+
onEnable: true,
|
|
43
|
+
onConfigure: true,
|
|
44
|
+
onDisable: true,
|
|
45
|
+
onUninstall: true,
|
|
46
|
+
},
|
|
47
|
+
// Outbound HTTP is allowlisted by host — everything else is egress-denied.
|
|
48
|
+
network: { allowedHosts: ["api.__PLUGIN_KEY__.example", "*.__PLUGIN_KEY__.example"] },
|
|
49
|
+
routes: [
|
|
50
|
+
{
|
|
51
|
+
method: "GET",
|
|
52
|
+
path: "/status",
|
|
53
|
+
surface: "public",
|
|
54
|
+
handler: "provider-status",
|
|
55
|
+
output: statusOutputSchema,
|
|
56
|
+
rateLimit: { windowSec: 60, max: 120 },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
method: "POST",
|
|
60
|
+
path: "/tx",
|
|
61
|
+
surface: "callback",
|
|
62
|
+
handler: "tx-callback",
|
|
63
|
+
input: { body: txCallbackBodySchema },
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
jobs: {
|
|
67
|
+
reconcile: { schedule: "0 4 * * *", handler: "reconcile", timeoutSec: 600 },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
hooks: { onInstall, onEnable, onConfigure, onDisable, onUninstall },
|
|
71
|
+
handlers: {
|
|
72
|
+
routes: { "provider-status": providerStatus, "tx-callback": txCallback },
|
|
73
|
+
jobs: { reconcile },
|
|
74
|
+
},
|
|
75
|
+
setup: (ctx) => {
|
|
76
|
+
const { baseUrl, operatorId } = ctx.settings.get<{ baseUrl?: string; operatorId?: string }>();
|
|
77
|
+
const backend = new FakeProviderBackend({
|
|
78
|
+
baseUrl: baseUrl ?? "https://api.__PLUGIN_KEY__.example",
|
|
79
|
+
operatorId: operatorId ?? "op-demo",
|
|
80
|
+
});
|
|
81
|
+
ctx.registerProviderAdapter?.(new ProviderSkeletonAdapter(ctx, backend));
|
|
82
|
+
},
|
|
83
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { settingsField, type PluginSettingsSchema } from "@clovnet/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provider credentials + endpoints. Both secrets are write-only: stored
|
|
6
|
+
* encrypted, injected server-side (`secretHeaders`) or read via
|
|
7
|
+
* `ctx.secrets.get(...)` — they never appear in logs or API responses.
|
|
8
|
+
*/
|
|
9
|
+
export const settings: PluginSettingsSchema = {
|
|
10
|
+
fields: {
|
|
11
|
+
baseUrl: settingsField.string({
|
|
12
|
+
label: "API base URL",
|
|
13
|
+
description: "The provider API endpoint the adapter calls.",
|
|
14
|
+
required: true,
|
|
15
|
+
default: "https://api.__PLUGIN_KEY__.example",
|
|
16
|
+
zod: z.string().url(),
|
|
17
|
+
}),
|
|
18
|
+
operatorId: settingsField.string({
|
|
19
|
+
label: "Operator ID",
|
|
20
|
+
description: "Your operator account with the provider.",
|
|
21
|
+
required: true,
|
|
22
|
+
default: "op-demo",
|
|
23
|
+
}),
|
|
24
|
+
apiKey: settingsField.string({
|
|
25
|
+
label: "API key",
|
|
26
|
+
description: "Provider API key. Write-only.",
|
|
27
|
+
required: true,
|
|
28
|
+
secret: true,
|
|
29
|
+
}),
|
|
30
|
+
callbackSigningSecret: settingsField.string({
|
|
31
|
+
label: "Callback signing secret",
|
|
32
|
+
description: "HMAC secret used to verify provider callbacks. Write-only.",
|
|
33
|
+
required: true,
|
|
34
|
+
secret: true,
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -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 { txCallback } from "../src/handlers/routes.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Unit tests for the money path — wallet commands are canned + recorded by
|
|
9
|
+
* the in-memory test context (they NEVER execute), while the same
|
|
10
|
+
* permission/idempotency discipline as production applies.
|
|
11
|
+
*/
|
|
12
|
+
const asRequest = (partial: Partial<PluginRequest>): PluginRequest =>
|
|
13
|
+
({
|
|
14
|
+
params: {},
|
|
15
|
+
query: {},
|
|
16
|
+
headers: {},
|
|
17
|
+
// The test context's verifySignature honors trustSignature-style fakes;
|
|
18
|
+
// for direct handler calls we stub it as verified.
|
|
19
|
+
verifySignature: async () => {},
|
|
20
|
+
...partial,
|
|
21
|
+
}) as unknown as PluginRequest;
|
|
22
|
+
|
|
23
|
+
function makeContext() {
|
|
24
|
+
return createTestContext(plugin, {
|
|
25
|
+
tenantId: "t-test",
|
|
26
|
+
settings: { baseUrl: "https://api.__PLUGIN_KEY__.example", operatorId: "op-demo" },
|
|
27
|
+
secrets: { apiKey: "test-key", callbackSigningSecret: "test-secret" },
|
|
28
|
+
commandResults: {
|
|
29
|
+
"wallet.bet.place": { transactionId: "tx_bet_1", legs: [{ balanceAfter: "95.0000" }] },
|
|
30
|
+
"wallet.bet.settle": { transactionId: "tx_settle_1", legs: [{ balanceAfter: "105.0000" }] },
|
|
31
|
+
"wallet.bet.rollback": { transactionId: "tx_rb_1", legs: [{ balanceAfter: "100.0000" }] },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("__PLUGIN_KEY__", () => {
|
|
37
|
+
it("blocks enable until the apiKey secret is saved", async () => {
|
|
38
|
+
const bare = createTestContext(plugin, { tenantId: "t-test", settings: {} });
|
|
39
|
+
await expect(bare.runHook("onEnable")).rejects.toThrow(/apiKey/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("wager callback dispatches wallet.bet.place with the provider tx id as idempotency key", async () => {
|
|
43
|
+
const t = makeContext();
|
|
44
|
+
const response = await txCallback(
|
|
45
|
+
asRequest({
|
|
46
|
+
body: {
|
|
47
|
+
type: "wager",
|
|
48
|
+
playerId: "p1",
|
|
49
|
+
roundId: "r1",
|
|
50
|
+
amount: "5.0000",
|
|
51
|
+
currency: "EUR",
|
|
52
|
+
providerTxId: "ptx_1",
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
t.ctx,
|
|
56
|
+
);
|
|
57
|
+
expect(response.body).toMatchObject({ status: "ok", balance: "95.0000" });
|
|
58
|
+
expect(t.recorder.commands).toHaveLength(1);
|
|
59
|
+
expect(t.recorder.commands[0]).toMatchObject({ name: "wallet.bet.place" });
|
|
60
|
+
const input = t.recorder.commands[0]?.input as { idempotencyKey: string; externalTransactionId: string };
|
|
61
|
+
expect(input.idempotencyKey).toBe("__PLUGIN_KEY__:ptx_1");
|
|
62
|
+
expect(input.externalTransactionId).toBe("ptx_1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("settlement emits the round_completed event", async () => {
|
|
66
|
+
const t = makeContext();
|
|
67
|
+
await txCallback(
|
|
68
|
+
asRequest({
|
|
69
|
+
body: {
|
|
70
|
+
type: "result",
|
|
71
|
+
playerId: "p1",
|
|
72
|
+
roundId: "r1",
|
|
73
|
+
amount: "10.0000",
|
|
74
|
+
currency: "EUR",
|
|
75
|
+
providerTxId: "ptx_2",
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
t.ctx,
|
|
79
|
+
);
|
|
80
|
+
expect(t.recorder.events).toHaveLength(1);
|
|
81
|
+
expect(t.recorder.events[0]?.name).toBe("plugin.__PLUGIN_KEY__.round_completed");
|
|
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
|
+
}
|