lumi-check 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/package.json +3 -3
- package/src/check.js +11 -11
- package/src/index.js +1 -0
- package/src/lumi_keys.js +16 -0
- package/src/lumi_signed.js +25 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`npm install lumi-check` (Node >= 20). CLI: `npx lumi-check` or, once installed, `lumi-scan`.
|
|
4
4
|
|
|
5
|
-
A thin client for checking an x402 seller **before you pay** and, if you choose, contributing **signed,
|
|
5
|
+
A thin client for checking an x402 seller **before you pay** (a free, signed allow/warn/block verdict backed by Lumi's own paid test-buys on XRPL, Base and Stellar) and, if you choose, contributing **signed,
|
|
6
6
|
on-ledger-verifiable delivery receipts** to Lumi ("Tested by Lumi"). Scoring, verification, the catalog and anti-fraud
|
|
7
7
|
stay on Lumi's server.
|
|
8
8
|
|
|
@@ -20,7 +20,7 @@ sent (query strings are dropped). Paying Lumi never changes any score, tier, ran
|
|
|
20
20
|
```js
|
|
21
21
|
import { createLumiCheck, x402BeforePaymentHook, mcpOnPaymentRequiredHook, withOutcomeReporting } from "lumi-check";
|
|
22
22
|
|
|
23
|
-
const lumi = createLumiCheck(); // fail-open, 2.5 s timeout;
|
|
23
|
+
const lumi = createLumiCheck(); // fail-open, 2.5 s timeout; verdicts must be signed by a pinned Lumi key
|
|
24
24
|
|
|
25
25
|
// From code
|
|
26
26
|
const r = await lumi.check({ url: "https://seller.example/x402/data", payTo: "0x...", amountUsd: 0.01, network: "eip155:8453" });
|
|
@@ -40,11 +40,18 @@ CLI: `lumi-scan check https://seller.example/x402/data --pay-to 0x... --amount 0
|
|
|
40
40
|
|
|
41
41
|
**Fail mode (defaults):** `fail_mode: "open"` with `timeout_ms: 2500`. If Lumi is slow,
|
|
42
42
|
unreachable, or returns a verdict whose signature does not verify, the result is `warn` (`lumi_unreachable` /
|
|
43
|
-
`lumi_unverified`
|
|
43
|
+
`lumi_unverified`, or `lumi_untrusted_key` / `lumi_retired_key` when it is signed by a key that is not pinned) and the
|
|
44
|
+
payment goes ahead. Set `"fail_mode": "closed"` in the config (or `failMode: "closed"`) to
|
|
44
45
|
block instead. The detailed paid check (`/v1/trust/check`, x402) adds reason details and a signed `lumi_receipt`.
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
**Pinned keys.** The kit ships Lumi's signing public keys in `src/lumi_keys.js` (`LUMI_SIGNING_KEYS`, with a rotation
|
|
48
|
+
status: `next`, `active` or `retired`) and never fetches keys at runtime, so whoever answers a request cannot choose the
|
|
49
|
+
key it is checked against. A verdict or receipt signed by any other key is rejected with `lumi_untrusted_key` (a retired
|
|
50
|
+
key: `lumi_retired_key`); a key rotation ships as a kit update. Current key: `lsk_5c8c2f54fd228547`.
|
|
51
|
+
`/v1/check/keys` lists the same keys for reference only.
|
|
52
|
+
|
|
53
|
+
Verify any Lumi-signed response yourself: `verifyLumiSigned(response)` checks against the pinned keys; the result has
|
|
54
|
+
`ok`, `errors`, and on failure `error` + `detail`. `{ publicKeys }` replaces the pins (tests or a self-hosted Lumi only).
|
|
48
55
|
|
|
49
56
|
## What is shared
|
|
50
57
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lumi-check",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Lumi Check: a free signed
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Lumi Check: a free signed allow/warn/block check before your agent pays an x402 seller, backed by Lumi's own paid test-buys on XRPL, Base and Stellar. Plus optional, opt-in signed delivery receipts.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": { "type": "git", "url": "git+https://github.com/lumitrades/lumi-api.git", "directory": "packages/scanner-kit" },
|
|
7
7
|
"homepage": "https://github.com/lumitrades/lumi-api/tree/main/packages/scanner-kit#readme",
|
|
8
8
|
"bugs": { "url": "https://github.com/lumitrades/lumi-api/issues" },
|
|
9
|
-
"keywords": ["x402", "lumi-check", "pre-pay check", "
|
|
9
|
+
"keywords": ["x402", "lumi-check", "x402-seller-check", "check-seller-before-paying", "payment-safety", "agent-payments", "ai-agents", "pre-pay check", "xrpl", "base", "stellar", "usdc", "mcp"],
|
|
10
10
|
"type": "module",
|
|
11
11
|
"main": "src/index.js",
|
|
12
12
|
"exports": { ".": "./src/index.js" },
|
package/src/check.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { originPath } from "./origin.js";
|
|
12
12
|
import { verifyLumiSigned } from "./lumi_signed.js";
|
|
13
|
+
import { pinnedPublicKeys } from "./lumi_keys.js";
|
|
13
14
|
import { loadConfig } from "./config.js";
|
|
14
15
|
import { reportOutcome } from "./telemetry.js";
|
|
15
16
|
import { kitHeader, DEFAULT_API } from "./version.js";
|
|
@@ -37,15 +38,11 @@ export function createLumiCheck({ api = DEFAULT_API, publicKeys, timeoutMs, fail
|
|
|
37
38
|
const base = api.replace(/\/+$/, "");
|
|
38
39
|
const ms = timeoutMs ?? cfg.timeout_ms ?? 2500;
|
|
39
40
|
const mode = (failMode ?? cfg.fail_mode) === "closed" ? "closed" : "open";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const j = await r.json();
|
|
46
|
-
keys = (j.keys ?? []).map((k) => k.public_key).filter(Boolean);
|
|
47
|
-
return keys;
|
|
48
|
-
}
|
|
41
|
+
// Pinned keys shipped with the kit. No network fetch and no config-file keys: the server being checked must not be able
|
|
42
|
+
// to choose the key. `publicKeys` (code only) replaces the pins, for tests or a self-hosted Lumi.
|
|
43
|
+
if (publicKeys !== undefined && (!Array.isArray(publicKeys) || !publicKeys.length || !publicKeys.every((k) => /^[0-9a-f]{64}$/i.test(String(k)))))
|
|
44
|
+
throw new TypeError("publicKeys must be a non-empty array of 64-hex Ed25519 public keys");
|
|
45
|
+
const keys = publicKeys ?? pinnedPublicKeys();
|
|
49
46
|
const unavailable = (code, detail, extra = {}) => ({
|
|
50
47
|
decision: mode === "closed" ? "block" : "warn", reasons: [{ code, severity: mode === "closed" ? "block" : "warn", detail }],
|
|
51
48
|
score: null, link: null, verified: false, fail_mode: mode, unavailable: true, ...extra,
|
|
@@ -66,8 +63,11 @@ export function createLumiCheck({ api = DEFAULT_API, publicKeys, timeoutMs, fail
|
|
|
66
63
|
return unavailable("lumi_unreachable", e?.name === "TimeoutError" ? `No answer within ${ms} ms.` : "Network error reaching Lumi.");
|
|
67
64
|
}
|
|
68
65
|
let v;
|
|
69
|
-
try { v = verifyLumiSigned(j.receipt, { publicKeys:
|
|
70
|
-
if (!v.ok)
|
|
66
|
+
try { v = verifyLumiSigned(j.receipt, { publicKeys: keys, now: now() }); } catch { v = { ok: false, errors: ["verify_failed"] }; }
|
|
67
|
+
if (!v.ok) {
|
|
68
|
+
const keyErr = v.error === "lumi_untrusted_key" || v.error === "lumi_retired_key";
|
|
69
|
+
return unavailable(keyErr ? v.error : "lumi_unverified", v.detail ?? `Lumi's signature did not verify (${(v.errors ?? []).join(", ")}).`, { errors: v.errors });
|
|
70
|
+
}
|
|
71
71
|
const p = j.receipt.payload; // the signed payload is the source of truth
|
|
72
72
|
return { decision: p.decision, reasons: p.reasons, score: p.score, link: p.link, matched: p.matched, verified: true, key_id: v.key_id, receipt: j.receipt, fail_mode: mode };
|
|
73
73
|
}
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { testBuy, pickQuote } from "./buy.js";
|
|
|
13
13
|
export { capsFrom, checkSpend, recordSpend, loadState, saveState, defaultStatePath, DEFAULT_CAPS, HARD_CEILING } from "./caps.js";
|
|
14
14
|
export { originPath } from "./origin.js";
|
|
15
15
|
export { verifyLumiSigned, lumiSignedMessage, lumiKeyId, LUMI_SIGNED_TYPE } from "./lumi_signed.js";
|
|
16
|
+
export { LUMI_SIGNING_KEYS, pinnedPublicKeys, retiredPublicKeys } from "./lumi_keys.js";
|
|
16
17
|
export { loadConfig, saveConfig, recordShareChoice, sharingEnabled, telemetryEnabled, defaultConfigPath, DEFAULT_CONFIG, DISCLOSURE, TIER2_NOTICE, ACCOUNT_DELETE_NOTICE, SHARING_OFF_ERROR } from "./config.js";
|
|
17
18
|
export { maybeAskToShare, shareQuestionState, renderShareQuestion, parseShareAnswer, hintOnce, isCi, SHARE_QUESTION, SHARE_DETAIL, SHARE_HINT } from "./first_run.js";
|
|
18
19
|
export { reportOutcome, buildOutcome, subjectOf, statusClassOf, OUTCOME_FIELDS } from "./telemetry.js";
|
package/src/lumi_keys.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned Lumi signing keys (public keys only). Lumi Check answers, paid trust/check `lumi_receipt`s and Tested-by-Lumi
|
|
3
|
+
* badge verdicts are accepted ONLY when signed by a non-retired key from this list. The kit never fetches keys at
|
|
4
|
+
* runtime: whoever answers a request must not be able to choose the key it is checked against.
|
|
5
|
+
* Same list as the server (src/trust/lumi_keys.ts) and lumi-trades-mcp (mcp/src/lumi_keys.js); a test keeps them equal.
|
|
6
|
+
*
|
|
7
|
+
* Rotation: a new key ships here as "next" first (trusted), becomes "active" when Lumi switches to it, and the old key
|
|
8
|
+
* becomes "retired" (rejected with lumi_retired_key). See docs/lumi-check-keys.md in the Lumi API repo.
|
|
9
|
+
*/
|
|
10
|
+
export const LUMI_SIGNING_KEYS = Object.freeze([
|
|
11
|
+
Object.freeze({ key_id: "lsk_5c8c2f54fd228547", public_key: "7d8806265f0ce62af2bf354392246019e5f607ae2470c611bffedba59ef82e52", status: "active", added: "2026-10-23" }),
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** Public keys the kit trusts by default: every pinned key that is not retired. */
|
|
15
|
+
export const pinnedPublicKeys = (list = LUMI_SIGNING_KEYS) => list.filter((k) => k.status !== "retired").map((k) => k.public_key.toLowerCase());
|
|
16
|
+
export const retiredPublicKeys = (list = LUMI_SIGNING_KEYS) => list.filter((k) => k.status === "retired").map((k) => k.public_key.toLowerCase());
|
package/src/lumi_signed.js
CHANGED
|
@@ -2,18 +2,25 @@
|
|
|
2
2
|
* Verify a Lumi-signed response (free /v1/check receipt, paid trust/check `lumi_receipt`, Tested-by-Lumi badge verdict).
|
|
3
3
|
* Same shape as RECEIPT_FORMAT: receipt_hash = sha256(canonical JSON without receipt_hash and signer);
|
|
4
4
|
* signer.message = `lumi-signed:<kind>:<receipt_hash>`; ed25519 over the UTF-8 message; public key hex; signature base64.
|
|
5
|
-
*
|
|
5
|
+
* Trusted keys default to the keys pinned in this package (./lumi_keys.js), never to keys fetched from the network.
|
|
6
|
+
* A response signed by any other key fails with `untrusted_key` (error lumi_untrusted_key); a retired pinned key with
|
|
7
|
+
* `retired_key` (lumi_retired_key).
|
|
6
8
|
*/
|
|
7
9
|
import { ed25519 } from "@noble/curves/ed25519";
|
|
8
10
|
import { canonicalJson, sha256Hex, fromHex, unb64, enc } from "./util.js";
|
|
11
|
+
import { pinnedPublicKeys, retiredPublicKeys, LUMI_SIGNING_KEYS } from "./lumi_keys.js";
|
|
9
12
|
|
|
10
13
|
export const LUMI_SIGNED_TYPE = "lumi_signed_response";
|
|
11
14
|
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
12
15
|
export const lumiSignedMessage = (kind, hash) => `lumi-signed:${kind}:${hash}`;
|
|
13
16
|
export const lumiKeyId = (publicKeyHex) => "lsk_" + sha256Hex(String(publicKeyHex).toLowerCase()).slice(0, 16);
|
|
14
17
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* @param publicKeys trusted public keys (hex). Default: the pinned, non-retired Lumi keys. Pass your own only for tests or
|
|
20
|
+
* a self-hosted Lumi.
|
|
21
|
+
* @returns {{ ok: boolean, errors: string[], expired: boolean, key_id: string|null, error?: string, detail?: string }}
|
|
22
|
+
*/
|
|
23
|
+
export function verifyLumiSigned(r, { publicKeys = pinnedPublicKeys(), now = Date.now() } = {}) {
|
|
17
24
|
const errors = [];
|
|
18
25
|
if (!r || typeof r !== "object" || r.type !== LUMI_SIGNED_TYPE) return { ok: false, errors: ["not_a_lumi_signed_response"], expired: false, key_id: null };
|
|
19
26
|
const { receipt_hash: _h, signer: _s, ...body } = r;
|
|
@@ -26,9 +33,22 @@ export function verifyLumiSigned(r, { publicKeys = [], now = Date.now() } = {})
|
|
|
26
33
|
try { good = ed25519.verify(unb64(String(s.signature)), enc.encode(String(s.message)), fromHex(String(s.public_key).toLowerCase())); } catch { good = false; }
|
|
27
34
|
if (!good) errors.push("bad_signature");
|
|
28
35
|
if (s.key_id !== lumiKeyId(s.public_key)) errors.push("key_id_mismatch");
|
|
29
|
-
|
|
36
|
+
const pk = String(s.public_key).toLowerCase();
|
|
37
|
+
if (!publicKeys.map((k) => String(k).toLowerCase()).includes(pk)) errors.push(retiredPublicKeys().includes(pk) ? "retired_key" : "untrusted_key");
|
|
30
38
|
}
|
|
31
39
|
const expired = !(Date.parse(String(r.expires_at)) >= now);
|
|
32
40
|
if (expired) errors.push("expired");
|
|
33
|
-
|
|
41
|
+
const ok = errors.length === 0;
|
|
42
|
+
return { ok, errors, expired, key_id: s.key_id ?? null, ...(ok ? {} : explain(errors, s)) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One clear error code + sentence for a failed verification (key problems first: they mean "not from Lumi"). */
|
|
46
|
+
function explain(errors, s) {
|
|
47
|
+
const kid = typeof s.key_id === "string" ? s.key_id.slice(0, 40) : "unknown";
|
|
48
|
+
const pinned = LUMI_SIGNING_KEYS.filter((k) => k.status !== "retired").map((k) => k.key_id).join(", ");
|
|
49
|
+
if (errors.includes("untrusted_key")) return { error: "lumi_untrusted_key", detail: `Signed by key ${kid}, which is not a pinned Lumi signing key (pinned: ${pinned}). Rejected: this answer did not come from Lumi, or this kit is older than a key rotation (update it).` };
|
|
50
|
+
if (errors.includes("retired_key")) return { error: "lumi_retired_key", detail: `Signed by retired Lumi key ${kid}. Rejected: retired keys are no longer trusted.` };
|
|
51
|
+
if (errors.includes("unsigned")) return { error: "lumi_unsigned", detail: "The response carries no Lumi signature." };
|
|
52
|
+
if (errors.includes("expired") && errors.length === 1) return { error: "lumi_expired", detail: "The signed answer has expired; ask Lumi again." };
|
|
53
|
+
return { error: "lumi_bad_signature", detail: `Lumi's signature did not verify (${errors.join(", ")}).` };
|
|
34
54
|
}
|