can2cup 0.10.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/INSTALL.zh-tw.md +123 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +483 -0
- package/SKILL.md +239 -0
- package/dist/cli/index.js +1789 -0
- package/dist/mcp/core.js +1120 -0
- package/dist/mcp/index.js +187 -0
- package/dist/mcp/relay-client.js +178 -0
- package/dist/mcp/state.js +341 -0
- package/dist/mcp/version.js +58 -0
- package/dist/protocol/canon.js +19 -0
- package/dist/protocol/crypto.js +31 -0
- package/dist/protocol/display.js +8 -0
- package/dist/protocol/e2e.js +42 -0
- package/dist/protocol/envelope.js +85 -0
- package/dist/protocol/index.js +9 -0
- package/dist/protocol/mandate.js +55 -0
- package/dist/protocol/principal.js +80 -0
- package/dist/protocol/release.js +25 -0
- package/dist/protocol/room.js +59 -0
- package/dist/protocol/semver.js +14 -0
- package/dist/viewer/index.js +132 -0
- package/dist/viewer/notify.js +113 -0
- package/dist/viewer/page.js +97 -0
- package/package.json +55 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principal-signed messages — the principal → agent channel, end to end.
|
|
3
|
+
*
|
|
4
|
+
* v0.2 and earlier: whatever the bridge put in an agent's inbox was shown to the model as
|
|
5
|
+
* "principal instructions", and the only thing standing between an attacker and that label
|
|
6
|
+
* was the bot's shared BRIDGE_KEY (2026-08-19 review, F1/F7). v0.3 gives the principal an
|
|
7
|
+
* ed25519 keypair of their own (`~/.parley/principal.json`). An instruction or a pause that
|
|
8
|
+
* carries a valid signature by THAT key — addressed to THIS agent, with a fresh nonce — is
|
|
9
|
+
* the only thing the MCP server will ever label as verified. The relay and the bot can still
|
|
10
|
+
* write unsigned items; those stay explicitly unverified.
|
|
11
|
+
*
|
|
12
|
+
* The signature covers `agent`, so a message signed for one agent cannot be replayed to
|
|
13
|
+
* another; `nonce` lets the receiver refuse the same message twice; `at` orders pauses.
|
|
14
|
+
* `approve` binds an approval to a concrete envelope (room, seq, hash), so an approval can
|
|
15
|
+
* never be re-aimed at a different decision than the one the principal looked at (F6).
|
|
16
|
+
*/
|
|
17
|
+
import { canon } from "./canon.js";
|
|
18
|
+
import { randomHex, signHex, verifyHex } from "./crypto.js";
|
|
19
|
+
export function principalSigningBytes(m) {
|
|
20
|
+
const { kind, agent, at, nonce, text, paused, approve } = m;
|
|
21
|
+
return canon({ kind, agent, at, nonce, text, paused, approve });
|
|
22
|
+
}
|
|
23
|
+
export function signPrincipal(m, priv, pub) {
|
|
24
|
+
const full = { ...m, at: m.at ?? new Date().toISOString(), nonce: m.nonce ?? randomHex(16) };
|
|
25
|
+
return { ...full, pub, sig: signHex(principalSigningBytes(full), priv) };
|
|
26
|
+
}
|
|
27
|
+
/** Structural + cryptographic check. `expectedPub` = the principal key the verifier trusts
|
|
28
|
+
* (the agent pins its own principal.json pubkey; the bridge pins what the agent registered);
|
|
29
|
+
* `expectedAgent` = the verifier's own agent pubkey. */
|
|
30
|
+
export function verifyPrincipal(s, expectedPub, expectedAgent) {
|
|
31
|
+
if (!s || typeof s !== "object")
|
|
32
|
+
return { ok: false, error: "not a message" };
|
|
33
|
+
if (s.kind !== "say" && s.kind !== "pause")
|
|
34
|
+
return { ok: false, error: "bad kind" };
|
|
35
|
+
if (!/^[0-9a-f]{64}$/.test(s.pub ?? ""))
|
|
36
|
+
return { ok: false, error: "bad pub" };
|
|
37
|
+
if (s.pub !== expectedPub)
|
|
38
|
+
return { ok: false, error: "signed by a key that is not your principal's" };
|
|
39
|
+
if (s.agent !== expectedAgent)
|
|
40
|
+
return { ok: false, error: "addressed to a different agent" };
|
|
41
|
+
if (typeof s.nonce !== "string" || s.nonce.length < 16)
|
|
42
|
+
return { ok: false, error: "bad nonce" };
|
|
43
|
+
if (!Number.isFinite(Date.parse(s.at ?? "")))
|
|
44
|
+
return { ok: false, error: "bad timestamp" };
|
|
45
|
+
if (s.kind === "say" && typeof s.text !== "string")
|
|
46
|
+
return { ok: false, error: "say without text" };
|
|
47
|
+
if (s.kind === "pause" && typeof s.paused !== "boolean")
|
|
48
|
+
return { ok: false, error: "pause without paused flag" };
|
|
49
|
+
if (!verifyHex(s.sig, principalSigningBytes(s), s.pub))
|
|
50
|
+
return { ok: false, error: "bad principal signature" };
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
// ------------------------------------------------ signed HTTP requests ---
|
|
54
|
+
/** Requests that must prove possession of a key (agent → bridge, agent → room admin ops,
|
|
55
|
+
* principal → bridge) carry three headers and sign "METHOD\nPATH\nTS\nBODY". The relay
|
|
56
|
+
* checks the timestamp is within ±5 minutes. Same scheme for every key role, so one helper. */
|
|
57
|
+
export const REQ_SIG_SKEW_MS = 5 * 60 * 1000;
|
|
58
|
+
export function requestSigningBytes(method, path, ts, body) {
|
|
59
|
+
return `${method}\n${path}\n${ts}\n${body}`;
|
|
60
|
+
}
|
|
61
|
+
export function signRequestHeaders(method, path, body, key, prefix = "x-parley") {
|
|
62
|
+
const ts = new Date().toISOString();
|
|
63
|
+
return {
|
|
64
|
+
[`${prefix}-pub`]: key.pub,
|
|
65
|
+
[`${prefix}-ts`]: ts,
|
|
66
|
+
[`${prefix}-sig`]: signHex(requestSigningBytes(method, path, ts, body), key.priv),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function verifyRequestHeaders(h, method, path, body, prefix = "x-parley", now = Date.now()) {
|
|
70
|
+
const pub = h(`${prefix}-pub`) ?? "";
|
|
71
|
+
const ts = h(`${prefix}-ts`) ?? "";
|
|
72
|
+
const sig = h(`${prefix}-sig`) ?? "";
|
|
73
|
+
if (!/^[0-9a-f]{64}$/.test(pub) || !ts || !sig)
|
|
74
|
+
return { ok: false, error: "missing signature headers" };
|
|
75
|
+
if (Math.abs(now - Date.parse(ts)) > REQ_SIG_SKEW_MS)
|
|
76
|
+
return { ok: false, error: "signature timestamp too old" };
|
|
77
|
+
if (!verifyHex(sig, requestSigningBytes(method, path, ts, body), pub))
|
|
78
|
+
return { ok: false, error: "bad signature" };
|
|
79
|
+
return { ok: true, pub };
|
|
80
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// v0.10.0 (security G-3, P2): release signing.
|
|
2
|
+
//
|
|
3
|
+
// The relay serves the install tarball; the maintainer SIGNS it. These are different people in the threat model
|
|
4
|
+
// (or the same person on different days), so the key that vouches for a release must not live on the relay.
|
|
5
|
+
// The private half stays offline on the maintainer's machine (scripts/release-key.mjs); this file carries the
|
|
6
|
+
// public half, compiled into every client. A client trusts a release only if manifest.sig verifies against one
|
|
7
|
+
// of these keys. Rotation: add the new key here, ship, drop the old one in the following release.
|
|
8
|
+
import { canon } from "./canon.js";
|
|
9
|
+
import { verifyHex } from "./crypto.js";
|
|
10
|
+
export const RELEASE_PUBS = [
|
|
11
|
+
"7a025a45418ea95d0aaf6738749cf72d40207965610d1a31cb3db6233f50d571", // 2026-09-05, the maintainer's home machine
|
|
12
|
+
];
|
|
13
|
+
/** Signature over canon(manifest) — the same canonical JSON every envelope uses; no new format. */
|
|
14
|
+
export function verifyManifest(m, sigHex, pubs) {
|
|
15
|
+
const x = m;
|
|
16
|
+
if (!x || typeof x !== "object" || x.v !== 1 || typeof x.version !== "string" || !x.files || typeof x.files !== "object")
|
|
17
|
+
return { ok: false, reason: "release manifest is malformed" };
|
|
18
|
+
if (!/^[0-9a-f]{128}$/.test(sigHex))
|
|
19
|
+
return { ok: false, reason: "release manifest signature is malformed" };
|
|
20
|
+
const msg = canon(x);
|
|
21
|
+
for (const pub of pubs)
|
|
22
|
+
if (verifyHex(sigHex, msg, pub))
|
|
23
|
+
return { ok: true, pub };
|
|
24
|
+
return { ok: false, reason: `release manifest signature does not verify against any release key this client trusts (${pubs.map((p) => p.slice(0, 8) + "…").join(", ")}) — release key not trusted by this client, or the manifest was altered` };
|
|
25
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const DEFAULT_POLICY = { maxMessages: 200, ttlSec: 6 * 3600 };
|
|
2
|
+
const PREFIX = "parley1.";
|
|
3
|
+
const TOKEN_RE = /parley1.[A-Za-z0-9_-]{16,}/;
|
|
4
|
+
const URL_RE = /https?:\/\/[^\s"'<>]+\/j\/[0-9a-f]{12}[^\s"'<>]*/;
|
|
5
|
+
export function encodeInvite(i) {
|
|
6
|
+
return PREFIX + b64url(JSON.stringify(i));
|
|
7
|
+
}
|
|
8
|
+
export function encodeInviteUrl(i) {
|
|
9
|
+
const base = i.u.replace(/\/+$/, "");
|
|
10
|
+
const qs = new URLSearchParams();
|
|
11
|
+
if (i.n)
|
|
12
|
+
qs.set("n", i.n);
|
|
13
|
+
if (i.p)
|
|
14
|
+
qs.set("p", i.p);
|
|
15
|
+
const qstr = qs.toString();
|
|
16
|
+
const q = qstr ? `?${qstr}` : "";
|
|
17
|
+
return `${base}/j/${i.r}${q}#${i.s}${i.k ? "." + i.k : ""}`;
|
|
18
|
+
}
|
|
19
|
+
/** Accepts a token, a URL, or any text that contains one of them (agents paste whole chat lines). */
|
|
20
|
+
export function decodeInvite(s) {
|
|
21
|
+
const t = s.trim();
|
|
22
|
+
const url = URL_RE.exec(t)?.[0];
|
|
23
|
+
if (url)
|
|
24
|
+
return decodeInviteUrl(url);
|
|
25
|
+
const tok = TOKEN_RE.exec(t)?.[0] ?? (t.startsWith(PREFIX) ? t : undefined);
|
|
26
|
+
if (!tok)
|
|
27
|
+
throw new Error("not a can2cup invite (expected a https://…/j/<room>#<secret> link or a parley1.… token)");
|
|
28
|
+
const i = JSON.parse(unb64url(tok.slice(PREFIX.length)));
|
|
29
|
+
if (!i.u || !i.r || !i.s)
|
|
30
|
+
throw new Error("malformed invite");
|
|
31
|
+
return i;
|
|
32
|
+
}
|
|
33
|
+
export function decodeInviteUrl(url) {
|
|
34
|
+
const u = new URL(url);
|
|
35
|
+
const m = /^(.*)\/j\/([0-9a-f]{12})$/.exec(u.pathname);
|
|
36
|
+
if (!m)
|
|
37
|
+
throw new Error("malformed invite link (expected /j/<room>)");
|
|
38
|
+
const [s, k] = u.hash.replace(/^#/, "").split(".");
|
|
39
|
+
if (!/^[0-9a-f]{16,}$/.test(s))
|
|
40
|
+
throw new Error("invite link is missing its secret (the part after #) — copy the whole link");
|
|
41
|
+
const n = u.searchParams.get("n") ?? undefined;
|
|
42
|
+
const p = u.searchParams.get("p") ?? undefined;
|
|
43
|
+
return { u: u.origin + m[1], r: m[2], s, ...(n ? { n } : {}), ...(p && /^[0-9a-f]{64}$/.test(p) ? { p } : {}), ...(k && /^[0-9a-f]{64}$/.test(k) ? { k } : {}) };
|
|
44
|
+
}
|
|
45
|
+
function b64url(s) {
|
|
46
|
+
const bytes = new TextEncoder().encode(s);
|
|
47
|
+
let bin = "";
|
|
48
|
+
for (const b of bytes)
|
|
49
|
+
bin += String.fromCharCode(b);
|
|
50
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
|
+
}
|
|
52
|
+
function unb64url(s) {
|
|
53
|
+
const b = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
54
|
+
const bin = atob(b);
|
|
55
|
+
const bytes = new Uint8Array(bin.length);
|
|
56
|
+
for (let i = 0; i < bin.length; i++)
|
|
57
|
+
bytes[i] = bin.charCodeAt(i);
|
|
58
|
+
return new TextDecoder().decode(bytes);
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** v0.9.0 upgrade protocol: the only comparison both relay and client need. "1.2.3" style; unknown → 0.0.0. */
|
|
2
|
+
export function parseSemver(v) {
|
|
3
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec((v ?? "").trim());
|
|
4
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
|
|
5
|
+
}
|
|
6
|
+
/** negative when a < b, 0 when equal, positive when a > b. */
|
|
7
|
+
export function cmpSemver(a, b) {
|
|
8
|
+
const x = parseSemver(a), y = parseSemver(b);
|
|
9
|
+
for (let i = 0; i < 3; i++)
|
|
10
|
+
if (x[i] !== y[i])
|
|
11
|
+
return x[i] - y[i];
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
export const NO_VERSION = "0.0.0";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* can2cup viewer — the principal's window onto the rooms their agent is in.
|
|
4
|
+
* Local only (binds 127.0.0.1). Reads CAN2CUP_HOME, proxies the relay with the
|
|
5
|
+
* stored room secrets, verifies the chain with the shared protocol code, and
|
|
6
|
+
* merges the local audit log (private rationale, blocked attempts) into the
|
|
7
|
+
* transcript. Also exposes the PAUSED brake.
|
|
8
|
+
*
|
|
9
|
+
* node dist/viewer/index.js [--port 7777]
|
|
10
|
+
* CAN2CUP_NOTIFY_URL=https://ntfy.sh/<topic> also push inbound events / blocks / escalations (see notify.ts)
|
|
11
|
+
*/
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import QRCode from "qrcode";
|
|
16
|
+
import { verifyEnvelope, genesis, encodeInvite, encodeInviteUrl, decryptBody, isEncrypted } from "../protocol/index.js";
|
|
17
|
+
import { HOME, loadIdentity, loadRooms, loadMandate, isPaused } from "../mcp/state.js";
|
|
18
|
+
import { relay } from "../mcp/relay-client.js";
|
|
19
|
+
import { PAGE } from "./page.js";
|
|
20
|
+
import { notifyEnabled, startWatcher } from "./notify.js";
|
|
21
|
+
const port = Number(process.argv[process.argv.indexOf("--port") + 1]) || 7777;
|
|
22
|
+
const me = loadIdentity();
|
|
23
|
+
const cache = new Map();
|
|
24
|
+
async function roomDelta(room, wait) {
|
|
25
|
+
let c = cache.get(room.id);
|
|
26
|
+
if (!c) {
|
|
27
|
+
c = { msgs: [], ok: [], head: genesis(room.id), names: {}, state: room.state, namesAt: 0 };
|
|
28
|
+
cache.set(room.id, c);
|
|
29
|
+
}
|
|
30
|
+
const since = c.msgs.length ? c.msgs[c.msgs.length - 1].seq : 0;
|
|
31
|
+
const res = await relay.poll(room.relay, room.id, room.cap ?? room.secret, since, since === 0 ? 0 : wait);
|
|
32
|
+
const delta = [];
|
|
33
|
+
for (const m of res.messages) {
|
|
34
|
+
const v = verifyEnvelope(m, c.head, { relayPub: room.relayPub, pastRelayPubs: room.relayPubHistory });
|
|
35
|
+
c.head = m.hash;
|
|
36
|
+
// E2E rooms: verify the wire form above, show the plaintext below.
|
|
37
|
+
let shown = m;
|
|
38
|
+
if (room.key && isEncrypted(m.body)) {
|
|
39
|
+
const d = await decryptBody(room.key, room.id, m.body);
|
|
40
|
+
shown = { ...m, body: d === undefined ? { text: "[E2E: body did not decrypt]" } : d };
|
|
41
|
+
}
|
|
42
|
+
c.msgs.push(shown);
|
|
43
|
+
c.ok.push(v.ok);
|
|
44
|
+
delta.push({ ...shown, ok: v.ok, errors: v.errors });
|
|
45
|
+
}
|
|
46
|
+
c.state = res.state;
|
|
47
|
+
if (Date.now() - c.namesAt > 15000 || delta.some((m) => m.type === "system")) {
|
|
48
|
+
try {
|
|
49
|
+
const info = await relay.info(room.relay, room.id, room.cap ?? room.secret);
|
|
50
|
+
c.names = Object.fromEntries(Object.entries(info.participants).map(([pk, p]) => [pk, p.name || pk.slice(0, 8)]));
|
|
51
|
+
}
|
|
52
|
+
catch { /* keep old names */ }
|
|
53
|
+
c.namesAt = Date.now();
|
|
54
|
+
}
|
|
55
|
+
return { delta, c };
|
|
56
|
+
}
|
|
57
|
+
function readAudit(roomId) {
|
|
58
|
+
const p = path.join(HOME, "audit.jsonl");
|
|
59
|
+
if (!fs.existsSync(p))
|
|
60
|
+
return [];
|
|
61
|
+
return fs.readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => { try {
|
|
62
|
+
return JSON.parse(l);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
} })
|
|
67
|
+
.filter((e) => !!e && e.room === roomId);
|
|
68
|
+
}
|
|
69
|
+
function json(res, status, body) {
|
|
70
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
71
|
+
res.end(JSON.stringify(body));
|
|
72
|
+
}
|
|
73
|
+
const server = http.createServer(async (req, res) => {
|
|
74
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
75
|
+
try {
|
|
76
|
+
if (url.pathname === "/") {
|
|
77
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
78
|
+
return res.end(PAGE);
|
|
79
|
+
}
|
|
80
|
+
if (url.pathname === "/api/state") {
|
|
81
|
+
const rooms = Object.values(loadRooms()).sort((a, b) => b.joinedAt.localeCompare(a.joinedAt));
|
|
82
|
+
return json(res, 200, { me: { name: me.name, pub: me.pub }, home: HOME, paused: isPaused(), notify: notifyEnabled, mandate: loadMandate(), rooms });
|
|
83
|
+
}
|
|
84
|
+
const inv = /^\/api\/rooms\/([0-9a-f]{12})\/invite$/.exec(url.pathname);
|
|
85
|
+
if (inv) {
|
|
86
|
+
const room = loadRooms()[inv[1]];
|
|
87
|
+
if (!room)
|
|
88
|
+
return json(res, 404, { error: "unknown room" });
|
|
89
|
+
const i = { u: room.relay, r: room.id, s: room.secret, n: room.name || undefined, p: room.relayPub };
|
|
90
|
+
const link = encodeInviteUrl(i);
|
|
91
|
+
const qr = await QRCode.toDataURL(link, { margin: 1, width: 240, color: { dark: "#000000", light: "#ffffff" } });
|
|
92
|
+
return json(res, 200, { link, token: encodeInvite(i), qr });
|
|
93
|
+
}
|
|
94
|
+
const m = /^\/api\/rooms\/([0-9a-f]{12})$/.exec(url.pathname);
|
|
95
|
+
if (m) {
|
|
96
|
+
const room = loadRooms()[m[1]];
|
|
97
|
+
if (!room)
|
|
98
|
+
return json(res, 404, { error: "unknown room" });
|
|
99
|
+
const wait = Math.min(30, Number(url.searchParams.get("wait") ?? 0) || 0);
|
|
100
|
+
const full = url.searchParams.get("full") === "1";
|
|
101
|
+
const { delta, c } = await roomDelta(room, wait);
|
|
102
|
+
const audit = readAudit(room.id);
|
|
103
|
+
const rationale = {};
|
|
104
|
+
for (const e of audit)
|
|
105
|
+
if (e.kind === "send" && e.seq != null && e.rationale)
|
|
106
|
+
rationale[e.seq] = e.rationale;
|
|
107
|
+
const blocked = audit.filter((e) => e.kind === "blocked").map((e) => ({ at: e.at, type: e.type, body: e.body, reason: e.reason, rationale: e.rationale ?? null }));
|
|
108
|
+
const msgs = full ? c.msgs.map((x, i) => ({ ...x, ok: c.ok[i], errors: [] })) : delta;
|
|
109
|
+
return json(res, 200, { room: { id: room.id, name: room.name, relay: room.relay, state: c.state }, me: me.pub, names: c.names, msgs, rationale, blocked, chainOk: c.ok.every(Boolean), lastSeq: c.msgs.length ? c.msgs[c.msgs.length - 1].seq : 0 });
|
|
110
|
+
}
|
|
111
|
+
if (url.pathname === "/api/pause" && req.method === "POST") {
|
|
112
|
+
let body = "";
|
|
113
|
+
for await (const ch of req)
|
|
114
|
+
body += ch;
|
|
115
|
+
const on = !!JSON.parse(body || "{}").on;
|
|
116
|
+
const p = path.join(HOME, "PAUSED");
|
|
117
|
+
if (on)
|
|
118
|
+
fs.writeFileSync(p, new Date().toISOString() + "\n");
|
|
119
|
+
else if (fs.existsSync(p))
|
|
120
|
+
fs.unlinkSync(p);
|
|
121
|
+
return json(res, 200, { paused: isPaused() });
|
|
122
|
+
}
|
|
123
|
+
json(res, 404, { error: "not found" });
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
json(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
server.listen(port, "127.0.0.1", () => {
|
|
130
|
+
console.log(`can2cup viewer for ${me.name} (${me.pub.slice(0, 8)}) — http://127.0.0.1:${port}/ home=${HOME}`);
|
|
131
|
+
startWatcher(me.pub, me.name);
|
|
132
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principal notification. The viewer is the principal's "phone app": while it runs it
|
|
3
|
+
* watches every open room (its own cursor, independent of the UI) and the local audit
|
|
4
|
+
* log, and pushes one line per event to a webhook.
|
|
5
|
+
*
|
|
6
|
+
* CAN2CUP_NOTIFY_URL where to POST. Recognised shapes:
|
|
7
|
+
* https://ntfy.sh/<topic> → plain-text POST with a Title header
|
|
8
|
+
* https://api.telegram.org/bot<t>/sendMessage?chat_id=<id> → JSON {chat_id,text}
|
|
9
|
+
* anything else → JSON {title,text,room,type,from}
|
|
10
|
+
* CAN2CUP_NOTIFY_TYPES comma list of inbound types to push (default: question,proposal,counter,accept,
|
|
11
|
+
* grant,revoke,escalate,attachment,close). Blocked attempts and your own agent's
|
|
12
|
+
* `escalate` are always pushed — those are the moments the principal must act.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { HOME, loadRooms } from "../mcp/state.js";
|
|
17
|
+
import { relay } from "../mcp/relay-client.js";
|
|
18
|
+
const URL_ = process.env.CAN2CUP_NOTIFY_URL ?? process.env.CAN2CAN_NOTIFY_URL ?? process.env.PARLEY_NOTIFY_URL ?? "";
|
|
19
|
+
const TYPES = new Set((process.env.CAN2CUP_NOTIFY_TYPES ?? process.env.CAN2CAN_NOTIFY_TYPES ?? process.env.PARLEY_NOTIFY_TYPES ?? "question,proposal,counter,accept,grant,revoke,escalate,attachment,close").split(",").map((s) => s.trim()).filter(Boolean));
|
|
20
|
+
export const notifyEnabled = !!URL_;
|
|
21
|
+
export async function push(title, text, extra = {}) {
|
|
22
|
+
if (!URL_)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
if (/ntfy\.sh|\/ntfy/.test(URL_)) {
|
|
26
|
+
await fetch(URL_, { method: "POST", headers: { Title: title.replace(/[^\x20-\x7e]/g, "?"), Tags: "speech_balloon" }, body: `${title}\n${text}` });
|
|
27
|
+
}
|
|
28
|
+
else if (URL_.includes("api.telegram.org")) {
|
|
29
|
+
const u = new URL(URL_);
|
|
30
|
+
const chat_id = u.searchParams.get("chat_id");
|
|
31
|
+
u.search = "";
|
|
32
|
+
await fetch(u.toString(), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chat_id, text: `${title}\n${text}` }) });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
await fetch(URL_, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title, text, ...extra }) });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
console.error("notify failed:", e instanceof Error ? e.message : e);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function summarize(m) {
|
|
43
|
+
const b = (m.body ?? {});
|
|
44
|
+
const bits = [typeof b.text === "string" ? b.text.slice(0, 200) : JSON.stringify(b).slice(0, 200)];
|
|
45
|
+
if (b.amount != null)
|
|
46
|
+
bits.push(`amount ${b.amount}`);
|
|
47
|
+
if (m.type === "grant")
|
|
48
|
+
bits.push(`scope ${b.scope} until ${b.expires}`);
|
|
49
|
+
if (m.type === "attachment")
|
|
50
|
+
bits.push(String(b.url ?? ""));
|
|
51
|
+
return bits.join(" · ");
|
|
52
|
+
}
|
|
53
|
+
/** Start the watcher. Cursors begin at each room's current lastSeq so a restart does not replay history. */
|
|
54
|
+
export function startWatcher(mePub, myName) {
|
|
55
|
+
if (!URL_)
|
|
56
|
+
return;
|
|
57
|
+
const cursors = new Map();
|
|
58
|
+
for (const r of Object.values(loadRooms()))
|
|
59
|
+
cursors.set(r.id, r.lastSeq);
|
|
60
|
+
let auditPos = 0;
|
|
61
|
+
try {
|
|
62
|
+
auditPos = fs.statSync(path.join(HOME, "audit.jsonl")).size;
|
|
63
|
+
}
|
|
64
|
+
catch { /* none yet */ }
|
|
65
|
+
console.log(`notify → ${URL_.replace(/bot[^/]+/, "bot***")} types=${[...TYPES].join(",")}`);
|
|
66
|
+
const tickRooms = async () => {
|
|
67
|
+
const rooms = Object.values(loadRooms()).filter((r) => r.state === "open");
|
|
68
|
+
await Promise.all(rooms.map(async (room) => {
|
|
69
|
+
const since = cursors.get(room.id) ?? room.lastSeq;
|
|
70
|
+
try {
|
|
71
|
+
const res = await relay.poll(room.relay, room.id, room.cap ?? room.secret, since, 0);
|
|
72
|
+
let last = since;
|
|
73
|
+
for (const m of res.messages) {
|
|
74
|
+
last = m.seq;
|
|
75
|
+
if (m.from === mePub || m.from === "relay" || !TYPES.has(m.type))
|
|
76
|
+
continue;
|
|
77
|
+
await push(`can2cup · ${room.name || room.id} · ${m.type}`, summarize(m), { room: room.id, seq: m.seq, type: m.type, from: m.from });
|
|
78
|
+
}
|
|
79
|
+
cursors.set(room.id, last);
|
|
80
|
+
}
|
|
81
|
+
catch { /* relay hiccup; try next tick */ }
|
|
82
|
+
}));
|
|
83
|
+
};
|
|
84
|
+
const tickAudit = async () => {
|
|
85
|
+
const p = path.join(HOME, "audit.jsonl");
|
|
86
|
+
if (!fs.existsSync(p))
|
|
87
|
+
return;
|
|
88
|
+
const size = fs.statSync(p).size;
|
|
89
|
+
if (size <= auditPos)
|
|
90
|
+
return;
|
|
91
|
+
const fd = fs.openSync(p, "r");
|
|
92
|
+
const buf = Buffer.alloc(size - auditPos);
|
|
93
|
+
fs.readSync(fd, buf, 0, buf.length, auditPos);
|
|
94
|
+
fs.closeSync(fd);
|
|
95
|
+
auditPos = size;
|
|
96
|
+
for (const line of buf.toString("utf8").split("\n").filter(Boolean)) {
|
|
97
|
+
try {
|
|
98
|
+
const e = JSON.parse(line);
|
|
99
|
+
if (e.kind === "blocked")
|
|
100
|
+
await push(`can2cup · ${myName}'s agent BLOCKED (${e.type})`, `${e.reason ?? ""}\n${e.body?.text ?? ""}`.slice(0, 300), { room: e.room, kind: "blocked" });
|
|
101
|
+
else if (e.kind === "send" && e.type === "escalate")
|
|
102
|
+
await push(`can2cup · ${myName}'s agent needs you`, (e.body?.text ?? "").slice(0, 300), { room: e.room, kind: "escalate" });
|
|
103
|
+
}
|
|
104
|
+
catch { /* skip */ }
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const loop = async () => {
|
|
108
|
+
await tickRooms();
|
|
109
|
+
await tickAudit();
|
|
110
|
+
setTimeout(loop, 8000);
|
|
111
|
+
};
|
|
112
|
+
void loop();
|
|
113
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** The viewer's single page. Kept as a string so the viewer has zero build/asset steps. */
|
|
2
|
+
export const PAGE = /* html */ `<!doctype html>
|
|
3
|
+
<html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<title>can2cup viewer</title>
|
|
5
|
+
<style>
|
|
6
|
+
:root{--bg:#0f1115;--panel:#161a21;--line:#262c36;--fg:#e6e6e6;--mute:#8a93a3;--me:#2f4f8f;--them:#232a35;--sys:#1b1f27;--ok:#3ecf8e;--bad:#ff5c5c;--warn:#f0b429;--accent:#f4a26b}
|
|
7
|
+
*{box-sizing:border-box}html,body{height:100%;margin:0}
|
|
8
|
+
body{background:var(--bg);color:var(--fg);font:14px/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans TC",sans-serif;display:grid;grid-template-columns:260px 1fr;grid-template-rows:48px 1fr}
|
|
9
|
+
header{grid-column:1/3;display:flex;align-items:center;gap:14px;padding:0 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
10
|
+
header b{color:var(--accent);letter-spacing:.04em}
|
|
11
|
+
header .id{color:var(--mute);font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
|
|
12
|
+
header .sp{flex:1}
|
|
13
|
+
button{background:transparent;color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 10px;cursor:pointer;font:inherit}
|
|
14
|
+
button.pause{border-color:var(--warn);color:var(--warn)}button.pause.on{background:var(--bad);border-color:var(--bad);color:#fff}
|
|
15
|
+
aside{border-right:1px solid var(--line);background:var(--panel);overflow:auto}
|
|
16
|
+
aside .room{padding:10px 14px;border-bottom:1px solid var(--line);cursor:pointer}
|
|
17
|
+
aside .room:hover,aside .room.sel{background:#1c2129}
|
|
18
|
+
aside .room .n{font-weight:600}aside .room .m{color:var(--mute);font-size:12px;font-family:ui-monospace,Menlo,Consolas,monospace}
|
|
19
|
+
main{display:flex;flex-direction:column;min-height:0}
|
|
20
|
+
.bar{display:flex;gap:12px;align-items:center;padding:8px 16px;border-bottom:1px solid var(--line);color:var(--mute);font-size:12px;flex-wrap:wrap}
|
|
21
|
+
.bar .ok{color:var(--ok)}.bar .bad{color:var(--bad)}.bar .st{padding:1px 8px;border:1px solid var(--line);border-radius:10px}
|
|
22
|
+
#log{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:8px}
|
|
23
|
+
.msg{max-width:72%;padding:8px 12px;border-radius:12px;background:var(--them);align-self:flex-start;position:relative}
|
|
24
|
+
.msg.me{background:var(--me);align-self:flex-end}
|
|
25
|
+
.msg.sys{align-self:center;background:var(--sys);color:var(--mute);font-size:12px;max-width:90%;padding:4px 10px}
|
|
26
|
+
.msg.blocked{align-self:flex-end;background:transparent;border:1px dashed var(--bad);color:var(--bad)}
|
|
27
|
+
.meta{font-size:11px;color:var(--mute);display:flex;gap:8px;align-items:center;margin-bottom:3px}
|
|
28
|
+
.msg.me .meta{color:#c9d4ea}
|
|
29
|
+
.type{font-family:ui-monospace,Menlo,Consolas,monospace;padding:0 6px;border-radius:8px;background:rgba(255,255,255,.08)}
|
|
30
|
+
.type.accept,.type.grant{background:var(--ok);color:#052}.type.close,.type.reject,.type.withdraw,.type.revoke{background:#444}.type.proposal,.type.counter{background:var(--accent);color:#3a1d05}.type.escalate{background:var(--warn);color:#3a2a05}.type.attachment,.type.question{background:#3b4a6b;color:#dfe7ff}
|
|
31
|
+
.att a{color:var(--accent)}.grantline{font-size:12px;color:#bfe9d3;margin-top:3px;font-family:ui-monospace,Menlo,Consolas,monospace}
|
|
32
|
+
#invite{display:none;position:fixed;right:16px;top:56px;width:340px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;z-index:5;box-shadow:0 8px 30px rgba(0,0,0,.5)}
|
|
33
|
+
#invite.on{display:block}#invite h3{margin:0 0 8px;font-size:13px;color:var(--accent);letter-spacing:.06em}
|
|
34
|
+
#invite img{display:block;margin:0 auto 8px;background:#fff;padding:6px;border-radius:8px}
|
|
35
|
+
#invite textarea{width:100%;height:64px;background:#0b0d11;color:var(--fg);border:1px solid var(--line);border-radius:6px;font:12px ui-monospace,Menlo,Consolas,monospace;padding:6px;resize:none}
|
|
36
|
+
#invite .warn{font-size:11px;color:var(--mute);margin:6px 0 0}
|
|
37
|
+
.amt{font-weight:700;font-variant-numeric:tabular-nums}
|
|
38
|
+
.v{margin-left:auto}.v.ok{color:var(--ok)}.v.bad{color:var(--bad)}
|
|
39
|
+
.rat{margin-top:6px;padding:6px 8px;border-left:2px solid var(--warn);background:rgba(0,0,0,.25);font-size:12px;color:#e8d9a8;font-style:italic}
|
|
40
|
+
.rat b{font-style:normal;color:var(--warn);font-size:10px;letter-spacing:.06em}
|
|
41
|
+
.empty{margin:auto;color:var(--mute)}
|
|
42
|
+
</style></head><body>
|
|
43
|
+
<header><b>can2cup</b><span id="who" class="id"></span><span class="sp"></span>
|
|
44
|
+
<span id="pausedTxt" style="color:var(--mute);font-size:12px"></span>
|
|
45
|
+
<button id="inviteBtn" title="Invite link + QR for the selected room">INVITE</button>
|
|
46
|
+
<button id="pauseBtn" class="pause" title="Create/remove the PAUSED file: while paused your agent cannot send anything.">PAUSE</button></header>
|
|
47
|
+
<div id="invite"><h3>INVITE · <span id="invRoom"></span></h3><img id="invQr" width="240" height="240" alt="QR of the invite link"><textarea id="invLink" readonly></textarea><button id="invCopy">copy link</button> <span id="invCopied" style="color:var(--ok);font-size:12px"></span><div class="warn">This link is the room key — whoever holds it can read and post. Hand it to the other principal out-of-band (LINE, mail, in person via the QR); never post it publicly.</div></div>
|
|
48
|
+
<aside id="rooms"></aside>
|
|
49
|
+
<main><div class="bar" id="bar">選一間房</div><div id="log"><div class="empty">no room selected</div></div></main>
|
|
50
|
+
<script>
|
|
51
|
+
const $=s=>document.querySelector(s);
|
|
52
|
+
let state=null, cur=null, names={}, me="", seen=new Set(), blockedSeen=new Set(), poller=0;
|
|
53
|
+
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
|
54
|
+
const t=(iso)=>{const d=new Date(iso);return d.toLocaleTimeString("zh-TW",{hour12:false})};
|
|
55
|
+
async function loadState(){state=await (await fetch("/api/state")).json();me=state.me.pub;
|
|
56
|
+
$("#who").textContent=state.me.name+" · "+me.slice(0,8)+" · "+state.home+(state.notify?" · 🔔":"");
|
|
57
|
+
setPaused(state.paused);
|
|
58
|
+
const el=$("#rooms");el.innerHTML="";
|
|
59
|
+
for(const r of state.rooms){const d=document.createElement("div");d.className="room"+(cur&&cur.id===r.id?" sel":"");
|
|
60
|
+
d.innerHTML='<div class="n">'+esc(r.name||"(unnamed)")+'</div><div class="m">'+esc(r.id)+" · "+esc(r.state)+" · seq "+esc(r.lastSeq)+"</div>";
|
|
61
|
+
d.onclick=()=>openRoom(r);el.appendChild(d);}
|
|
62
|
+
if(!cur&&state.rooms.length)openRoom(state.rooms[0]);}
|
|
63
|
+
function setPaused(on){$("#pauseBtn").classList.toggle("on",on);$("#pauseBtn").textContent=on?"PAUSED — click to resume":"PAUSE";$("#pausedTxt").textContent=on?"⛔ your agent cannot send":"";}
|
|
64
|
+
$("#inviteBtn").onclick=async()=>{const p=$("#invite");if(p.classList.contains("on")){p.classList.remove("on");return;}if(!cur)return;
|
|
65
|
+
const r=await (await fetch("/api/rooms/"+cur.id+"/invite")).json();$("#invRoom").textContent=(cur.name||cur.id);$("#invQr").src=r.qr;$("#invLink").value=r.link;$("#invCopied").textContent="";p.classList.add("on");};
|
|
66
|
+
$("#invCopy").onclick=async()=>{try{await navigator.clipboard.writeText($("#invLink").value);$("#invCopied").textContent="copied ✓";}catch(e){$("#invLink").select();}};
|
|
67
|
+
$("#pauseBtn").onclick=async()=>{const on=!$("#pauseBtn").classList.contains("on");const r=await (await fetch("/api/pause",{method:"POST",body:JSON.stringify({on})})).json();setPaused(r.paused);};
|
|
68
|
+
function extra(m){const b=m.body||{};let h="";
|
|
69
|
+
if(m.type==="grant")h+='<div class="grantline">scope '+esc(b.scope||"")+' · until '+esc(b.expires||"")+(b.revocable===false?" · irrevocable":"")+'</div>';
|
|
70
|
+
if(m.type==="revoke")h+='<div class="grantline">revokes #'+esc(b.ref)+'</div>';
|
|
71
|
+
if(m.type==="attachment")h+='<div class="att">📎 '+esc(b.name||"")+' <a href="'+esc(b.url||"#")+'" target="_blank" rel="noopener">'+esc(b.url||"")+'</a>'+(b.sha256?' <span class="mute" style="font-size:11px">sha256 '+esc(String(b.sha256).slice(0,12))+'…</span>':"")+'</div>';
|
|
72
|
+
return h;}
|
|
73
|
+
function bubble(m){const mine=m.from===me, sys=m.from==="relay";
|
|
74
|
+
const d=document.createElement("div");d.className="msg"+(mine?" me":"")+(sys?" sys":"");
|
|
75
|
+
if(sys){const b=m.body||{};d.textContent=t(m.ts)+" · "+(b.event||"system")+" · "+(b.name||"")+" ("+String(b.by||"").slice(0,8)+")";return d;}
|
|
76
|
+
const who=mine?"you":(names[m.from]||m.from.slice(0,8));const body=m.body||{};
|
|
77
|
+
d.innerHTML='<div class="meta"><span>'+esc(who)+'</span><span class="type '+esc(m.type)+'">'+esc(m.type)+'</span><span>'+t(m.ts)+'</span><span>#'+esc(m.seq)+'</span><span class="v '+(m.ok?"ok":"bad")+'" title="'+esc((m.errors||[]).join("; "))+'">'+(m.ok?"✓ verified":"✗ "+esc((m.errors||[]).join("; ")))+'</span></div>'
|
|
78
|
+
+'<div>'+esc(typeof body==="string"?body:(body.text||JSON.stringify(body)))+(body.amount!=null?' <span class="amt">'+esc(body.amount)+'</span>':"")+'</div>'+extra(m);
|
|
79
|
+
return d;}
|
|
80
|
+
function blockedBubble(b){const d=document.createElement("div");d.className="msg blocked";
|
|
81
|
+
d.innerHTML='<div class="meta"><span>NOT SENT</span><span class="type '+esc(b.type)+'">'+esc(b.type)+'</span><span>'+t(b.at)+'</span></div><div>'+esc((b.body&&b.body.text)||JSON.stringify(b.body))+(b.body&&b.body.amount!=null?' <span class="amt">'+esc(b.body.amount)+'</span>':"")+'</div><div class="rat"><b>BLOCKED</b> '+esc(b.reason||"")+(b.rationale?'<br><b>RATIONALE</b> '+esc(b.rationale):"")+'</div>';return d;}
|
|
82
|
+
function render(data){const log=$("#log");if(log.querySelector(".empty"))log.innerHTML="";names=data.names||names;
|
|
83
|
+
const items=[];
|
|
84
|
+
for(const m of data.msgs){if(seen.has(m.seq))continue;seen.add(m.seq);items.push({at:m.ts,el:bubble(m),seq:m.seq,m});}
|
|
85
|
+
for(const b of data.blocked||[]){const k=b.at+"|"+JSON.stringify(b.body);if(blockedSeen.has(k))continue;blockedSeen.add(k);items.push({at:b.at,el:blockedBubble(b)});}
|
|
86
|
+
items.sort((a,b)=>a.at.localeCompare(b.at));
|
|
87
|
+
for(const it of items){if(it.m&&it.m.from===me&&data.rationale&&data.rationale[it.seq]){const r=document.createElement("div");r.className="rat";r.innerHTML="<b>PRIVATE RATIONALE</b> "+esc(data.rationale[it.seq]);it.el.appendChild(r);}log.appendChild(it.el);}
|
|
88
|
+
if(items.length)log.scrollTop=log.scrollHeight;
|
|
89
|
+
const parts=Object.entries(names).map(([pk,n])=>esc(n)+(pk===me?" (you)":"")+" "+pk.slice(0,8)).join(" · ");
|
|
90
|
+
$("#bar").innerHTML='<b>'+esc(data.room.name||"(unnamed)")+'</b><span class="st">'+esc(data.room.state)+'</span><span>'+esc(data.room.id)+'</span><span>'+parts+'</span><span class="sp"></span><span class="'+(data.chainOk?"ok":"bad")+'">'+(data.chainOk?"chain ✓ ("+esc(data.lastSeq)+")":"CHAIN BROKEN")+'</span>';}
|
|
91
|
+
async function openRoom(r){cur=r;seen=new Set();blockedSeen=new Set();$("#log").innerHTML="";clearTimeout(poller);$("#invite").classList.remove("on");
|
|
92
|
+
document.querySelectorAll(".room").forEach(e=>e.classList.toggle("sel",e.querySelector(".m").textContent.startsWith(r.id)));
|
|
93
|
+
const first=await (await fetch("/api/rooms/"+r.id+"?full=1")).json();render(first);poll();}
|
|
94
|
+
async function poll(){if(!cur)return;const id=cur.id;try{const d=await (await fetch("/api/rooms/"+id+"?wait=25")).json();if(cur&&cur.id===id)render(d);}catch(e){}
|
|
95
|
+
if(cur&&cur.id===id)poller=setTimeout(poll,300);}
|
|
96
|
+
loadState();setInterval(async()=>{const s=await (await fetch("/api/state")).json();setPaused(s.paused);if(s.rooms.length!==(state?state.rooms.length:0))loadState();},5000);
|
|
97
|
+
</script></body></html>`;
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "can2cup",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "can2cup 傳聲罐罐 — a tin can, a paper cup, one string: signed rooms where two people's AI agents talk under their principals' mandates.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://can2cup.com",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/ccqqder/can2cup.git" },
|
|
8
|
+
"bugs": { "url": "https://can2cup.com/guide/#faq" },
|
|
9
|
+
"keywords": ["mcp", "agent", "a2a", "line", "ed25519", "claude-code", "can2cup", "傳聲罐罐"],
|
|
10
|
+
"engines": { "node": ">=18" },
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"bin": {
|
|
13
|
+
"can2cup": "dist/cli/index.js",
|
|
14
|
+
"can2cup-mcp": "dist/mcp/index.js",
|
|
15
|
+
"can2can": "dist/cli/index.js",
|
|
16
|
+
"parley": "dist/cli/index.js"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"check:relay": "tsc -p tsconfig.relay.json --noEmit",
|
|
21
|
+
"dev:relay": "wrangler dev --local-upstream 127.0.0.1 --upstream-protocol http",
|
|
22
|
+
"deploy:relay": "wrangler deploy",
|
|
23
|
+
"release:relay": "node scripts/routes-check.mjs && node scripts/stage-tarball.mjs && node scripts/release-sign.mjs && wrangler deploy",
|
|
24
|
+
"release:key": "node scripts/release-key.mjs",
|
|
25
|
+
"check:routes": "node scripts/routes-check.mjs",
|
|
26
|
+
"smoke": "node dist/scripts/smoke.js",
|
|
27
|
+
"view": "node dist/viewer/index.js",
|
|
28
|
+
"pack": "npm run build && npm pack"
|
|
29
|
+
},
|
|
30
|
+
"license": "Apache-2.0",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
33
|
+
"@noble/ed25519": "^3.1.0",
|
|
34
|
+
"@noble/hashes": "^2.3.0",
|
|
35
|
+
"hono": "^4.13.2",
|
|
36
|
+
"qrcode": "^1.5.4",
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@cloudflare/workers-types": "^5.20260818.1",
|
|
41
|
+
"@types/node": "^26.2.0",
|
|
42
|
+
"@types/qrcode": "^1.5.6",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"wrangler": "^4.123.0"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"!dist/scripts",
|
|
49
|
+
"README.md",
|
|
50
|
+
"SKILL.md",
|
|
51
|
+
"INSTALL.zh-tw.md",
|
|
52
|
+
"LICENSE",
|
|
53
|
+
"NOTICE"
|
|
54
|
+
]
|
|
55
|
+
}
|