diff --git a/README.md b/README.md index 7f6ffbc1..f9b6eec2 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle ## Privacy -Your prompts, replies, keys and accounts go only to the providers you use. Once a day a released magpie tells us it is in use: a random id, its version and system, and which agents, providers and models it is used with, by magpie's own ids (a provider you added yourself is only `custom`), and for each partner listed first in the add sheet, how many times a day it was shown, opened and added (counts only). No names, URLs, accounts, keys, prompts or usage. Turn part or all of it off in **Settings → Privacy**, or with `DO_NOT_TRACK=1`. [What is sent, exactly](docs/reference.md#counting-users). +Your prompts, replies, keys and accounts go only to the providers you use. Once a day a released magpie tells us it is in use: a random id, its version and system, and which agents, providers and models it is used with, by magpie's own ids (a provider you added yourself is only `custom`), and for each partner listed first in the add sheet, how many times a day it was shown, opened and added (counts only). No names, URLs, accounts, keys, prompts or usage. Turn part or all of it off in **Settings → Privacy**, or with `DO_NOT_TRACK=1`. A partner's website and key links go through `usemagpie.ai/go/…`, which counts the click, and usemagpie.ai counts fetches of the partner list; the server sees these whatever the settings, and keeps no id or address. [What is sent, exactly](docs/reference.md#counting-users). ## Community diff --git a/README.zh-CN.md b/README.zh-CN.md index 3718a5fd..644f0bae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -283,7 +283,7 @@ magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}} ## 隐私 -你的提示词、回复、密钥和账号只发给你用的供应商。正式版 magpie 每天告诉我们一次它在用:一个随机 ID、版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(你自己添加的供应商只记作 `custom`),以及添加供应商页里排在最前的合作伙伴每天被展示、打开和添加的次数(只有次数)。不发送名称、URL、账号、密钥、提示词和用量。可以在 **设置 → 隐私** 里关掉一部分或全部,也可以设 `DO_NOT_TRACK=1`。[具体发送哪些内容](docs/reference.md#counting-users)。 +你的提示词、回复、密钥和账号只发给你用的供应商。正式版 magpie 每天告诉我们一次它在用:一个随机 ID、版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(你自己添加的供应商只记作 `custom`),以及添加供应商页里排在最前的合作伙伴每天被展示、打开和添加的次数(只有次数)。不发送名称、URL、账号、密钥、提示词和用量。可以在 **设置 → 隐私** 里关掉一部分或全部,也可以设 `DO_NOT_TRACK=1`。合作伙伴的官网和获取 Key 链接经 `usemagpie.ai/go/…` 跳转并计一次点击,usemagpie.ai 也统计合作伙伴列表的拉取次数;这是服务器本身看到的,不受设置影响,不记录 ID 和 IP。[具体发送哪些内容](docs/reference.md#counting-users)。 ## 社区 diff --git a/docs/reference.md b/docs/reference.md index dc9db3a0..0503d5d4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1959,6 +1959,17 @@ Settings → Privacy → Count me as a user, or with `DO_NOT_TRACK=1` or `MAGPIE_NO_STATS=1`. Builds from source never send it. The code is [internal/stats](../internal/stats/stats.go). +Partners are also counted where usemagpie.ai sees them, whatever these +settings say, as any website sees its visits. The partner list +(`/api/partners`) gives each partner's website and key page as a +`https://usemagpie.ai/go//site|keys[/]` link, which sends a +`magpie partner go` event (the partner, which link, the region, and the +country Cloudflare gives) before sending the browser on; and one in ten +fetches of the list sends a `magpie partners fetch` event with weight 10 +and the country, while a partner is listed. Each event has an id of its +own: no install id, address or account goes with it. The code is +[site/worker.js](../site/worker.js). + ## Community Questions, setups worth sharing, ideas, bugs: come talk to us and other diff --git a/site/partners.js b/site/partners.js index e539605b..9f8415a4 100644 --- a/site/partners.js +++ b/site/partners.js @@ -2,8 +2,8 @@ // magpie's "Add provider" sheet, under a heading that says they sponsor // magpie. The app fetches this list from /api/partners every six hours and // keeps it (internal/provider/partners.go), so a change here reaches users -// with `npx wrangler deploy`, no release. At most 12 are listed, in this -// order. +// with `npx wrangler deploy`, no release. At most 12 whose term hasn't +// ended are listed, in this order. // // An entry is a preset (internal/provider/presets.go) and a few fields more: // @@ -26,4 +26,10 @@ // from, until RFC 3339 times bounding when it is listed (the paid term) // // Every URL is https. An entry that breaks a rule is left out by the app. +// +// The app gets keysUrl and website (a region's too) as usemagpie.ai/go/… +// links, which count the click and send the browser on (worker.js). The +// providers users add keep those links, so an entry stays here after its +// term ends: /api/partners leaves it out, /go still finds it. Remove one +// only when nobody can still be using it. export const PARTNERS = []; diff --git a/site/public/docs/ja/start.html b/site/public/docs/ja/start.html index 1cd3d10d..a54e9773 100644 --- a/site/public/docs/ja/start.html +++ b/site/public/docs/ja/start.html @@ -266,7 +266,7 @@ magpie claude group/opus-anywhere # これを使う

インストーラと magpie update にプロキシを渡します:curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890、または magpie update --proxy socks5://127.0.0.1:1080(http・https・socks5・socks5h に対応。HTTPS_PROXY と ALL_PROXY も使われます)。--mirror <prefix> を付けると、指定した GitHub ダウンロードミラーから <prefix>https://github.com/… としてリリースファイルを取得します。magpie update mirror <prefix> でアプリ自身の更新を含め毎回使い、magpie update mirror off で解除します。指定しない限りミラーは使われず、ファイルの SHA-256 はミラーではなく常に usemagpie.ai から取得するので、ミラーが書き換えたファイルはインストールされません。アプリ自身の更新は Settings → Proxy を通ります。

magpie は何を送信しますか?

-

プロンプト、返答、キー、アカウントは、使っているプロバイダーにだけ送られます。間に magpie 自身のサーバーはありません。リリース版の magpie は 1 日 1 回、使われていることを知らせます。何人が使っていて、何を優先して面倒を見るべきかを知るためです。送るのは、このコンピュータで作ったランダムな ID、magpie のバージョンとシステム、そして使っているエージェント、プロバイダー、モデルを magpie 自身の ID(claude、deepseek、deepseek/deepseek-v4)で、それぞれの数とともに。自分で追加したプロバイダーとそのモデルは custom とだけ記録します。プロバイダー追加シートの先頭に載るパートナーについては、1 日に表示された回数、開かれた回数、追加された回数も送ります(回数のみ)。名前、Base URL、アカウント、キー、プロンプト、使用量は送りません。設定 → プライバシー → 使っているエージェント、プロバイダー、モデルを共有 でエージェント、プロバイダー、モデルとパートナーの回数を外せます。ユーザー数に含める、DO_NOT_TRACK=1、MAGPIE_NO_STATS=1 のどれかですべてオフになります。ソースからのビルドは送信しません。詳しくはリファレンスを。

+

プロンプト、返答、キー、アカウントは、使っているプロバイダーにだけ送られます。間に magpie 自身のサーバーはありません。リリース版の magpie は 1 日 1 回、使われていることを知らせます。何人が使っていて、何を優先して面倒を見るべきかを知るためです。送るのは、このコンピュータで作ったランダムな ID、magpie のバージョンとシステム、そして使っているエージェント、プロバイダー、モデルを magpie 自身の ID(claude、deepseek、deepseek/deepseek-v4)で、それぞれの数とともに。自分で追加したプロバイダーとそのモデルは custom とだけ記録します。プロバイダー追加シートの先頭に載るパートナーについては、1 日に表示された回数、開かれた回数、追加された回数も送ります(回数のみ)。名前、Base URL、アカウント、キー、プロンプト、使用量は送りません。設定 → プライバシー → 使っているエージェント、プロバイダー、モデルを共有 でエージェント、プロバイダー、モデルとパートナーの回数を外せます。ユーザー数に含める、DO_NOT_TRACK=1、MAGPIE_NO_STATS=1 のどれかですべてオフになります。ソースからのビルドは送信しません。パートナーの公式サイトと「キーを取得」のリンクは usemagpie.ai/go/… を経由し、移動の前にクリックを 1 回数えます(どのパートナーか、どのリンクか、国)。usemagpie.ai はパートナー一覧が取得された回数も数えます。これはサーバー自身が見るもので、どの Web サイトのアクセス集計とも同じく、上の設定には左右されません。ID、IP アドレス、アカウントは記録しません。詳しくはリファレンスを。

エージェントが 502 を返す、または接続できず、magpie の Routing タブに何も出ない?

プロキシアプリ(Surge、Clash、mihomo など。システムプロキシ、TUN、拡張モードのいずれかが有効)が、エージェントから http://127.0.0.1:3425 へのリクエストを横取りして別の場所へ送っているため、magpie に届いていません。除外リスト(skip-proxy、バイパス)に 127.0.0.1 を書くだけでは足りないことがあります。Codex など一部のエージェントは、それでもプロキシを通します。ローカルアドレスを直結にするルールを、他のルールより前に追加してください:

diff --git a/site/public/docs/start.html b/site/public/docs/start.html index dcae3bc4..62cd5c2d 100644 --- a/site/public/docs/start.html +++ b/site/public/docs/start.html @@ -266,7 +266,7 @@ magpie claude group/opus-anywhere # use it

Give the installer and magpie update a proxy: curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890, or magpie update --proxy socks5://127.0.0.1:1080 (http, https, socks5 and socks5h; HTTPS_PROXY and ALL_PROXY are honoured too). --mirror <prefix> fetches the release file from a GitHub download mirror you choose, as <prefix>https://github.com/…; magpie update mirror <prefix> keeps it for every update, the app’s too, and magpie update mirror off removes it. No mirror is used unless you give one, and the file’s SHA-256 still comes from usemagpie.ai, never from the mirror: a file the mirror changed is refused. The app’s own updates go through Settings → Proxy.

What does magpie send about me?

-

Your prompts, replies, keys and accounts go only to the providers you use; magpie has no server of its own in the path. Once a day a released magpie tells us it is in use, so we know how many people use it and what to look after first: a random id made up on your computer, magpie’s version and your system, and which agents, providers and models it is used with, by magpie’s own ids (claude, deepseek, deepseek/deepseek-v4) with how many of each. A provider you added yourself, and a model on it, is only custom. For each partner listed first in the add sheet, how many times a day it was shown, opened and added from goes with them, as counts only. No names, base URLs, accounts, keys, prompts or usage are sent. Settings → Privacy → Share the agents, providers and models I use leaves out the agents, providers and models, and the partner counts; Count me as a user, DO_NOT_TRACK=1 or MAGPIE_NO_STATS=1 turns it all off. Builds from source never send it. The details are in the reference.

+

Your prompts, replies, keys and accounts go only to the providers you use; magpie has no server of its own in the path. Once a day a released magpie tells us it is in use, so we know how many people use it and what to look after first: a random id made up on your computer, magpie’s version and your system, and which agents, providers and models it is used with, by magpie’s own ids (claude, deepseek, deepseek/deepseek-v4) with how many of each. A provider you added yourself, and a model on it, is only custom. For each partner listed first in the add sheet, how many times a day it was shown, opened and added from goes with them, as counts only. No names, base URLs, accounts, keys, prompts or usage are sent. Settings → Privacy → Share the agents, providers and models I use leaves out the agents, providers and models, and the partner counts; Count me as a user, DO_NOT_TRACK=1 or MAGPIE_NO_STATS=1 turns it all off. Builds from source never send it. A partner's website and key-page links go through usemagpie.ai/go/…, which counts the click (the partner, which link, and a country) before sending you on, and usemagpie.ai counts how many times the partner list is fetched. The server sees these whatever the settings say, as any website sees its visits; no id, address or account is kept with them. The details are in the reference.

The agent gets 502 or can’t connect, and nothing shows in magpie’s Routing tab?

A proxy app (Surge, Clash, mihomo and the like, with system proxy, TUN or enhanced mode on) has taken the agent’s request to http://127.0.0.1:3425 and sent it somewhere else, so it never reaches magpie. The bypass list (skip-proxy, “bypass”) isn’t always enough: some agents, Codex among them, send it through the proxy anyway. Add rules that send the local addresses direct, ahead of your other rules:

diff --git a/site/public/docs/zh/start.html b/site/public/docs/zh/start.html index aec36b86..410a7b6e 100644 --- a/site/public/docs/zh/start.html +++ b/site/public/docs/zh/start.html @@ -266,7 +266,7 @@ magpie claude group/opus-anywhere # 使用它

给安装脚本和 magpie update 加代理:curl -fsSL https://usemagpie.ai/install.sh | sh -s -- --proxy http://127.0.0.1:7890,或 magpie update --proxy socks5://127.0.0.1:1080(支持 http、https、socks5、socks5h;环境变量 HTTPS_PROXY、ALL_PROXY 同样生效)。--mirror <前缀> 从你指定的 GitHub 下载镜像取发布文件,即 <前缀>https://github.com/…;magpie update mirror <前缀> 让之后每次更新(包括应用自己的更新)都用它,magpie update mirror off 取消。不指定就不用任何镜像;文件的 SHA-256 始终来自 usemagpie.ai,而不是镜像,镜像改过的文件会被拒绝安装。应用自己的更新走 设置 → 代理。

magpie 会发送我的哪些信息?

-

你的提示词、回复、密钥和账号只发给你用的供应商,中间没有 magpie 自己的服务器。正式发布的 magpie 每天告诉我们一次它在用,好让我们知道有多少人在用、该先照顾哪些:本机随机生成的 ID、magpie 版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(claude、deepseek、deepseek/deepseek-v4)和各自的数量。你自己添加的供应商及其上的模型只记作 custom。添加供应商页里排在最前的合作伙伴,每天被展示、打开和添加的次数也一并发送,只有次数。不发送名称、Base URL、账号、密钥、提示词和用量。设置 → 隐私 → 分享我用的 agent、provider 和模型 可以去掉 agent、供应商、模型和合作伙伴次数这部分;计入用户数、DO_NOT_TRACK=1 或 MAGPIE_NO_STATS=1 全部关闭。从源码构建的版本从不发送。详见参考文档。

+

你的提示词、回复、密钥和账号只发给你用的供应商,中间没有 magpie 自己的服务器。正式发布的 magpie 每天告诉我们一次它在用,好让我们知道有多少人在用、该先照顾哪些:本机随机生成的 ID、magpie 版本和系统,以及用了哪些 agent、供应商和模型,只用 magpie 自己的 ID(claude、deepseek、deepseek/deepseek-v4)和各自的数量。你自己添加的供应商及其上的模型只记作 custom。添加供应商页里排在最前的合作伙伴,每天被展示、打开和添加的次数也一并发送,只有次数。不发送名称、Base URL、账号、密钥、提示词和用量。设置 → 隐私 → 分享我用的 agent、provider 和模型 可以去掉 agent、供应商、模型和合作伙伴次数这部分;计入用户数、DO_NOT_TRACK=1 或 MAGPIE_NO_STATS=1 全部关闭。从源码构建的版本从不发送。合作伙伴的官网和「获取 Key」链接经 usemagpie.ai/go/… 跳转,跳转前记一次点击(哪个合作伙伴、哪个链接、哪个国家),usemagpie.ai 也会统计合作伙伴列表被拉取的次数。这部分是服务器本身看到的,和任何网站统计访问一样,不受上面的设置影响;不记录 ID、IP 和账号。详见参考文档。

Agent 报 502 或连不上,magpie 的路由标签页里却什么都没有?

代理软件(Surge、Clash、mihomo 等,开了系统代理、TUN 或增强模式)把 Agent 发往 http://127.0.0.1:3425 的请求接走、转去了别处,请求根本没到 magpie。只在跳过列表(skip-proxy、「绕过」)里写 127.0.0.1 不一定够:有些 Agent(比如 Codex)照样会走代理。在代理软件里加两条本地地址直连的规则,放在其他规则前面:

diff --git a/site/worker.js b/site/worker.js index 91bb67ab..81afe8de 100644 --- a/site/worker.js +++ b/site/worker.js @@ -8,7 +8,10 @@ // Both take ?lang=zh for the notes in Chinese, where a // release has them (below its marker); // any other lang, or none, is the English alone. -// /api/partners {partners: [...]}: the add sheet's Partners (partners.js) +// /api/partners {partners: [...]}: the add sheet's Partners (partners.js), +// their website and key links through /go +// /go//keys[/], /go//site[/] +// a partner's key page or website, counted // /download the Apple Silicon dmg // /download/mac-arm64 the same; /download/mac-intel the Intel dmg // /download/windows the Windows app (x64); /download/windows-arm64 @@ -59,7 +62,20 @@ export default { return json({ ...got.rel, notes: inLang(got.rel.notes, lang) }, 200, cacheFor(got)); } if (url.pathname === "/api/partners") { - return json({ partners: PARTNERS }, 200, { "Cache-Control": "public, max-age=600" }); + const now = Date.now(); + if (PARTNERS.some((p) => live(p, now)) && Math.random() < 1 / FETCH_SAMPLE) + ctx.waitUntil(capture("magpie partners fetch", { weight: FETCH_SAMPLE, country: country(req) })); + return json({ partners: served(PARTNERS, now) }, 200, { "Cache-Control": "public, max-age=600" }); + } + if (url.pathname.startsWith("/go/")) { + let id, what, region, to; + try { + [, , id, what, region] = url.pathname.split("/").map(decodeURIComponent); + to = goTarget(PARTNERS, id, what, region); + } catch {} + if (!to) return new Response("not found\n", { status: 404 }); + if (req.method === "GET") ctx.waitUntil(capture("magpie partner go", { id, what, region: region || "", country: country(req) })); + return new Response(null, { status: 302, headers: { Location: to, "Cache-Control": "no-store" } }); } if (url.pathname === "/api/notes") { const got = await releases(ctx, env); @@ -117,6 +133,66 @@ export default { }, }; +// A partner's reach and clicks are counted here, not in the app: the app's +// own counts (provider.CountPartner) go only from users who let the stats +// go, and these are what the server sees anyway. /api/partners hands out +// each partner's website and key page as a /go link, which counts the +// click (event "magpie partner go") and sends the browser on. Each app +// fetches the list every six hours, so the fetches tell how many apps a +// partner can be seen in: one in FETCH_SAMPLE is sent, as "magpie partners +// fetch" with weight FETCH_SAMPLE (sum it), and only while someone is +// listed. No id, address or account goes: a country, at most. +const FETCH_SAMPLE = 10; +const GO = "https://usemagpie.ai/go/"; +const POSTHOG = "https://us.i.posthog.com/i/v0/e/"; +// the Magpie project's PostHog key, as the app's (internal/stats): it can +// send events and read nothing +const POSTHOG_KEY = "phc_nyDyYyrUTRGvNq5U7FBesCyM2j4GdKeqGp8ko7DBerTC"; + +// live reports whether a partner's term includes now. +function live(p, now) { + return (!p.from || Date.parse(p.from) <= now) && (!p.until || now < Date.parse(p.until)); +} + +// served is the list the app gets: those whose term hasn't ended (an +// ended one stays in partners.js, so the /go links of providers added from +// it keep working), with their website and key page as /go links. +export function served(list, now) { + const go = (p, what, r) => GO + [p.id, what, r && r.id].filter(Boolean).map(encodeURIComponent).join("/"); + const linked = (o, p, r) => ({ ...o, ...(o.website && { website: go(p, "site", r) }), ...(o.keysUrl && { keysUrl: go(p, "keys", r) }) }); + return list + .filter((p) => !p.until || now < Date.parse(p.until)) + .map((p) => ({ ...linked(p, p), ...(p.regions && { regions: p.regions.map((r) => linked(r, p, r)) }) })); +} + +// goTarget is where a /go link sends the browser: the partner's own key +// page or website, the region's where it has one. Only partners.js's own +// addresses, never one the link names. +export function goTarget(list, id, what, region) { + const p = list.find((x) => x.id === id); + const field = what === "keys" ? "keysUrl" : what === "site" ? "website" : ""; + if (!p || !field) return ""; + if (!region) return p[field] || ""; + const r = (p.regions || []).find((x) => x.id === region); + return r ? r[field] || p[field] || "" : ""; +} + +function country(req) { + return (req.cf && req.cf.country) || ""; +} + +// capture sends one event to PostHog, under an id of its own: a count, not +// a person. +async function capture(event, props) { + try { + await fetch(POSTHOG, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ api_key: POSTHOG_KEY, event, distinct_id: crypto.randomUUID(), properties: { ...props, $process_person_profile: false } }), + }); + } catch {} +} + // preferred is the home page's language for this browser: the one picked on // the page (the lang cookie), else the first of its Accept-Language that the // site has, else English. diff --git a/site/worker.test.mjs b/site/worker.test.mjs index f51281ac..944afdb5 100644 --- a/site/worker.test.mjs +++ b/site/worker.test.mjs @@ -2,7 +2,7 @@ // (freecss on Discord). GitHub and the edge cache are stood in for. import { test } from "node:test"; import assert from "node:assert/strict"; -import worker from "./worker.js"; +import worker, { served, goTarget } from "./worker.js"; const EN = "### Features\n\n- One thing (#1)\n\n### Install\n\nDownload it."; const ZH = "### 新功能\n\n- 一件事 (#1)"; @@ -52,8 +52,14 @@ const FEED = ` </ul> `; +// what the worker sent PostHog +const captured = []; globalThis.fetch = async (u, init = {}) => { u = String(u); + if (u === "https://us.i.posthog.com/i/v0/e/") { + captured.push(JSON.parse(init.body)); + return new Response("{}"); + } if (u.startsWith("https://api.github.com/")) { seen.push(new Headers(init.headers)); if (apiDown) return new Response('{"message":"API rate limit exceeded"}', { status: 403 }); @@ -279,8 +285,9 @@ test("partners", async () => { const res = await ask("/api/partners"); assert.equal(res.status, 200); assert.equal(res.headers.get("Cache-Control"), "public, max-age=600"); - assert.deepEqual(await res.json(), { partners: PARTNERS }); - assert(PARTNERS.length <= 12); + const { partners } = await res.json(); + assert.deepEqual(partners, served(PARTNERS, Date.now())); + assert(partners.length <= 12); const ids = new Set(); for (const p of PARTNERS) { assert.match(p.id, /^[a-z0-9][a-z0-9-]{1,40}$/, p.id); @@ -293,3 +300,96 @@ test("partners", async () => { for (const t of [p.from, p.until].filter(Boolean)) assert(!isNaN(Date.parse(t)), p.id + ": " + t); } }); + +// The website and key links the app gets go through /go, which counts the +// click and sends the browser on; one whose term ended leaves the list but +// its links, kept in the providers added from it, still go. +const LIST = [ + { id: "acme", name: "Acme", chat: "https://api.acme.example/v1", website: "https://acme.example/", keysUrl: "https://acme.example/keys", iconUrl: "https://acme.example/i.png", + regions: [{ id: "cn", name: "China", chat: "https://cn.acme.example/v1", keysUrl: "https://cn.acme.example/keys" }, { id: "us east", name: "US", chat: "https://us.acme.example/v1" }] }, + { id: "plain", name: "Plain", chat: "https://api.plain.example/v1" }, + { id: "gone", name: "Gone", chat: "https://api.gone.example/v1", keysUrl: "https://gone.example/keys", until: "2026-01-01T00:00:00Z" }, +]; +const NOW = Date.parse("2026-10-10T00:00:00Z"); + +test("partners: links go through /go", () => { + const out = served(LIST, NOW); + assert.deepEqual(out.map((p) => p.id), ["acme", "plain"]); + const [acme, plain] = out; + assert.equal(acme.website, "https://usemagpie.ai/go/acme/site"); + assert.equal(acme.keysUrl, "https://usemagpie.ai/go/acme/keys"); + assert.equal(acme.iconUrl, LIST[0].iconUrl); + assert.equal(acme.chat, LIST[0].chat); + assert.equal(acme.regions[0].keysUrl, "https://usemagpie.ai/go/acme/keys/cn"); + assert.equal(acme.regions[0].website, undefined); + assert.equal(acme.regions[1].keysUrl, undefined); + assert.deepEqual(plain, LIST[1]); + // partners.js itself is left as it is + assert.equal(LIST[0].keysUrl, "https://acme.example/keys"); + assert.equal(served([{ ...LIST[0], until: "2026-10-10T00:00:01Z" }], NOW).length, 1); +}); + +test("partners: /go sends the browser on", () => { + assert.equal(goTarget(LIST, "acme", "keys"), "https://acme.example/keys"); + assert.equal(goTarget(LIST, "acme", "site"), "https://acme.example/"); + assert.equal(goTarget(LIST, "acme", "keys", "cn"), "https://cn.acme.example/keys"); + // a region without its own page has the partner's + assert.equal(goTarget(LIST, "acme", "keys", "us east"), "https://acme.example/keys"); + assert.equal(goTarget(LIST, "acme", "site", "cn"), "https://acme.example/"); + // ended, still goes + assert.equal(goTarget(LIST, "gone", "keys"), "https://gone.example/keys"); + for (const [id, what, region] of [["nobody", "keys"], ["acme", "chat"], ["acme", "keys", "mars"], ["plain", "keys"], ["acme", "constructor"]]) + assert.equal(goTarget(LIST, id, what, region), "", [id, what, region].join(" ")); +}); + +test("partners: /go counts the click", async () => { + const { PARTNERS } = await import("./partners.js"); + const p = PARTNERS.find((x) => x.keysUrl || x.website); + if (!p) { + // nobody listed: every /go is not found, and nothing is sent + captured.length = 0; + for (const path of ["/go/acme/keys", "/go/%E0/keys", "/go/"]) assert.equal((await ask(path)).status, 404, path); + assert.deepEqual(captured, []); + return; + } + const what = p.keysUrl ? "keys" : "site"; + captured.length = 0; + const res = await worker.fetch(new Request("https://usemagpie.ai/go/" + p.id + "/" + what), {}, ctx); + assert.equal(res.status, 302); + assert.equal(res.headers.get("Location"), p.keysUrl || p.website); + await Promise.resolve(); + assert.equal(captured.length, 1); + assert.equal(captured[0].event, "magpie partner go"); + assert.equal(captured[0].properties.id, p.id); + assert.equal(captured[0].properties.what, what); + assert.equal(captured[0].properties.$process_person_profile, false); + // a HEAD goes on but isn't counted + captured.length = 0; + const head = await worker.fetch(new Request("https://usemagpie.ai/go/" + p.id + "/" + what, { method: "HEAD" }), {}, ctx); + assert.equal(head.status, 302); + assert.deepEqual(captured, []); + assert.equal((await ask("/go/nobody-at-all/keys")).status, 404); + assert.equal((await ask("/go/%E0/keys")).status, 404); +}); + +test("partners: the list's fetches are counted, one in ten", async () => { + const { PARTNERS } = await import("./partners.js"); + const anyLive = PARTNERS.some((p) => (!p.from || Date.parse(p.from) <= Date.now()) && (!p.until || Date.now() < Date.parse(p.until))); + const random = Math.random; + try { + for (const [r, sent] of [[0.05, anyLive], [0.5, false]]) { + Math.random = () => r; + captured.length = 0; + const res = await ask("/api/partners"); + assert.equal(res.status, 200); + await new Promise((ok) => setTimeout(ok, 0)); + assert.equal(captured.length, sent ? 1 : 0, "random " + r); + if (sent) { + assert.equal(captured[0].event, "magpie partners fetch"); + assert.equal(captured[0].properties.weight, 10); + } + } + } finally { + Math.random = random; + } +});