@wopr-network/platform-core 1.34.0 → 1.35.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/dist/billing/crypto/btc/address-gen.d.ts +4 -0
- package/dist/billing/crypto/btc/address-gen.js +44 -0
- package/dist/billing/crypto/oracle/chainlink.js +2 -2
- package/dist/billing/crypto/oracle/types.d.ts +1 -1
- package/dist/billing/crypto/unified-checkout.js +33 -20
- package/dist/email/handlebars-renderer.test.d.ts +1 -0
- package/dist/email/handlebars-renderer.test.js +174 -0
- package/dist/fleet/fleet-notification-listener.test.d.ts +1 -0
- package/dist/fleet/fleet-notification-listener.test.js +244 -0
- package/dist/fleet/init-fleet-updater.d.ts +1 -1
- package/dist/fleet/init-fleet-updater.js +19 -3
- package/dist/fleet/init-fleet-updater.test.d.ts +1 -0
- package/dist/fleet/init-fleet-updater.test.js +186 -0
- package/dist/trpc/admin-fleet-update-router.test.d.ts +1 -0
- package/dist/trpc/admin-fleet-update-router.test.js +164 -0
- package/drizzle/migrations/0012_seed_popular_tokens.sql +12 -0
- package/package.json +1 -1
- package/src/billing/crypto/btc/address-gen.ts +49 -0
- package/src/billing/crypto/oracle/chainlink.ts +3 -3
- package/src/billing/crypto/oracle/types.ts +1 -1
- package/src/billing/crypto/unified-checkout.ts +34 -20
- package/src/email/handlebars-renderer.test.ts +209 -0
- package/src/fleet/fleet-notification-listener.test.ts +322 -0
- package/src/fleet/init-fleet-updater.test.ts +234 -0
- package/src/fleet/init-fleet-updater.ts +22 -4
- package/src/trpc/admin-fleet-update-router.test.ts +201 -0
|
@@ -11,6 +11,10 @@ export type UtxoNetwork = "mainnet" | "testnet" | "regtest";
|
|
|
11
11
|
export declare function deriveAddress(xpub: string, index: number, network?: UtxoNetwork, chain?: UtxoChain): string;
|
|
12
12
|
/** Derive the treasury address (internal chain, index 0). */
|
|
13
13
|
export declare function deriveTreasury(xpub: string, network?: UtxoNetwork, chain?: UtxoChain): string;
|
|
14
|
+
/**
|
|
15
|
+
* Derive a P2PKH (Base58Check) address for chains without bech32 (e.g., DOGE → D...).
|
|
16
|
+
*/
|
|
17
|
+
export declare function deriveP2pkhAddress(xpub: string, index: number, chain: string, network?: "mainnet" | "testnet"): string;
|
|
14
18
|
/** @deprecated Use `deriveAddress` instead. */
|
|
15
19
|
export declare const deriveBtcAddress: typeof deriveAddress;
|
|
16
20
|
/** @deprecated Use `deriveTreasury` instead. */
|
|
@@ -39,6 +39,50 @@ export function deriveTreasury(xpub, network = "mainnet", chain = "bitcoin") {
|
|
|
39
39
|
const words = bech32.toWords(hash160);
|
|
40
40
|
return bech32.encode(prefix, [0, ...words]);
|
|
41
41
|
}
|
|
42
|
+
/** P2PKH version bytes for Base58Check encoding (chains without bech32). */
|
|
43
|
+
const P2PKH_VERSION = {
|
|
44
|
+
dogecoin: { mainnet: 0x1e, testnet: 0x71 },
|
|
45
|
+
};
|
|
46
|
+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
47
|
+
function base58encode(data) {
|
|
48
|
+
let num = 0n;
|
|
49
|
+
for (const byte of data)
|
|
50
|
+
num = num * 256n + BigInt(byte);
|
|
51
|
+
let encoded = "";
|
|
52
|
+
while (num > 0n) {
|
|
53
|
+
encoded = BASE58_ALPHABET[Number(num % 58n)] + encoded;
|
|
54
|
+
num = num / 58n;
|
|
55
|
+
}
|
|
56
|
+
for (const byte of data) {
|
|
57
|
+
if (byte !== 0)
|
|
58
|
+
break;
|
|
59
|
+
encoded = "1" + encoded;
|
|
60
|
+
}
|
|
61
|
+
return encoded;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Derive a P2PKH (Base58Check) address for chains without bech32 (e.g., DOGE → D...).
|
|
65
|
+
*/
|
|
66
|
+
export function deriveP2pkhAddress(xpub, index, chain, network = "mainnet") {
|
|
67
|
+
if (!Number.isInteger(index) || index < 0)
|
|
68
|
+
throw new Error(`Invalid derivation index: ${index}`);
|
|
69
|
+
const version = P2PKH_VERSION[chain]?.[network];
|
|
70
|
+
if (version === undefined)
|
|
71
|
+
throw new Error(`No P2PKH version for chain=${chain} network=${network}`);
|
|
72
|
+
const master = HDKey.fromExtendedKey(xpub);
|
|
73
|
+
const child = master.deriveChild(0).deriveChild(index);
|
|
74
|
+
if (!child.publicKey)
|
|
75
|
+
throw new Error("Failed to derive public key");
|
|
76
|
+
const hash160 = ripemd160(sha256(child.publicKey));
|
|
77
|
+
const payload = new Uint8Array(21);
|
|
78
|
+
payload[0] = version;
|
|
79
|
+
payload.set(hash160, 1);
|
|
80
|
+
const checksum = sha256(sha256(payload));
|
|
81
|
+
const full = new Uint8Array(25);
|
|
82
|
+
full.set(payload);
|
|
83
|
+
full.set(checksum.slice(0, 4), 21);
|
|
84
|
+
return base58encode(full);
|
|
85
|
+
}
|
|
42
86
|
/** @deprecated Use `deriveAddress` instead. */
|
|
43
87
|
export const deriveBtcAddress = deriveAddress;
|
|
44
88
|
/** @deprecated Use `deriveTreasury` instead. */
|
|
@@ -25,11 +25,11 @@ export class ChainlinkOracle {
|
|
|
25
25
|
maxStalenessMs;
|
|
26
26
|
constructor(opts) {
|
|
27
27
|
this.rpc = opts.rpcCall;
|
|
28
|
-
this.feeds = { ...FEED_ADDRESSES, ...opts.feedAddresses };
|
|
28
|
+
this.feeds = new Map(Object.entries({ ...FEED_ADDRESSES, ...opts.feedAddresses }));
|
|
29
29
|
this.maxStalenessMs = opts.maxStalenessMs ?? DEFAULT_MAX_STALENESS_MS;
|
|
30
30
|
}
|
|
31
31
|
async getPrice(asset) {
|
|
32
|
-
const feedAddress = this.feeds
|
|
32
|
+
const feedAddress = this.feeds.get(asset);
|
|
33
33
|
if (!feedAddress)
|
|
34
34
|
throw new Error(`No price feed for asset: ${asset}`);
|
|
35
35
|
const result = (await this.rpc("eth_call", [{ to: feedAddress, data: LATEST_ROUND_DATA }, "latest"]));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Credit } from "../../credits/credit.js";
|
|
2
|
+
import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
|
|
2
3
|
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
3
4
|
import { centsToNative } from "./oracle/convert.js";
|
|
4
5
|
export const MIN_CHECKOUT_USD = 10;
|
|
@@ -20,11 +21,11 @@ export async function createUnifiedCheckout(deps, method, opts) {
|
|
|
20
21
|
if (method.type === "erc20") {
|
|
21
22
|
return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
22
23
|
}
|
|
23
|
-
if (method.
|
|
24
|
-
return
|
|
24
|
+
if (method.type === "native" && method.chain === "base") {
|
|
25
|
+
return handleNativeEvm(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
25
26
|
}
|
|
26
|
-
if (method.
|
|
27
|
-
return
|
|
27
|
+
if (method.type === "native") {
|
|
28
|
+
return handleNativeUtxo(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
|
|
28
29
|
}
|
|
29
30
|
throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
|
|
30
31
|
}
|
|
@@ -39,7 +40,7 @@ async function handleErc20(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
|
39
40
|
referenceId: `erc20:${method.chain}:${depositAddress}`,
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
|
-
async function
|
|
43
|
+
async function handleNativeEvm(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
43
44
|
const { priceCents } = await deps.oracle.getPrice("ETH");
|
|
44
45
|
const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
|
|
45
46
|
const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
|
|
@@ -56,35 +57,47 @@ async function handleNativeEth(deps, method, tenant, amountUsdCents, amountUsd)
|
|
|
56
57
|
priceCents,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Handle native UTXO coins (BTC, LTC, DOGE, BCH, etc.).
|
|
62
|
+
* Uses the xpub from the payment method record (DB-driven).
|
|
63
|
+
* Derives bech32 addresses for BTC/LTC, Base58 P2PKH for DOGE.
|
|
64
|
+
*/
|
|
65
|
+
async function handleNativeUtxo(deps, method, tenant, amountUsdCents, amountUsd) {
|
|
66
|
+
const xpub = method.xpub ?? deps.btcXpub;
|
|
67
|
+
if (!xpub)
|
|
68
|
+
throw new Error(`${method.token} payments not configured (no xpub)`);
|
|
69
|
+
const { priceCents } = await deps.oracle.getPrice(method.token);
|
|
70
|
+
const rawAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
|
|
66
71
|
const maxRetries = 3;
|
|
67
72
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
68
73
|
const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
// Derive address by chain type
|
|
75
|
+
let depositAddress;
|
|
76
|
+
if (method.chain === "dogecoin") {
|
|
77
|
+
depositAddress = deriveP2pkhAddress(xpub, derivationIndex, "dogecoin");
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
depositAddress = deriveAddress(xpub, derivationIndex, "mainnet", method.chain);
|
|
81
|
+
}
|
|
82
|
+
const referenceId = `${method.token.toLowerCase()}:${depositAddress}`;
|
|
71
83
|
try {
|
|
72
84
|
await deps.chargeStore.createStablecoinCharge({
|
|
73
85
|
referenceId,
|
|
74
86
|
tenantId: tenant,
|
|
75
87
|
amountUsdCents,
|
|
76
|
-
chain:
|
|
77
|
-
token:
|
|
88
|
+
chain: method.chain,
|
|
89
|
+
token: method.token,
|
|
78
90
|
depositAddress,
|
|
79
91
|
derivationIndex,
|
|
80
92
|
});
|
|
81
|
-
const
|
|
93
|
+
const divisor = 10 ** method.decimals;
|
|
94
|
+
const displayAmt = (Number(rawAmount) / divisor).toFixed(method.decimals);
|
|
82
95
|
return {
|
|
83
96
|
depositAddress,
|
|
84
|
-
displayAmount: `${
|
|
97
|
+
displayAmount: `${displayAmt} ${method.token}`,
|
|
85
98
|
amountUsd,
|
|
86
|
-
token:
|
|
87
|
-
chain:
|
|
99
|
+
token: method.token,
|
|
100
|
+
chain: method.chain,
|
|
88
101
|
referenceId,
|
|
89
102
|
priceCents,
|
|
90
103
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { HandlebarsRenderer } from "./handlebars-renderer.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function makeTemplate(overrides = {}) {
|
|
7
|
+
return {
|
|
8
|
+
id: "tpl-1",
|
|
9
|
+
name: "test-template",
|
|
10
|
+
description: "A test template",
|
|
11
|
+
subject: "Hello {{name}}",
|
|
12
|
+
htmlBody: "<h1>Hello {{name}}</h1>",
|
|
13
|
+
textBody: "Hello {{name}}",
|
|
14
|
+
active: true,
|
|
15
|
+
createdAt: Date.now(),
|
|
16
|
+
updatedAt: Date.now(),
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function makeRepo(templates = {}) {
|
|
21
|
+
return {
|
|
22
|
+
getByName: vi.fn().mockImplementation((name) => Promise.resolve(templates[name] ?? null)),
|
|
23
|
+
list: vi.fn().mockResolvedValue(Object.values(templates).filter(Boolean)),
|
|
24
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Tests
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
describe("HandlebarsRenderer", () => {
|
|
31
|
+
it("returns null for unknown template", async () => {
|
|
32
|
+
const repo = makeRepo({});
|
|
33
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
34
|
+
const result = await renderer.render("nonexistent", { name: "World" });
|
|
35
|
+
expect(result).toBeNull();
|
|
36
|
+
expect(repo.getByName).toHaveBeenCalledWith("nonexistent");
|
|
37
|
+
});
|
|
38
|
+
it("returns null for inactive template", async () => {
|
|
39
|
+
const repo = makeRepo({
|
|
40
|
+
"inactive-tpl": makeTemplate({ name: "inactive-tpl", active: false }),
|
|
41
|
+
});
|
|
42
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
43
|
+
const result = await renderer.render("inactive-tpl", { name: "World" });
|
|
44
|
+
expect(result).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
it("compiles Handlebars and returns subject/html/text", async () => {
|
|
47
|
+
const repo = makeRepo({
|
|
48
|
+
greeting: makeTemplate({
|
|
49
|
+
name: "greeting",
|
|
50
|
+
subject: "Hi {{name}}!",
|
|
51
|
+
htmlBody: "<p>Welcome, {{name}}!</p>",
|
|
52
|
+
textBody: "Welcome, {{name}}!",
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
56
|
+
const result = await renderer.render("greeting", { name: "Alice" });
|
|
57
|
+
expect(result).not.toBeNull();
|
|
58
|
+
expect(result?.subject).toBe("Hi Alice!");
|
|
59
|
+
expect(result?.html).toBe("<p>Welcome, Alice!</p>");
|
|
60
|
+
expect(result?.text).toBe("Welcome, Alice!");
|
|
61
|
+
});
|
|
62
|
+
it("injects currentYear automatically", async () => {
|
|
63
|
+
const repo = makeRepo({
|
|
64
|
+
footer: makeTemplate({
|
|
65
|
+
name: "footer",
|
|
66
|
+
subject: "Year: {{currentYear}}",
|
|
67
|
+
htmlBody: "<p>© {{currentYear}}</p>",
|
|
68
|
+
textBody: "(c) {{currentYear}}",
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
72
|
+
const result = await renderer.render("footer", {});
|
|
73
|
+
const year = new Date().getFullYear();
|
|
74
|
+
expect(result?.subject).toBe(`Year: ${year}`);
|
|
75
|
+
expect(result?.html).toBe(`<p>© ${year}</p>`);
|
|
76
|
+
expect(result?.text).toBe(`(c) ${year}`);
|
|
77
|
+
});
|
|
78
|
+
it("does not override explicit currentYear from data", async () => {
|
|
79
|
+
const repo = makeRepo({
|
|
80
|
+
footer: makeTemplate({
|
|
81
|
+
name: "footer",
|
|
82
|
+
subject: "Year: {{currentYear}}",
|
|
83
|
+
htmlBody: "<p>{{currentYear}}</p>",
|
|
84
|
+
textBody: "{{currentYear}}",
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
88
|
+
const result = await renderer.render("footer", { currentYear: 2099 });
|
|
89
|
+
expect(result?.subject).toBe("Year: 2099");
|
|
90
|
+
});
|
|
91
|
+
describe("helpers", () => {
|
|
92
|
+
it("eq helper returns true for equal values", async () => {
|
|
93
|
+
const repo = makeRepo({
|
|
94
|
+
cond: makeTemplate({
|
|
95
|
+
name: "cond",
|
|
96
|
+
subject: '{{#if (eq status "active")}}Active{{else}}Inactive{{/if}}',
|
|
97
|
+
htmlBody: "ok",
|
|
98
|
+
textBody: "ok",
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
102
|
+
const active = await renderer.render("cond", { status: "active" });
|
|
103
|
+
expect(active?.subject).toBe("Active");
|
|
104
|
+
const inactive = await renderer.render("cond", { status: "disabled" });
|
|
105
|
+
expect(inactive?.subject).toBe("Inactive");
|
|
106
|
+
});
|
|
107
|
+
it("gt helper compares numbers", async () => {
|
|
108
|
+
const repo = makeRepo({
|
|
109
|
+
gt: makeTemplate({
|
|
110
|
+
name: "gt",
|
|
111
|
+
subject: "{{#if (gt count 5)}}Many{{else}}Few{{/if}}",
|
|
112
|
+
htmlBody: "ok",
|
|
113
|
+
textBody: "ok",
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
117
|
+
const many = await renderer.render("gt", { count: 10 });
|
|
118
|
+
expect(many?.subject).toBe("Many");
|
|
119
|
+
const few = await renderer.render("gt", { count: 3 });
|
|
120
|
+
expect(few?.subject).toBe("Few");
|
|
121
|
+
});
|
|
122
|
+
it("formatDate helper formats timestamps", async () => {
|
|
123
|
+
const repo = makeRepo({
|
|
124
|
+
dated: makeTemplate({
|
|
125
|
+
name: "dated",
|
|
126
|
+
subject: "Date: {{formatDate ts}}",
|
|
127
|
+
htmlBody: "ok",
|
|
128
|
+
textBody: "ok",
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
132
|
+
// Use noon UTC to avoid timezone date-boundary shifts
|
|
133
|
+
const ts = new Date("2025-01-15T12:00:00Z").getTime();
|
|
134
|
+
const result = await renderer.render("dated", { ts });
|
|
135
|
+
// The formatted string uses en-US locale with year/month/day
|
|
136
|
+
const expected = new Date(ts).toLocaleDateString("en-US", {
|
|
137
|
+
year: "numeric",
|
|
138
|
+
month: "long",
|
|
139
|
+
day: "numeric",
|
|
140
|
+
});
|
|
141
|
+
expect(result?.subject).toBe(`Date: ${expected}`);
|
|
142
|
+
});
|
|
143
|
+
it("formatDate helper passes through non-numeric values", async () => {
|
|
144
|
+
const repo = makeRepo({
|
|
145
|
+
dated: makeTemplate({
|
|
146
|
+
name: "dated",
|
|
147
|
+
subject: "Date: {{formatDate ts}}",
|
|
148
|
+
htmlBody: "ok",
|
|
149
|
+
textBody: "ok",
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
153
|
+
const result = await renderer.render("dated", { ts: "not-a-number" });
|
|
154
|
+
expect(result?.subject).toBe("Date: not-a-number");
|
|
155
|
+
});
|
|
156
|
+
it("escapeHtml helper escapes special characters", async () => {
|
|
157
|
+
const repo = makeRepo({
|
|
158
|
+
esc: makeTemplate({
|
|
159
|
+
name: "esc",
|
|
160
|
+
subject: "Safe: {{escapeHtml input}}",
|
|
161
|
+
htmlBody: "ok",
|
|
162
|
+
textBody: "ok",
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
const renderer = new HandlebarsRenderer(repo);
|
|
166
|
+
const result = await renderer.render("esc", {
|
|
167
|
+
input: '<script>alert("xss")</script>',
|
|
168
|
+
});
|
|
169
|
+
expect(result?.subject).toContain("<script>");
|
|
170
|
+
expect(result?.subject).toContain(""xss"");
|
|
171
|
+
expect(result?.subject).not.toContain("<script>");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
3
|
+
import { initFleetNotificationListener } from "./fleet-notification-listener.js";
|
|
4
|
+
vi.mock("../config/logger.js", () => ({
|
|
5
|
+
logger: {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
error: vi.fn(),
|
|
8
|
+
warn: vi.fn(),
|
|
9
|
+
debug: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const DEBOUNCE_MS = 100;
|
|
16
|
+
function makePrefs(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
billing_low_balance: true,
|
|
19
|
+
billing_receipts: true,
|
|
20
|
+
billing_auto_topup: true,
|
|
21
|
+
agent_channel_disconnect: true,
|
|
22
|
+
agent_status_changes: false,
|
|
23
|
+
account_role_changes: true,
|
|
24
|
+
account_team_invites: true,
|
|
25
|
+
fleet_updates: true,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function makePrefsRepo(prefs = makePrefs()) {
|
|
30
|
+
return {
|
|
31
|
+
get: vi.fn().mockResolvedValue(prefs),
|
|
32
|
+
update: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function makeNotificationService() {
|
|
36
|
+
return {
|
|
37
|
+
notifyFleetUpdateComplete: vi.fn(),
|
|
38
|
+
notifyFleetUpdateAvailable: vi.fn(),
|
|
39
|
+
notifyLowBalance: vi.fn(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function botUpdated(tenantId, version = "v1.2.0") {
|
|
43
|
+
return {
|
|
44
|
+
type: "bot.updated",
|
|
45
|
+
botId: `bot-${Math.random().toString(36).slice(2, 6)}`,
|
|
46
|
+
tenantId,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
version,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function botUpdateFailed(tenantId, version = "v1.2.0") {
|
|
52
|
+
return {
|
|
53
|
+
type: "bot.update_failed",
|
|
54
|
+
botId: `bot-${Math.random().toString(36).slice(2, 6)}`,
|
|
55
|
+
tenantId,
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
version,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Tests
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
describe("initFleetNotificationListener", () => {
|
|
64
|
+
let emitter;
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.useFakeTimers();
|
|
67
|
+
emitter = new FleetEventEmitter();
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
});
|
|
72
|
+
it("ignores non-bot events (node events)", async () => {
|
|
73
|
+
const notificationService = makeNotificationService();
|
|
74
|
+
const preferences = makePrefsRepo();
|
|
75
|
+
initFleetNotificationListener({
|
|
76
|
+
eventEmitter: emitter,
|
|
77
|
+
notificationService,
|
|
78
|
+
preferences,
|
|
79
|
+
resolveEmail: vi.fn().mockResolvedValue("user@example.com"),
|
|
80
|
+
debounceMs: DEBOUNCE_MS,
|
|
81
|
+
});
|
|
82
|
+
emitter.emit({
|
|
83
|
+
type: "node.provisioned",
|
|
84
|
+
nodeId: "node-1",
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
});
|
|
87
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
88
|
+
expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
it("ignores non-update bot events (bot.started, bot.stopped)", async () => {
|
|
91
|
+
const notificationService = makeNotificationService();
|
|
92
|
+
const preferences = makePrefsRepo();
|
|
93
|
+
initFleetNotificationListener({
|
|
94
|
+
eventEmitter: emitter,
|
|
95
|
+
notificationService,
|
|
96
|
+
preferences,
|
|
97
|
+
resolveEmail: vi.fn().mockResolvedValue("user@example.com"),
|
|
98
|
+
debounceMs: DEBOUNCE_MS,
|
|
99
|
+
});
|
|
100
|
+
emitter.emit({
|
|
101
|
+
type: "bot.started",
|
|
102
|
+
botId: "bot-1",
|
|
103
|
+
tenantId: "t1",
|
|
104
|
+
timestamp: new Date().toISOString(),
|
|
105
|
+
});
|
|
106
|
+
emitter.emit({
|
|
107
|
+
type: "bot.stopped",
|
|
108
|
+
botId: "bot-2",
|
|
109
|
+
tenantId: "t1",
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
});
|
|
112
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
113
|
+
expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
it("aggregates multiple bot.updated events per tenant into one notification", async () => {
|
|
116
|
+
const notificationService = makeNotificationService();
|
|
117
|
+
const preferences = makePrefsRepo();
|
|
118
|
+
const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
|
|
119
|
+
initFleetNotificationListener({
|
|
120
|
+
eventEmitter: emitter,
|
|
121
|
+
notificationService,
|
|
122
|
+
preferences,
|
|
123
|
+
resolveEmail,
|
|
124
|
+
debounceMs: DEBOUNCE_MS,
|
|
125
|
+
});
|
|
126
|
+
// Emit 3 successful updates for the same tenant
|
|
127
|
+
emitter.emit(botUpdated("t1", "v2.0.0"));
|
|
128
|
+
emitter.emit(botUpdated("t1", "v2.0.0"));
|
|
129
|
+
emitter.emit(botUpdated("t1", "v2.0.0"));
|
|
130
|
+
// No notification yet — debounce hasn't fired
|
|
131
|
+
expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
|
|
132
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
133
|
+
expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledOnce();
|
|
134
|
+
expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledWith("t1", "owner@example.com", "v2.0.0", 3, // succeeded
|
|
135
|
+
0);
|
|
136
|
+
});
|
|
137
|
+
it("sends summary with correct succeeded/failed counts after debounce", async () => {
|
|
138
|
+
const notificationService = makeNotificationService();
|
|
139
|
+
const preferences = makePrefsRepo();
|
|
140
|
+
const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
|
|
141
|
+
initFleetNotificationListener({
|
|
142
|
+
eventEmitter: emitter,
|
|
143
|
+
notificationService,
|
|
144
|
+
preferences,
|
|
145
|
+
resolveEmail,
|
|
146
|
+
debounceMs: DEBOUNCE_MS,
|
|
147
|
+
});
|
|
148
|
+
emitter.emit(botUpdated("t1", "v3.0.0"));
|
|
149
|
+
emitter.emit(botUpdated("t1", "v3.0.0"));
|
|
150
|
+
emitter.emit(botUpdateFailed("t1", "v3.0.0"));
|
|
151
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
152
|
+
expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledWith("t1", "owner@example.com", "v3.0.0", 2, // succeeded
|
|
153
|
+
1);
|
|
154
|
+
});
|
|
155
|
+
it("updates version from subsequent events", async () => {
|
|
156
|
+
const notificationService = makeNotificationService();
|
|
157
|
+
const preferences = makePrefsRepo();
|
|
158
|
+
const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
|
|
159
|
+
initFleetNotificationListener({
|
|
160
|
+
eventEmitter: emitter,
|
|
161
|
+
notificationService,
|
|
162
|
+
preferences,
|
|
163
|
+
resolveEmail,
|
|
164
|
+
debounceMs: DEBOUNCE_MS,
|
|
165
|
+
});
|
|
166
|
+
// First event has v1.0.0, subsequent event changes to v1.1.0
|
|
167
|
+
emitter.emit(botUpdated("t1", "v1.0.0"));
|
|
168
|
+
emitter.emit(botUpdated("t1", "v1.1.0"));
|
|
169
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
170
|
+
expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledWith("t1", "owner@example.com", "v1.1.0", // updated to latest version
|
|
171
|
+
2, 0);
|
|
172
|
+
});
|
|
173
|
+
it("checks fleet_updates preference — skips if disabled", async () => {
|
|
174
|
+
const notificationService = makeNotificationService();
|
|
175
|
+
const preferences = makePrefsRepo(makePrefs({ fleet_updates: false }));
|
|
176
|
+
const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
|
|
177
|
+
initFleetNotificationListener({
|
|
178
|
+
eventEmitter: emitter,
|
|
179
|
+
notificationService,
|
|
180
|
+
preferences,
|
|
181
|
+
resolveEmail,
|
|
182
|
+
debounceMs: DEBOUNCE_MS,
|
|
183
|
+
});
|
|
184
|
+
emitter.emit(botUpdated("t1"));
|
|
185
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
186
|
+
expect(preferences.get).toHaveBeenCalledWith("t1");
|
|
187
|
+
expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
it("skips if email resolver returns null", async () => {
|
|
190
|
+
const notificationService = makeNotificationService();
|
|
191
|
+
const preferences = makePrefsRepo();
|
|
192
|
+
const resolveEmail = vi.fn().mockResolvedValue(null);
|
|
193
|
+
initFleetNotificationListener({
|
|
194
|
+
eventEmitter: emitter,
|
|
195
|
+
notificationService,
|
|
196
|
+
preferences,
|
|
197
|
+
resolveEmail,
|
|
198
|
+
debounceMs: DEBOUNCE_MS,
|
|
199
|
+
});
|
|
200
|
+
emitter.emit(botUpdated("t1"));
|
|
201
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
202
|
+
expect(resolveEmail).toHaveBeenCalledWith("t1");
|
|
203
|
+
expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
it("async shutdown flushes pending notifications", async () => {
|
|
206
|
+
const notificationService = makeNotificationService();
|
|
207
|
+
const preferences = makePrefsRepo();
|
|
208
|
+
const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
|
|
209
|
+
const shutdown = initFleetNotificationListener({
|
|
210
|
+
eventEmitter: emitter,
|
|
211
|
+
notificationService,
|
|
212
|
+
preferences,
|
|
213
|
+
resolveEmail,
|
|
214
|
+
debounceMs: DEBOUNCE_MS,
|
|
215
|
+
});
|
|
216
|
+
emitter.emit(botUpdated("t1", "v5.0.0"));
|
|
217
|
+
emitter.emit(botUpdated("t2", "v5.0.0"));
|
|
218
|
+
// Don't advance timers — flush via shutdown instead
|
|
219
|
+
await shutdown();
|
|
220
|
+
// Both tenants should have been flushed
|
|
221
|
+
expect(notificationService.notifyFleetUpdateComplete).toHaveBeenCalledTimes(2);
|
|
222
|
+
const calls = vi.mocked(notificationService.notifyFleetUpdateComplete).mock.calls;
|
|
223
|
+
const tenantIds = calls.map((c) => c[0]);
|
|
224
|
+
expect(tenantIds).toContain("t1");
|
|
225
|
+
expect(tenantIds).toContain("t2");
|
|
226
|
+
});
|
|
227
|
+
it("no further events after shutdown", async () => {
|
|
228
|
+
const notificationService = makeNotificationService();
|
|
229
|
+
const preferences = makePrefsRepo();
|
|
230
|
+
const resolveEmail = vi.fn().mockResolvedValue("owner@example.com");
|
|
231
|
+
const shutdown = initFleetNotificationListener({
|
|
232
|
+
eventEmitter: emitter,
|
|
233
|
+
notificationService,
|
|
234
|
+
preferences,
|
|
235
|
+
resolveEmail,
|
|
236
|
+
debounceMs: DEBOUNCE_MS,
|
|
237
|
+
});
|
|
238
|
+
await shutdown();
|
|
239
|
+
// Events after shutdown should not trigger anything
|
|
240
|
+
emitter.emit(botUpdated("t1"));
|
|
241
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS + 50);
|
|
242
|
+
expect(notificationService.notifyFleetUpdateComplete).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -41,7 +41,7 @@ export interface FleetUpdaterConfig {
|
|
|
41
41
|
/** Optional fleet event emitter. When provided, bot.updated / bot.update_failed events are emitted. */
|
|
42
42
|
eventEmitter?: FleetEventEmitter;
|
|
43
43
|
/** Called with manual-mode tenant IDs when a new image is available but they are excluded from rollout. */
|
|
44
|
-
onManualTenantsSkipped?: (tenantIds: string[]) => void;
|
|
44
|
+
onManualTenantsSkipped?: (tenantIds: string[], imageTag: string) => void;
|
|
45
45
|
}
|
|
46
46
|
export interface FleetUpdaterHandle {
|
|
47
47
|
poller: ImagePoller;
|
|
@@ -36,6 +36,10 @@ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, confi
|
|
|
36
36
|
const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
|
|
37
37
|
const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
|
|
38
38
|
const strategy = createRolloutStrategy(strategyType, strategyOptions);
|
|
39
|
+
// Captured by the onUpdateAvailable handler and read by getUpdatableProfiles.
|
|
40
|
+
// Set before each orchestrator.rollout() call so the callback receives the
|
|
41
|
+
// image tag that triggered this rollout rather than a stale "latest" placeholder.
|
|
42
|
+
let currentImageTag = "latest";
|
|
39
43
|
const orchestrator = new RolloutOrchestrator({
|
|
40
44
|
updater,
|
|
41
45
|
snapshotManager,
|
|
@@ -53,7 +57,7 @@ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, confi
|
|
|
53
57
|
});
|
|
54
58
|
if (!configRepo) {
|
|
55
59
|
if (manualPolicyIds.length > 0 && onManualTenantsSkipped) {
|
|
56
|
-
onManualTenantsSkipped([...new Set(manualPolicyIds)]);
|
|
60
|
+
onManualTenantsSkipped([...new Set(manualPolicyIds)], currentImageTag);
|
|
57
61
|
}
|
|
58
62
|
return nonManualPolicy;
|
|
59
63
|
}
|
|
@@ -70,7 +74,7 @@ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, confi
|
|
|
70
74
|
}));
|
|
71
75
|
const allManualIds = [...manualPolicyIds, ...configManualIds];
|
|
72
76
|
if (allManualIds.length > 0 && onManualTenantsSkipped) {
|
|
73
|
-
onManualTenantsSkipped([...new Set(allManualIds)]);
|
|
77
|
+
onManualTenantsSkipped([...new Set(allManualIds)], currentImageTag);
|
|
74
78
|
}
|
|
75
79
|
return results.filter((p) => p !== null);
|
|
76
80
|
},
|
|
@@ -106,11 +110,23 @@ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, confi
|
|
|
106
110
|
// Wire the detection → orchestration pipeline.
|
|
107
111
|
// Any digest change triggers a fleet-wide rollout because the managed image
|
|
108
112
|
// is shared across all tenants — one new digest means all bots need updating.
|
|
109
|
-
poller.onUpdateAvailable = async (
|
|
113
|
+
poller.onUpdateAvailable = async (botId, _newDigest) => {
|
|
110
114
|
if (orchestrator.isRolling) {
|
|
111
115
|
logger.debug("Skipping update trigger — rollout already in progress");
|
|
112
116
|
return;
|
|
113
117
|
}
|
|
118
|
+
// Resolve the image tag from the bot that triggered the update so that
|
|
119
|
+
// onManualTenantsSkipped receives the real version instead of "latest".
|
|
120
|
+
try {
|
|
121
|
+
const triggeringProfile = await profileStore.get(botId);
|
|
122
|
+
if (triggeringProfile) {
|
|
123
|
+
const img = triggeringProfile.image;
|
|
124
|
+
currentImageTag = img.includes(":") ? (img.split(":").pop() ?? "latest") : "latest";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Best-effort — currentImageTag stays at previous value
|
|
129
|
+
}
|
|
114
130
|
logger.info("New image digest detected — starting fleet-wide rollout");
|
|
115
131
|
await orchestrator.rollout().catch((err) => {
|
|
116
132
|
logger.error("Rollout failed", { err });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|