routstrd 0.3.11 → 0.4.0
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/IMPLEMENTATION.md +253 -0
- package/README.md +41 -0
- package/SECURITY.md +23 -0
- package/SKILL.md +13 -3
- package/bun.lock +68 -41
- package/dist/daemon/index.js +47850 -11349
- package/dist/index.js +4557 -399
- package/package.json +8 -3
- package/src/cli.test.ts +55 -0
- package/src/cli.ts +454 -204
- package/src/daemon/args.ts +8 -1
- package/src/daemon/models.ts +119 -39
- package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
- package/src/daemon/wallet/coco-client.test.ts +508 -0
- package/src/daemon/wallet/coco-client.ts +835 -0
- package/src/daemon/wallet/cocod-client.ts +86 -2
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +147 -0
- package/src/daemon/wallet/migration.ts +209 -0
- package/src/daemon/wallet/paths.ts +33 -0
- package/src/integrations/claudecode.ts +2 -1
- package/src/integrations/hermes.ts +46 -6
- package/src/integrations/openclaw.ts +3 -2
- package/src/integrations/opencode.ts +3 -2
- package/src/integrations/pi.ts +3 -2
- package/src/integrations/registry.ts +7 -5
- package/src/start-daemon.ts +168 -22
- package/src/utils/clients.ts +21 -0
- package/src/utils/config.ts +11 -0
- package/src/utils/daemon-client.ts +104 -25
- package/src/utils/logger.ts +42 -28
- package/tests/utils/daemon-client.test.ts +61 -0
- package/tests/wallet/short-keyset-token.test.ts +106 -0
package/src/daemon/args.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export function parseArgs(argv: string[]): {
|
|
2
2
|
port: number;
|
|
3
|
+
host: string | null;
|
|
3
4
|
provider: string | null;
|
|
4
5
|
} {
|
|
5
6
|
const portFlagIndex = argv.findIndex((arg) => arg === "--port");
|
|
7
|
+
const hostFlagIndex = argv.findIndex((arg) => arg === "--host");
|
|
6
8
|
const providerFlagIndex = argv.findIndex(
|
|
7
9
|
(arg) => arg === "--provider" || arg === "-p",
|
|
8
10
|
);
|
|
@@ -11,9 +13,14 @@ export function parseArgs(argv: string[]): {
|
|
|
11
13
|
portFlagIndex !== -1
|
|
12
14
|
? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10)
|
|
13
15
|
: 8008;
|
|
16
|
+
|
|
17
|
+
const hostValue =
|
|
18
|
+
hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined;
|
|
19
|
+
const host = hostValue?.trim() || null;
|
|
20
|
+
|
|
14
21
|
const providerValue =
|
|
15
22
|
providerFlagIndex !== -1 ? argv[providerFlagIndex + 1] : undefined;
|
|
16
23
|
const provider = providerValue ? providerValue.trim() : null;
|
|
17
24
|
|
|
18
|
-
return { port, provider };
|
|
25
|
+
return { port, host, provider };
|
|
19
26
|
}
|
package/src/daemon/models.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { ModelManager, type SdkStore } from "@routstr/sdk";
|
|
1
|
+
import { ModelManager, ProviderManager, type SdkStore } from "@routstr/sdk";
|
|
2
2
|
import type { ExposedModel } from "./types";
|
|
3
3
|
import { logger } from "../utils/logger";
|
|
4
4
|
|
|
5
5
|
export type ModelProviderInfo = {
|
|
6
6
|
baseUrl: string;
|
|
7
|
+
disabled: boolean;
|
|
7
8
|
pricing: {
|
|
8
9
|
prompt: number;
|
|
9
10
|
completion: number;
|
|
@@ -16,16 +17,75 @@ export type ModelWithProviders = ExposedModel & {
|
|
|
16
17
|
providers: ModelProviderInfo[];
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
export function createModelService(
|
|
20
|
+
export function createModelService(
|
|
21
|
+
modelManager: ModelManager,
|
|
22
|
+
providerManager: ProviderManager,
|
|
23
|
+
store: SdkStore,
|
|
24
|
+
) {
|
|
20
25
|
let providerBootstrapPromise: Promise<void> | null = null;
|
|
21
26
|
|
|
27
|
+
const normalizeBaseUrl = (url: string): string =>
|
|
28
|
+
url.endsWith("/") ? url : `${url}/`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the same cheapest-per-model view as ModelManager.fetchModels, but
|
|
32
|
+
* exclusively from the persisted cache. Model-list reads must not wait for
|
|
33
|
+
* unavailable providers; explicit and scheduled refreshes own network I/O.
|
|
34
|
+
*/
|
|
35
|
+
const getCachedModels = (): ExposedModel[] => {
|
|
36
|
+
type PricedModel = ExposedModel & {
|
|
37
|
+
sats_pricing?: { completion?: number };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const cachedByProvider = modelManager.getAllCachedModels();
|
|
41
|
+
const currentProviders = new Set(
|
|
42
|
+
modelManager.getBaseUrls().map(normalizeBaseUrl),
|
|
43
|
+
);
|
|
44
|
+
const s = store.getState();
|
|
45
|
+
const manuallyEnabled = new Set(
|
|
46
|
+
(s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
|
|
47
|
+
);
|
|
48
|
+
const disabledProviders = new Set(
|
|
49
|
+
[
|
|
50
|
+
...(s.disabledProviders || []),
|
|
51
|
+
...(s.manuallyDisabledProviders || []),
|
|
52
|
+
]
|
|
53
|
+
.map(normalizeBaseUrl)
|
|
54
|
+
.filter((url) => !manuallyEnabled.has(url)),
|
|
55
|
+
);
|
|
56
|
+
const bestById = new Map<string, PricedModel>();
|
|
57
|
+
|
|
58
|
+
for (const [baseUrl, models] of Object.entries(cachedByProvider)) {
|
|
59
|
+
const normalized = normalizeBaseUrl(baseUrl);
|
|
60
|
+
if (
|
|
61
|
+
disabledProviders.has(normalized) ||
|
|
62
|
+
(currentProviders.size > 0 && !currentProviders.has(normalized))
|
|
63
|
+
) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const model of models as PricedModel[]) {
|
|
68
|
+
if (!model.sats_pricing) continue;
|
|
69
|
+
const existing = bestById.get(model.id);
|
|
70
|
+
if (
|
|
71
|
+
!existing ||
|
|
72
|
+
(model.sats_pricing.completion ?? 0) <
|
|
73
|
+
(existing.sats_pricing?.completion ?? 0)
|
|
74
|
+
) {
|
|
75
|
+
bestById.set(model.id, model);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return [...bestById.values()];
|
|
81
|
+
};
|
|
82
|
+
|
|
22
83
|
const ensureProvidersBootstrapped = (): Promise<void> => {
|
|
23
84
|
if (!providerBootstrapPromise) {
|
|
24
85
|
providerBootstrapPromise = (async () => {
|
|
25
86
|
logger.log("Bootstrapping providers...");
|
|
26
87
|
const providers = await modelManager.bootstrapProviders(false);
|
|
27
88
|
logger.log(`Bootstrapped ${providers.length} providers`);
|
|
28
|
-
await modelManager.fetchModels(providers);
|
|
29
89
|
|
|
30
90
|
// Sync discovered providers into the store so `providers list` reflects
|
|
31
91
|
// the same set that the model manager knows about.
|
|
@@ -44,6 +104,7 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
|
|
|
44
104
|
|
|
45
105
|
logger.log("Provider bootstrap complete.");
|
|
46
106
|
})().catch((error) => {
|
|
107
|
+
providerBootstrapPromise = null;
|
|
47
108
|
logger.error("Provider bootstrap failed:", error);
|
|
48
109
|
throw error;
|
|
49
110
|
});
|
|
@@ -54,16 +115,27 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
|
|
|
54
115
|
const getRoutstr21Models = async (
|
|
55
116
|
forceRefresh = false,
|
|
56
117
|
): Promise<ExposedModel[]> => {
|
|
57
|
-
await ensureProvidersBootstrapped();
|
|
58
|
-
|
|
59
118
|
const routstr21ModelIds = Array.from(
|
|
60
119
|
new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
|
|
61
120
|
).slice(0, 21);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
121
|
+
|
|
122
|
+
let discoveredModels: ExposedModel[];
|
|
123
|
+
if (!forceRefresh) {
|
|
124
|
+
discoveredModels = getCachedModels();
|
|
125
|
+
} else {
|
|
126
|
+
discoveredModels = [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Warm reads are cache-only. A cold start and explicit refresh still
|
|
130
|
+
// populate models from the provider network.
|
|
131
|
+
if (forceRefresh || discoveredModels.length === 0) {
|
|
132
|
+
await ensureProvidersBootstrapped();
|
|
133
|
+
discoveredModels = await modelManager.fetchModels(
|
|
134
|
+
modelManager.getBaseUrls(),
|
|
135
|
+
forceRefresh,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
67
139
|
const modelsById = new Map(discoveredModels.map((model) => [model.id, model]));
|
|
68
140
|
|
|
69
141
|
return routstr21ModelIds.map((modelId) => {
|
|
@@ -77,33 +149,42 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
|
|
|
77
149
|
): Promise<ModelWithProviders | null> => {
|
|
78
150
|
await ensureProvidersBootstrapped();
|
|
79
151
|
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
152
|
+
const s = store.getState();
|
|
153
|
+
const manuallyEnabled = new Set<string>(
|
|
154
|
+
(s.manuallyEnabledProviders || []).map(normalizeBaseUrl),
|
|
155
|
+
);
|
|
156
|
+
const disabledSet = new Set<string>(
|
|
157
|
+
[
|
|
158
|
+
...(s.disabledProviders || []),
|
|
159
|
+
...(s.manuallyDisabledProviders || []),
|
|
160
|
+
].filter((url) => !manuallyEnabled.has(normalizeBaseUrl(url))),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Use the SDK ranking (sorted by prompt+completion per million tokens)
|
|
164
|
+
// so the display order matches real routing. includeDisabled keeps
|
|
165
|
+
// disabled providers visible so we can annotate them.
|
|
166
|
+
const ranking = providerManager.getProviderPriceRankingForModel(modelId, {
|
|
167
|
+
includeDisabled: true,
|
|
168
|
+
});
|
|
97
169
|
|
|
98
|
-
|
|
99
|
-
|
|
170
|
+
const providers: ModelProviderInfo[] = ranking.map((entry: any) => ({
|
|
171
|
+
baseUrl: entry.baseUrl,
|
|
172
|
+
disabled: disabledSet.has(entry.baseUrl),
|
|
173
|
+
pricing: {
|
|
174
|
+
prompt: entry.promptPerMillion / 1_000_000,
|
|
175
|
+
completion: entry.completionPerMillion / 1_000_000,
|
|
176
|
+
request: entry.model.sats_pricing?.request ?? 0,
|
|
177
|
+
max_cost: entry.model.sats_pricing?.max_cost ?? 0,
|
|
178
|
+
},
|
|
179
|
+
}));
|
|
100
180
|
|
|
101
181
|
if (providers.length === 0) {
|
|
102
182
|
return null;
|
|
103
183
|
}
|
|
104
184
|
|
|
105
|
-
// Get model metadata from first provider
|
|
185
|
+
// Get model metadata from first (cheapest) provider
|
|
106
186
|
const cheapest = providers[0]!;
|
|
187
|
+
const allModels = modelManager.getAllCachedModels();
|
|
107
188
|
const firstProvider = allModels[cheapest.baseUrl];
|
|
108
189
|
const modelInfo = firstProvider?.find((m: { id: string }) => m.id === modelId);
|
|
109
190
|
|
|
@@ -149,28 +230,27 @@ export function createModelService(modelManager: ModelManager, store: SdkStore)
|
|
|
149
230
|
undefined,
|
|
150
231
|
true,
|
|
151
232
|
);
|
|
152
|
-
if (reviewedDisabled.length > 0) {
|
|
233
|
+
if (reviewedDisabled && reviewedDisabled.length > 0) {
|
|
153
234
|
console.log(
|
|
154
235
|
`Review sync disabled ${reviewedDisabled.length} provider(s): ${reviewedDisabled.join(", ")}`,
|
|
155
236
|
);
|
|
156
237
|
}
|
|
157
238
|
|
|
158
239
|
// Sync discovered providers into the store
|
|
159
|
-
const {
|
|
160
|
-
store.getState() as any;
|
|
240
|
+
const { setBaseUrlsList, setDisabledProviders } = store.getState() as any;
|
|
161
241
|
|
|
162
242
|
// Replace baseUrlsList with the fresh provider list
|
|
163
243
|
setBaseUrlsList(providers);
|
|
164
244
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
245
|
+
// Mirror the review-disabled set into the store's auto-disabled list.
|
|
246
|
+
// `null` means the review sync left the adapter unchanged (e.g. no lgtm
|
|
247
|
+
// reviews found), so we must not clobber the store list with an empty array.
|
|
248
|
+
if (reviewedDisabled !== null) {
|
|
249
|
+
setDisabledProviders(reviewedDisabled);
|
|
169
250
|
}
|
|
170
|
-
setDisabledProviders([...existingDisabled]);
|
|
171
251
|
|
|
172
252
|
console.log(
|
|
173
|
-
`Provider refresh complete: ${providers.length} total, ${
|
|
253
|
+
`Provider refresh complete: ${providers.length} total, ${reviewedDisabled?.length ?? store.getState().disabledProviders?.length ?? 0} review-disabled`,
|
|
174
254
|
);
|
|
175
255
|
};
|
|
176
256
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
// The real NPCPlugin opens a websocket to npubx.cash once the wallet is ready.
|
|
7
|
+
// Mock the module before importing coco-client so tests never touch the network.
|
|
8
|
+
const npcState = {
|
|
9
|
+
info: { name: "alice" as string | null, pubkey: "ab".repeat(32) },
|
|
10
|
+
usernameResult: { success: true } as
|
|
11
|
+
| { success: true }
|
|
12
|
+
| { success: false; pr: { amount: number; mints: string[] } },
|
|
13
|
+
setUsernameCalls: [] as Array<{ username: string; attemptPayment?: boolean }>,
|
|
14
|
+
syncCalls: 0,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
class MockNPCPlugin {
|
|
18
|
+
readonly name = "npc";
|
|
19
|
+
readonly required = [] as const;
|
|
20
|
+
private readonly api = {
|
|
21
|
+
getInfo: async () => npcState.info,
|
|
22
|
+
setUsername: async (username: string, attemptPayment?: boolean) => {
|
|
23
|
+
npcState.setUsernameCalls.push({ username, attemptPayment });
|
|
24
|
+
return npcState.usernameResult;
|
|
25
|
+
},
|
|
26
|
+
sync: async () => {
|
|
27
|
+
npcState.syncCalls += 1;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
onInit(ctx: { registerExtension: (key: string, api: unknown) => void }) {
|
|
32
|
+
ctx.registerExtension("npc", this.api);
|
|
33
|
+
return () => {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
mock.module("coco-cashu-plugin-npc", () => ({
|
|
38
|
+
NPCPlugin: MockNPCPlugin,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const { createCocoClient } = await import("./coco-client");
|
|
42
|
+
const { createCocodClient, CocodHttpError } = await import("./cocod-client");
|
|
43
|
+
|
|
44
|
+
const TEST_MNEMONIC =
|
|
45
|
+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
46
|
+
|
|
47
|
+
const tempDirs: string[] = [];
|
|
48
|
+
|
|
49
|
+
function makeWalletDir(): string {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-"));
|
|
51
|
+
tempDirs.push(dir);
|
|
52
|
+
const walletDir = join(dir, "wallet");
|
|
53
|
+
mkdirSync(walletDir, { recursive: true });
|
|
54
|
+
writeFileSync(
|
|
55
|
+
join(walletDir, "config.json"),
|
|
56
|
+
JSON.stringify({ version: 1, mnemonic: TEST_MNEMONIC, encrypted: false }),
|
|
57
|
+
);
|
|
58
|
+
return walletDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function testClientOptions(walletDir = makeWalletDir()) {
|
|
62
|
+
return {
|
|
63
|
+
walletDir,
|
|
64
|
+
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
|
65
|
+
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resetNpcState(): void {
|
|
70
|
+
npcState.info = { name: "alice", pubkey: "ab".repeat(32) };
|
|
71
|
+
npcState.usernameResult = { success: true };
|
|
72
|
+
npcState.setUsernameCalls = [];
|
|
73
|
+
npcState.syncCalls = 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
resetNpcState();
|
|
78
|
+
for (const dir of tempDirs.splice(0)) {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("in-process coco client NPC integration", () => {
|
|
84
|
+
it("registers the plugin and exposes the wallet's NPC lightning address", async () => {
|
|
85
|
+
const client = await createCocoClient(testClientOptions());
|
|
86
|
+
try {
|
|
87
|
+
const info = await client.getNpcAddress();
|
|
88
|
+
expect(info).toEqual({
|
|
89
|
+
address: "alice@npubx.cash",
|
|
90
|
+
name: "alice",
|
|
91
|
+
pubkey: "ab".repeat(32),
|
|
92
|
+
});
|
|
93
|
+
} finally {
|
|
94
|
+
await client.dispose?.();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to the npub address when no username is set", async () => {
|
|
99
|
+
npcState.info = { name: null, pubkey: "ab".repeat(32) };
|
|
100
|
+
const client = await createCocoClient(testClientOptions());
|
|
101
|
+
try {
|
|
102
|
+
const info = await client.getNpcAddress();
|
|
103
|
+
expect(info.address).toStartWith("npub1");
|
|
104
|
+
expect(info.address).toEndWith("@npubx.cash");
|
|
105
|
+
expect(info.name).toBeUndefined();
|
|
106
|
+
expect(info.pubkey).toBe("ab".repeat(32));
|
|
107
|
+
} finally {
|
|
108
|
+
await client.dispose?.();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("maps payment-required username claims without paying", async () => {
|
|
113
|
+
npcState.usernameResult = {
|
|
114
|
+
success: false,
|
|
115
|
+
pr: { amount: 21, mints: ["https://mint.example.com"] },
|
|
116
|
+
};
|
|
117
|
+
const client = await createCocoClient(testClientOptions());
|
|
118
|
+
try {
|
|
119
|
+
const result = await client.setNpcUsername("bob");
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
success: false,
|
|
122
|
+
paymentRequest: { amount: 21, mints: ["https://mint.example.com"] },
|
|
123
|
+
});
|
|
124
|
+
expect(npcState.setUsernameCalls).toEqual([
|
|
125
|
+
{ username: "bob", attemptPayment: false },
|
|
126
|
+
]);
|
|
127
|
+
} finally {
|
|
128
|
+
await client.dispose?.();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("confirms the claim fee payment when requested", async () => {
|
|
133
|
+
const client = await createCocoClient(testClientOptions());
|
|
134
|
+
try {
|
|
135
|
+
const result = await client.setNpcUsername("bob", true);
|
|
136
|
+
expect(result).toEqual({ success: true });
|
|
137
|
+
expect(npcState.setUsernameCalls).toEqual([
|
|
138
|
+
{ username: "bob", attemptPayment: true },
|
|
139
|
+
]);
|
|
140
|
+
} finally {
|
|
141
|
+
await client.dispose?.();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("triggers a manual quote sync", async () => {
|
|
146
|
+
const client = await createCocoClient(testClientOptions());
|
|
147
|
+
try {
|
|
148
|
+
await client.syncNpc();
|
|
149
|
+
expect(npcState.syncCalls).toBe(1);
|
|
150
|
+
} finally {
|
|
151
|
+
await client.dispose?.();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects NPC calls with a clear error when the plugin is disabled", async () => {
|
|
156
|
+
const client = await createCocoClient({
|
|
157
|
+
...testClientOptions(),
|
|
158
|
+
enableNpc: false,
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
await expect(client.getNpcAddress()).rejects.toThrow(
|
|
162
|
+
"NPC plugin is not enabled",
|
|
163
|
+
);
|
|
164
|
+
await expect(client.syncNpc()).rejects.toThrow(
|
|
165
|
+
"NPC plugin is not enabled",
|
|
166
|
+
);
|
|
167
|
+
} finally {
|
|
168
|
+
await client.dispose?.();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("legacy cocod client NPC passthroughs", () => {
|
|
174
|
+
type FetchImpl = (
|
|
175
|
+
input: string | URL | Request,
|
|
176
|
+
init?: RequestInit & { unix: string },
|
|
177
|
+
) => Promise<Response>;
|
|
178
|
+
|
|
179
|
+
function legacyClient(handler: (path: string, body?: unknown) => Response) {
|
|
180
|
+
const requests: Array<{ path: string; body?: unknown }> = [];
|
|
181
|
+
const fetchImpl: FetchImpl = async (input, init) => {
|
|
182
|
+
const path = String(input).replace("http://localhost", "");
|
|
183
|
+
const body =
|
|
184
|
+
typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
|
185
|
+
if (path !== "/ping") requests.push({ path, body });
|
|
186
|
+
if (path === "/ping") {
|
|
187
|
+
return new Response(JSON.stringify({ output: "pong" }));
|
|
188
|
+
}
|
|
189
|
+
return handler(path, body);
|
|
190
|
+
};
|
|
191
|
+
const client = createCocodClient({
|
|
192
|
+
socketPath: "/tmp/routstrd-test-legacy-npc/cocod.sock",
|
|
193
|
+
fetchImpl,
|
|
194
|
+
});
|
|
195
|
+
return { client, requests };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it("fetches and parses a username NPC address", async () => {
|
|
199
|
+
const { client, requests } = legacyClient(
|
|
200
|
+
() => new Response(JSON.stringify({ output: "alice@npubx.cash" })),
|
|
201
|
+
);
|
|
202
|
+
const info = await client.getNpcAddress();
|
|
203
|
+
expect(info).toEqual({ address: "alice@npubx.cash", name: "alice" });
|
|
204
|
+
expect(requests[0]?.path).toBe("/npc/address");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("omits the name for npub fallback addresses", async () => {
|
|
208
|
+
const { client } = legacyClient(
|
|
209
|
+
() =>
|
|
210
|
+
new Response(JSON.stringify({ output: "npub1abc123@npubx.cash" })),
|
|
211
|
+
);
|
|
212
|
+
const info = await client.getNpcAddress();
|
|
213
|
+
expect(info).toEqual({ address: "npub1abc123@npubx.cash" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("posts username claims with the confirm flag", async () => {
|
|
217
|
+
const { client, requests } = legacyClient(
|
|
218
|
+
() => new Response(JSON.stringify({ output: { success: true } })),
|
|
219
|
+
);
|
|
220
|
+
const result = await client.setNpcUsername("bob", true);
|
|
221
|
+
expect(result).toEqual({ success: true });
|
|
222
|
+
expect(requests[0]).toEqual({
|
|
223
|
+
path: "/npc/username",
|
|
224
|
+
body: { username: "bob", confirm: true },
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("preserves the 402 payment-required error from cocod", async () => {
|
|
229
|
+
const { client } = legacyClient(
|
|
230
|
+
() =>
|
|
231
|
+
new Response(
|
|
232
|
+
JSON.stringify({ error: "Payment required to set username" }),
|
|
233
|
+
{ status: 402 },
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
try {
|
|
237
|
+
await client.setNpcUsername("bob");
|
|
238
|
+
expect.unreachable("should have thrown");
|
|
239
|
+
} catch (error) {
|
|
240
|
+
expect(error).toBeInstanceOf(CocodHttpError);
|
|
241
|
+
expect((error as InstanceType<typeof CocodHttpError>).status).toBe(402);
|
|
242
|
+
expect((error as Error).message).toContain("Payment required");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("reports manual sync as unsupported", async () => {
|
|
247
|
+
const { client } = legacyClient(
|
|
248
|
+
() => new Response(JSON.stringify({ output: {} })),
|
|
249
|
+
);
|
|
250
|
+
await expect(client.syncNpc()).rejects.toThrow("not supported");
|
|
251
|
+
});
|
|
252
|
+
});
|