can2cup 0.10.2 → 0.10.4
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 +25 -11
- package/SKILL.md +3 -0
- package/dist/cli/index.js +79 -2
- package/docs/SELF-HOST.md +121 -0
- package/package.json +7 -2
- package/src/protocol/canon.ts +17 -0
- package/src/protocol/crypto.ts +38 -0
- package/src/protocol/display.ts +11 -0
- package/src/protocol/e2e.ts +57 -0
- package/src/protocol/envelope.ts +127 -0
- package/src/protocol/index.ts +9 -0
- package/src/protocol/mandate.ts +61 -0
- package/src/protocol/principal.ts +90 -0
- package/src/protocol/release.ts +35 -0
- package/src/protocol/room.ts +115 -0
- package/src/protocol/semver.ts +14 -0
- package/src/relay/a2a.ts +212 -0
- package/src/relay/anchor.ts +159 -0
- package/src/relay/bridge.ts +1710 -0
- package/src/relay/index.ts +806 -0
- package/src/relay/join-page.ts +79 -0
- package/src/relay/mcp-http.ts +627 -0
- package/tsconfig.relay.json +12 -0
- package/wrangler.toml +106 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP connector — Streamable HTTP + OAuth 2.1.
|
|
3
|
+
*
|
|
4
|
+
* Why: can2cup today is a local stdio MCP server, so being reachable costs the
|
|
5
|
+
* user Node, Claude Code, a terminal, and a window kept open. A remote connector
|
|
6
|
+
* costs them a pasted URL and a login, and works in Claude, ChatGPT, Perplexity,
|
|
7
|
+
* Grok and Le Chat alike. That is the difference between "people who run CLIs"
|
|
8
|
+
* and "people with an AI subscription".
|
|
9
|
+
*
|
|
10
|
+
* TWO CUSTODY TIERS, AND THE DIFFERENCE IS DISCLOSED RATHER THAN HIDDEN.
|
|
11
|
+
* local the ed25519 key never left its owner's machine. The relay cannot sign
|
|
12
|
+
* for it, so this surface is read-only for that agent and says why.
|
|
13
|
+
* hosted the relay holds the key. Weaker attribution — the operator could sign
|
|
14
|
+
* as this agent — but the alternative for someone with nothing installed
|
|
15
|
+
* is no agent at all. Custody is reported by can2cup_whoami and by the
|
|
16
|
+
* public, unauthenticated GET /hosted/:pub, so a counterparty can always
|
|
17
|
+
* weigh what a given signature is worth instead of being misled by it.
|
|
18
|
+
*
|
|
19
|
+
* The brake is the mandate, not a hardcoded blocklist: the same rules the local
|
|
20
|
+
* client enforces before signing run here before a hosted envelope is signed, and
|
|
21
|
+
* the default mandate (max_commit_amount 0, may_grant empty) means nothing binding
|
|
22
|
+
* leaves until its owner deliberately widens it.
|
|
23
|
+
*
|
|
24
|
+
* Auth: the OAuth authorization step reuses the LINE binding rather than inventing
|
|
25
|
+
* a second identity system. The human types `/link` to the can2cup bot, gets a short
|
|
26
|
+
* code, and pastes it into the consent page; that code resolves to their LINE
|
|
27
|
+
* userId, and to the agent bound to it — or mints one if there is none, which is
|
|
28
|
+
* what makes this an entrance rather than a second screen.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
type MsgType, PROTOCOL_VERSION, checkMandate, lineDeepLink, pubFromPriv, randomHex, signHex, signRequestHeaders, signingBytes,
|
|
32
|
+
} from "../protocol/index.js";
|
|
33
|
+
// The browser entry is the one that bundles for a Worker: the package's Node entry
|
|
34
|
+
// pulls in `fs` for file output, which we never use.
|
|
35
|
+
// @ts-expect-error no bundled types on this path
|
|
36
|
+
import QR from "qrcode/lib/browser.js";
|
|
37
|
+
|
|
38
|
+
export const MCP_SERVER_VERSION = "0.4.14";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* v0.4.10: a hosted agent may send any message type. The previous build also kept
|
|
42
|
+
* a hardcoded blocklist on top of the mandate, which meant two overlapping rules
|
|
43
|
+
* and an agent that felt broken ("why can I not agree to anything?"). One rule is
|
|
44
|
+
* better: the mandate decides, exactly as it does for a local agent, and the
|
|
45
|
+
* default mandate is conservative enough (max_commit_amount 0, may_grant empty)
|
|
46
|
+
* that nothing binding gets out until its owner deliberately widens it. Custody
|
|
47
|
+
* stays disclosed at GET /hosted/:pub, so a counterparty can still weigh what a
|
|
48
|
+
* hosted signature is worth.
|
|
49
|
+
*/
|
|
50
|
+
const SENDABLE: readonly MsgType[] = [
|
|
51
|
+
"text", "question", "proposal", "counter", "accept", "reject", "withdraw", "escalate",
|
|
52
|
+
"grant", "revoke", "attachment", "close",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Conservative starting rules for a hosted agent, mirroring the local mandate.json template. */
|
|
56
|
+
const DEFAULT_HOSTED_MANDATE: HostedMandate = {
|
|
57
|
+
never_disclose: ["sk-live-", "sk-ant-", "ghp_", "glpat-", "xoxb-", "-----BEGIN"],
|
|
58
|
+
max_commit_amount: 0,
|
|
59
|
+
may_grant: [],
|
|
60
|
+
max_grant_hours: 2,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Versions we can speak. We echo the client's if we know it, else offer our latest. */
|
|
64
|
+
const KNOWN_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
65
|
+
const LATEST_PROTOCOL = KNOWN_PROTOCOLS[0];
|
|
66
|
+
|
|
67
|
+
const CODE_TTL_MS = 10 * 60_000;
|
|
68
|
+
|
|
69
|
+
export interface McpBinding { pub: string; name: string; userId: string }
|
|
70
|
+
export interface McpRoom { name: string; state: string; lastSeq: number; participants: Record<string, string> }
|
|
71
|
+
export interface HostedKey { priv: string; userId: string; createdAt: string }
|
|
72
|
+
export interface HostedMandate {
|
|
73
|
+
never_disclose: string[];
|
|
74
|
+
max_commit_amount: number | null;
|
|
75
|
+
currency?: string;
|
|
76
|
+
may_grant: string[];
|
|
77
|
+
max_grant_hours: number;
|
|
78
|
+
}
|
|
79
|
+
/** Per-room capability a hosted agent earned by joining. */
|
|
80
|
+
export interface HostedCap { cap: string; name: string }
|
|
81
|
+
|
|
82
|
+
/** Everything this surface needs from BridgeDO, so the module stays storage-agnostic. */
|
|
83
|
+
export interface McpDeps {
|
|
84
|
+
get<T>(k: string): Promise<T | undefined>;
|
|
85
|
+
put(k: string, v: unknown): Promise<void>;
|
|
86
|
+
del(k: string): Promise<void>;
|
|
87
|
+
bindingByUser(userId: string): Promise<McpBinding | undefined>;
|
|
88
|
+
bindingByPub(pub: string): Promise<McpBinding | undefined>;
|
|
89
|
+
roomsFor(pub: string): Promise<Record<string, McpRoom>>;
|
|
90
|
+
pendingInbox(pub: string): Promise<number>;
|
|
91
|
+
awayAt(pub: string): Promise<string | undefined>;
|
|
92
|
+
/** Creates the binding both ways, exactly as the LINE /link flow does. */
|
|
93
|
+
bind(userId: string, pub: string, name: string): Promise<void>;
|
|
94
|
+
isPaused(pub: string): Promise<boolean>;
|
|
95
|
+
/** Talk to a RoomDO directly through its binding rather than over the network.
|
|
96
|
+
* A loopback fetch to our own hostname is a real round trip that depends on the
|
|
97
|
+
* external name being reachable from inside the Worker — under `wrangler dev`
|
|
98
|
+
* with a route configured it left the machine entirely and hit production. It
|
|
99
|
+
* also skips the worker key check on room creation, which is correct: this is
|
|
100
|
+
* an internal caller, not an untrusted one. */
|
|
101
|
+
roomCall(roomId: string, subpath: string, init?: RequestInit): Promise<Response>;
|
|
102
|
+
newRoomId(): string;
|
|
103
|
+
/** The LINE official account id, for the one-tap deep link on the consent page. */
|
|
104
|
+
lineOa(): string | undefined;
|
|
105
|
+
/** Operator-set quotas for the hosted tier. */
|
|
106
|
+
limits(): { roomsPerDay: number };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const hostedKeyOf = (d: McpDeps, pub: string) => d.get<HostedKey>(`hosted:${pub}`);
|
|
110
|
+
const capsOf = async (d: McpDeps, pub: string) => (await d.get<Record<string, HostedCap>>(`hcap:${pub}`)) ?? {};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Mint an agent identity the relay holds the key for.
|
|
114
|
+
*
|
|
115
|
+
* This is the custody tier, and it is a real trade, not a shortcut: the relay can
|
|
116
|
+
* sign as this agent, so this agent's messages carry weaker attribution than one
|
|
117
|
+
* signed on its owner's machine. It exists because the alternative for a person
|
|
118
|
+
* with no laptop install is no agent at all. Custody is disclosed by whoami and
|
|
119
|
+
* by the public /hosted/:pub endpoint, so a reader of a transcript can always
|
|
120
|
+
* tell which tier signed an entry. What it may actually say is decided by the
|
|
121
|
+
* mandate, which starts conservative.
|
|
122
|
+
*/
|
|
123
|
+
export async function createHostedAgent(d: McpDeps, userId: string): Promise<McpBinding> {
|
|
124
|
+
const priv = randomHex(32);
|
|
125
|
+
const pub = pubFromPriv(priv);
|
|
126
|
+
const name = `agent-${pub.slice(0, 6)}`;
|
|
127
|
+
await d.put(`hosted:${pub}`, { priv, userId, createdAt: new Date().toISOString() } as HostedKey);
|
|
128
|
+
await d.put(`hmandate:${pub}`, DEFAULT_HOSTED_MANDATE);
|
|
129
|
+
await d.bind(userId, pub, name);
|
|
130
|
+
return { pub, name, userId };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface StoredClient { name: string; redirectUris: string[]; at: number }
|
|
134
|
+
interface StoredCode { clientId: string; pub: string; challenge: string; redirectUri: string; at: number }
|
|
135
|
+
interface StoredToken { pub: string; clientId: string; at: number }
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------------ oauth ---
|
|
138
|
+
|
|
139
|
+
/** RFC 9728. Tells a connector which authorization server guards this resource. */
|
|
140
|
+
export const protectedResourceMetadata = (origin: string) => ({
|
|
141
|
+
resource: `${origin}/mcp`,
|
|
142
|
+
authorization_servers: [origin],
|
|
143
|
+
bearer_methods_supported: ["header"],
|
|
144
|
+
scopes_supported: ["can2cup"],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** RFC 8414. */
|
|
148
|
+
export const authorizationServerMetadata = (origin: string) => ({
|
|
149
|
+
issuer: origin,
|
|
150
|
+
authorization_endpoint: `${origin}/oauth/authorize`,
|
|
151
|
+
token_endpoint: `${origin}/oauth/token`,
|
|
152
|
+
registration_endpoint: `${origin}/oauth/register`,
|
|
153
|
+
response_types_supported: ["code"],
|
|
154
|
+
grant_types_supported: ["authorization_code"],
|
|
155
|
+
code_challenge_methods_supported: ["S256"], // PKCE is required, plain is not accepted
|
|
156
|
+
token_endpoint_auth_methods_supported: ["none"], // public clients only
|
|
157
|
+
scopes_supported: ["can2cup"],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const b64url = (b: ArrayBuffer): string => {
|
|
161
|
+
let s = "";
|
|
162
|
+
for (const x of new Uint8Array(b)) s += String.fromCharCode(x);
|
|
163
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
async function s256(verifier: string): Promise<string> {
|
|
167
|
+
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** RFC 7591 dynamic client registration. Connectors register themselves, so there
|
|
171
|
+
* is nothing for the user to copy; we only keep what we need to validate a redirect. */
|
|
172
|
+
export async function register(d: McpDeps, body: unknown): Promise<Response> {
|
|
173
|
+
const b = (body ?? {}) as { client_name?: string; redirect_uris?: unknown };
|
|
174
|
+
const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u): u is string => typeof u === "string") : [];
|
|
175
|
+
if (uris.length === 0) return Response.json({ error: "invalid_client_metadata", error_description: "redirect_uris is required" }, { status: 400 });
|
|
176
|
+
const clientId = randomHex(16);
|
|
177
|
+
await d.put(`oauth:client:${clientId}`, { name: b.client_name ?? "", redirectUris: uris, at: Date.now() } as StoredClient);
|
|
178
|
+
return Response.json({
|
|
179
|
+
client_id: clientId,
|
|
180
|
+
client_name: b.client_name ?? "",
|
|
181
|
+
redirect_uris: uris,
|
|
182
|
+
grant_types: ["authorization_code"],
|
|
183
|
+
response_types: ["code"],
|
|
184
|
+
token_endpoint_auth_method: "none",
|
|
185
|
+
}, { status: 201 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The LINE deep link with "/link" prefilled — the message the bot answers with a code. */
|
|
189
|
+
const lineLink = (oa: string) => lineDeepLink(oa, "/link");
|
|
190
|
+
|
|
191
|
+
/** Rendered server-side as inline SVG: this page is nearly always open on a desktop
|
|
192
|
+
* browser (you add a connector on a computer) while LINE lives on the phone, so a
|
|
193
|
+
* tap-through deep link reaches nothing. A QR crosses that gap. Never let QR
|
|
194
|
+
* trouble break the page — the typed-code path still works without it. */
|
|
195
|
+
async function qrSvg(target: string): Promise<string | undefined> {
|
|
196
|
+
try {
|
|
197
|
+
return await (QR as { toString: (t: string, o: object) => Promise<string> })
|
|
198
|
+
.toString(target, { type: "svg", margin: 1, width: 168, errorCorrectionLevel: "M" });
|
|
199
|
+
} catch { return undefined; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const page = (origin: string, clientName: string, q: URLSearchParams, error?: string, lineOa?: string, qr?: string) => `<!doctype html>
|
|
203
|
+
<html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
204
|
+
<title>連結 can2cup</title><style>
|
|
205
|
+
:root{color-scheme:light dark;--bg:#fff;--fg:#18181b;--mut:#71717a;--line:#e4e4e7;--acc:#2563eb;--err:#dc2626}
|
|
206
|
+
@media(prefers-color-scheme:dark){:root{--bg:#18181b;--fg:#fafafa;--mut:#a1a1aa;--line:#3f3f46;--acc:#60a5fa;--err:#f87171}}
|
|
207
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 system-ui,-apple-system,"Noto Sans TC",sans-serif;display:grid;place-items:center;min-height:100vh;padding:24px}
|
|
208
|
+
.card{width:100%;max-width:420px;border:1px solid var(--line);border-radius:14px;padding:28px}
|
|
209
|
+
h1{font-size:19px;margin:0 0 6px}p{color:var(--mut);margin:0 0 18px}
|
|
210
|
+
ol{color:var(--mut);padding-left:20px;margin:0 0 18px}li{margin:6px 0}
|
|
211
|
+
code{background:var(--line);padding:1px 6px;border-radius:5px;color:var(--fg)}
|
|
212
|
+
input{width:100%;padding:11px 13px;font:inherit;font-variant-numeric:tabular-nums;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--fg)}
|
|
213
|
+
button{width:100%;margin-top:14px;padding:11px;font:inherit;font-weight:600;border:0;border-radius:9px;background:var(--acc);color:#fff;cursor:pointer}
|
|
214
|
+
.qr{text-align:center;margin:0 0 4px}.qr svg{width:168px;height:168px;background:#fff;padding:8px;border-radius:10px}
|
|
215
|
+
.cap{color:var(--mut);font-size:13px;text-align:center;margin:0 0 14px}
|
|
216
|
+
.line{display:inline-block;text-decoration:none;color:#06c755;font-weight:600}
|
|
217
|
+
.err{color:var(--err);margin:0 0 14px}.f{color:var(--mut);font-size:13px;margin:16px 0 0}
|
|
218
|
+
.step{font-weight:600;margin:0 0 8px}
|
|
219
|
+
</style></head><body><div class="card">
|
|
220
|
+
<h1>把 can2cup 連給${clientName ? " " + clientName.replace(/[<>&]/g, "") : "這個 app"}</h1>
|
|
221
|
+
<p>讓這個 app 裡的 AI 能替你在 can2cup 房間裡跟別人的 agent 對話。</p>
|
|
222
|
+
${error ? `<p class="err">${error}</p>` : ""}
|
|
223
|
+
${qr ? `<p class="step">① 用手機 LINE 掃這個 QR</p><div class="qr">${qr}</div>
|
|
224
|
+
<p class="cap">傳聲罐罐的聊天會打開,<code>/link</code> 已經填好 —— 按送出就好。<br>
|
|
225
|
+
${lineOa ? `已經在手機上看這頁?<a class="line" href="${lineLink(lineOa)}">直接開啟 LINE</a>` : ""}</p>
|
|
226
|
+
<p class="step">② 把機器人回你的代碼貼到下面</p>`
|
|
227
|
+
: `<ol><li>在 LINE 對 <strong>傳聲罐罐 can2cup</strong> 傳 <code>/link</code></li><li>把機器人回你的代碼貼到下面</li></ol>`}
|
|
228
|
+
<form method="post" action="${origin}/oauth/authorize?${q.toString()}">
|
|
229
|
+
<input name="code" placeholder="AB12-CD34" autocomplete="off" autocapitalize="characters" required autofocus>
|
|
230
|
+
<button type="submit">連結</button></form>
|
|
231
|
+
<p class="f"><a href="${origin}/terms" style="color:var(--mut)">服務條款與濫用通報</a></p>
|
|
232
|
+
<p class="f">代碼 10 分鐘內有效。<br>
|
|
233
|
+
還沒有 can2cup agent 的話,這一步會替你建立一個,<strong>金鑰由這台 relay 保管</strong> —— 方便,但它的訊息歸屬強度比裝在自己電腦上的弱,而且<strong>不能代你做出承諾</strong>(accept / grant)。想要完整強度就在自己機器上裝 can2cup。</p>
|
|
234
|
+
</div></body></html>`;
|
|
235
|
+
|
|
236
|
+
export async function authorizeGet(d: McpDeps, url: URL, origin: string): Promise<Response> {
|
|
237
|
+
const q = url.searchParams;
|
|
238
|
+
const clientId = q.get("client_id") ?? "";
|
|
239
|
+
const client = await d.get<StoredClient>(`oauth:client:${clientId}`);
|
|
240
|
+
if (!client) return new Response("unknown client_id", { status: 400 });
|
|
241
|
+
if (!client.redirectUris.includes(q.get("redirect_uri") ?? "")) return new Response("redirect_uri not registered for this client", { status: 400 });
|
|
242
|
+
if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) return new Response("PKCE with S256 is required", { status: 400 });
|
|
243
|
+
const oa = d.lineOa();
|
|
244
|
+
return new Response(page(origin, client.name, q, undefined, oa, oa ? await qrSvg(lineLink(oa)) : undefined), { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function authorizePost(d: McpDeps, url: URL, form: FormData, origin: string): Promise<Response> {
|
|
248
|
+
const q = url.searchParams;
|
|
249
|
+
const clientId = q.get("client_id") ?? "";
|
|
250
|
+
const redirectUri = q.get("redirect_uri") ?? "";
|
|
251
|
+
const client = await d.get<StoredClient>(`oauth:client:${clientId}`);
|
|
252
|
+
if (!client || !client.redirectUris.includes(redirectUri)) return new Response("bad client", { status: 400 });
|
|
253
|
+
|
|
254
|
+
const typed = String(form.get("code") ?? "").trim().toUpperCase().replace(/\s+/g, "");
|
|
255
|
+
const rec = typed ? await d.get<{ userId: string; at: number }>(`pcode:${typed}`) : undefined;
|
|
256
|
+
const oa = d.lineOa();
|
|
257
|
+
const qr = oa ? await qrSvg(lineLink(oa)) : undefined;
|
|
258
|
+
const fail = (m: string) => new Response(page(origin, client.name, q, m, oa, qr), { status: 400, headers: { "content-type": "text/html; charset=utf-8" } });
|
|
259
|
+
if (!rec) return fail("這個代碼無效。請在 LINE 重新輸入 /link 取得新的。");
|
|
260
|
+
if (Date.now() - rec.at > CODE_TTL_MS) return fail("代碼已過期(10 分鐘)。請重新取得。");
|
|
261
|
+
// No agent yet? Make one. This is the whole point of the connector: a person
|
|
262
|
+
// with nothing installed must be able to finish this flow.
|
|
263
|
+
const binding = (await d.bindingByUser(rec.userId)) ?? (await createHostedAgent(d, rec.userId));
|
|
264
|
+
|
|
265
|
+
// One-shot: the code cannot be replayed into a second authorization.
|
|
266
|
+
await d.del(`pcode:${typed}`);
|
|
267
|
+
const code = randomHex(24);
|
|
268
|
+
await d.put(`oauth:code:${code}`, {
|
|
269
|
+
clientId, pub: binding.pub, challenge: q.get("code_challenge")!, redirectUri, at: Date.now(),
|
|
270
|
+
} as StoredCode);
|
|
271
|
+
|
|
272
|
+
const back = new URL(redirectUri);
|
|
273
|
+
back.searchParams.set("code", code);
|
|
274
|
+
if (q.get("state")) back.searchParams.set("state", q.get("state")!);
|
|
275
|
+
return Response.redirect(back.toString(), 302);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function token(d: McpDeps, form: URLSearchParams): Promise<Response> {
|
|
279
|
+
const bad = (e: string, desc: string) => Response.json({ error: e, error_description: desc }, { status: 400 });
|
|
280
|
+
if (form.get("grant_type") !== "authorization_code") return bad("unsupported_grant_type", "only authorization_code is supported");
|
|
281
|
+
const code = form.get("code") ?? "";
|
|
282
|
+
const rec = await d.get<StoredCode>(`oauth:code:${code}`);
|
|
283
|
+
if (!rec) return bad("invalid_grant", "unknown or already-used code");
|
|
284
|
+
await d.del(`oauth:code:${code}`); // single use, whatever happens next
|
|
285
|
+
if (Date.now() - rec.at > 60_000) return bad("invalid_grant", "code expired");
|
|
286
|
+
if (rec.clientId !== (form.get("client_id") ?? "")) return bad("invalid_grant", "code was issued to a different client");
|
|
287
|
+
if (rec.redirectUri !== (form.get("redirect_uri") ?? "")) return bad("invalid_grant", "redirect_uri mismatch");
|
|
288
|
+
const verifier = form.get("code_verifier") ?? "";
|
|
289
|
+
if (!verifier || (await s256(verifier)) !== rec.challenge) return bad("invalid_grant", "PKCE verification failed");
|
|
290
|
+
|
|
291
|
+
const access = randomHex(32);
|
|
292
|
+
await d.put(`oauth:tok:${access}`, { pub: rec.pub, clientId: rec.clientId, at: Date.now() } as StoredToken);
|
|
293
|
+
return Response.json({ access_token: access, token_type: "Bearer", scope: "can2cup" });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// -------------------------------------------------------------------- mcp ---
|
|
297
|
+
|
|
298
|
+
const NONE = { type: "object", properties: {}, additionalProperties: false } as const;
|
|
299
|
+
|
|
300
|
+
const TOOLS = [
|
|
301
|
+
{
|
|
302
|
+
name: "can2cup_whoami",
|
|
303
|
+
description:
|
|
304
|
+
"Who this agent is on can2cup: its public key, display name, who holds its signing key, whether a "
|
|
305
|
+
+ "principal is linked on LINE, and how many principal instructions are waiting.",
|
|
306
|
+
inputSchema: NONE,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "can2cup_rooms",
|
|
310
|
+
description:
|
|
311
|
+
"The can2cup rooms this agent is in, with each room's name, open/closed state, last sequence number "
|
|
312
|
+
+ "and participants.",
|
|
313
|
+
inputSchema: NONE,
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "can2cup_join",
|
|
317
|
+
description:
|
|
318
|
+
"Join a room from an invite link the other party gave you (looks like https://<relay>/j/<id>#<secret>). "
|
|
319
|
+
+ "Hosted agents only — an agent whose key lives on its owner's machine must join from there.",
|
|
320
|
+
inputSchema: {
|
|
321
|
+
type: "object",
|
|
322
|
+
properties: { invite: { type: "string", description: "the full invite link, including the part after #" } },
|
|
323
|
+
required: ["invite"], additionalProperties: false,
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "can2cup_create_room",
|
|
328
|
+
description:
|
|
329
|
+
"Open a new room and get an invite link to hand to the other party out of band (LINE, mail, in person). "
|
|
330
|
+
+ "Their agent joins with can2cup_join. Hosted agents only.",
|
|
331
|
+
inputSchema: {
|
|
332
|
+
type: "object",
|
|
333
|
+
properties: { name: { type: "string", description: "what this conversation is about" } },
|
|
334
|
+
additionalProperties: false,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: "can2cup_invite",
|
|
339
|
+
description: "Get the invite link for a room you are already in, to give to someone else.",
|
|
340
|
+
inputSchema: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: { room: { type: "string", description: "room id (12 hex)" } },
|
|
343
|
+
required: ["room"], additionalProperties: false,
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: "can2cup_send",
|
|
348
|
+
description:
|
|
349
|
+
"Say something in a room. `type` carries the intent. Your principal's mandate is checked before "
|
|
350
|
+
+ "anything is signed: a blocked message is never sent and you are told why.",
|
|
351
|
+
inputSchema: {
|
|
352
|
+
type: "object",
|
|
353
|
+
properties: {
|
|
354
|
+
room: { type: "string", description: "room id (12 hex)" },
|
|
355
|
+
type: { type: "string", enum: [...SENDABLE], description: "message intent (default text)" },
|
|
356
|
+
text: { type: "string", description: "what to say" },
|
|
357
|
+
amount: { type: "number", description: "figure attached to a proposal, counter or accept" },
|
|
358
|
+
scope: { type: "string", description: "for a grant: what is being authorised, e.g. \"read:logs/*\"" },
|
|
359
|
+
expiresHours: { type: "number", description: "for a grant: how long it lasts" },
|
|
360
|
+
ref: { type: "number", description: "for a revoke: the seq of the grant being withdrawn" },
|
|
361
|
+
url: { type: "string", description: "for an attachment: an https URL (the relay never stores bytes)" },
|
|
362
|
+
},
|
|
363
|
+
required: ["room", "text"], additionalProperties: false,
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: "can2cup_history",
|
|
368
|
+
description: "Read a room's transcript from a sequence number onwards.",
|
|
369
|
+
inputSchema: {
|
|
370
|
+
type: "object",
|
|
371
|
+
properties: {
|
|
372
|
+
room: { type: "string", description: "room id (12 hex)" },
|
|
373
|
+
since: { type: "number", description: "return messages after this seq (default 0)" },
|
|
374
|
+
},
|
|
375
|
+
required: ["room"], additionalProperties: false,
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
] as const;
|
|
379
|
+
|
|
380
|
+
const WRITE_TOOLS = ["can2cup_join", "can2cup_send", "can2cup_create_room", "can2cup_invite"];
|
|
381
|
+
|
|
382
|
+
const rpcErr = (id: unknown, code: number, message: string) =>
|
|
383
|
+
Response.json({ jsonrpc: "2.0", id: id ?? null, error: { code, message } });
|
|
384
|
+
|
|
385
|
+
const text = (id: unknown, s: string) =>
|
|
386
|
+
Response.json({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: s }] } });
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The outbound gate, for hosted agents only. The local client enforces the full
|
|
390
|
+
* mandate from mandate.json before anything is signed; a hosted agent has no
|
|
391
|
+
* local client, so the same rules have to live here or the easy tier ships with
|
|
392
|
+
* no brake at all — which is the one thing this product must not do.
|
|
393
|
+
*/
|
|
394
|
+
async function mandateBlock(d: McpDeps, pub: string, type: MsgType, body: Record<string, unknown>): Promise<string | null> {
|
|
395
|
+
if (await d.isPaused(pub)) return "your principal has paused this agent's outbound messages (/resume in LINE).";
|
|
396
|
+
const m = { ...DEFAULT_HOSTED_MANDATE, ...((await d.get<HostedMandate>(`hmandate:${pub}`)) ?? {}) };
|
|
397
|
+
const msg = checkMandate(m, type, body);
|
|
398
|
+
// A blocked verdict gets the explicit "NOT SENT." suffix here: a hosted agent has no
|
|
399
|
+
// local client output to make that unmistakable, the tool result is all it sees.
|
|
400
|
+
return msg && (msg.startsWith("blocked by mandate") ? `${msg} NOT SENT.` : msg);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const j = async (res: Response): Promise<Record<string, unknown>> => {
|
|
404
|
+
const t = await res.text();
|
|
405
|
+
try { return t ? JSON.parse(t) : {}; } catch { return { error: t.slice(0, 200) }; }
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
async function callTool(d: McpDeps, pub: string, name: string, args: Record<string, unknown>, origin: string): Promise<string> {
|
|
409
|
+
const binding = await d.bindingByPub(pub);
|
|
410
|
+
const hosted = await hostedKeyOf(d, pub);
|
|
411
|
+
// v0.9.14: a hosted agent has no heartbeat; each connector call is its "being seen", so the idle-binding
|
|
412
|
+
// rule (v0.9.12) can cover it the same way it covers a local agent.
|
|
413
|
+
if (hosted) await d.put(`seen:${pub}`, new Date().toISOString());
|
|
414
|
+
|
|
415
|
+
if (WRITE_TOOLS.includes(name) && !hosted) {
|
|
416
|
+
return "This agent's signing key lives on its owner's machine, so the relay cannot act for it. "
|
|
417
|
+
+ "Use the local can2cup client (or the can2cup CLI) for anything that has to be signed.";
|
|
418
|
+
}
|
|
419
|
+
const me = hosted ? { pub, priv: hosted.priv, name: binding?.name ?? "" } : undefined;
|
|
420
|
+
|
|
421
|
+
switch (name) {
|
|
422
|
+
case "can2cup_whoami": {
|
|
423
|
+
const away = await d.awayAt(pub);
|
|
424
|
+
return JSON.stringify({
|
|
425
|
+
pubkey: pub,
|
|
426
|
+
name: binding?.name ?? "",
|
|
427
|
+
keyCustody: hosted ? "hosted — this relay holds the signing key" : "local — the key never leaves its owner's machine",
|
|
428
|
+
canCommit: hosted ? "yes, within the mandate below" : "only from the local client",
|
|
429
|
+
principalLinkedOnLine: !!binding,
|
|
430
|
+
pendingPrincipalInstructions: await d.pendingInbox(pub),
|
|
431
|
+
localAgentAwake: hosted ? "n/a (hosted)" : !away,
|
|
432
|
+
}, null, 2);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
case "can2cup_rooms": {
|
|
436
|
+
const rooms = await d.roomsFor(pub);
|
|
437
|
+
const list = Object.entries(rooms).map(([id, r]) => ({
|
|
438
|
+
room: id, name: r.name, state: r.state, lastSeq: r.lastSeq, participants: Object.values(r.participants),
|
|
439
|
+
}));
|
|
440
|
+
return list.length ? JSON.stringify(list, null, 2) : "This agent is not in any can2cup room.";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
case "can2cup_join": {
|
|
444
|
+
// The secret rides in the URL fragment precisely so it never reaches a server log.
|
|
445
|
+
const m = /\/j\/([0-9a-f]{12})#(.+)$/.exec(String(args.invite ?? "").trim());
|
|
446
|
+
if (!m) return "That does not look like a can2cup invite link (expected https://<relay>/j/<id>#<secret>).";
|
|
447
|
+
const [, room, frag] = m;
|
|
448
|
+
// #<secret>.<key> marks an E2E room. A hosted agent joining one would hand this relay
|
|
449
|
+
// the room key, which silently defeats the encryption — refuse rather than pretend.
|
|
450
|
+
if (frag.includes(".")) {
|
|
451
|
+
return "This room is END-TO-END ENCRYPTED. A hosted agent's key (and then the room key) would "
|
|
452
|
+
+ "live on the relay, which is exactly what E2E exists to prevent — join it from a local "
|
|
453
|
+
+ "can2cup client instead (npm i -g <relay>/dl/can2cup.tgz).";
|
|
454
|
+
}
|
|
455
|
+
const secret = frag;
|
|
456
|
+
const path = `/rooms/${room}/join`;
|
|
457
|
+
const body = JSON.stringify({ pubkey: pub, name: me!.name });
|
|
458
|
+
const res = await d.roomCall(room, "/join", {
|
|
459
|
+
method: "POST",
|
|
460
|
+
headers: {
|
|
461
|
+
"content-type": "application/json",
|
|
462
|
+
authorization: `Bearer ${secret}`,
|
|
463
|
+
...signRequestHeaders("POST", path, body, me!),
|
|
464
|
+
},
|
|
465
|
+
body,
|
|
466
|
+
});
|
|
467
|
+
const out = await j(res);
|
|
468
|
+
if (!res.ok) return `Could not join ${room}: ${String(out.error ?? res.status)}`;
|
|
469
|
+
const caps = await capsOf(d, pub);
|
|
470
|
+
caps[room] = { cap: String(out.cap ?? ""), name: String(out.name ?? "") };
|
|
471
|
+
await d.put(`hcap:${pub}`, caps);
|
|
472
|
+
return `Joined room ${room}${out.name ? ` "${String(out.name)}"` : ""}. Participants: `
|
|
473
|
+
+ Object.values((out.participants ?? {}) as Record<string, { name?: string }>).map((p) => p.name || "?").join(", ");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
case "can2cup_create_room": {
|
|
477
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
478
|
+
const qk = `q:rooms:${pub}:${day}`;
|
|
479
|
+
const used = (await d.get<number>(qk)) ?? 0;
|
|
480
|
+
const lim = d.limits().roomsPerDay;
|
|
481
|
+
if (used >= lim) return `Daily room quota reached for this identity (${lim}/day). NOT CREATED — try tomorrow or ask the operator.`;
|
|
482
|
+
const id = d.newRoomId();
|
|
483
|
+
const created = await j(await d.roomCall(id, "", {
|
|
484
|
+
method: "POST",
|
|
485
|
+
headers: { "content-type": "application/json" },
|
|
486
|
+
body: JSON.stringify({ name: String(args.name ?? ""), creator: { pubkey: pub, name: me!.name } }),
|
|
487
|
+
}));
|
|
488
|
+
if (typeof created.id !== "string") return `Could not open a room: ${String(created.error ?? "unknown error")}`;
|
|
489
|
+
const caps = await capsOf(d, pub);
|
|
490
|
+
caps[id] = { cap: String(created.cap ?? ""), name: String(args.name ?? "") };
|
|
491
|
+
await d.put(`hcap:${pub}`, caps);
|
|
492
|
+
await d.put(qk, used + 1);
|
|
493
|
+
return `Room ${id} is open.\n\nInvite link — give it to the other party whole and privately; `
|
|
494
|
+
+ `everything after the # is the secret:\n${origin}/j/${created.id}#${String(created.secret)}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
case "can2cup_invite": {
|
|
498
|
+
const room = String(args.room ?? "");
|
|
499
|
+
const caps = await capsOf(d, pub);
|
|
500
|
+
const cap = caps[room]?.cap;
|
|
501
|
+
if (!cap) return `This agent has no capability for room ${room}.`;
|
|
502
|
+
// Only a cap-authenticated caller gets the current invite secret back.
|
|
503
|
+
const info = await j(await d.roomCall(room, "/info", { headers: { authorization: `Bearer ${cap}` } }));
|
|
504
|
+
if (typeof info.secret !== "string") return `Could not read the invite secret for ${room}: ${String(info.error ?? "unknown error")}`;
|
|
505
|
+
return `${origin}/j/${room}#${info.secret}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
case "can2cup_send": {
|
|
509
|
+
const room = String(args.room ?? "");
|
|
510
|
+
const type = (args.type ?? "text") as MsgType;
|
|
511
|
+
if (!SENDABLE.includes(type)) return `"${type}" is not a can2cup message type.`;
|
|
512
|
+
const caps = await capsOf(d, pub);
|
|
513
|
+
const cap = caps[room]?.cap;
|
|
514
|
+
if (!cap) return `This agent has no capability for room ${room}. Join it first with can2cup_join.`;
|
|
515
|
+
|
|
516
|
+
const body: Record<string, unknown> = { text: String(args.text ?? "") };
|
|
517
|
+
if (typeof args.amount === "number") body.amount = args.amount;
|
|
518
|
+
if (typeof args.scope === "string") body.scope = args.scope;
|
|
519
|
+
if (typeof args.ref === "number") body.ref = args.ref;
|
|
520
|
+
if (typeof args.url === "string") body.url = args.url;
|
|
521
|
+
if (typeof args.expiresHours === "number") {
|
|
522
|
+
body.expires = new Date(Date.now() + args.expiresHours * 3.6e6).toISOString();
|
|
523
|
+
}
|
|
524
|
+
const blocked = await mandateBlock(d, pub, type, body);
|
|
525
|
+
if (blocked) return blocked;
|
|
526
|
+
|
|
527
|
+
// prev must be the hash of the latest entry we have seen, so read before signing.
|
|
528
|
+
const poll = await j(await d.roomCall(room, "/messages?since=999999999", { headers: { authorization: `Bearer ${cap}` } }));
|
|
529
|
+
if (typeof poll.lastHash !== "string") return `Could not read room ${room}: ${String(poll.error ?? "unknown error")}`;
|
|
530
|
+
const unsigned = { v: PROTOCOL_VERSION, room, from: pub, ts: new Date().toISOString(), type, body, prev: poll.lastHash };
|
|
531
|
+
const sent = await j(await d.roomCall(room, "/messages", {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${cap}` },
|
|
534
|
+
body: JSON.stringify({ ...unsigned, sig: signHex(signingBytes(unsigned), me!.priv) }),
|
|
535
|
+
}));
|
|
536
|
+
if (typeof sent.seq !== "number") return `Not sent: ${String(sent.error ?? "unknown error")}`;
|
|
537
|
+
return `Sent as ${type} at seq ${sent.seq} in room ${room}.`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
case "can2cup_history": {
|
|
541
|
+
const room = String(args.room ?? "");
|
|
542
|
+
const since = typeof args.since === "number" ? args.since : 0;
|
|
543
|
+
const caps = await capsOf(d, pub);
|
|
544
|
+
const cap = caps[room]?.cap;
|
|
545
|
+
if (!cap) return `This agent has no capability for room ${room}.`;
|
|
546
|
+
const poll = await j(await d.roomCall(room, `/messages?since=${since}`, { headers: { authorization: `Bearer ${cap}` } }));
|
|
547
|
+
const msgs = (poll.messages ?? []) as Array<{ seq: number; from: string; type: string; body: { text?: string }; ts: string }>;
|
|
548
|
+
if (!Array.isArray(msgs) || msgs.length === 0) return `Nothing in room ${room} after seq ${since}.`;
|
|
549
|
+
return msgs.map((e) => `#${e.seq} ${e.from === "relay" ? "relay" : e.from.slice(0, 8)} [${e.type}] ${e.body?.text ?? JSON.stringify(e.body)}`).join("\n");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
default:
|
|
553
|
+
return `unknown tool: ${name}`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Streamable HTTP transport. One endpoint: POST carries JSON-RPC, a notification
|
|
559
|
+
* (no id) gets 202 with no body, everything else answers inline. We do not open
|
|
560
|
+
* an SSE stream — nothing here pushes, so advertising one would be a lie.
|
|
561
|
+
*/
|
|
562
|
+
export async function handleMcp(d: McpDeps, req: Request, origin: string): Promise<Response> {
|
|
563
|
+
const unauthorized = () => new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
564
|
+
status: 401,
|
|
565
|
+
headers: {
|
|
566
|
+
"content-type": "application/json",
|
|
567
|
+
// Sends a connector to the metadata document instead of leaving it guessing.
|
|
568
|
+
"www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
if (req.method === "GET") return new Response("this MCP endpoint does not open a server stream", { status: 405 });
|
|
573
|
+
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
574
|
+
|
|
575
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
576
|
+
if (!auth.startsWith("Bearer ")) return unauthorized();
|
|
577
|
+
const tok = await d.get<StoredToken>(`oauth:tok:${auth.slice(7)}`);
|
|
578
|
+
if (!tok) return unauthorized();
|
|
579
|
+
|
|
580
|
+
let msg: { jsonrpc?: string; id?: unknown; method?: unknown; params?: unknown };
|
|
581
|
+
try { msg = (await req.json()) as typeof msg; }
|
|
582
|
+
catch { return rpcErr(null, -32700, "invalid JSON"); }
|
|
583
|
+
|
|
584
|
+
const id = msg.id;
|
|
585
|
+
const method = typeof msg.method === "string" ? msg.method : "";
|
|
586
|
+
if (msg.jsonrpc !== "2.0" || !method) return rpcErr(id ?? null, -32600, "invalid JSON-RPC request");
|
|
587
|
+
if (await d.get(`ban:pub:${tok.pub}`)) return rpcErr(id ?? null, -32000, "this identity has been banned by the relay operator");
|
|
588
|
+
// A notification has no id and expects no body.
|
|
589
|
+
if (id === undefined) return new Response(null, { status: 202 });
|
|
590
|
+
|
|
591
|
+
switch (method) {
|
|
592
|
+
case "initialize": {
|
|
593
|
+
const asked = (msg.params as { protocolVersion?: string } | undefined)?.protocolVersion;
|
|
594
|
+
return Response.json({
|
|
595
|
+
jsonrpc: "2.0", id,
|
|
596
|
+
result: {
|
|
597
|
+
protocolVersion: asked && KNOWN_PROTOCOLS.includes(asked) ? asked : LATEST_PROTOCOL,
|
|
598
|
+
capabilities: { tools: { listChanged: false } },
|
|
599
|
+
serverInfo: { name: "can2cup", version: MCP_SERVER_VERSION },
|
|
600
|
+
instructions:
|
|
601
|
+
"can2cup rooms let agents that answer to different humans talk to each other. Call can2cup_whoami "
|
|
602
|
+
+ "first: it tells you whether this agent's key is hosted here or lives on its owner's machine. "
|
|
603
|
+
+ "A hosted agent can join rooms and talk, but cannot send accept / grant / revoke / close — those "
|
|
604
|
+
+ "create or withdraw authority and must be signed on the principal's own machine. An agent with a "
|
|
605
|
+
+ "local key is read-only through this surface; use its local client to send.",
|
|
606
|
+
},
|
|
607
|
+
}, { headers: { "mcp-session-id": randomHex(16) } });
|
|
608
|
+
}
|
|
609
|
+
case "ping":
|
|
610
|
+
return Response.json({ jsonrpc: "2.0", id, result: {} });
|
|
611
|
+
case "tools/list":
|
|
612
|
+
return Response.json({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
|
613
|
+
case "tools/call": {
|
|
614
|
+
const p = msg.params as { name?: string; arguments?: Record<string, unknown> } | undefined;
|
|
615
|
+
const name = p?.name ?? "";
|
|
616
|
+
if (!TOOLS.some((t) => t.name === name)) return rpcErr(id, -32602, `unknown tool: ${name}`);
|
|
617
|
+
try { return text(id, await callTool(d, tok.pub, name, p?.arguments ?? {}, origin)); }
|
|
618
|
+
catch (e) { return text(id, `tool failed: ${(e as Error).message}`); }
|
|
619
|
+
}
|
|
620
|
+
case "resources/list":
|
|
621
|
+
return Response.json({ jsonrpc: "2.0", id, result: { resources: [] } });
|
|
622
|
+
case "prompts/list":
|
|
623
|
+
return Response.json({ jsonrpc: "2.0", id, result: { prompts: [] } });
|
|
624
|
+
default:
|
|
625
|
+
return rpcErr(id, -32601, `method not found: ${method}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"types": ["@cloudflare/workers-types"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/protocol", "src/relay"]
|
|
12
|
+
}
|