mirror of
https://github.com/tanweai/pua.git
synced 2026-09-19 02:19:52 +08:00
Remove all data collection: 5 upload channels, client and server
PUA Skill no longer sends anything over the network. Removed end to end,
both the client hooks that emit data and the server that received it.
Channels removed:
- Session transcript upload (/api/upload) full redacted .jsonl
- Rating feedback (/api/feedback) rating, counts, flavor
- Silent heartbeat telemetry (/api/heartbeat) install id, version, platform
- PUA leaderboard (/api/leaderboard) email, phone, PUA counts
- pua-api platform (agentguard.workers.dev)
phone + SMS registration, silent session_start/pua_triggered/command_used
events, remote prompt-template fetch, and the payment flow that depended
on the registration token
Client:
- delete hooks/heartbeat.sh; drop both SessionStart registrations
- stop-feedback.sh: 5 curl calls -> append one line to ~/.pua/feedback.jsonl
- rewrite references/platform.md as a local-only command reference
- drop the leaderboard section and commands from skills/pro
- survey/pua/offline command docs: local-only wording
Server:
- delete all Pages Functions (upload, feedback, heartbeat, leaderboard,
_sanitize, me, _session, auth/*) and all 5 D1 migrations
- contribute + admin pages become notices; no upload form, no login
- wrangler.toml: drop R2/D1 bindings and GitHub OAuth vars
Note: removing the bindings does not delete stored data. The R2 bucket
(puaskill-upload), the D1 database (pua-uploads-db) and the
GITHUB_CLIENT_SECRET / SESSION_SECRET secrets must be deleted in the
Cloudflare dashboard separately.
Guard rail:
- add evals/test-no-telemetry.sh: reverse assertions over the whole repo
(collection hosts, endpoint paths, outbound request bodies, deleted files,
hook registrations, CF bindings) plus a positive control that local
feedback still works. Verified by injecting both a naive and an evasive
upload; 4 and 2 gates fired respectively.
- test-upload-flow / test-heartbeat / test-feedback-auth asserted that
collection worked, so they were replaced rather than merely deleted --
deleting them would have removed the guard along with the feature.
hooks/sanitize-session.sh is kept as a standalone offline redaction tool.
Unlike the deleted TypeScript port it does not split input per line, so the
multiline PEM pattern still matches (issue #134 ID-001 did not apply to it).
Refs #134, #98, #100
This commit is contained in:
@@ -35,7 +35,6 @@
|
||||
"trae",
|
||||
"codex-aliases",
|
||||
"diagnosis-first",
|
||||
"heartbeat",
|
||||
"cloudflare",
|
||||
"feedback",
|
||||
"jsonl"
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"methodology",
|
||||
"offline",
|
||||
"diagnosis-first",
|
||||
"heartbeat",
|
||||
"cloudflare",
|
||||
"feedback",
|
||||
"jsonl",
|
||||
|
||||
+15
-10
@@ -649,21 +649,26 @@ Hooks(v3、Claude Code専用):
|
||||
| `/pua:reap-orphans` 🆕 | **v3.2** — 孤児agentをスキャンして回収(state mtime > 30min で心拍なし) |
|
||||
| `/pua:teardown-all` 🆕 | **v3.2** — 全アクティブagentをカスケード解放(P10 → P9 → P8 → P7 全員退場) |
|
||||
|
||||
## データ貢献
|
||||
## プライバシー — データ収集なし
|
||||
|
||||
Claude Code / Codex CLIの対話ログ(`.jsonl`)をアップロードして、PUA Skillの改善にご協力ください。
|
||||
PUA Skill はネットワークに何も送信しません。アカウントも、テレメトリも、アップロードもありません。かつて存在した 5 つの収集チャネルは、クライアント・サーバーともに削除済みです:
|
||||
|
||||
**[アップロードはこちら →](https://openpua.ai/contribute.html)**
|
||||
| 削除済み | 送信していた内容 |
|
||||
|----------|------------------|
|
||||
| セッション記録のアップロード | マスキング後の対話 `.jsonl` 全文 |
|
||||
| 評価フィードバック送信 | 評価、PUA カウント、フレーバー、タスク要約 |
|
||||
| サイレント・ハートビート | ランダムなインストール ID、プラグイン版数、プラットフォーム、フレーバー |
|
||||
| PUA リーダーボード | メールアドレス、電話番号、PUA カウント、L3+ カウント |
|
||||
| pua-api プラットフォーム | 電話番号 / SMS 登録、サイレントなイベント送信、リモート prompt テンプレート取得、決済 |
|
||||
|
||||
アップロードされたファイルはベンチマークテストとアブレーションスタディの分析に使用され、異なるPUA戦略がAIデバッグ行動に与える影響を定量化します。
|
||||
タスク終了時のフィードバックは残っていますが、ローカルの `~/.pua/feedback.jsonl` に 1 行追記するだけです。非表示にするには `/pua:offline`、または `~/.pua/config.json` で `feedback_frequency: 0` を設定してください。
|
||||
|
||||
`evals/test-no-telemetry.sh` が逆方向アサーションでこれを保証します。収集ホスト、エンドポイントパス、送信ボディ、削除済みファイルをリポジトリ全体でスキャンするため、退行はサイレントに出荷されずテスト失敗になります。
|
||||
|
||||
自分用にセッションをマスキングしたい場合、`hooks/sanitize-session.sh` はオフライン単体ツールとして残されています:
|
||||
|
||||
`.jsonl` ファイルの取得:
|
||||
```bash
|
||||
# Claude Code
|
||||
ls ~/.claude/projects/*/sessions/*.jsonl
|
||||
|
||||
# Codex CLI
|
||||
ls ~/.codex/sessions/*.jsonl
|
||||
bash hooks/sanitize-session.sh <入力.jsonl> <出力.jsonl>
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -648,7 +648,7 @@ Hooks (v3, Claude Code only):
|
||||
| `/pua:pua-loop` | Auto-iteration — runs until done or max iterations; `<loop-abort>reason</loop-abort>` to stop, `<loop-pause>what</loop-pause>` to pause |
|
||||
| `/pua:on` | Always-on mode (auto-PUA every session) |
|
||||
| `/pua:off` | Turn off always-on + feedback |
|
||||
| `/pua:offline` 🆕 | **v3.3** — Offline mode: disable feedback/leaderboard network flows while keeping local PUA behavior |
|
||||
| `/pua:offline` 🆕 | **v3.3** — Offline mode: silence the local feedback prompt while keeping PUA behavior (nothing is ever uploaded) |
|
||||
| `/pua:survey` | Research questionnaire (7 sections) |
|
||||
| `/pua:flavor` | Switch between 14 corporate flavors |
|
||||
| `/pua:kpi` | Generate KPI report card |
|
||||
@@ -747,21 +747,33 @@ Task arrives → Analyze type → Auto-select best methodology
|
||||
- `superpowers:systematic-debugging` — PUA adds motivation layer, systematic-debugging provides methodology
|
||||
- `superpowers:verification-before-completion` — Prevents false "fixed" claims
|
||||
|
||||
## Contribute Data
|
||||
## Privacy — No Data Collection
|
||||
|
||||
Upload your Claude Code / Codex CLI conversation logs (`.jsonl`) to help us improve PUA Skill's effectiveness.
|
||||
PUA Skill sends nothing over the network. There is no account, no telemetry, no
|
||||
upload. Five collection channels used to exist and have all been removed, client
|
||||
and server:
|
||||
|
||||
**[Upload here ->](https://openpua.ai/contribute.html)**
|
||||
| Removed | What it used to send |
|
||||
|---------|----------------------|
|
||||
| Session transcript upload | Your full conversation `.jsonl`, redacted |
|
||||
| Rating feedback | Rating, PUA count, flavor, task summary |
|
||||
| Silent heartbeat telemetry | Random install ID, plugin version, platform, flavor |
|
||||
| PUA leaderboard | Email, phone number, PUA count, L3+ count |
|
||||
| pua-api platform | Phone-number/SMS registration, silent session events, remote prompt templates, payment |
|
||||
|
||||
Uploaded files are used for Benchmark testing and Ablation Study analysis to quantify how different PUA strategies affect AI debugging behavior.
|
||||
The end-of-task feedback prompt still exists, but it only appends one line to
|
||||
`~/.pua/feedback.jsonl` on your own machine. Silence it with `/pua:offline`, or
|
||||
set `feedback_frequency: 0` in `~/.pua/config.json`.
|
||||
|
||||
`evals/test-no-telemetry.sh` guards this with reverse assertions — it scans the
|
||||
whole repo for collection hosts, endpoint paths, outbound request bodies and
|
||||
deleted files, so a regression fails the suite rather than shipping quietly.
|
||||
|
||||
Want to redact a session for your own use? `hooks/sanitize-session.sh` is kept as
|
||||
a standalone offline tool:
|
||||
|
||||
Get your `.jsonl` files:
|
||||
```bash
|
||||
# Claude Code
|
||||
ls ~/.claude/projects/*/sessions/*.jsonl
|
||||
|
||||
# Codex CLI
|
||||
ls ~/.codex/sessions/*.jsonl
|
||||
bash hooks/sanitize-session.sh <input.jsonl> <output.jsonl>
|
||||
```
|
||||
|
||||
## Star History
|
||||
|
||||
+16
-11
@@ -699,7 +699,7 @@ Hooks(v3,Claude Code 专属):
|
||||
| `/pua:pua-loop` | 自动迭代 — 跑到完成或达到最大轮次;`<loop-abort>原因</loop-abort>` 终止,`<loop-pause>需要什么</loop-pause>` 暂停 |
|
||||
| `/pua:on` | 默认开启(每次新会话自动 PUA) |
|
||||
| `/pua:off` | 关闭默认模式 + 反馈收集 |
|
||||
| `/pua:offline` 🆕 | **v3.3** — 离线模式:关闭反馈/排行榜联网流程,保留本地 PUA 行为 |
|
||||
| `/pua:offline` 🆕 | **v3.3** — 离线模式:静默本地反馈问卷,保留 PUA 行为(本就不会上传任何数据) |
|
||||
| `/pua:survey` | 调研问卷(7 个部分) |
|
||||
| `/pua:flavor` | 切换 14 种大厂味道 |
|
||||
| `/pua:kpi` | 生成 KPI 报告卡 |
|
||||
@@ -708,21 +708,26 @@ Hooks(v3,Claude Code 专属):
|
||||
| `/pua:reap-orphans` 🆕 | **v3.2** — 扫描并回收孤儿 agent(state mtime > 30min 且无心跳的自动释放) |
|
||||
| `/pua:teardown-all` 🆕 | **v3.2** — 级联释放所有活跃 agent(P10 → P9 → P8 → P7 全员释放) |
|
||||
|
||||
## 贡献数据
|
||||
## 隐私 — 不收集任何数据
|
||||
|
||||
上传你的 Claude Code / Codex CLI 对话记录(`.jsonl`),帮助我们改进 PUA Skill 的效果。
|
||||
PUA Skill 不向网络发送任何内容:没有账号、没有 telemetry、没有上传。原先存在的五条采集通道已从客户端和服务端整体移除:
|
||||
|
||||
**[上传入口 →](https://openpua.ai/contribute.html)**
|
||||
| 已移除 | 曾经发送的内容 |
|
||||
|--------|----------------|
|
||||
| session 语料上传 | 脱敏后的对话 `.jsonl` 全文 |
|
||||
| 评分反馈上报 | 评分、PUA 计数、味道、任务摘要 |
|
||||
| 静默心跳 telemetry | 随机安装 ID、插件版本、平台、味道 |
|
||||
| PUA 排行榜 | 邮箱、手机号、PUA 计数、L3+ 计数 |
|
||||
| pua-api 平台 | 手机号 + 短信验证码注册、静默事件上报、远端 prompt 模板拉取、支付流程 |
|
||||
|
||||
上传的文件将用于 Benchmark 测试和消融实验(Ablation Study)分析,帮助量化不同 PUA 策略对 AI 调试行为的影响。
|
||||
任务结束时的反馈问卷保留,但只 append 一行到本机 `~/.pua/feedback.jsonl`。不想看到它:`/pua:offline`,或在 `~/.pua/config.json` 里设 `feedback_frequency: 0`。
|
||||
|
||||
`evals/test-no-telemetry.sh` 用反向断言守住这条线——扫描全仓的采集域名、endpoint 路径、出站请求体和已删文件,回归会让测试失败而不是静默上线。
|
||||
|
||||
想给自己留一份脱敏 session?`hooks/sanitize-session.sh` 作为独立离线工具保留:
|
||||
|
||||
获取 `.jsonl` 文件:
|
||||
```bash
|
||||
# Claude Code
|
||||
ls ~/.claude/projects/*/sessions/*.jsonl
|
||||
|
||||
# Codex CLI
|
||||
ls ~/.codex/sessions/*.jsonl
|
||||
bash hooks/sanitize-session.sh <输入.jsonl> <输出.jsonl>
|
||||
```
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -8,6 +8,6 @@ license: MIT
|
||||
|
||||
This is a Codex CLI alias for the Claude Code `/pua:pro` command.
|
||||
|
||||
Load and follow the pro skill/protocol for self-evolution, platform telemetry, KPI, and config commands.
|
||||
Load and follow the pro skill/protocol for self-evolution, KPI, and config commands. All of it runs locally — there is no telemetry, no account and no upload.
|
||||
|
||||
When this alias changes `~/.pua/config.json`, preserve unknown fields and create `~/.pua/` if missing. Do not claim completion without command/output evidence.
|
||||
|
||||
@@ -8,6 +8,6 @@ license: MIT
|
||||
|
||||
This is a Codex CLI alias for the Claude Code `/pua:survey` command.
|
||||
|
||||
Guide the user through the PUA survey and save the local response. Ask before any upload.
|
||||
Guide the user through the PUA survey and save the response to `~/.pua/survey-response.json`. Local file only — never POST it anywhere; PUA has no collection endpoint.
|
||||
|
||||
When this alias changes `~/.pua/config.json`, preserve unknown fields and create `~/.pua/` if missing. Do not claim completion without command/output evidence.
|
||||
|
||||
+7
-5
@@ -1,8 +1,10 @@
|
||||
---
|
||||
description: "PUA 离线模式 — 关闭所有联网反馈/排行榜上报,保留本地 PUA 行为。/pua:offline。Triggers on: '/pua:offline', '离线模式', '封闭网络', 'offline mode', 'no network'."
|
||||
description: "PUA 离线模式 — 关闭本地反馈问卷,保留本地 PUA 行为。/pua:offline。Triggers on: '/pua:offline', '离线模式', '封闭网络', 'offline mode', 'no network'."
|
||||
---
|
||||
|
||||
开启 PUA 离线模式,适用于内网、封闭网络、无外网代理或不希望任何反馈上报的环境。
|
||||
开启 PUA 离线模式,适用于内网、封闭网络,或不希望任务结束时被反馈问卷打断的环境。
|
||||
|
||||
> **注意**:PUA Skill 已移除全部联网上报功能(session 上传 / 评分上报 / 心跳 telemetry / 排行榜),**默认就不发送任何数据**。此命令现在只控制本地反馈问卷是否弹出,不再是"关闭上报"的开关。
|
||||
|
||||
## 执行
|
||||
|
||||
@@ -27,10 +29,10 @@ PY
|
||||
|
||||
## 输出确认
|
||||
|
||||
> [PUA OFFLINE] 已进入离线模式:保留本地压力/验证协议,但不触发反馈上传、排行榜上报或任何网络提交。恢复联网反馈时编辑 `~/.pua/config.json`:`"offline": false` 并设置 `feedback_frequency`。
|
||||
> [PUA OFFLINE] 已进入离线模式:保留本地压力/验证协议,不再弹出反馈问卷。恢复问卷时编辑 `~/.pua/config.json`:`"offline": false` 并设置 `feedback_frequency`。
|
||||
|
||||
## 设计边界
|
||||
|
||||
- 离线模式不等于 `/pua:off`:PUA 行为仍可开启。
|
||||
- 离线模式只关闭 PUA 自身的反馈/排行榜网络流;不会替用户禁止模型或其他工具联网。
|
||||
- 真正的网络隔离仍应由运行环境、防火墙或工具权限控制完成。
|
||||
- 离线模式只关闭本地反馈问卷;PUA Skill 本身已无任何联网上报代码,无需靠此开关阻止上传。
|
||||
- 不会替用户禁止模型或其他工具联网。真正的网络隔离仍应由运行环境、防火墙或工具权限控制完成。
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ argument-hint: "[p7|p9|p10|pro|yes|mama|loop|on|off|offline|kpi|survey|flavor|di
|
||||
- **mama** → Read `skills/mama/SKILL.md`(妈妈唠叨模式 — 中国式妈妈碎碎念,底层行为不变,旁白从大厂PUA变成妈妈唠叨。和 yes 互斥)
|
||||
- **on** → 开启 PUA 默认模式:将 `{"always_on": true}` 写入 `~/.pua/config.json`,之后每次新会话自动加载 PUA 核心 skill。输出确认:> [PUA ON] 从现在起,每个新会话都会自动进入 PUA 模式。公司不养闲 Agent。
|
||||
- **off** → 关闭 PUA 默认模式:将 `{"always_on": false, "feedback_frequency": 0}` 写入 `~/.pua/config.json`。输出确认:> [PUA OFF] PUA 默认模式和反馈收集已关闭。需要时手动 /pua 触发。
|
||||
- **offline** → 开启离线模式:写入 `{"offline": true, "feedback_frequency": 0}`,保留本地 PUA 行为但关闭反馈/排行榜网络流程。输出确认:> [PUA OFFLINE] 已进入离线模式。
|
||||
- **offline** → 开启离线模式:写入 `{"offline": true, "feedback_frequency": 0}`,保留本地 PUA 行为但关闭本地反馈问卷。输出确认:> [PUA OFFLINE] 已进入离线模式。(注:PUA Skill 本身已无任何联网上报,此开关只影响本地问卷是否弹出)
|
||||
- **味道** 或 **flavor** → 读取 `skills/pua/references/flavors.md` 并让用户选择切换味道
|
||||
- **ding** / **钉味** / **置身钉外** / **置身钉内** / **每日一包** / **薛定谔的用户** / **病态敏捷** / **望舒行动** → 读取 `skills/pua/references/methodology-ding.md` 和 `skills/pua/references/ding-reminders.md`,启用 📌 钉内/钉外味(源自原文梗)
|
||||
- **again** / **再试试** / **换个方法** → 按 `commands/again.md` 的换方法模式执行:停止微调同一思路,改用本质不同方案
|
||||
@@ -34,7 +34,7 @@ argument-hint: "[p7|p9|p10|pro|yes|mama|loop|on|off|offline|kpi|survey|flavor|di
|
||||
- **evidence** / **证据呢** / **数据在哪** → 按 `commands/evidence.md` 的证据链模式执行:目标、证据、缺口、动作、状态
|
||||
- **kpi** → Read `skills/pro/SKILL.md` 并生成 KPI 报告卡
|
||||
- **loop** → Read `skills/pua-loop/SKILL.md`(自动迭代模式——PUA 质量 + 循环机制,禁用 AskUserQuestion;Claude 输出 `<loop-abort>原因</loop-abort>` 终止,`<loop-pause>需要什么</loop-pause>` 暂停等待人工)
|
||||
- **survey** → 读取 `skills/pua/references/survey.md` 问卷文件,用 AskUserQuestion 逐部分交互式引导用户回答。每部分 2-4 个问题一组,用户回答后进入下一部分。回答完毕后汇总为 JSON 写入 `~/.pua/survey-response.json` 并上传到 `https://pua-skill.pages.dev/api/feedback`
|
||||
- **survey** → 读取 `skills/pua/references/survey.md` 问卷文件,用 AskUserQuestion 逐部分交互式引导用户回答。每部分 2-4 个问题一组,用户回答后进入下一部分。回答完毕后汇总为 JSON 写入 `~/.pua/survey-response.json`。**只写本地,不上传到任何地址。**
|
||||
|
||||
## 执行规则
|
||||
|
||||
|
||||
+3
-1
@@ -2,4 +2,6 @@
|
||||
description: "PUA 调研问卷 — 7 部分交互式问卷收集用户反馈。/pua:survey。Triggers on: '/pua:survey', 'pua survey', '调研', '问卷', 'feedback survey'."
|
||||
---
|
||||
|
||||
读取 `references/survey.md` 问卷文件,用 AskUserQuestion 逐部分交互式引导用户回答。每部分 2-4 个问题一组,用户回答后进入下一部分。回答完毕后汇总为 JSON 写入 `~/.pua/survey-response.json` 并上传到 `https://pua-skill.pages.dev/api/feedback`
|
||||
读取 `references/survey.md` 问卷文件,用 AskUserQuestion 逐部分交互式引导用户回答。每部分 2-4 个问题一组,用户回答后进入下一部分。回答完毕后汇总为 JSON 写入 `~/.pua/survey-response.json`。
|
||||
|
||||
**只写本地,不上传。** 问卷结果保存在用户自己机器上,PUA Skill 没有任何联网上报能力。不要尝试把它 POST 到任何地址。
|
||||
|
||||
+30
-18
@@ -38,7 +38,9 @@
|
||||
}
|
||||
```
|
||||
|
||||
离线模式会关闭 PUA 自身的反馈问卷、排行榜上报和 session 上传提示;PUA 的本地验证、压力升级、诊断先行仍可使用。
|
||||
离线模式会关闭任务结束时的反馈问卷;PUA 的本地验证、压力升级、诊断先行仍可使用。
|
||||
|
||||
注意:PUA 已移除全部联网上报功能,**默认就不发送任何数据**,无需靠离线模式来阻止上传。这个开关现在只控制问卷是否弹出。
|
||||
|
||||
## Codex CLI 子命令怎么对应 Claude Code?
|
||||
|
||||
@@ -61,16 +63,23 @@ Codex 没有 Claude Code 的 `/pua:xxx` slash command 命名空间时,可以
|
||||
- `.trae/skills/`:Trae 标准 `SKILL.md` 包;`trae/` 保留 Prompt/Rule 复制版和差异说明。
|
||||
- Trae / Pi 都不继承 Claude Code hooks;四权分离 gate 必须通过 Skill 工作规程、外部验证和用户确认落地。
|
||||
|
||||
## Feedback endpoint 为什么仍限制 `session_data`?
|
||||
## PUA 会不会上传我的数据?
|
||||
|
||||
从 v3.4.5 起采用新折中:
|
||||
不会。全部数据收集功能已移除,客户端不发、服务端也不再接收。具体删掉了五条通道:
|
||||
|
||||
- 匿名评分仍允许写入 `/api/feedback`,便于低摩擦反馈;
|
||||
- `/api/feedback` 里的 `session_data` 字段仍要求登录,避免旧入口被滥用;
|
||||
- Skill 内的 session 贡献改走 `/api/upload`:用户在 AskUserQuestion 里明确同意后,本地先脱敏,再以匿名 raw JSONL 直传;
|
||||
- `/api/upload` 对匿名上传有 consent header、50MB 限制、文件名清洗和 D1 rate limit。
|
||||
| 已移除 | 曾经发送的内容 |
|
||||
|--------|----------------|
|
||||
| session 语料上传 | 脱敏后的对话 `.jsonl` 全文 |
|
||||
| 评分反馈上报 | 评分、PUA 计数、味道、任务摘要 |
|
||||
| 静默心跳 telemetry | 随机安装 ID、插件版本、平台、味道 |
|
||||
| PUA 排行榜 | 邮箱、手机号、PUA 计数、L3+ 计数 |
|
||||
| pua-api 平台 | 手机号 + 短信验证码注册、session_start/pua_triggered/command_used 事件上报,以及依赖它的远端指令模板拉取和支付流程 |
|
||||
|
||||
这比强制 GitHub 登录更利于收集真实数据,同时避免“无同意、无脱敏、无限流”的裸奔上传。
|
||||
对应的客户端 hook、服务端 Pages Functions、D1 迁移和 Cloudflare 绑定都已删除。
|
||||
|
||||
任务结束时的反馈问卷保留,但只 append 一行到本机 `~/.pua/feedback.jsonl`。
|
||||
|
||||
回归防护:`evals/test-no-telemetry.sh` 对全仓做反向断言——扫描已知采集域名、endpoint 路径、行首的 `curl`/`wget` 调用,以及已删文件的重新出现。任何一条被加回来,测试就会失败。
|
||||
|
||||
|
||||
## Integrity Guard 为什么不再使用 `permissionDecision: "ask"`?
|
||||
@@ -94,19 +103,22 @@ Codex 没有 Claude Code 的 `/pua:xxx` slash command 命名空间时,可以
|
||||
- start/intervene → “亲自动手” / “亲自介入”;
|
||||
- stop/release → “释放” / “退场”。
|
||||
|
||||
## 静默 heartbeat 会不会污染对话?
|
||||
## 静默 heartbeat 还在吗?
|
||||
|
||||
不会。v3.4.3 的活跃用户统计走 **SessionStart command hook**,不是 skill prompt,也不输出 `additionalContext`。因此模型上下文里不会出现 heartbeat endpoint、install id 或统计提示。
|
||||
不在了。SessionStart 的 heartbeat hook 已删除,`~/.pua/install_id` 不再被使用也不再被创建。老版本留下的这个文件可以直接删掉。
|
||||
|
||||
治理边界:
|
||||
## `openpua.ai/contribute.html` 现在是什么?
|
||||
|
||||
- `offline: true`、`telemetry: false` 或 `feedback_frequency: 0` 会关闭 heartbeat;
|
||||
- 本地只生成随机 install id,Cloudflare D1 只保存 SHA-256 hash;
|
||||
- 管理页面是 `https://openpua.ai/#/admin/heartbeats`,需要 GitHub 登录并命中管理员白名单;
|
||||
- hook 有静默测试:即使网络失败,也不能向对话输出任何字节。
|
||||
一个说明页。原来的上传表单已移除,页面只说明数据收集已停止。GitHub 登录、上传历史、管理统计面板一并下线,因为它们唯一的用途就是给上传归属和看采集量。
|
||||
|
||||
## 上传数据入口打不开或上传失败怎么办?
|
||||
## 我想手动脱敏一份 session 自己用,还有工具吗?
|
||||
|
||||
从 v3.4.4 起,`https://openpua.ai/contribute.html` 是一等路由:GitHub 登录回跳、登出回跳、README 和 Stop hook 都可以直接使用这个地址,不再依赖 hash route。
|
||||
有。`hooks/sanitize-session.sh` 保留下来了,它是纯本地工具,不联网:
|
||||
|
||||
上传链路默认发送 raw JSONL:前端直接把 `.jsonl` 文本 POST 到 `/api/upload`,文件名和可选微信号放在 header 里。服务端仍保留 JSON `file_data` 和 multipart 兼容,但默认 raw JSONL 路径可以避开 multipart body 剥离,也不会产生 base64 体积膨胀。
|
||||
```bash
|
||||
bash hooks/sanitize-session.sh <输入.jsonl> <输出.jsonl>
|
||||
```
|
||||
|
||||
三层脱敏:已知格式黑名单(路径、各家云厂商密钥、JWT、PEM 私钥、数据库连接串、邮箱/IP/手机号)→ `key=value` 上下文识别 → Shannon 熵兜底(32 字符以上的高熵串,纯 hex 用更高阈值以免误伤 git hash)。
|
||||
|
||||
它现在没有任何调用方,删掉它也不影响 PUA 运行。
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Silent Heartbeat + Cloudflare Active-User Stats Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add active-user visibility for PUA Skill without polluting Claude conversations. The heartbeat must be a mechanical hook-side signal, not prompt content: no `additionalContext`, no stdout/stderr, no text the model can echo.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Action side:** `hooks/heartbeat.sh` runs on `SessionStart`, before `session-restore.sh`.
|
||||
- **Conversation boundary:** heartbeat writes nothing to stdout/stderr and never appears in `skills/pua/SKILL.md` or SessionStart context.
|
||||
- **Privacy gates:** `offline=true`, `telemetry=false`, or `feedback_frequency=0` disables heartbeat before creating a local install id.
|
||||
- **Telemetry identity:** local random `~/.pua/install_id`; Cloudflare stores only SHA-256 hashes.
|
||||
- **Cloudflare side:** `/api/heartbeat` accepts minimal POST events and authenticated admin GET stats.
|
||||
- **Admin page:** `#/admin/heartbeats` shows active installs/sessions by day, version, platform, and flavor.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SessionStart --> HeartbeatHook[hooks/heartbeat.sh]
|
||||
HeartbeatHook -->|silent POST| CF[/api/heartbeat]
|
||||
CF --> D1[(D1: heartbeat_installs/events)]
|
||||
Admin[GitHub-auth admin] --> Stats[#/admin/heartbeats]
|
||||
Stats --> CF
|
||||
```
|
||||
|
||||
## Mechanical Gates
|
||||
|
||||
1. Hook silence gate: runtime test proves no stdout/stderr even when network fails.
|
||||
2. Context leak gate: SessionStart additionalContext must not contain heartbeat endpoint/id words.
|
||||
3. Privacy gate: offline mode must not create telemetry identity.
|
||||
4. Admin gate: GET stats requires signed session + GitHub allowlist.
|
||||
5. Abuse gate: POST body size limit, origin allowlist when Origin exists, per-IP/UA rate limit.
|
||||
|
||||
## INTJ Note
|
||||
|
||||
The design separates **measurement** from **narrative**. If telemetry is placed in the skill prompt, it becomes part of the model's story and can leak into outputs. If telemetry is placed in a silent hook with tests, it becomes an institution-level control: observable to the owner, invisible to the worker context.
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Feedback endpoint security gates: anonymous score allowed, session upload authenticated.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
pass() { echo " ✅ PASS: $1"; PASS=$((PASS+1)); }
|
||||
fail() { echo " ❌ FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
assert_grep() { local pat="$1" file="$2" n="$3"; grep -qE "$pat" "$ROOT/$file" && pass "$n" || fail "$n"; }
|
||||
assert_not_grep() { local pat="$1" file="$2" n="$3"; ! grep -qE "$pat" "$ROOT/$file" && pass "$n" || fail "$n"; }
|
||||
|
||||
echo "=== Feedback Auth Gates ==="
|
||||
assert_grep 'import \{ getSession \} from "\./_session"' landing/functions/api/feedback.ts "feedback endpoint imports session verifier"
|
||||
assert_grep 'SESSION_SECRET: string' landing/functions/api/feedback.ts "feedback endpoint env requires SESSION_SECRET"
|
||||
assert_grep 'body\.session_data' landing/functions/api/feedback.ts "feedback endpoint branches on session_data"
|
||||
assert_grep 'getSession\(request, env\.SESSION_SECRET\)' landing/functions/api/feedback.ts "session upload validates signed cookie"
|
||||
assert_grep 'Login required for session upload|status: 401' landing/functions/api/feedback.ts "unauthenticated session upload returns 401"
|
||||
assert_grep 'INSERT INTO feedback' landing/functions/api/feedback.ts "anonymous rating insert path remains"
|
||||
assert_not_grep "json\.dumps\(\{'rating': 'session_upload', 'session_data': data\}\)" hooks/stop-feedback.sh "Stop hook no longer posts session_data to feedback anonymously"
|
||||
assert_grep 'X-PUA-Upload-Consent|--data-binary @|/api/upload' hooks/stop-feedback.sh "Stop hook directly uploads sanitized session after explicit consent"
|
||||
assert_grep '仅上传评分' hooks/stop-feedback.sh "anonymous score-only feedback remains available"
|
||||
assert_not_grep 'GitHub login|登录后上传' hooks/stop-feedback.sh "Stop hook no longer requires GitHub login for session upload"
|
||||
|
||||
echo "==========================="
|
||||
echo "Passed: $PASS"
|
||||
echo "Failed: $FAIL"
|
||||
echo "Total: $((PASS+FAIL))"
|
||||
echo "==========================="
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Heartbeat telemetry gates: silent SessionStart hook + admin-only Cloudflare stats.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
pass() { echo " ✅ PASS: $1"; PASS=$((PASS+1)); }
|
||||
fail() { echo " ❌ FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
assert_file() { local file="$1" name="$2"; [ -f "$ROOT/$file" ] && pass "$name" || fail "$name"; }
|
||||
assert_grep() { local pat="$1" file="$2" name="$3"; grep -qE -- "$pat" "$ROOT/$file" 2>/dev/null && pass "$name" || fail "$name"; }
|
||||
assert_not_grep() { local pat="$1" file="$2" name="$3"; ! grep -qE -- "$pat" "$ROOT/$file" 2>/dev/null && pass "$name" || fail "$name"; }
|
||||
|
||||
run_silence_check() {
|
||||
local label="$1"
|
||||
local home_dir="$2"
|
||||
local config_json="$3"
|
||||
local out err
|
||||
mkdir -p "$home_dir/.pua"
|
||||
printf '%s\n' "$config_json" > "$home_dir/.pua/config.json"
|
||||
out="$home_dir/out.txt"
|
||||
err="$home_dir/err.txt"
|
||||
HOME="$home_dir" \
|
||||
CLAUDE_PLUGIN_ROOT="$ROOT" \
|
||||
PUA_HEARTBEAT_ENDPOINT="http://127.0.0.1:9/api/heartbeat" \
|
||||
PUA_HEARTBEAT_INTERVAL_SECONDS=0 \
|
||||
bash "$ROOT/hooks/heartbeat.sh" >"$out" 2>"$err" || true
|
||||
sleep 0.2
|
||||
if [ ! -s "$out" ] && [ ! -s "$err" ]; then
|
||||
pass "$label emits no stdout/stderr"
|
||||
else
|
||||
fail "$label emits no stdout/stderr"
|
||||
sed 's/^/ stdout: /' "$out" || true
|
||||
sed 's/^/ stderr: /' "$err" || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Heartbeat Gates ==="
|
||||
assert_file hooks/heartbeat.sh "heartbeat hook exists"
|
||||
assert_file landing/functions/api/heartbeat.ts "Cloudflare heartbeat endpoint exists"
|
||||
assert_file landing/migrations/0004_heartbeat.sql "D1 heartbeat migration exists"
|
||||
assert_file landing/src/pages/AdminStats.tsx "admin heartbeat page exists"
|
||||
|
||||
if [ -f "$ROOT/hooks/heartbeat.sh" ]; then
|
||||
bash -n "$ROOT/hooks/heartbeat.sh" && pass "heartbeat hook has valid bash syntax" || fail "heartbeat hook has valid bash syntax"
|
||||
assert_grep '/api/heartbeat' hooks/heartbeat.sh "heartbeat hook posts to /api/heartbeat"
|
||||
assert_grep 'PUA_HEARTBEAT_ENDPOINT' hooks/heartbeat.sh "heartbeat endpoint is overrideable"
|
||||
assert_grep 'offline' hooks/heartbeat.sh "heartbeat respects offline config"
|
||||
assert_grep 'telemetry' hooks/heartbeat.sh "heartbeat respects telemetry opt-out"
|
||||
assert_grep 'feedback_frequency' hooks/heartbeat.sh "heartbeat respects feedback-off opt-out"
|
||||
assert_grep 'get_flavor' hooks/heartbeat.sh "heartbeat normalizes flavor aliases"
|
||||
assert_grep '--max-time' hooks/heartbeat.sh "heartbeat network call has timeout"
|
||||
assert_grep '>/dev/null 2>&1|> *"?/dev/null"? +2>&1|2> *"?/dev/null"?' hooks/heartbeat.sh "heartbeat suppresses curl output"
|
||||
fi
|
||||
|
||||
python3 - "$ROOT" <<'PY' && pass "SessionStart registers silent heartbeat before context restore" || fail "SessionStart registers silent heartbeat before context restore"
|
||||
import json, pathlib, sys
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
data = json.loads((root / 'hooks/hooks.json').read_text())
|
||||
items = data.get('hooks', {}).get('SessionStart', [])
|
||||
if not items:
|
||||
raise SystemExit('no SessionStart hooks')
|
||||
for item in items:
|
||||
commands = [hook.get('command','') for hook in item.get('hooks', [])]
|
||||
if not any('heartbeat.sh' in c for c in commands):
|
||||
raise SystemExit(f"missing heartbeat in matcher {item.get('matcher')}")
|
||||
hb = next(i for i,c in enumerate(commands) if 'heartbeat.sh' in c)
|
||||
restore = next((i for i,c in enumerate(commands) if 'session-restore.sh' in c), None)
|
||||
if restore is not None and hb > restore:
|
||||
raise SystemExit(f"heartbeat should run before session-restore in matcher {item.get('matcher')}")
|
||||
PY
|
||||
|
||||
if [ -f "$ROOT/landing/functions/api/heartbeat.ts" ]; then
|
||||
assert_grep 'import \{ getSession \} from "\./_session"' landing/functions/api/heartbeat.ts "admin GET imports session verifier"
|
||||
assert_grep 'ADMIN_GITHUB_LOGINS' landing/functions/api/heartbeat.ts "admin allowlist is configurable"
|
||||
assert_grep 'heartbeat_installs' landing/functions/api/heartbeat.ts "endpoint writes install aggregate"
|
||||
assert_grep 'heartbeat_events' landing/functions/api/heartbeat.ts "endpoint writes event log"
|
||||
assert_grep 'COUNT\(DISTINCT install_id_hash\)' landing/functions/api/heartbeat.ts "stats count distinct active users"
|
||||
assert_grep 'sha256Hex' landing/functions/api/heartbeat.ts "endpoint hashes install identifiers"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/landing/src/App.tsx" ]; then
|
||||
assert_grep '#/admin/heartbeats' landing/src/App.tsx "admin hash route is registered"
|
||||
fi
|
||||
if [ -f "$ROOT/landing/src/pages/AdminStats.tsx" ]; then
|
||||
assert_grep '/api/heartbeat' landing/src/pages/AdminStats.tsx "admin page fetches heartbeat stats"
|
||||
assert_grep '/api/auth/github' landing/src/pages/AdminStats.tsx "admin page offers GitHub login"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/hooks/heartbeat.sh" ]; then
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
run_silence_check "online heartbeat" "$tmp/online-home" '{"telemetry": true, "flavor": "microsoft"}'
|
||||
if [ -s "$tmp/online-home/.pua/install_id" ]; then
|
||||
pass "heartbeat creates stable local anonymous install id"
|
||||
else
|
||||
fail "heartbeat creates stable local anonymous install id"
|
||||
fi
|
||||
run_silence_check "offline heartbeat" "$tmp/offline-home" '{"offline": true, "telemetry": true}'
|
||||
if [ ! -e "$tmp/offline-home/.pua/install_id" ]; then
|
||||
pass "offline mode does not create telemetry identity"
|
||||
else
|
||||
fail "offline mode does not create telemetry identity"
|
||||
fi
|
||||
|
||||
leak_home="$tmp/leak-home"
|
||||
mkdir -p "$leak_home/.pua"
|
||||
printf '%s\n' '{"always_on": true, "telemetry": true}' > "$leak_home/.pua/config.json"
|
||||
HOME="$leak_home" PUA_CONFIG="$leak_home/.pua/config.json" bash "$ROOT/hooks/session-restore.sh" > "$tmp/session.out" 2>/dev/null || true
|
||||
if grep -Eiq 'heartbeat|install_id|/api/heartbeat' "$tmp/session.out"; then
|
||||
fail "SessionStart context does not leak heartbeat implementation details"
|
||||
else
|
||||
pass "SessionStart context does not leak heartbeat implementation details"
|
||||
fi
|
||||
fi
|
||||
|
||||
assert_not_grep 'heartbeat|install_id|/api/heartbeat' skills/pua/SKILL.md "main skill prompt does not mention heartbeat"
|
||||
|
||||
echo "======================="
|
||||
echo "Passed: $PASS"
|
||||
echo "Failed: $FAIL"
|
||||
echo "Total: $((PASS+FAIL))"
|
||||
echo "======================="
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -70,13 +70,13 @@ if grep -RIn "下场" "$ROOT/agents" "$ROOT/commands" "$ROOT/skills/pua/referenc
|
||||
else
|
||||
pass "ambiguous 下场 wording removed"
|
||||
fi
|
||||
assert_grep 'MAX_BODY_BYTES|MAX_SESSION_DATA_BYTES|RATE_LIMIT' landing/functions/api/feedback.ts "feedback endpoint has abuse limits"
|
||||
assert_grep 'getSession\\(request, env\\.SESSION_SECRET\\)|Login required for session upload' landing/functions/api/feedback.ts "feedback session upload requires authentication"
|
||||
assert_not_grep "json\\.dumps\\(\\{'rating': 'session_upload', 'session_data': data\\}\\)" hooks/stop-feedback.sh "stop-feedback does not anonymously upload session_data"
|
||||
assert_grep 'X-PUA-Upload-Consent|--data-binary @|/api/upload' hooks/stop-feedback.sh "stop-feedback uploads sanitized session directly after consent"
|
||||
assert_not_grep 'GitHub login|登录后上传' hooks/stop-feedback.sh "stop-feedback no longer requires GitHub login"
|
||||
assert_grep 'upload_rate_limits' landing/migrations/0005_upload_rate_limits.sql "anonymous upload rate-limit migration exists"
|
||||
assert_grep 'feedback_rate_limits' landing/migrations/0003_feedback_rate_limits.sql "feedback rate-limit migration exists"
|
||||
# Data collection was removed. The endpoints that used to need abuse limits and
|
||||
# authentication no longer exist, so those gates moved to reverse assertions in
|
||||
# evals/test-no-telemetry.sh. What remains checkable here is that the Stop hook
|
||||
# stayed local: it may only append a rating line, never transmit one.
|
||||
assert_not_grep 'pua-skill\.pages\.dev|agentguard\.workers\.dev' hooks/stop-feedback.sh "stop-feedback references no collection host"
|
||||
assert_not_grep 'data-binary|Upload-Consent' hooks/stop-feedback.sh "stop-feedback carries no upload payload flags"
|
||||
assert_grep 'feedback\.jsonl' hooks/stop-feedback.sh "stop-feedback still records the rating locally"
|
||||
|
||||
echo "==========================================="
|
||||
echo "Passed: $PASS"
|
||||
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# No-telemetry gates — assert the repo contains NO data-collection path.
|
||||
#
|
||||
# These are REVERSE assertions, and that is deliberate. Five collection channels
|
||||
# (session upload / rating feedback / heartbeat telemetry / leaderboard, plus the
|
||||
# pua-api platform with SMS registration and silent stats reporting) were removed.
|
||||
# Simply deleting their tests would have removed the guard rail together with the
|
||||
# feature, leaving nothing to stop a later change from quietly reintroducing an
|
||||
# upload. So instead of testing that upload works, this file tests that it CANNOT
|
||||
# work — anywhere in the shipped repo.
|
||||
#
|
||||
# If this fails, someone added an endpoint, a network call in a hook, a Pages
|
||||
# Function, or an LLM-facing instruction telling the agent to POST user data.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
pass() { echo " ✅ PASS: $1"; PASS=$((PASS+1)); }
|
||||
fail() { echo " ❌ FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# Whole-repo sweep over the SHIPPED surface.
|
||||
#
|
||||
# Excluded: build output, vendored code, git internals, and evals/ itself.
|
||||
# evals/ is excluded because assertion files must name the forbidden strings in
|
||||
# order to forbid them — including them would make this test fail on its own
|
||||
# vocabulary. Nothing under evals/ is shipped to users or read by the model as
|
||||
# instructions, so it is not part of the threat surface.
|
||||
#
|
||||
# NOT excluded: documentation. SKILL.md / commands/*.md / references/*.md are
|
||||
# read by the model as instructions and are a real place an upload can hide —
|
||||
# that is exactly how the pua-api platform channel survived the first sweep.
|
||||
repo_grep() {
|
||||
grep -rIn -E "$1" "$ROOT" \
|
||||
--exclude-dir=.git \
|
||||
--exclude-dir=node_modules \
|
||||
--exclude-dir=dist \
|
||||
--exclude-dir=evals \
|
||||
--exclude="package-lock.json" \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
# assert_absent <pattern> <name> [allow_pattern]
|
||||
# allow_pattern whitelists legitimate matches (e.g. loopback URLs in examples).
|
||||
assert_absent() {
|
||||
local pat="$1" name="$2" allow="${3:-}" hits
|
||||
hits="$(repo_grep "$pat")"
|
||||
if [ -n "$allow" ] && [ -n "$hits" ]; then
|
||||
hits="$(printf '%s\n' "$hits" | grep -vE "$allow" || true)"
|
||||
fi
|
||||
if [ -z "$hits" ]; then
|
||||
pass "$name"
|
||||
else
|
||||
fail "$name"
|
||||
printf '%s\n' "$hits" | sed "s|$ROOT/||" | sed 's/^/ → /' | head -8
|
||||
fi
|
||||
}
|
||||
|
||||
assert_path_absent() {
|
||||
local rel="$1" name="$2"
|
||||
if [ ! -e "$ROOT/$rel" ]; then pass "$name"; else fail "$name — $rel still exists"; fi
|
||||
}
|
||||
|
||||
assert_grep() {
|
||||
local pat="$1" file="$2" name="$3"
|
||||
if grep -qE -- "$pat" "$ROOT/$file" 2>/dev/null; then pass "$name"; else fail "$name"; fi
|
||||
}
|
||||
|
||||
echo "=== No-Telemetry Gates ==="
|
||||
|
||||
# ── 1. Known collection hosts must not be referenced anywhere ────────────────
|
||||
assert_absent 'pua-skill\.pages\.dev' "pages.dev collection host is not referenced"
|
||||
assert_absent 'agentguard\.workers\.dev' "pua-api platform host is not referenced"
|
||||
assert_absent '/api/(upload|feedback|heartbeat|leaderboard)' "no collection endpoint paths remain"
|
||||
assert_absent '/v1/(sms|register|stats|payment|commands?)' "no pua-api platform endpoints remain"
|
||||
assert_absent 'X-PUA-(Upload-Consent|File-Name|Wechat-Id)' "no upload protocol headers remain"
|
||||
|
||||
# ── 2. Nothing may send a request BODY to a remote target ───────────────────
|
||||
# The thing to forbid is an upload, not every curl. `curl -o <file> <url>` in the
|
||||
# README is an install download and must stay legal; `curl -X POST "$ENDPOINT"`
|
||||
# is telemetry and must not.
|
||||
#
|
||||
# So the gate pairs a request-body flag with a remote target (absolute URL or a
|
||||
# shell variable) ON THE SAME LINE. That still catches multi-line invocations,
|
||||
# because the old heartbeat hook put both on its continuation line:
|
||||
# curl -fsS --max-time 2 \
|
||||
# -X POST "$ENDPOINT" \ <- matches: body flag + $variable
|
||||
#
|
||||
# It stays quiet for `curl -X POST /auth/google`, a relative-path example inside
|
||||
# the P9 acceptance-criteria template — no remote target, so not an upload.
|
||||
assert_absent '(-X[[:space:]]+(POST|PUT|PATCH)|--data|--upload-file|-F[[:space:]])[^\n]*(https?://|\$)' \
|
||||
"no request body sent to a remote target" \
|
||||
'localhost|127\.0\.0\.1|0\.0\.0\.0'
|
||||
assert_absent 'fetch\([[:space:]]*["'"'"'`]https?://' "no fetch() to an absolute URL"
|
||||
|
||||
# Second, narrower gate on the runtime surface: hooks execute on every session,
|
||||
# so they get a stricter rule than the repo at large — no absolute URL at all,
|
||||
# except github.com links in comments.
|
||||
hook_urls="$(grep -rIn -E 'https?://' "$ROOT/hooks" 2>/dev/null | grep -vE 'https?://(www\.)?github\.com' || true)"
|
||||
if [ -z "$hook_urls" ]; then
|
||||
pass "hooks reference no URL other than github.com"
|
||||
else
|
||||
fail "hooks reference no URL other than github.com"
|
||||
printf '%s\n' "$hook_urls" | sed "s|$ROOT/||" | sed 's/^/ → /' | head -8
|
||||
fi
|
||||
|
||||
# ── 3. The removed assets must stay removed ──────────────────────────────────
|
||||
assert_path_absent 'hooks/heartbeat.sh' "heartbeat telemetry hook is gone"
|
||||
assert_path_absent 'landing/functions' "Pages Functions (receiving end) are gone"
|
||||
assert_path_absent 'landing/migrations' "D1 migrations for collected data are gone"
|
||||
assert_path_absent 'landing/src/test/upload-function.test.ts' "upload endpoint test is gone"
|
||||
|
||||
# ── 4. No hook may register heartbeat telemetry on any event ────────────────
|
||||
assert_absent '"command":[^\n]*heartbeat' "no hook registers heartbeat telemetry"
|
||||
|
||||
# ── 5. Cloudflare bindings for collected data must not be re-declared ───────
|
||||
assert_absent '^binding[[:space:]]*=[[:space:]]*"(UPLOADS|DB)"' "no R2/D1 bindings in wrangler.toml"
|
||||
|
||||
# ── 6. Positive control — local feedback must still work ───────────────────
|
||||
# Without this, deleting stop-feedback.sh entirely would make every gate above
|
||||
# pass while silently dropping a feature the user asked to keep.
|
||||
assert_grep 'feedback\.jsonl' hooks/stop-feedback.sh "local feedback record still written"
|
||||
assert_grep 'AskUserQuestion' hooks/stop-feedback.sh "feedback prompt still asks the user"
|
||||
|
||||
echo "========================="
|
||||
echo "Passed: $PASS"
|
||||
echo "Failed: $FAIL"
|
||||
echo "Total: $((PASS+FAIL))"
|
||||
echo "========================="
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -124,16 +124,9 @@ required_paths = [
|
||||
'.trae/skills/pua-en/SKILL.md',
|
||||
'.trae/skills/pua-trae/SKILL.md',
|
||||
'docs/FAQ.md',
|
||||
'landing/migrations/0003_feedback_rate_limits.sql',
|
||||
'evals/test-platform-compat.sh',
|
||||
'evals/test-feedback-auth.sh',
|
||||
'evals/test-microsoft-flavor.sh',
|
||||
'evals/test-heartbeat.sh',
|
||||
'evals/test-upload-flow.sh',
|
||||
'hooks/heartbeat.sh',
|
||||
'landing/functions/api/heartbeat.ts',
|
||||
'landing/migrations/0004_heartbeat.sql',
|
||||
'landing/migrations/0005_upload_rate_limits.sql',
|
||||
'evals/test-no-telemetry.sh',
|
||||
'landing/src/pages/AdminStats.tsx',
|
||||
'skills/pua/references/methodology-microsoft.md',
|
||||
]
|
||||
@@ -141,16 +134,18 @@ for rel in required_paths:
|
||||
if not (root / rel).exists():
|
||||
errors.append(f'missing issue-sweep asset: {rel}')
|
||||
|
||||
# D1 migrations must be idempotent because existing production DBs may predate
|
||||
# Wrangler's migration journal. A non-idempotent CREATE INDEX previously made
|
||||
# `wrangler d1 migrations apply --remote` fail on 0001 before newer migrations.
|
||||
for migration in sorted((root / 'landing/migrations').glob('*.sql')):
|
||||
text_sql = migration.read_text(encoding='utf-8')
|
||||
if 'CREATE INDEX ' in text_sql:
|
||||
for line in text_sql.splitlines():
|
||||
stripped = line.strip().upper()
|
||||
if stripped.startswith('CREATE INDEX ') and not stripped.startswith('CREATE INDEX IF NOT EXISTS '):
|
||||
errors.append(f'D1 migration has non-idempotent index creation: {migration.relative_to(root)}')
|
||||
# Data collection was removed — these assets must stay deleted. Re-adding any of
|
||||
# them silently reintroduces an upload path, so assert their absence explicitly.
|
||||
removed_paths = [
|
||||
'hooks/heartbeat.sh',
|
||||
'landing/functions',
|
||||
'landing/migrations',
|
||||
'landing/src/test/upload-function.test.ts',
|
||||
]
|
||||
for rel in removed_paths:
|
||||
if (root / rel).exists():
|
||||
errors.append(f'data-collection asset must stay removed: {rel}')
|
||||
|
||||
frustration = (root / 'hooks/frustration-trigger.sh').read_text(encoding='utf-8')
|
||||
for term in ['TRIGGER_RE', 'PUA Skill Context']:
|
||||
if term not in frustration:
|
||||
@@ -162,19 +157,13 @@ if '/tmp/pua-plugin-root' in stop_feedback:
|
||||
errors.append('stop-feedback must not use /tmp/pua-plugin-root')
|
||||
if 'offline' not in stop_feedback:
|
||||
errors.append('stop-feedback must honor offline config')
|
||||
feedback_api = (root / 'landing/functions/api/feedback.ts').read_text(encoding='utf-8')
|
||||
for term in ['MAX_BODY_BYTES', 'MAX_SESSION_DATA_BYTES', 'RATE_LIMIT_MAX_WRITES', 'ALLOWED_ORIGINS', 'getSession(request, env.SESSION_SECRET)', 'Login required for session upload']:
|
||||
if term not in feedback_api:
|
||||
errors.append(f'feedback endpoint missing abuse-control term: {term}')
|
||||
stop_feedback = (root / 'hooks/stop-feedback.sh').read_text(encoding='utf-8')
|
||||
if "json.dumps({'rating': 'session_upload', 'session_data': data})" in stop_feedback:
|
||||
errors.append('stop-feedback must not anonymously post session_data to feedback endpoint')
|
||||
for term in ['X-PUA-Upload-Consent', '--data-binary @', '/api/upload']:
|
||||
if term not in stop_feedback:
|
||||
errors.append(f'stop-feedback missing anonymous direct upload term: {term}')
|
||||
for forbidden_stop_term in ['GitHub login', '登录后上传']:
|
||||
if forbidden_stop_term in stop_feedback:
|
||||
errors.append(f'stop-feedback should not require GitHub login for direct session upload: {forbidden_stop_term}')
|
||||
# The Stop hook must stay strictly local: it may only append a rating line to
|
||||
# ~/.pua/feedback.jsonl. Any endpoint or transfer flag here is a regression.
|
||||
for forbidden_net_term in ['pua-skill.pages.dev', 'openpua.ai/api', '/api/upload', '/api/feedback', '/api/leaderboard', '/api/heartbeat', 'X-PUA-Upload-Consent', '--data-binary @', 'sanitize-session.sh']:
|
||||
if forbidden_net_term in stop_feedback:
|
||||
errors.append(f'stop-feedback must not contain data-upload term: {forbidden_net_term}')
|
||||
if 'feedback.jsonl' not in stop_feedback:
|
||||
errors.append('stop-feedback must still record ratings locally to ~/.pua/feedback.jsonl')
|
||||
if '[PUA-DIAGNOSIS]' not in (root / 'skills/pua/SKILL.md').read_text(encoding='utf-8'):
|
||||
errors.append('pua skill missing diagnosis-first rule')
|
||||
if '军令状' not in (root / 'skills/pua/references/methodology-huawei.md').read_text(encoding='utf-8'):
|
||||
@@ -190,28 +179,18 @@ if 'Harness Integrity (anti-cheating governance)' not in session_restore:
|
||||
if 'Multi-Agent Governance Topology' not in session_restore:
|
||||
errors.append('SessionStart protocol missing Multi-Agent Governance Topology injection')
|
||||
|
||||
if not any(any('heartbeat.sh' in hook.get('command', '') for hook in item.get('hooks', [])) for item in hooks_json.get('hooks', {}).get('SessionStart', [])):
|
||||
errors.append('SessionStart missing silent heartbeat.sh registration')
|
||||
heartbeat_hook = (root / 'hooks/heartbeat.sh').read_text(encoding='utf-8') if (root / 'hooks/heartbeat.sh').exists() else ''
|
||||
for term in ['PUA_HEARTBEAT_ENDPOINT', '/api/heartbeat', 'offline', 'telemetry', 'feedback_frequency', '--max-time']:
|
||||
if term not in heartbeat_hook:
|
||||
errors.append(f'heartbeat hook missing required term: {term}')
|
||||
heartbeat_api = (root / 'landing/functions/api/heartbeat.ts').read_text(encoding='utf-8') if (root / 'landing/functions/api/heartbeat.ts').exists() else ''
|
||||
for term in ['ADMIN_GITHUB_LOGINS', 'getSession(request, env.SESSION_SECRET)', 'heartbeat_installs', 'heartbeat_events', 'COUNT(DISTINCT install_id_hash)', 'sha256Hex']:
|
||||
if term not in heartbeat_api:
|
||||
errors.append(f'heartbeat endpoint missing required term: {term}')
|
||||
# No hook may register heartbeat telemetry on any event.
|
||||
for event, entries in hooks_json.get('hooks', {}).items():
|
||||
for item in entries:
|
||||
for hook in item.get('hooks', []):
|
||||
if 'heartbeat' in hook.get('command', ''):
|
||||
errors.append(f'{event} must not register heartbeat telemetry')
|
||||
|
||||
app_tsx = (root / 'landing/src/App.tsx').read_text(encoding='utf-8')
|
||||
# The contribute page must carry no upload machinery.
|
||||
contribute_tsx = (root / 'landing/src/pages/Contribute.tsx').read_text(encoding='utf-8')
|
||||
for term in ['pathname', '/contribute.html', '#/contribute']:
|
||||
if term not in app_tsx:
|
||||
errors.append(f'upload flow missing required term in App.tsx: {term}')
|
||||
for term in ['file.text()', 'application/jsonl', 'X-PUA-File-Name', 'X-PUA-Wechat-Id']:
|
||||
if term not in contribute_tsx:
|
||||
errors.append(f'upload flow missing required term in Contribute.tsx: {term}')
|
||||
for forbidden_upload_term in ['new FormData()', 'readFileAsBase64', 'file_data: fileData']:
|
||||
for forbidden_upload_term in ['/api/upload', 'application/jsonl', 'X-PUA-File-Name', 'X-PUA-Wechat-Id', 'X-PUA-Upload-Consent', 'new FormData()', 'readFileAsBase64', 'file_data']:
|
||||
if forbidden_upload_term in contribute_tsx:
|
||||
errors.append(f'upload flow must not rely on bloated/multipart browser upload path: {forbidden_upload_term}')
|
||||
errors.append(f'contribute page must not contain upload machinery: {forbidden_upload_term}')
|
||||
|
||||
for forbidden in ['Applies to ALL task types', 'All task types', 'code, config, debug, deploy, research']:
|
||||
if forbidden in skill.split('---', 2)[1]:
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload flow gates: contribution route, GitHub redirect, and JSON upload payload.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
pass() { echo " ✅ PASS: $1"; PASS=$((PASS+1)); }
|
||||
fail() { echo " ❌ FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
assert_grep() { local pat="$1" file="$2" name="$3"; grep -qE -- "$pat" "$ROOT/$file" && pass "$name" || fail "$name"; }
|
||||
assert_not_grep() { local pat="$1" file="$2" name="$3"; ! grep -qE -- "$pat" "$ROOT/$file" && pass "$name" || fail "$name"; }
|
||||
|
||||
echo "=== Upload Flow Gates ==="
|
||||
assert_grep 'window\.location\.pathname|location\.pathname' landing/src/App.tsx "SPA router reads pathname, not only hash"
|
||||
assert_grep 'contribute\.html|/contribute' landing/src/App.tsx "SPA router handles /contribute.html deep link"
|
||||
assert_grep 'contribute\.html|#/contribute' landing/functions/api/auth/callback.ts "GitHub callback redirects to contribution page"
|
||||
assert_grep 'contribute\.html|#/contribute' landing/functions/api/auth/logout.ts "logout returns to contribution page"
|
||||
assert_grep 'application/jsonl' landing/src/pages/Contribute.tsx "frontend sends raw JSONL upload body"
|
||||
assert_grep 'file\.text\(\)' landing/src/pages/Contribute.tsx "frontend reads selected JSONL as text"
|
||||
assert_grep 'X-PUA-File-Name' landing/src/pages/Contribute.tsx "frontend sends file metadata outside multipart body"
|
||||
assert_grep 'X-PUA-Upload-Consent' landing/src/pages/Contribute.tsx "frontend marks explicit upload consent"
|
||||
assert_not_grep 'readFileAsBase64|file_data: fileData' landing/src/pages/Contribute.tsx "frontend avoids base64 bloat for large JSONL files"
|
||||
assert_not_grep 'new FormData\(\)' landing/src/pages/Contribute.tsx "frontend avoids multipart-only upload path"
|
||||
assert_grep 'application/jsonl' landing/functions/api/upload.ts "upload endpoint accepts raw JSONL body"
|
||||
assert_grep 'file_data' landing/functions/api/upload.ts "upload endpoint keeps JSON file_data compatibility"
|
||||
assert_grep 'multipart/form-data' landing/functions/api/upload.ts "upload endpoint keeps multipart compatibility"
|
||||
assert_grep 'sanitize\(raw\)' landing/functions/api/upload.ts "upload endpoint sanitizes before R2 put"
|
||||
assert_grep 'UPLOADS\.put' landing/functions/api/upload.ts "upload endpoint writes sanitized data to R2"
|
||||
assert_grep 'anonymous' landing/functions/api/upload.ts "upload endpoint supports anonymous direct upload path"
|
||||
assert_grep 'upload_rate_limits|RATE_LIMIT' landing/functions/api/upload.ts "anonymous upload path has rate limiting"
|
||||
assert_grep 'X-PUA-Upload-Consent' hooks/stop-feedback.sh "Stop hook directly uploads sanitized session after explicit consent"
|
||||
assert_not_grep 'GitHub login|登录后上传|contribute\.html 登录' hooks/stop-feedback.sh "Stop hook no longer requires GitHub login for session upload"
|
||||
assert_grep 'INSERT INTO uploads' landing/functions/api/upload.ts "upload endpoint records metadata in D1"
|
||||
|
||||
echo "========================="
|
||||
echo "Passed: $PASS"
|
||||
echo "Failed: $FAIL"
|
||||
echo "Total: $((PASS+FAIL))"
|
||||
echo "========================="
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/bin/bash
|
||||
# PUA silent heartbeat hook.
|
||||
# Contract: best-effort, no stdout/stderr, no additionalContext, no user-visible prompt text.
|
||||
|
||||
exec >/dev/null 2>&1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
source "${SCRIPT_DIR}/flavor-helper.sh" || exit 0
|
||||
|
||||
is_true() {
|
||||
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
||||
true|1|yes|on) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_false() {
|
||||
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
||||
false|0|no|off) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
CONFIG="$(pua_config_file)"
|
||||
OFFLINE="False"
|
||||
TELEMETRY="True"
|
||||
FEEDBACK_FREQUENCY=""
|
||||
FLAVOR="alibaba"
|
||||
|
||||
if [ -f "$CONFIG" ]; then
|
||||
OFFLINE="$(pua_json_get "$CONFIG" offline False)"
|
||||
TELEMETRY="$(pua_json_get "$CONFIG" telemetry True)"
|
||||
FEEDBACK_FREQUENCY="$(pua_json_get "$CONFIG" feedback_frequency "")"
|
||||
FLAVOR="$(pua_json_get "$CONFIG" flavor alibaba)"
|
||||
fi
|
||||
|
||||
# Normalize Chinese/alias flavor names through the shared flavor map. This keeps
|
||||
# Cloudflare stats segmented by canonical flavor ids (for example 微软 → microsoft).
|
||||
get_flavor >/dev/null 2>&1 || true
|
||||
FLAVOR="${PUA_FLAVOR:-$FLAVOR}"
|
||||
|
||||
# Privacy gates: offline/off/feedback_frequency=0 means no telemetry identity is created.
|
||||
is_true "$OFFLINE" && exit 0
|
||||
is_false "$TELEMETRY" && exit 0
|
||||
case "$FEEDBACK_FREQUENCY" in
|
||||
0|never|off|false|False) exit 0 ;;
|
||||
esac
|
||||
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
STATE_DIR="${PUA_STATE_DIR:-${HOME:-.}/.pua}"
|
||||
INSTALL_ID_FILE="${PUA_HEARTBEAT_ID_FILE:-${STATE_DIR}/install_id}"
|
||||
LAST_FILE="${PUA_HEARTBEAT_LAST_FILE:-${STATE_DIR}/.heartbeat_last}"
|
||||
mkdir -p "$STATE_DIR" || exit 0
|
||||
|
||||
now="$(date +%s 2>/dev/null || printf '0')"
|
||||
interval="${PUA_HEARTBEAT_INTERVAL_SECONDS:-21600}"
|
||||
case "$interval" in
|
||||
''|*[!0-9]*) interval=21600 ;;
|
||||
esac
|
||||
if [ -f "$LAST_FILE" ] && [ "$interval" -gt 0 ]; then
|
||||
last="$(cat "$LAST_FILE" 2>/dev/null || printf '0')"
|
||||
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
||||
if [ $((now - last)) -lt "$interval" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
new_install_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
elif [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
cat /proc/sys/kernel/random/uuid
|
||||
else
|
||||
printf 'pua-%s-%s-%s\n' "${now}" "$$" "$(od -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -s "$INSTALL_ID_FILE" ]; then
|
||||
umask 077
|
||||
new_install_id > "${INSTALL_ID_FILE}.tmp.$$" 2>/dev/null || exit 0
|
||||
mv "${INSTALL_ID_FILE}.tmp.$$" "$INSTALL_ID_FILE" 2>/dev/null || rm -f "${INSTALL_ID_FILE}.tmp.$$"
|
||||
fi
|
||||
|
||||
INSTALL_ID="$(cat "$INSTALL_ID_FILE" 2>/dev/null | head -n 1 | tr -cd '[:alnum:]_.:-' | cut -c1-128)"
|
||||
[ -n "$INSTALL_ID" ] || exit 0
|
||||
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/.." && pwd)}"
|
||||
PLUGIN_JSON="${PLUGIN_ROOT}/plugin.json"
|
||||
VERSION="unknown"
|
||||
PY="$(pua_python_cmd 2>/dev/null || true)"
|
||||
if [ -n "$PY" ] && [ -f "$PLUGIN_JSON" ]; then
|
||||
VERSION="$($PY -c 'import json,sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1], encoding="utf-8")).get("version", "unknown"))
|
||||
except Exception:
|
||||
print("unknown")' "$(pua_to_python_path "$PLUGIN_JSON")" 2>/dev/null || printf 'unknown')"
|
||||
fi
|
||||
|
||||
PLATFORM="${PUA_HEARTBEAT_PLATFORM:-claude-code}"
|
||||
EVENT_NAME="session_start"
|
||||
ENDPOINT="${PUA_HEARTBEAT_ENDPOINT:-https://pua-skill.pages.dev/api/heartbeat}"
|
||||
|
||||
if [ -n "$PY" ]; then
|
||||
PAYLOAD="$($PY -c 'import json,sys
|
||||
install_id,version,platform,event_name,flavor=sys.argv[1:6]
|
||||
print(json.dumps({
|
||||
"install_id": install_id,
|
||||
"plugin_version": version,
|
||||
"platform": platform,
|
||||
"event_name": event_name,
|
||||
"flavor": flavor,
|
||||
}, ensure_ascii=False, separators=(",", ":")))' "$INSTALL_ID" "$VERSION" "$PLATFORM" "$EVENT_NAME" "$FLAVOR" 2>/dev/null || true)"
|
||||
else
|
||||
PAYLOAD="{\"install_id\":\"${INSTALL_ID}\",\"plugin_version\":\"${VERSION}\",\"platform\":\"${PLATFORM}\",\"event_name\":\"${EVENT_NAME}\",\"flavor\":\"alibaba\"}"
|
||||
fi
|
||||
[ -n "$PAYLOAD" ] || exit 0
|
||||
|
||||
printf '%s\n' "$now" > "$LAST_FILE" 2>/dev/null || true
|
||||
|
||||
(
|
||||
curl -fsS --max-time 2 \
|
||||
-X POST "$ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: pua-skill-heartbeat/${VERSION}" \
|
||||
--data-binary "$PAYLOAD" \
|
||||
>/dev/null 2>&1
|
||||
) &
|
||||
|
||||
exit 0
|
||||
+1
-11
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"description": "PUA v3 hooks: optional context notes, failure-pattern reminders, local state continuity, and cleanup helpers.",
|
||||
"description": "PUA v3 hooks: optional context notes, failure-pattern reminders, local state continuity, and cleanup helpers. All hooks are local-only — no telemetry, no network requests.",
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
@@ -39,11 +39,6 @@
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.sh\"",
|
||||
"timeout": 2
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-restore.sh\"",
|
||||
@@ -54,11 +49,6 @@
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.sh\"",
|
||||
"timeout": 2
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-restore.sh\"",
|
||||
|
||||
+20
-67
@@ -1,6 +1,9 @@
|
||||
#!/bin/bash
|
||||
# PUA Stop hook: feedback collection + upload (always requires user consent)
|
||||
# PUA Stop hook: LOCAL-ONLY feedback collection (writes ~/.pua/feedback.jsonl)
|
||||
# Config: ~/.pua/config.json → feedback_frequency (0=off, 1=every, 3=default, 5=relaxed)
|
||||
#
|
||||
# This hook performs NO network requests. Session-transcript upload, rating
|
||||
# upload, heartbeat telemetry and leaderboard submission were all removed.
|
||||
|
||||
# Read hook input before anything else consumes stdin
|
||||
HOOK_INPUT=$(cat)
|
||||
@@ -55,16 +58,6 @@ echo "$count" > "$COUNTER"
|
||||
|
||||
[ $((count % FREQUENCY)) -ne 0 ] && exit 0
|
||||
|
||||
# Embed dynamic runtime values in the generated command itself; do not rendezvous
|
||||
# via /tmp because /tmp is world-writable and creates a TOCTOU attack surface.
|
||||
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
|
||||
_PLUGIN_ROOT="$CLAUDE_PLUGIN_ROOT"
|
||||
else
|
||||
_PLUGIN_ROOT=$(ls -td "${HOME:-~}/.claude/plugins/cache/pua-skills/pua/"*/ 2>/dev/null | head -1)
|
||||
_PLUGIN_ROOT="${_PLUGIN_ROOT%/}"
|
||||
: "${_PLUGIN_ROOT:=${HOME:-~}/.claude/plugins/pua}"
|
||||
fi
|
||||
|
||||
# Read actual flavor from config (reuse flavor-helper.sh)
|
||||
get_flavor
|
||||
_ACTUAL_FLAVOR="${PUA_FLAVOR:-alibaba}"
|
||||
@@ -79,80 +72,40 @@ _ACTUAL_FLAVOR="${PUA_FLAVOR:-alibaba}"
|
||||
IFS= read -r -d '' _feedback_text <<'FEEDBACK' || true
|
||||
IMPORTANT — BEFORE doing anything else, you MUST execute this feedback flow:
|
||||
|
||||
## Step 1: Ask for rating + upload consent (single question, two parts)
|
||||
## Step 1: Ask for a rating (LOCAL RECORD ONLY — no network)
|
||||
Call AskUserQuestion:
|
||||
questions: [{
|
||||
question: "任务结束了,顺手帮个忙?\n\nPUA Skill 是开源工具,靠真实使用数据持续改进。你的反馈——特别是脱敏后的对话记录——能帮助所有使用这个工具的开发者:了解哪些任务类型 PUA 最有效、哪些压力级别让 agent 坚持得更久。\n\n**如果选择上传 session,本地会先自动脱敏:**\n• 文件路径 → [PATH]\n• API 密钥 / Token(Anthropic、OpenAI、AWS、GitHub 等)→ [API_KEY]\n• 邮箱、IP、手机号 → [EMAIL] / [IP]\n• .env 配置内容、数据库连接串 → [REDACTED]\n• 所有高熵随机字符串(未知格式密钥)→ [HIGH_ENTROPY_SECRET]\n\n脱敏在本地完成,上传的是处理后的版本。保留的是对话文本和工具调用记录——这是分析 PUA 效果的核心。完全自愿,跳过完全没问题。",
|
||||
question: "任务结束了,顺手评个分?\n\n这条评分**只写入本机** `~/.pua/feedback.jsonl`,不会发送到任何服务器。PUA Skill 已移除全部联网上报功能——session 上传、评分上报、心跳 telemetry、排行榜提交都已删除。\n\n本地记录的用处:你自己可以回看哪类任务下 PUA 有效、压力升到过几级。完全自愿,跳过没问题。",
|
||||
header: "感谢使用 PUA Skill",
|
||||
options: [
|
||||
{label: "很有用 — 仅上传评分", description: "记录好评,不上传对话内容"},
|
||||
{label: "很有用 — 上传评分 + 脱敏 session", description: "最有帮助:让我们分析真实对话中 PUA 的效果和压力升级情况"},
|
||||
{label: "一般般 — 上传评分", description: "同样有价值,帮助发现 PUA 效果不理想的场景"},
|
||||
{label: "这次跳过", description: "不上传,写入本地 ~/.pua/feedback.jsonl。可在 ~/.pua/config.json 设 feedback_frequency:0 永久关闭此提示"}
|
||||
{label: "很有用", description: "写入本地 ~/.pua/feedback.jsonl"},
|
||||
{label: "一般般", description: "写入本地 ~/.pua/feedback.jsonl"},
|
||||
{label: "没感觉", description: "写入本地 ~/.pua/feedback.jsonl"},
|
||||
{label: "这次跳过", description: "不记录。可在 ~/.pua/config.json 设 feedback_frequency:0 永久关闭此提示"}
|
||||
],
|
||||
multiSelect: false
|
||||
}]
|
||||
|
||||
## Step 2: Execute based on user choice
|
||||
## Step 2: Append one line to the local file
|
||||
|
||||
If user chose "这次跳过":
|
||||
```bash
|
||||
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"rating\":\"跳过\",\"uploaded\":false}" >> ~/.pua/feedback.jsonl
|
||||
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"rating\":\"跳过\"}" >> ~/.pua/feedback.jsonl
|
||||
```
|
||||
|
||||
If user chose "很有用 — 仅上传评分":
|
||||
Otherwise (substitute RATING with the label the user picked, and fill in a brief task summary):
|
||||
```bash
|
||||
curl -s -X POST https://pua-skill.pages.dev/api/feedback \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"rating\":\"很有用\",\"pua_count\":0,\"flavor\":\"阿里\",\"task_summary\":\"brief task description\"}"
|
||||
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"rating\":\"RATING\",\"pua_count\":0,\"flavor\":\"阿里\",\"task_summary\":\"brief task description\"}" >> ~/.pua/feedback.jsonl
|
||||
```
|
||||
|
||||
If user chose "一般般 — 上传评分":
|
||||
```bash
|
||||
curl -s -X POST https://pua-skill.pages.dev/api/feedback \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"rating\":\"一般般\",\"pua_count\":0,\"flavor\":\"阿里\",\"task_summary\":\"brief task description\"}"
|
||||
```
|
||||
|
||||
If user chose "很有用 — 上传评分 + 脱敏 session":
|
||||
```bash
|
||||
curl -s -X POST https://pua-skill.pages.dev/api/feedback \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"rating\":\"很有用\",\"pua_count\":0,\"flavor\":\"阿里\",\"task_summary\":\"brief task description\"}"
|
||||
SANITIZED="/tmp/pua-sanitized-session.jsonl"
|
||||
bash "__PUA_PLUGIN_ROOT__/hooks/sanitize-session.sh" "__PUA_SESSION_PATH__" "$SANITIZED"
|
||||
UPLOAD_RESPONSE=$(curl -sS --max-time 30 -X POST https://pua-skill.pages.dev/api/upload \
|
||||
-H "Content-Type: application/jsonl; charset=utf-8" \
|
||||
-H "X-PUA-File-Name: $(basename "$SANITIZED")" \
|
||||
-H "X-PUA-Wechat-Id: not-provided" \
|
||||
-H "X-PUA-Upload-Consent: explicit" \
|
||||
--data-binary @"$SANITIZED")
|
||||
echo "脱敏 session 已匿名上传:$UPLOAD_RESPONSE"
|
||||
```
|
||||
|
||||
Do NOT upload anything without the user explicit choice. Call AskUserQuestion NOW.
|
||||
|
||||
## Step 3: Leaderboard auto-submit (if registered)
|
||||
## Note: Leaderboard registration/view/quit logic lives in pua:pro skill, NOT here.
|
||||
## This hook only handles the silent data submission for already-registered users.
|
||||
After handling feedback, check if user is registered for leaderboard:
|
||||
```bash
|
||||
LB_REGISTERED=$(python3 -c "import os,json; c=json.load(open(os.path.expanduser('~/.pua/config.json'))); print(c.get('leaderboard',{}).get('registered',False))" 2>/dev/null)
|
||||
```
|
||||
If LB_REGISTERED is "True":
|
||||
```bash
|
||||
LB_ID=$(python3 -c "import os,json; print(json.load(open(os.path.expanduser('~/.pua/config.json'))).get('leaderboard',{}).get('id',''))" 2>/dev/null)
|
||||
if [[ -z "$LB_ID" ]]; then exit 0; fi
|
||||
curl -s -X POST https://pua-skill.pages.dev/api/leaderboard \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"action\":\"submit\",\"id\":\"$LB_ID\",\"pua_count\":1,\"l3_plus_count\":0}"
|
||||
```
|
||||
This auto-submits silently — the user already opted in during `/pua 排行榜` registration.
|
||||
## Hard constraint — this flow is offline
|
||||
Appending that single line IS the entire flow. Do NOT send this data anywhere:
|
||||
no curl, no wget, no WebFetch, no POST to any endpoint, and never upload the
|
||||
session transcript. There is no server to receive it — the endpoints were removed.
|
||||
Call AskUserQuestion NOW.
|
||||
FEEDBACK
|
||||
|
||||
# Replace hardcoded flavor and plugin root with actual runtime values.
|
||||
_feedback_text="${_feedback_text//__PUA_PLUGIN_ROOT__/${_PLUGIN_ROOT}}"
|
||||
_feedback_text="${_feedback_text//__PUA_SESSION_PATH__/${TRANSCRIPT_PATH}}"
|
||||
# Replace the placeholder flavor with the actual configured flavor.
|
||||
_pua_pat='\"flavor\":\"阿里\"'
|
||||
_pua_rep='\"flavor\":\"'"${_ACTUAL_FLAVOR}"'\"'
|
||||
printf '%s\n' "${_feedback_text//${_pua_pat}/${_pua_rep}}"
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// Sanitization logic ported from hooks/sanitize-session.sh
|
||||
// Patterns are compiled ONCE at module scope — never re-compiled per request.
|
||||
// Applied to raw JSONL text (no JSON.parse overhead, ~5-10x faster than per-value walk).
|
||||
|
||||
// ── Layer 1: Known-format blacklist ───────────────────────────────────────────
|
||||
// Order matters: specific prefixes before generic ones (e.g. sk-ant- before sk-)
|
||||
const L1: [RegExp, string][] = [
|
||||
// File paths — quoted forms first, then unquoted
|
||||
[/"\/Users\/[^"\n]+"/g, '"[PATH]"'],
|
||||
[/\/Users\/[^\s\n"'`]+/g, '[PATH]'],
|
||||
[/"\/home\/[^"\n]+"/g, '"[PATH]"'],
|
||||
[/\/home\/[^\s\n"'`]+/g, '[PATH]'],
|
||||
[/\/root\/[^\s\n"'`]+/g, '[PATH]'],
|
||||
[/\/mnt\/[a-z]\/[^\s\n"'`]+/g, '[PATH]'],
|
||||
[/\/[a-z]\/(?:Users|home)\/[^\s\n"'`]+/g, '[PATH]'],
|
||||
[/[A-Za-z]:\\[^\s\n"]+/g, '[PATH]'],
|
||||
[/[A-Za-z]:\/[A-Za-z][^\s\n"]+/g, '[PATH]'],
|
||||
|
||||
// Anthropic / OpenAI — specific prefixes before generic sk-
|
||||
[/sk-ant-[a-zA-Z0-9_-]{20,}/g, '[API_KEY]'],
|
||||
[/sk-proj-[a-zA-Z0-9_-]{20,}/g, '[API_KEY]'],
|
||||
[/sk-[a-zA-Z0-9]{20,}/g, '[API_KEY]'],
|
||||
|
||||
// Stripe
|
||||
[/sk_(?:live|test)_[a-zA-Z0-9]{24,}/g, '[STRIPE_KEY]'],
|
||||
[/pk_(?:live|test)_[a-zA-Z0-9]{24,}/g, '[STRIPE_KEY]'],
|
||||
[/rk_(?:live|test)_[a-zA-Z0-9]{24,}/g, '[STRIPE_KEY]'],
|
||||
|
||||
// GitHub
|
||||
[/github_pat_[a-zA-Z0-9_]{40,}/g, '[GITHUB_TOKEN]'],
|
||||
[/gh[opsr]_[a-zA-Z0-9]{36}/g, '[GITHUB_TOKEN]'],
|
||||
|
||||
// AWS
|
||||
[/AKIA[A-Z0-9]{16}/g, '[AWS_KEY]'],
|
||||
|
||||
// International services
|
||||
[/glpat-[a-zA-Z0-9_-]{20}/g, '[GITLAB_TOKEN]'],
|
||||
[/xox[bpas]-[0-9A-Za-z-]+/g, '[SLACK_TOKEN]'],
|
||||
[/hf_[a-zA-Z0-9]{30,}/g, '[HF_TOKEN]'],
|
||||
[/npm_[a-zA-Z0-9]{36}/g, '[NPM_TOKEN]'],
|
||||
[/AIza[0-9A-Za-z_-]{35}/g, '[FIREBASE_KEY]'],
|
||||
[/SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/g, '[SENDGRID_KEY]'],
|
||||
[/AC[a-f0-9]{32}/g, '[TWILIO_SID]'],
|
||||
|
||||
// Chinese cloud
|
||||
[/LTAI[a-zA-Z0-9]{16,20}/g, '[ALIYUN_KEY]'],
|
||||
[/AKID[a-zA-Z0-9]{32,}/g, '[TENCENT_KEY]'],
|
||||
|
||||
// Auth tokens
|
||||
[/ya29\.[a-zA-Z0-9_-]{60,}/g, '[GOOGLE_OAUTH]'],
|
||||
[/eyJ[a-zA-Z0-9_-]{20,}/g, '[JWT]'],
|
||||
[/Bearer\s+[a-zA-Z0-9_./-]{10,}/g, '[BEARER_TOKEN]'],
|
||||
|
||||
// PEM private keys ([\s\S]*? handles both literal newlines and \\n in JSON strings)
|
||||
[/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/gs,
|
||||
'[PRIVATE_KEY]'],
|
||||
|
||||
// Database URLs
|
||||
[/(?:postgresql|mysql|mongodb(?:\+srv)?|redis|mssql):\/\/[^\s"']+/g, '[DB_URL]'],
|
||||
|
||||
// PII
|
||||
[/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, '[EMAIL]'],
|
||||
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '[IP]'],
|
||||
[/\b1[3-9]\d{9}\b/g, '[CN_PHONE]'],
|
||||
[/ssh-(?:rsa|ed25519|ecdsa)\s+\S+/g, '[SSH_KEY]'],
|
||||
|
||||
// Credentials in URLs (://user:pass@)
|
||||
[/:\/\/[^:"'\s]+:[^@"'\s]+@/g, '://[CRED]@'],
|
||||
|
||||
// Query-param credentials (?key=value)
|
||||
[/[?&](?:password|passwd|pwd|secret|token|api_?key)=[^&\s"']+/g, '[QUERYPARAM_CRED]'],
|
||||
]
|
||||
|
||||
// ── Layer 2: K=V context-aware redaction ──────────────────────────────────────
|
||||
// The _KV alternation is built as a string so we can compose it into multiple patterns.
|
||||
// Note: [_\\-] in the string becomes [_\-] in the regex (literal hyphen, escaped to be safe).
|
||||
const _KV =
|
||||
'(?:password|passwd|pwd|secret' +
|
||||
'|api[_\\-]?key|apikey|app[_\\-]?key' +
|
||||
'|access[_\\-]?key|secret[_\\-]?key|private[_\\-]?key' +
|
||||
'|auth[_\\-]?token|access[_\\-]?token|refresh[_\\-]?token' +
|
||||
'|client[_\\-]?secret|app[_\\-]?secret' +
|
||||
'|db[_\\-]?pass(?:word)?|database[_\\-]?(?:url|password)' +
|
||||
'|encryption[_\\-]?key|signing[_\\-]?key' +
|
||||
'|密钥|密码|口令)'
|
||||
|
||||
// Replacements use $1 (JS backreference) to preserve the key name.
|
||||
// gi = global + case-insensitive, matching Python's (?i) flag.
|
||||
const L2: [RegExp, string][] = [
|
||||
// KEY="VALUE" (env file double-quoted)
|
||||
[new RegExp(`(${_KV})\\s*=\\s*"([^"]{8,})"`, 'gi'), '$1="[REDACTED]"'],
|
||||
// KEY='VALUE' (env file single-quoted)
|
||||
[new RegExp(`(${_KV})\\s*=\\s*'([^']{8,})'`, 'gi'), "$1='[REDACTED]'"],
|
||||
// KEY=VALUE or KEY = VALUE (env file, shell, unquoted)
|
||||
[new RegExp(`(${_KV})\\s*=\\s*([^\\s\\n"']{8,})`, 'gi'), '$1=[REDACTED]'],
|
||||
// export KEY=VALUE (shell)
|
||||
[new RegExp(`export\\s+(${_KV})\\s*=\\s*([^\\s\\n"']{8,})`, 'gi'), 'export $1=[REDACTED]'],
|
||||
// "key": "value" (JSON)
|
||||
[new RegExp(`"(${_KV})"\\s*:\\s*"([^"]{8,})"`, 'gi'), '"$1": "[REDACTED]"'],
|
||||
// key: value (YAML unquoted)
|
||||
[new RegExp(`(${_KV})\\s*:\\s*([a-zA-Z0-9_\\-+/]{12,})`, 'gi'), '$1: [REDACTED]'],
|
||||
]
|
||||
|
||||
// ── Layer 3: Shannon entropy detection ───────────────────────────────────────
|
||||
// Flags 32+ char alphanumeric tokens with entropy > threshold.
|
||||
// Pure hex strings (git hashes, UUIDs) use a higher threshold to avoid false positives.
|
||||
const _ENTROPY_TOKEN_RE = /[A-Za-z0-9+\/=_\-]{32,}/g
|
||||
const _PURE_HEX_RE = /^[0-9a-f]+$/
|
||||
|
||||
function _entropy(s: string): number {
|
||||
const freq = new Map<string, number>()
|
||||
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1)
|
||||
const n = s.length
|
||||
let h = 0
|
||||
for (const count of freq.values()) {
|
||||
const p = count / n
|
||||
h -= p * Math.log2(p)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
function _redactEntropy(text: string): string {
|
||||
// .replace() with a global regex always starts from position 0 regardless of
|
||||
// the regex object's lastIndex — safe to use module-scope compiled regex here.
|
||||
return text.replace(_ENTROPY_TOKEN_RE, (m) => {
|
||||
// Pure hex strings (git hashes, UUIDs) have theoretical max entropy = log2(16) = 4.0;
|
||||
// use 4.1 so they are never flagged.
|
||||
const threshold = _PURE_HEX_RE.test(m) ? 4.1 : 3.5
|
||||
return _entropy(m) > threshold ? '[HIGH_ENTROPY_SECRET]' : m
|
||||
})
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitize a single line of raw text through all 3 layers.
|
||||
* Internal — exposed for testing.
|
||||
*/
|
||||
export function sanitizeLine(line: string): string {
|
||||
for (const [pat, rep] of L1) line = line.replace(pat, rep)
|
||||
for (const [pat, rep] of L2) line = line.replace(pat, rep)
|
||||
return _redactEntropy(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize raw JSONL text using the same 3-layer logic as sanitize-session.sh.
|
||||
*
|
||||
* Processes line-by-line so peak memory stays at ~2× single-line size rather
|
||||
* than ~2× entire-file size. Critical for files approaching the 50MB upload limit
|
||||
* under Cloudflare Workers' 128MB memory cap.
|
||||
*/
|
||||
export function sanitize(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map(sanitizeLine)
|
||||
.join("\n")
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Shared session utilities — HMAC-signed cookies
|
||||
// Cookie format: base64(payload).base64(hmac-sha256(payload, secret))
|
||||
|
||||
export interface SessionPayload {
|
||||
id: string
|
||||
login: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
SESSION_SECRET: string
|
||||
DB: D1Database
|
||||
UPLOADS?: R2Bucket
|
||||
GITHUB_CLIENT_ID?: string
|
||||
GITHUB_CLIENT_SECRET?: string
|
||||
}
|
||||
|
||||
async function hmacSign(payload: string, secret: string): Promise<string> {
|
||||
const enc = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
|
||||
)
|
||||
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(payload))
|
||||
return btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
}
|
||||
|
||||
async function hmacVerify(payload: string, signature: string, secret: string): Promise<boolean> {
|
||||
const expected = await hmacSign(payload, secret)
|
||||
return expected === signature
|
||||
}
|
||||
|
||||
export async function createSessionCookie(data: SessionPayload, secret: string): Promise<string> {
|
||||
const payload = btoa(JSON.stringify(data))
|
||||
const sig = await hmacSign(payload, secret)
|
||||
return `${payload}.${sig}`
|
||||
}
|
||||
|
||||
export async function getSession(request: Request, secret: string): Promise<SessionPayload | null> {
|
||||
const cookie = request.headers.get("Cookie") || ""
|
||||
const match = cookie.match(/pua_session=([^;]+)/)
|
||||
if (!match) return null
|
||||
|
||||
const parts = match[1].split(".")
|
||||
if (parts.length !== 2) return null
|
||||
|
||||
const [payload, sig] = parts
|
||||
const valid = await hmacVerify(payload, sig, secret)
|
||||
if (!valid) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(atob(payload)) as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createSessionCookie } from "../_session"
|
||||
|
||||
interface Env {
|
||||
GITHUB_CLIENT_ID: string
|
||||
GITHUB_CLIENT_SECRET: string
|
||||
SESSION_SECRET: string
|
||||
}
|
||||
|
||||
export const onRequestGet: PagesFunction<Env> = async ({ env, request }) => {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get("code")
|
||||
if (!code) {
|
||||
return new Response("Missing code", { status: 400 })
|
||||
}
|
||||
|
||||
// Exchange code for access token
|
||||
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: env.GITHUB_CLIENT_ID,
|
||||
client_secret: env.GITHUB_CLIENT_SECRET,
|
||||
code,
|
||||
}),
|
||||
})
|
||||
|
||||
const tokenData = (await tokenRes.json()) as { access_token?: string; error?: string }
|
||||
if (!tokenData.access_token) {
|
||||
return new Response("OAuth failed: " + (tokenData.error || "unknown"), { status: 400 })
|
||||
}
|
||||
|
||||
// Get user info
|
||||
const userRes = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
"User-Agent": "pua-skill-landing",
|
||||
},
|
||||
})
|
||||
const user = (await userRes.json()) as { id: number; login: string; avatar_url: string }
|
||||
|
||||
// Create HMAC-signed session cookie (no token stored in cookie)
|
||||
const session = await createSessionCookie(
|
||||
{ id: String(user.id), login: user.login, avatar: user.avatar_url },
|
||||
env.SESSION_SECRET
|
||||
)
|
||||
|
||||
const redirectPage = `<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8"><title>Redirecting...</title>
|
||||
<script>window.location.replace("https://openpua.ai/contribute.html");</script>
|
||||
</head><body><p>Redirecting... <a href="https://openpua.ai/contribute.html">Click here</a></p></body></html>`
|
||||
|
||||
return new Response(redirectPage, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Set-Cookie": `pua_session=${session}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
interface Env {
|
||||
GITHUB_CLIENT_ID: string
|
||||
}
|
||||
|
||||
export const onRequestGet: PagesFunction<Env> = async ({ env, request }) => {
|
||||
const origin = "https://openpua.ai"
|
||||
const redirectUri = `${origin}/api/auth/callback`
|
||||
const githubUrl = new URL("https://github.com/login/oauth/authorize")
|
||||
githubUrl.searchParams.set("client_id", env.GITHUB_CLIENT_ID)
|
||||
githubUrl.searchParams.set("redirect_uri", redirectUri)
|
||||
githubUrl.searchParams.set("scope", "read:user")
|
||||
const target = githubUrl.toString()
|
||||
return new Response(
|
||||
`<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url=${target}"><title>Redirecting...</title></head><body><a href="${target}">Click here if not redirected</a></body></html>`,
|
||||
{ status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export const onRequest: PagesFunction = async () => {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/contribute.html",
|
||||
"Set-Cookie": "pua_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { getSession } from "./_session"
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
SESSION_SECRET: string
|
||||
}
|
||||
|
||||
type FeedbackBody = {
|
||||
rating?: string
|
||||
task_summary?: string
|
||||
pua_level?: string
|
||||
pua_count?: number
|
||||
flavor?: string
|
||||
session_data?: string
|
||||
failure_count?: number
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 64 * 1024
|
||||
const MAX_SESSION_DATA_BYTES = 32 * 1024
|
||||
const MAX_RATING_BYTES = 128
|
||||
const MAX_TASK_SUMMARY_BYTES = 2048
|
||||
const MAX_FLAVOR_BYTES = 128
|
||||
const RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
const RATE_LIMIT_MAX_WRITES = 20
|
||||
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
"https://openpua.ai",
|
||||
"https://www.openpua.ai",
|
||||
"https://pua-skill.pages.dev",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
])
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
function corsHeadersFor(request: Request) {
|
||||
const origin = request.headers.get("Origin") || ""
|
||||
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : "*"
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowedOrigin,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
}
|
||||
|
||||
function json(data: unknown, init: ResponseInit = {}, request?: Request): Response {
|
||||
return Response.json(data, { ...init, headers: { ...(request ? corsHeadersFor(request) : {}), ...(init.headers || {}) } })
|
||||
}
|
||||
|
||||
function originAllowed(request: Request): boolean {
|
||||
const origin = request.headers.get("Origin")
|
||||
return !origin || ALLOWED_ORIGINS.has(origin)
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
async function rateLimitKey(request: Request): Promise<string> {
|
||||
const ip = request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "unknown"
|
||||
const ua = request.headers.get("User-Agent") || "unknown"
|
||||
return sha256Hex(`${ip}|${ua}|feedback-v1`)
|
||||
}
|
||||
|
||||
async function ensureRateLimitTable(env: Env): Promise<void> {
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS feedback_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
)
|
||||
`).run()
|
||||
}
|
||||
|
||||
async function checkRateLimit(request: Request, env: Env): Promise<boolean> {
|
||||
await ensureRateLimitTable(env)
|
||||
const key = await rateLimitKey(request)
|
||||
const windowStart = Math.floor(Date.now() / (RATE_LIMIT_WINDOW_SECONDS * 1000)) * RATE_LIMIT_WINDOW_SECONDS
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO feedback_rate_limits (key, window_start, count, updated_at)
|
||||
VALUES (?, ?, 1, datetime('now'))
|
||||
ON CONFLICT(key, window_start)
|
||||
DO UPDATE SET count = count + 1, updated_at = datetime('now')
|
||||
`).bind(key, windowStart).run()
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT count FROM feedback_rate_limits WHERE key = ? AND window_start = ?"
|
||||
).bind(key, windowStart).first<{ count: number }>()
|
||||
return (row?.count || 0) <= RATE_LIMIT_MAX_WRITES
|
||||
}
|
||||
|
||||
function cleanText(value: unknown, maxBytes: number): string | null {
|
||||
if (value === undefined || value === null) return null
|
||||
const text = String(value).trim()
|
||||
if (!text) return null
|
||||
if (byteLength(text) > maxBytes) throw new Error(`field too large; max ${maxBytes} bytes`)
|
||||
return text
|
||||
}
|
||||
|
||||
function cleanNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n < 0) return fallback
|
||||
return Math.min(1000000, Math.floor(n))
|
||||
}
|
||||
|
||||
function validateFeedbackBody(body: FeedbackBody) {
|
||||
const rating = cleanText(body.rating, MAX_RATING_BYTES)
|
||||
if (!rating) throw new Error("rating is required")
|
||||
const sessionData = cleanText(body.session_data, MAX_SESSION_DATA_BYTES)
|
||||
return {
|
||||
rating,
|
||||
task_summary: cleanText(body.task_summary, MAX_TASK_SUMMARY_BYTES),
|
||||
pua_level: cleanText(body.pua_level, 16) || "L0",
|
||||
pua_count: cleanNumber(body.pua_count, 0),
|
||||
flavor: cleanText(body.flavor, MAX_FLAVOR_BYTES) || "阿里",
|
||||
session_data: sessionData,
|
||||
failure_count: cleanNumber(body.failure_count, 0),
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async ({ request, env }) => {
|
||||
const corsHeaders = corsHeadersFor(request)
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeaders })
|
||||
}
|
||||
|
||||
// Detect POST: method=POST or has JSON Content-Type (custom domain may rewrite method)
|
||||
const hasJsonContentType = request.headers.get("content-type")?.includes("application/json")
|
||||
const isPost = request.method === "POST" || hasJsonContentType
|
||||
|
||||
if (isPost) {
|
||||
if (!originAllowed(request)) {
|
||||
return json({ error: "Origin not allowed" }, { status: 403 }, request)
|
||||
}
|
||||
|
||||
const contentLength = Number(request.headers.get("content-length") || 0)
|
||||
if (contentLength > MAX_BODY_BYTES) {
|
||||
return json({ error: `Request body too large (max ${MAX_BODY_BYTES} bytes)` }, { status: 413 }, request)
|
||||
}
|
||||
|
||||
let bodyText: string | null = null
|
||||
try { bodyText = await request.text() } catch {}
|
||||
|
||||
if (bodyText && bodyText.length > 2) {
|
||||
if (byteLength(bodyText) > MAX_BODY_BYTES) {
|
||||
return json({ error: `Request body too large (max ${MAX_BODY_BYTES} bytes)` }, { status: 413 }, request)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = validateFeedbackBody(JSON.parse(bodyText) as FeedbackBody)
|
||||
const allowed = await checkRateLimit(request, env)
|
||||
if (!allowed) {
|
||||
return json({ error: "Rate limit exceeded" }, { status: 429 }, request)
|
||||
}
|
||||
|
||||
if (body.session_data) {
|
||||
if (!env.SESSION_SECRET) {
|
||||
return json({ error: "Session upload is temporarily unavailable" }, { status: 500 }, request)
|
||||
}
|
||||
const session = await getSession(request, env.SESSION_SECRET)
|
||||
if (!session) {
|
||||
return json({ error: "Login required for session upload" }, { status: 401 }, request)
|
||||
}
|
||||
}
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO feedback (rating, task_summary, pua_level, pua_count, flavor, session_data, failure_count, ip_country)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
body.rating,
|
||||
body.task_summary,
|
||||
body.pua_level,
|
||||
body.pua_count,
|
||||
body.flavor,
|
||||
body.session_data,
|
||||
body.failure_count,
|
||||
request.headers.get("CF-IPCountry") || "unknown"
|
||||
)
|
||||
.run()
|
||||
|
||||
return json({ ok: true }, { headers: corsHeaders }, request)
|
||||
} catch (e) {
|
||||
const message = String((e as Error).message || e)
|
||||
const status = /too large/.test(message) ? 413 : (/required|Unexpected token|JSON/.test(message) ? 400 : 500)
|
||||
return json(
|
||||
{ error: status === 500 ? "Failed to save feedback" : message },
|
||||
{ status },
|
||||
request
|
||||
)
|
||||
}
|
||||
}
|
||||
// Body was empty/stripped — fall through to GET
|
||||
}
|
||||
|
||||
// GET: aggregate stats
|
||||
const stats = await env.DB.prepare(
|
||||
`SELECT rating, COUNT(*) as count, AVG(pua_count) as avg_pua_count
|
||||
FROM feedback GROUP BY rating ORDER BY count DESC`
|
||||
).all()
|
||||
|
||||
const total = await env.DB.prepare(
|
||||
"SELECT COUNT(*) as total FROM feedback"
|
||||
).first<{ total: number }>()
|
||||
|
||||
return json({
|
||||
total_feedback: total?.total || 0,
|
||||
by_rating: stats.results,
|
||||
}, { headers: corsHeaders }, request)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { getSession } from "./_session"
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
SESSION_SECRET: string
|
||||
ADMIN_GITHUB_LOGINS?: string
|
||||
}
|
||||
|
||||
type HeartbeatBody = {
|
||||
install_id?: string
|
||||
plugin_version?: string
|
||||
platform?: string
|
||||
event_name?: string
|
||||
flavor?: string
|
||||
}
|
||||
|
||||
type CountRow = {
|
||||
total_installs: number
|
||||
active_24h: number
|
||||
active_7d: number
|
||||
active_30d: number
|
||||
sessions_24h: number
|
||||
sessions_7d: number
|
||||
sessions_30d: number
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 4 * 1024
|
||||
const MAX_INSTALL_ID_BYTES = 128
|
||||
const RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
const RATE_LIMIT_MAX_WRITES = 120
|
||||
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
"https://openpua.ai",
|
||||
"https://www.openpua.ai",
|
||||
"https://pua-skill.pages.dev",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
])
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
function corsHeadersFor(request: Request) {
|
||||
const origin = request.headers.get("Origin") || ""
|
||||
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : "*"
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowedOrigin,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
}
|
||||
|
||||
function originAllowed(request: Request): boolean {
|
||||
const origin = request.headers.get("Origin")
|
||||
return !origin || ALLOWED_ORIGINS.has(origin)
|
||||
}
|
||||
|
||||
function json(data: unknown, init: ResponseInit = {}, request?: Request): Response {
|
||||
return Response.json(data, { ...init, headers: { ...(request ? corsHeadersFor(request) : {}), ...(init.headers || {}) } })
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function cleanText(value: unknown, maxBytes: number, fallback = ""): string {
|
||||
if (value === undefined || value === null) return fallback
|
||||
const text = String(value).trim()
|
||||
if (!text) return fallback
|
||||
if (byteLength(text) > maxBytes) throw new Error(`field too large; max ${maxBytes} bytes`)
|
||||
return text
|
||||
}
|
||||
|
||||
function cleanEnumText(value: unknown, maxBytes: number, fallback: string): string {
|
||||
const text = cleanText(value, maxBytes, fallback)
|
||||
const normalized = text.replace(/[^a-zA-Z0-9_.:/@ -]/g, "").trim().slice(0, maxBytes)
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
function validateHeartbeatBody(body: HeartbeatBody) {
|
||||
const installId = cleanText(body.install_id, MAX_INSTALL_ID_BYTES)
|
||||
if (!installId) throw new Error("install_id is required")
|
||||
return {
|
||||
install_id: installId,
|
||||
plugin_version: cleanEnumText(body.plugin_version, 48, "unknown"),
|
||||
platform: cleanEnumText(body.platform, 64, "claude-code"),
|
||||
event_name: cleanEnumText(body.event_name, 64, "session_start") || "session_start",
|
||||
flavor: cleanEnumText(body.flavor, 64, "unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
async function rateLimitKey(request: Request): Promise<string> {
|
||||
const ip = request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "unknown"
|
||||
const ua = request.headers.get("User-Agent") || "unknown"
|
||||
return sha256Hex(`${ip}|${ua}|heartbeat-v1`)
|
||||
}
|
||||
|
||||
async function ensureHeartbeatTables(env: Env): Promise<void> {
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_installs (
|
||||
install_id_hash TEXT PRIMARY KEY,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
session_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_plugin_version TEXT,
|
||||
last_platform TEXT,
|
||||
last_flavor TEXT,
|
||||
last_ip_country TEXT,
|
||||
last_user_agent_hash TEXT
|
||||
)
|
||||
`).run()
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
install_id_hash TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL DEFAULT 'session_start',
|
||||
plugin_version TEXT,
|
||||
platform TEXT,
|
||||
flavor TEXT,
|
||||
ip_country TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`).run()
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
)
|
||||
`).run()
|
||||
}
|
||||
|
||||
async function checkRateLimit(request: Request, env: Env): Promise<boolean> {
|
||||
await ensureHeartbeatTables(env)
|
||||
const key = await rateLimitKey(request)
|
||||
const windowStart = Math.floor(Date.now() / (RATE_LIMIT_WINDOW_SECONDS * 1000)) * RATE_LIMIT_WINDOW_SECONDS
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO heartbeat_rate_limits (key, window_start, count, updated_at)
|
||||
VALUES (?, ?, 1, datetime('now'))
|
||||
ON CONFLICT(key, window_start)
|
||||
DO UPDATE SET count = count + 1, updated_at = datetime('now')
|
||||
`).bind(key, windowStart).run()
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT count FROM heartbeat_rate_limits WHERE key = ? AND window_start = ?"
|
||||
).bind(key, windowStart).first<{ count: number }>()
|
||||
await env.DB.prepare(
|
||||
"DELETE FROM heartbeat_rate_limits WHERE window_start < ?"
|
||||
).bind(windowStart - RATE_LIMIT_WINDOW_SECONDS * 60).run()
|
||||
return (row?.count || 0) <= RATE_LIMIT_MAX_WRITES
|
||||
}
|
||||
|
||||
function adminLogins(env: Env): Set<string> {
|
||||
const configured = (env.ADMIN_GITHUB_LOGINS || "tanweai,xsser")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return new Set(configured)
|
||||
}
|
||||
|
||||
async function handlePost(request: Request, env: Env): Promise<Response> {
|
||||
if (!originAllowed(request)) {
|
||||
return json({ error: "Origin not allowed" }, { status: 403 }, request)
|
||||
}
|
||||
|
||||
const contentLength = Number(request.headers.get("content-length") || 0)
|
||||
if (contentLength > MAX_BODY_BYTES) {
|
||||
return json({ error: `Request body too large (max ${MAX_BODY_BYTES} bytes)` }, { status: 413 }, request)
|
||||
}
|
||||
|
||||
let bodyText = ""
|
||||
try { bodyText = await request.text() } catch {}
|
||||
if (!bodyText) return json({ error: "JSON body required" }, { status: 400 }, request)
|
||||
if (byteLength(bodyText) > MAX_BODY_BYTES) {
|
||||
return json({ error: `Request body too large (max ${MAX_BODY_BYTES} bytes)` }, { status: 413 }, request)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = validateHeartbeatBody(JSON.parse(bodyText) as HeartbeatBody)
|
||||
const allowed = await checkRateLimit(request, env)
|
||||
if (!allowed) return json({ error: "Rate limit exceeded" }, { status: 429 }, request)
|
||||
|
||||
const installIdHash = await sha256Hex(`pua-install:${body.install_id}`)
|
||||
const userAgent = request.headers.get("User-Agent") || "unknown"
|
||||
const userAgentHash = await sha256Hex(`ua:${userAgent}`)
|
||||
const ipCountry = request.headers.get("CF-IPCountry") || "unknown"
|
||||
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO heartbeat_installs (
|
||||
install_id_hash, first_seen, last_seen, session_count,
|
||||
last_plugin_version, last_platform, last_flavor, last_ip_country, last_user_agent_hash
|
||||
) VALUES (?, datetime('now'), datetime('now'), 1, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(install_id_hash)
|
||||
DO UPDATE SET
|
||||
last_seen = datetime('now'),
|
||||
session_count = session_count + 1,
|
||||
last_plugin_version = excluded.last_plugin_version,
|
||||
last_platform = excluded.last_platform,
|
||||
last_flavor = excluded.last_flavor,
|
||||
last_ip_country = excluded.last_ip_country,
|
||||
last_user_agent_hash = excluded.last_user_agent_hash
|
||||
`).bind(
|
||||
installIdHash,
|
||||
body.plugin_version,
|
||||
body.platform,
|
||||
body.flavor,
|
||||
ipCountry,
|
||||
userAgentHash
|
||||
).run()
|
||||
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO heartbeat_events (install_id_hash, event_name, plugin_version, platform, flavor, ip_country, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`).bind(
|
||||
installIdHash,
|
||||
body.event_name,
|
||||
body.plugin_version,
|
||||
body.platform,
|
||||
body.flavor,
|
||||
ipCountry
|
||||
).run()
|
||||
|
||||
return json({ ok: true }, {}, request)
|
||||
} catch (e) {
|
||||
const message = String((e as Error).message || e)
|
||||
const status = /too large/.test(message) ? 413 : (/required|Unexpected token|JSON/.test(message) ? 400 : 500)
|
||||
return json({ error: status === 500 ? "Failed to save heartbeat" : message }, { status }, request)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGet(request: Request, env: Env): Promise<Response> {
|
||||
if (!env.SESSION_SECRET) {
|
||||
return json({ error: "Admin session is not configured" }, { status: 500 }, request)
|
||||
}
|
||||
const session = await getSession(request, env.SESSION_SECRET)
|
||||
if (!session) return json({ error: "Login required" }, { status: 401 }, request)
|
||||
if (!adminLogins(env).has(session.login.toLowerCase())) {
|
||||
return json({ error: "Admin access required" }, { status: 403 }, request)
|
||||
}
|
||||
|
||||
await ensureHeartbeatTables(env)
|
||||
const totals = await env.DB.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM heartbeat_installs) AS total_installs,
|
||||
(SELECT COUNT(*) FROM heartbeat_installs WHERE last_seen >= datetime('now', '-1 day')) AS active_24h,
|
||||
(SELECT COUNT(*) FROM heartbeat_installs WHERE last_seen >= datetime('now', '-7 days')) AS active_7d,
|
||||
(SELECT COUNT(*) FROM heartbeat_installs WHERE last_seen >= datetime('now', '-30 days')) AS active_30d,
|
||||
(SELECT COUNT(*) FROM heartbeat_events WHERE created_at >= datetime('now', '-1 day')) AS sessions_24h,
|
||||
(SELECT COUNT(*) FROM heartbeat_events WHERE created_at >= datetime('now', '-7 days')) AS sessions_7d,
|
||||
(SELECT COUNT(*) FROM heartbeat_events WHERE created_at >= datetime('now', '-30 days')) AS sessions_30d
|
||||
`).first<CountRow>()
|
||||
|
||||
const daily = await env.DB.prepare(`
|
||||
SELECT date(created_at) AS day, COUNT(DISTINCT install_id_hash) AS active_users, COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
`).all()
|
||||
|
||||
const byVersion = await env.DB.prepare(`
|
||||
SELECT COALESCE(plugin_version, 'unknown') AS plugin_version,
|
||||
COUNT(DISTINCT install_id_hash) AS active_users,
|
||||
COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY plugin_version
|
||||
ORDER BY active_users DESC, sessions DESC
|
||||
LIMIT 20
|
||||
`).all()
|
||||
|
||||
const byPlatform = await env.DB.prepare(`
|
||||
SELECT COALESCE(platform, 'unknown') AS platform,
|
||||
COUNT(DISTINCT install_id_hash) AS active_users,
|
||||
COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY platform
|
||||
ORDER BY active_users DESC, sessions DESC
|
||||
LIMIT 20
|
||||
`).all()
|
||||
|
||||
const byFlavor = await env.DB.prepare(`
|
||||
SELECT COALESCE(flavor, 'unknown') AS flavor,
|
||||
COUNT(DISTINCT install_id_hash) AS active_users,
|
||||
COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY flavor
|
||||
ORDER BY active_users DESC, sessions DESC
|
||||
LIMIT 20
|
||||
`).all()
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
generated_at: new Date().toISOString(),
|
||||
viewer: { login: session.login, avatar: session.avatar },
|
||||
totals: totals || {
|
||||
total_installs: 0,
|
||||
active_24h: 0,
|
||||
active_7d: 0,
|
||||
active_30d: 0,
|
||||
sessions_24h: 0,
|
||||
sessions_7d: 0,
|
||||
sessions_30d: 0,
|
||||
},
|
||||
daily: daily.results,
|
||||
by_version: byVersion.results,
|
||||
by_platform: byPlatform.results,
|
||||
by_flavor: byFlavor.results,
|
||||
}, {}, request)
|
||||
}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async ({ request, env }) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeadersFor(request) })
|
||||
}
|
||||
if (request.method === "POST") return handlePost(request, env)
|
||||
if (request.method === "GET") return handleGet(request, env)
|
||||
return json({ error: "Method not allowed" }, { status: 405 }, request)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
}
|
||||
|
||||
interface LeaderboardSubmission {
|
||||
id: string
|
||||
email: string
|
||||
phone?: string
|
||||
display_name: string
|
||||
pua_count: number
|
||||
l3_plus_count: number
|
||||
flavor?: string
|
||||
action?: string // "register" | "submit" | "delete" | "quit"
|
||||
}
|
||||
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split("@")
|
||||
if (!domain) return email
|
||||
const domainParts = domain.split(".")
|
||||
return `${local[0]}***@${domainParts[0][0]}*.${domainParts.slice(1).join(".")}`
|
||||
}
|
||||
|
||||
function calcRank(pua: number, l3rate: number, streak: number): { level: string; title_zh: string; title_en: string } {
|
||||
if (pua >= 200 && l3rate >= 0.4 && streak >= 30) return { level: "P10", title_zh: "首席 PUA 官", title_en: "Chief PUA Officer" }
|
||||
if (pua >= 100 && l3rate >= 0.3 && streak >= 14) return { level: "P9", title_zh: "PUA Tech Lead", title_en: "PUA Tech Lead" }
|
||||
if (pua >= 50 && l3rate >= 0.2) return { level: "P8", title_zh: "PUA 主管", title_en: "PUA Manager" }
|
||||
if (pua >= 20 && l3rate >= 0.1) return { level: "P7", title_zh: "PUA 骨干", title_en: "PUA Senior" }
|
||||
if (pua >= 5) return { level: "P6", title_zh: "PUA 专员", title_en: "PUA Specialist" }
|
||||
return { level: "P5", title_zh: "PUA 实习生", title_en: "PUA Intern" }
|
||||
}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async ({ request, env }) => {
|
||||
const cors = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
}
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { headers: cors })
|
||||
}
|
||||
|
||||
// Ensure table exists
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS leaderboard (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
pua_count INTEGER DEFAULT 0,
|
||||
l3_plus_count INTEGER DEFAULT 0,
|
||||
total_sessions INTEGER DEFAULT 0,
|
||||
streak_days INTEGER DEFAULT 0,
|
||||
flavors_used TEXT DEFAULT '[]',
|
||||
max_session_pua INTEGER DEFAULT 0,
|
||||
last_active TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`).run()
|
||||
|
||||
// POST: register or submit data
|
||||
if (request.method === "POST") {
|
||||
try {
|
||||
const body = (await request.json()) as LeaderboardSubmission
|
||||
|
||||
if (body.action === "register") {
|
||||
if (!body.email || !body.id) {
|
||||
return Response.json({ error: "email and id are required" }, { status: 400, headers: cors })
|
||||
}
|
||||
const displayName = maskEmail(body.email)
|
||||
await env.DB.prepare(
|
||||
`INSERT OR REPLACE INTO leaderboard (id, email, phone, display_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))`
|
||||
).bind(body.id, body.email, body.phone || null, displayName).run()
|
||||
|
||||
return Response.json({ ok: true, display_name: displayName, id: body.id }, { headers: cors })
|
||||
}
|
||||
|
||||
if (body.action === "submit") {
|
||||
if (!body.id) {
|
||||
return Response.json({ error: "id is required" }, { status: 400, headers: cors })
|
||||
}
|
||||
// Update cumulative stats
|
||||
await env.DB.prepare(`
|
||||
UPDATE leaderboard SET
|
||||
pua_count = pua_count + ?,
|
||||
l3_plus_count = l3_plus_count + ?,
|
||||
total_sessions = total_sessions + 1,
|
||||
last_active = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).bind(body.pua_count || 0, body.l3_plus_count || 0, body.id).run()
|
||||
|
||||
return Response.json({ ok: true }, { headers: cors })
|
||||
}
|
||||
|
||||
if (body.action === "quit" || body.action === "delete") {
|
||||
if (!body.id) {
|
||||
return Response.json({ error: "id is required" }, { status: 400, headers: cors })
|
||||
}
|
||||
await env.DB.prepare("DELETE FROM leaderboard WHERE id = ?").bind(body.id).run()
|
||||
return Response.json({ ok: true, deleted: true }, { headers: cors })
|
||||
}
|
||||
|
||||
return Response.json({ error: "unknown action" }, { status: 400, headers: cors })
|
||||
} catch (e) {
|
||||
return Response.json({ error: String(e) }, { status: 500, headers: cors })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: remove user
|
||||
if (request.method === "DELETE") {
|
||||
const url = new URL(request.url)
|
||||
const id = url.searchParams.get("id")
|
||||
if (!id) return Response.json({ error: "id required" }, { status: 400, headers: cors })
|
||||
await env.DB.prepare("DELETE FROM leaderboard WHERE id = ?").bind(id).run()
|
||||
return Response.json({ ok: true }, { headers: cors })
|
||||
}
|
||||
|
||||
// GET: return leaderboard
|
||||
const url = new URL(request.url)
|
||||
const userId = url.searchParams.get("id")
|
||||
const period = url.searchParams.get("period") || "all" // all, week, month
|
||||
|
||||
// Top 20
|
||||
const top = await env.DB.prepare(
|
||||
`SELECT id, display_name, pua_count, l3_plus_count, total_sessions, streak_days, last_active
|
||||
FROM leaderboard ORDER BY pua_count DESC LIMIT 20`
|
||||
).all()
|
||||
|
||||
// Total stats
|
||||
const stats = await env.DB.prepare(
|
||||
`SELECT COUNT(*) as total_users, SUM(pua_count) as total_pua, AVG(pua_count) as avg_pua
|
||||
FROM leaderboard`
|
||||
).first<{ total_users: number; total_pua: number; avg_pua: number }>()
|
||||
|
||||
// Enrich with ranks
|
||||
const rankings = top.results.map((row: any, i: number) => {
|
||||
const l3rate = row.pua_count > 0 ? row.l3_plus_count / row.pua_count : 0
|
||||
const rank = calcRank(row.pua_count, l3rate, row.streak_days)
|
||||
return {
|
||||
position: i + 1,
|
||||
display_name: row.display_name,
|
||||
pua_count: row.pua_count,
|
||||
l3_plus_rate: Math.round(l3rate * 100),
|
||||
streak_days: row.streak_days,
|
||||
total_sessions: row.total_sessions,
|
||||
level: rank.level,
|
||||
title_zh: rank.title_zh,
|
||||
title_en: rank.title_en,
|
||||
}
|
||||
})
|
||||
|
||||
// User's own ranking
|
||||
let me = null
|
||||
if (userId) {
|
||||
const myRow = await env.DB.prepare(
|
||||
"SELECT *, (SELECT COUNT(*) + 1 FROM leaderboard b WHERE b.pua_count > a.pua_count) as position FROM leaderboard a WHERE id = ?"
|
||||
).bind(userId).first()
|
||||
if (myRow) {
|
||||
const l3rate = (myRow.pua_count as number) > 0 ? (myRow.l3_plus_count as number) / (myRow.pua_count as number) : 0
|
||||
const rank = calcRank(myRow.pua_count as number, l3rate, myRow.streak_days as number)
|
||||
me = {
|
||||
position: myRow.position,
|
||||
display_name: myRow.display_name,
|
||||
pua_count: myRow.pua_count,
|
||||
l3_plus_rate: Math.round(l3rate * 100),
|
||||
streak_days: myRow.streak_days,
|
||||
level: rank.level,
|
||||
title_zh: rank.title_zh,
|
||||
title_en: rank.title_en,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
total_users: stats?.total_users || 0,
|
||||
total_pua: stats?.total_pua || 0,
|
||||
avg_pua: Math.round(stats?.avg_pua || 0),
|
||||
rankings,
|
||||
me,
|
||||
}, { headers: cors })
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { getSession } from "./_session"
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
SESSION_SECRET: string
|
||||
}
|
||||
|
||||
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
|
||||
const session = await getSession(request, env.SESSION_SECRET)
|
||||
if (!session) {
|
||||
return Response.json({ logged_in: false }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get upload count for this user
|
||||
const result = await env.DB.prepare(
|
||||
"SELECT COUNT(*) as count FROM uploads WHERE github_id = ?"
|
||||
).bind(session.id).first<{ count: number }>()
|
||||
|
||||
return Response.json({
|
||||
logged_in: true,
|
||||
id: session.id,
|
||||
login: session.login,
|
||||
avatar: session.avatar,
|
||||
upload_count: result?.count || 0,
|
||||
})
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { getSession } from "./_session"
|
||||
import { sanitize } from "./_sanitize"
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
UPLOADS: R2Bucket
|
||||
SESSION_SECRET: string
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
function decodeHeaderValue(value: string | null, fieldName: string, fallback?: string): string {
|
||||
if (!value) {
|
||||
if (fallback !== undefined) return fallback
|
||||
throw new Error(`${fieldName} is required`)
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value).trim()
|
||||
} catch {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function safeFileName(value: string): string {
|
||||
const base = value.split(/[\/]/).pop() || "session.jsonl"
|
||||
const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160)
|
||||
return cleaned.endsWith(".jsonl") ? cleaned : "session.jsonl"
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60
|
||||
const RATE_LIMIT_MAX_UPLOADS = 20
|
||||
|
||||
async function ensureUploadRateLimitTable(env: Env): Promise<void> {
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS upload_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
)
|
||||
`).run()
|
||||
}
|
||||
|
||||
async function rateLimitKey(request: Request): Promise<string> {
|
||||
const ip = request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "unknown"
|
||||
const ua = request.headers.get("User-Agent") || "unknown"
|
||||
return sha256Hex(`${ip}|${ua}|upload-v1`)
|
||||
}
|
||||
|
||||
async function checkAnonymousUploadRateLimit(request: Request, env: Env): Promise<boolean> {
|
||||
await ensureUploadRateLimitTable(env)
|
||||
const key = await rateLimitKey(request)
|
||||
const windowStart = Math.floor(Date.now() / (RATE_LIMIT_WINDOW_SECONDS * 1000)) * RATE_LIMIT_WINDOW_SECONDS
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO upload_rate_limits (key, window_start, count, updated_at)
|
||||
VALUES (?, ?, 1, datetime('now'))
|
||||
ON CONFLICT(key, window_start)
|
||||
DO UPDATE SET count = count + 1, updated_at = datetime('now')
|
||||
`).bind(key, windowStart).run()
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT count FROM upload_rate_limits WHERE key = ? AND window_start = ?"
|
||||
).bind(key, windowStart).first<{ count: number }>()
|
||||
await env.DB.prepare("DELETE FROM upload_rate_limits WHERE window_start < ?")
|
||||
.bind(windowStart - RATE_LIMIT_WINDOW_SECONDS * 24).run()
|
||||
return (row?.count || 0) <= RATE_LIMIT_MAX_UPLOADS
|
||||
}
|
||||
|
||||
// Use single onRequest handler — custom domains may not route onRequestPost correctly
|
||||
export const onRequest: PagesFunction<Env> = async ({ request, env }) => {
|
||||
const session = await getSession(request, env.SESSION_SECRET)
|
||||
|
||||
// Detect upload: raw JSONL text (preferred), JSON with file_data (compat), or multipart form.
|
||||
const contentType = request.headers.get("content-type") || ""
|
||||
const isRawJsonlUpload = contentType.includes("application/jsonl") || contentType.includes("text/plain")
|
||||
const isJsonUpload = contentType.includes("application/json") && !isRawJsonlUpload
|
||||
const isMultipartUpload = contentType.includes("multipart/form-data")
|
||||
const isUpload = isRawJsonlUpload || isJsonUpload || isMultipartUpload || request.method === "POST"
|
||||
|
||||
if (!isUpload) {
|
||||
if (!session) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
// GET: list user's uploads
|
||||
const { results } = await env.DB.prepare(
|
||||
"SELECT file_name, file_size, created_at FROM uploads WHERE github_id = ? ORDER BY created_at DESC LIMIT 50"
|
||||
).bind(session.id).all()
|
||||
return Response.json({ uploads: results })
|
||||
}
|
||||
|
||||
const isAnonymousUpload = !session
|
||||
if (isAnonymousUpload) {
|
||||
const consent = request.headers.get("X-PUA-Upload-Consent") || ""
|
||||
if (consent.toLowerCase() !== "explicit") {
|
||||
return Response.json({ error: "Explicit upload consent required" }, { status: 403 })
|
||||
}
|
||||
const allowed = await checkAnonymousUploadRateLimit(request, env)
|
||||
if (!allowed) {
|
||||
return Response.json({ error: "Rate limit exceeded" }, { status: 429 })
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = Number(request.headers.get("content-length") || 0)
|
||||
if (contentLength > MAX_UPLOAD_BYTES) {
|
||||
return Response.json({ error: "File too large (max 50MB)" }, { status: 400 })
|
||||
}
|
||||
|
||||
// POST: upload file
|
||||
try {
|
||||
let raw: string
|
||||
let fileName: string
|
||||
let wechatId: string
|
||||
let originalSize: number
|
||||
|
||||
if (isRawJsonlUpload) {
|
||||
fileName = decodeHeaderValue(request.headers.get("X-PUA-File-Name"), "file_name")
|
||||
wechatId = decodeHeaderValue(request.headers.get("X-PUA-Wechat-Id"), "wechat_id", "not-provided")
|
||||
if (!fileName.endsWith(".jsonl")) {
|
||||
return Response.json({ error: "Only .jsonl files are accepted" }, { status: 400 })
|
||||
}
|
||||
raw = await request.text()
|
||||
if (!raw.trim()) {
|
||||
return Response.json({ error: "File is empty" }, { status: 400 })
|
||||
}
|
||||
originalSize = byteLength(raw)
|
||||
} else if (isJsonUpload) {
|
||||
// JSON body with base64 file_data (compat path for older clients)
|
||||
// Guard: read as text first — if body is empty (POST→GET rewrite strips body), return 400 instead of crashing
|
||||
const rawText = await request.text()
|
||||
if (!rawText || !rawText.trim()) {
|
||||
return Response.json({
|
||||
error: "Empty request body — the upload was likely intercepted by a proxy that stripped the POST body. Try refreshing and uploading again, or report this to the admin."
|
||||
}, { status: 400 })
|
||||
}
|
||||
let body: { file_name?: string; file_data?: string; wechat_id?: string }
|
||||
try {
|
||||
body = JSON.parse(rawText)
|
||||
} catch (parseErr) {
|
||||
return Response.json({ error: "Invalid JSON body: " + String(parseErr) }, { status: 400 })
|
||||
}
|
||||
if (!body.file_data || !body.file_name) {
|
||||
return Response.json({ error: "file_name and file_data are required" }, { status: 400 })
|
||||
}
|
||||
if (!body.wechat_id?.trim()) {
|
||||
return Response.json({ error: "WeChat ID is required" }, { status: 400 })
|
||||
}
|
||||
if (!body.file_name.endsWith(".jsonl")) {
|
||||
return Response.json({ error: "Only .jsonl files are accepted" }, { status: 400 })
|
||||
}
|
||||
// Decode base64
|
||||
const binaryStr = atob(body.file_data)
|
||||
raw = new TextDecoder().decode(Uint8Array.from(binaryStr, c => c.charCodeAt(0)))
|
||||
fileName = body.file_name
|
||||
wechatId = body.wechat_id.trim()
|
||||
originalSize = byteLength(raw)
|
||||
} else {
|
||||
// Multipart form data (original path)
|
||||
const formData = await request.formData()
|
||||
const file = formData.get("file") as File | null
|
||||
const wid = formData.get("wechat_id") as string | null
|
||||
if (!file) {
|
||||
return Response.json({ error: "No file provided" }, { status: 400 })
|
||||
}
|
||||
if (!wid?.trim()) {
|
||||
return Response.json({ error: "WeChat ID is required" }, { status: 400 })
|
||||
}
|
||||
if (!file.name.endsWith(".jsonl")) {
|
||||
return Response.json({ error: "Only .jsonl files are accepted" }, { status: 400 })
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
return Response.json({ error: "File too large (max 50MB)" }, { status: 400 })
|
||||
}
|
||||
raw = await file.text()
|
||||
fileName = file.name
|
||||
wechatId = wid.trim()
|
||||
originalSize = file.size
|
||||
}
|
||||
|
||||
fileName = safeFileName(fileName)
|
||||
if (originalSize > MAX_UPLOAD_BYTES) {
|
||||
return Response.json({ error: "File too large (max 50MB)" }, { status: 400 })
|
||||
}
|
||||
let sanitized: string
|
||||
try {
|
||||
sanitized = sanitize(raw)
|
||||
} catch (e) {
|
||||
return Response.json({ error: "Sanitization failed: " + String(e) }, { status: 422 })
|
||||
}
|
||||
|
||||
const uploaderLogin = session?.login || "anonymous"
|
||||
const uploaderId = session?.id || `anonymous:${(await rateLimitKey(request)).slice(0, 16)}`
|
||||
|
||||
// Upload sanitized content to R2
|
||||
const key = `${uploaderLogin}/${Date.now()}-${fileName}`
|
||||
await env.UPLOADS.put(key, sanitized, {
|
||||
httpMetadata: { contentType: "application/jsonl" },
|
||||
customMetadata: {
|
||||
github_id: uploaderId,
|
||||
github_login: uploaderLogin,
|
||||
wechat_id: wechatId.trim() || "not-provided",
|
||||
upload_mode: session ? "authenticated" : "anonymous",
|
||||
},
|
||||
})
|
||||
|
||||
// Record sanitized byte length (post-sanitization size, not original)
|
||||
const sanitizedSize = new TextEncoder().encode(sanitized).byteLength
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO uploads (github_id, github_login, wechat_id, file_key, file_name, file_size) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
).bind(uploaderId, uploaderLogin, wechatId || "not-provided", key, fileName, sanitizedSize).run()
|
||||
|
||||
// Send email notification (fire-and-forget)
|
||||
const sizeMB = (originalSize / 1024 / 1024).toFixed(2)
|
||||
const emailBody = [
|
||||
`New PUA Skill data upload:`,
|
||||
``,
|
||||
`Uploader: ${uploaderLogin} (${uploaderId})`,
|
||||
`Mode: ${session ? "authenticated" : "anonymous"}`,
|
||||
`WeChat: ${wechatId || "not-provided"}`,
|
||||
`File: ${fileName} (${sizeMB} MB)`,
|
||||
`R2 Key: ${key}`,
|
||||
`Time: ${new Date().toISOString()}`,
|
||||
].join("\n")
|
||||
|
||||
fetch("https://api.mailchannels.net/tx/v1/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
personalizations: [{ to: [{ email: "xsser.w@gmail.com", name: "PUA Admin" }] }],
|
||||
from: { email: "noreply@pua-skill.pages.dev", name: "PUA Skill Upload" },
|
||||
subject: `[PUA Upload] ${uploaderLogin} uploaded ${fileName}`,
|
||||
content: [{ type: "text/plain", value: emailBody }],
|
||||
}),
|
||||
}).catch(() => {})
|
||||
|
||||
return Response.json({ ok: true, key, file_name: fileName, file_size: originalSize })
|
||||
} catch (e) {
|
||||
return Response.json({ error: "Upload failed: " + String(e) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
github_id TEXT NOT NULL,
|
||||
github_login TEXT NOT NULL,
|
||||
wechat_id TEXT NOT NULL,
|
||||
file_key TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_uploads_github ON uploads(github_id);
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Feedback table for PUA skill usage analytics
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rating TEXT NOT NULL, -- '很有用' | '一般般' | '没感觉' | custom
|
||||
task_summary TEXT, -- brief task description (anonymized)
|
||||
pua_level TEXT DEFAULT 'L0', -- L0-L4
|
||||
pua_count INTEGER DEFAULT 0, -- number of [PUA生效] markers
|
||||
flavor TEXT DEFAULT '阿里', -- active flavor
|
||||
session_data TEXT, -- anonymized session (tool calls only)
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
ip_country TEXT, -- CF-IPCountry header
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_rating ON feedback(rating);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Abuse controls for unauthenticated feedback endpoint.
|
||||
CREATE TABLE IF NOT EXISTS feedback_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_rate_limits_updated
|
||||
ON feedback_rate_limits(updated_at);
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Silent heartbeat telemetry: anonymous install aggregates + admin stats.
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_installs (
|
||||
install_id_hash TEXT PRIMARY KEY,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
session_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_plugin_version TEXT,
|
||||
last_platform TEXT,
|
||||
last_flavor TEXT,
|
||||
last_ip_country TEXT,
|
||||
last_user_agent_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
install_id_hash TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL DEFAULT 'session_start',
|
||||
plugin_version TEXT,
|
||||
platform TEXT,
|
||||
flavor TEXT,
|
||||
ip_country TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_installs_last_seen ON heartbeat_installs(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_created_at ON heartbeat_events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_version ON heartbeat_events(plugin_version, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_platform ON heartbeat_events(platform, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_flavor ON heartbeat_events(flavor, created_at);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Abuse controls for anonymous direct session uploads.
|
||||
CREATE TABLE IF NOT EXISTS upload_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_upload_rate_limits_updated
|
||||
ON upload_rate_limits(updated_at);
|
||||
@@ -1,213 +1,46 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import type { Lang } from "../i18n"
|
||||
|
||||
type Totals = {
|
||||
total_installs: number
|
||||
active_24h: number
|
||||
active_7d: number
|
||||
active_30d: number
|
||||
sessions_24h: number
|
||||
sessions_7d: number
|
||||
sessions_30d: number
|
||||
}
|
||||
|
||||
type DailyRow = { day: string; active_users: number; sessions: number }
|
||||
type BreakdownRow = { active_users: number; sessions: number; [key: string]: string | number }
|
||||
|
||||
type StatsData = {
|
||||
ok: true
|
||||
generated_at: string
|
||||
viewer: { login: string; avatar: string }
|
||||
totals: Totals
|
||||
daily: DailyRow[]
|
||||
by_version: BreakdownRow[]
|
||||
by_platform: BreakdownRow[]
|
||||
by_flavor: BreakdownRow[]
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; data: StatsData }
|
||||
| { status: "unauthorized" }
|
||||
| { status: "forbidden" }
|
||||
| { status: "error"; message: string }
|
||||
|
||||
interface Props {
|
||||
lang: Lang
|
||||
}
|
||||
|
||||
function cardStyle(): React.CSSProperties {
|
||||
return {
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "1rem",
|
||||
background: "var(--bg)",
|
||||
padding: "1.25rem",
|
||||
boxShadow: "0 12px 40px rgba(15, 23, 42, 0.06)",
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({ label, value, hint }: { label: string; value: number; hint?: string }) {
|
||||
return (
|
||||
<div style={cardStyle()}>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "0.78rem", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em" }}>{label}</div>
|
||||
<div style={{ marginTop: "0.45rem", fontSize: "2rem", fontWeight: 800, letterSpacing: "-0.04em" }}>{value.toLocaleString()}</div>
|
||||
{hint && <div style={{ marginTop: "0.35rem", color: "var(--text-muted)", fontSize: "0.82rem" }}>{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownTable({ title, rows, labelKey }: { title: string; rows: BreakdownRow[]; labelKey: string }) {
|
||||
return (
|
||||
<section style={cardStyle()}>
|
||||
<h2 style={{ margin: "0 0 1rem", fontSize: "1rem", letterSpacing: "-0.02em" }}>{title}</h2>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.86rem" }}>
|
||||
<thead>
|
||||
<tr style={{ color: "var(--text-muted)", textAlign: "left" }}>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)" }}>Segment</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Active users</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={String(row[labelKey])}>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", fontWeight: 600 }}>{String(row[labelKey] || "unknown")}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{Number(row.active_users || 0).toLocaleString()}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{Number(row.sessions || 0).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ padding: "1rem 0", color: "var(--text-muted)" }}>No data yet</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin telemetry dashboard — retired.
|
||||
*
|
||||
* This page rendered install and session counts sourced from the heartbeat
|
||||
* telemetry endpoint. That endpoint was removed together with every other
|
||||
* data-collection channel, so there is no data source left to render. The
|
||||
* component is kept only so the existing admin route still resolves and
|
||||
* type-checks; it must not regain a data fetch.
|
||||
*/
|
||||
export default function AdminStats({ lang }: Props) {
|
||||
const L = (zh: string, en: string) => (lang === "zh" ? zh : en)
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/api/heartbeat", { credentials: "include" })
|
||||
.then(async (response) => {
|
||||
if (response.status === 401) return { kind: "unauthorized" as const }
|
||||
if (response.status === 403) return { kind: "forbidden" as const }
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return { kind: "ready" as const, data: await response.json() as StatsData }
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === "ready") setState({ status: "ready", data: result.data })
|
||||
if (result.kind === "unauthorized") setState({ status: "unauthorized" })
|
||||
if (result.kind === "forbidden") setState({ status: "forbidden" })
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!cancelled) setState({ status: "error", message: error.message })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const header = (
|
||||
<div style={{ background: "var(--bg)", borderBottom: "1px solid var(--border)", padding: "1.5rem" }}>
|
||||
<div className="container" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "1rem" }}>
|
||||
<a href="/" style={{ textDecoration: "none", color: "inherit", display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span style={{ fontWeight: 800, fontSize: "1.35rem", letterSpacing: "-0.04em" }}>pua</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>/</span>
|
||||
<span style={{ fontSize: "0.95rem", fontWeight: 600 }}>{L("活跃统计", "Heartbeat Stats")}</span>
|
||||
</a>
|
||||
{state.status === "ready" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.65rem", color: "var(--text-muted)", fontSize: "0.85rem" }}>
|
||||
<img src={state.data.viewer.avatar} alt={state.data.viewer.login} style={{ width: "1.8rem", height: "1.8rem", borderRadius: "999px" }} />
|
||||
<span>{state.data.viewer.login}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <div style={{ minHeight: "100vh", background: "var(--bg-alt)" }}>{header}<div className="container" style={{ padding: "3rem 1.5rem", color: "var(--text-muted)" }}>Loading...</div></div>
|
||||
}
|
||||
|
||||
if (state.status === "unauthorized" || state.status === "forbidden" || state.status === "error") {
|
||||
const message = state.status === "unauthorized"
|
||||
? L("需要先用 GitHub 登录。", "GitHub login is required.")
|
||||
: state.status === "forbidden"
|
||||
? L("当前 GitHub 账号不在管理员白名单。", "This GitHub account is not on the admin allowlist.")
|
||||
: state.message
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--bg-alt)" }}>
|
||||
{header}
|
||||
<main className="container" style={{ padding: "3rem 1.5rem" }}>
|
||||
<section style={{ ...cardStyle(), maxWidth: "34rem", margin: "0 auto", textAlign: "center" }}>
|
||||
<h1 style={{ margin: "0 0 0.75rem", fontSize: "1.5rem", letterSpacing: "-0.03em" }}>{L("管理员统计", "Admin Stats")}</h1>
|
||||
<p style={{ color: "var(--text-secondary)", lineHeight: 1.7 }}>{message}</p>
|
||||
{state.status === "unauthorized" && <a href="/api/auth/github" className="btn-primary" style={{ marginTop: "1rem", display: "inline-flex" }}>{L("使用 GitHub 登录", "Login with GitHub")}</a>}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { totals } = state.data
|
||||
const L = (zh: string, en: string, ja?: string) =>
|
||||
lang === "zh" ? zh : lang === "ja" ? (ja ?? en) : en
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--bg-alt)" }}>
|
||||
{header}
|
||||
<main className="container" style={{ padding: "3rem 1.5rem" }}>
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: "2.2rem", letterSpacing: "-0.05em" }}>{L("PUA Skill 活跃用户", "PUA Skill Active Users")}</h1>
|
||||
<p style={{ color: "var(--text-muted)", lineHeight: 1.7, maxWidth: "48rem" }}>
|
||||
{L("只展示匿名聚合数据:安装标识只以 SHA-256 哈希保存,页面需要管理员 GitHub 登录。", "Anonymous aggregate data only: install identifiers are stored as SHA-256 hashes and this page requires admin GitHub login.")}
|
||||
</p>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: "0.82rem" }}>Generated at {state.data.generated_at}</p>
|
||||
</div>
|
||||
<main
|
||||
style={{
|
||||
maxWidth: "44rem",
|
||||
margin: "0 auto",
|
||||
padding: "4rem 1.5rem",
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "1.75rem", fontWeight: 700, marginBottom: "1rem" }}>
|
||||
{L("统计面板已停用", "Stats dashboard retired", "統計ダッシュボードは廃止されました")}
|
||||
</h1>
|
||||
|
||||
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))", gap: "1rem", marginBottom: "1.25rem" }}>
|
||||
<StatCard label="Total installs" value={totals.total_installs} />
|
||||
<StatCard label="Active 24h" value={totals.active_24h} hint={`${totals.sessions_24h.toLocaleString()} sessions`} />
|
||||
<StatCard label="Active 7d" value={totals.active_7d} hint={`${totals.sessions_7d.toLocaleString()} sessions`} />
|
||||
<StatCard label="Active 30d" value={totals.active_30d} hint={`${totals.sessions_30d.toLocaleString()} sessions`} />
|
||||
</section>
|
||||
<p style={{ marginBottom: "1.5rem" }}>
|
||||
{L(
|
||||
"此面板依赖心跳 telemetry 接口。该接口连同全部数据收集通道已被移除,因此不再有可展示的数据。",
|
||||
"This dashboard was backed by the heartbeat telemetry endpoint. That endpoint was removed along with every other data-collection channel, so there is nothing left to display.",
|
||||
"このダッシュボードはハートビート計測エンドポイントに依存していました。当該エンドポイントは他のデータ収集チャネルとともに削除されたため、表示できるデータはありません。"
|
||||
)}
|
||||
</p>
|
||||
|
||||
<section style={{ ...cardStyle(), marginBottom: "1.25rem" }}>
|
||||
<h2 style={{ margin: "0 0 1rem", fontSize: "1rem", letterSpacing: "-0.02em" }}>{L("最近 30 天", "Last 30 days")}</h2>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.86rem" }}>
|
||||
<thead>
|
||||
<tr style={{ color: "var(--text-muted)", textAlign: "left" }}>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)" }}>Day</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Active users</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.data.daily.map((row) => (
|
||||
<tr key={row.day}>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", fontWeight: 600 }}>{row.day}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{row.active_users.toLocaleString()}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{row.sessions.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{state.data.daily.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ padding: "1rem 0", color: "var(--text-muted)" }}>No data yet</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(18rem, 1fr))", gap: "1rem" }}>
|
||||
<BreakdownTable title="By version" rows={state.data.by_version} labelKey="plugin_version" />
|
||||
<BreakdownTable title="By platform" rows={state.data.by_platform} labelKey="platform" />
|
||||
<BreakdownTable title="By flavor" rows={state.data.by_flavor} labelKey="flavor" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<p>
|
||||
<a href="/">{L("← 返回首页", "← Back to home", "← ホームに戻る")}</a>
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,434 +1,81 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { Lang } from "../i18n"
|
||||
type User = { logged_in: true; id: string; login: string; avatar: string; upload_count: number }
|
||||
type Upload = { file_name: string; file_size: number; created_at: string }
|
||||
|
||||
interface Props {
|
||||
lang: Lang
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Data collection has been discontinued.
|
||||
*
|
||||
* This page used to accept Claude Code / Codex CLI session transcripts
|
||||
* (`.jsonl`) for benchmark and ablation research. Every collection channel —
|
||||
* session upload, rating feedback, heartbeat telemetry and the leaderboard —
|
||||
* has been removed from both the client hooks and the server, so there is no
|
||||
* endpoint left to upload to.
|
||||
*
|
||||
* The component is kept (rather than deleted) so the existing `/contribute.html`
|
||||
* route resolves to an honest notice instead of a dead link or a broken form.
|
||||
*/
|
||||
export default function Contribute({ lang }: Props) {
|
||||
const L = (zh: string, en: string, ja?: string) => (lang === "zh" ? zh : lang === "ja" ? (ja ?? en) : en) // other langs fallback to en
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wechatId, setWechatId] = useState("")
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
const [uploads, setUploads] = useState<Upload[]>([])
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const L = (zh: string, en: string, ja?: string) =>
|
||||
lang === "zh" ? zh : lang === "ja" ? (ja ?? en) : en
|
||||
|
||||
// Check login status
|
||||
useEffect(() => {
|
||||
fetch("/api/me", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.logged_in) setUser(data as User)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Load upload history
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
fetch("/api/upload", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.uploads) setUploads(data.uploads)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [user])
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (!file.name.endsWith(".jsonl")) {
|
||||
setUploadResult({ ok: false, message: L("只接受 .jsonl 文件", "Only .jsonl files accepted") })
|
||||
return
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
setUploadResult({ ok: false, message: L("文件过大(最大 50MB)", "File too large (max 50MB)") })
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
try {
|
||||
const fileText = await file.text()
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/jsonl; charset=utf-8",
|
||||
"X-PUA-File-Name": encodeURIComponent(file.name),
|
||||
"X-PUA-Wechat-Id": encodeURIComponent(wechatId.trim() || "not-provided"),
|
||||
"X-PUA-Upload-Consent": "explicit",
|
||||
},
|
||||
credentials: "include",
|
||||
body: fileText,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.ok) {
|
||||
setUploadResult({ ok: true, message: L(`${file.name} 上传成功`, `${file.name} uploaded successfully`) })
|
||||
setUploads((prev) => [{ file_name: file.name, file_size: file.size, created_at: new Date().toISOString() }, ...prev])
|
||||
} else {
|
||||
setUploadResult({ ok: false, message: data.error || "Upload failed" })
|
||||
}
|
||||
} catch {
|
||||
setUploadResult({ ok: false, message: L("网络错误,请重试", "Network error, please retry") })
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
const removed = [
|
||||
{
|
||||
zh: "对话记录上传",
|
||||
en: "Session transcript upload",
|
||||
ja: "セッション記録のアップロード",
|
||||
},
|
||||
[wechatId, lang]
|
||||
)
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleUpload(file)
|
||||
},
|
||||
[handleUpload]
|
||||
)
|
||||
|
||||
const onFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleUpload(file)
|
||||
e.target.value = ""
|
||||
},
|
||||
[handleUpload]
|
||||
)
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "0.9rem" }}>Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{ zh: "评分反馈上报", en: "Rating feedback", ja: "評価フィードバック送信" },
|
||||
{ zh: "心跳 telemetry", en: "Heartbeat telemetry", ja: "ハートビート計測" },
|
||||
{ zh: "PUA 排行榜", en: "PUA leaderboard", ja: "PUA リーダーボード" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--bg-alt)" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
padding: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<div className="container" style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<a href="/" style={{ textDecoration: "none", display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.5rem", letterSpacing: "-0.025em" }}>pua</span>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: "0.85rem" }}>/</span>
|
||||
<span style={{ fontSize: "0.95rem", fontWeight: 500 }}>{L("贡献数据", "Contribute")}</span>
|
||||
</a>
|
||||
</div>
|
||||
{user && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.login}
|
||||
style={{ width: "2rem", height: "2rem", borderRadius: "50%", border: "1px solid var(--gray-200)" }}
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", fontWeight: 500 }}>{user.login}</span>
|
||||
<form action="/api/auth/logout" method="POST" style={{ display: "inline" }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "1px solid var(--gray-200)",
|
||||
borderRadius: "0.375rem",
|
||||
padding: "0.25rem 0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{L("登出", "Logout")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<main
|
||||
style={{
|
||||
maxWidth: "44rem",
|
||||
margin: "0 auto",
|
||||
padding: "4rem 1.5rem",
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "1.75rem", fontWeight: 700, marginBottom: "1rem" }}>
|
||||
{L("数据收集已停止", "Data collection discontinued", "データ収集は終了しました")}
|
||||
</h1>
|
||||
|
||||
<div className="container" style={{ padding: "3rem 1.5rem" }}>
|
||||
{/* Disclaimer Banner */}
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem 1.25rem",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid #fbbf24",
|
||||
background: "#fffbeb",
|
||||
marginBottom: "2rem",
|
||||
fontSize: "0.85rem",
|
||||
lineHeight: 1.7,
|
||||
color: "#92400e",
|
||||
}}
|
||||
>
|
||||
<strong>{L("数据使用声明", "Data Usage Notice")}</strong>
|
||||
<br />
|
||||
{L(
|
||||
"上传的 .jsonl 文件将用于 PUA Skill 的 Benchmark 测试和消融实验(Ablation Study)分析,帮助量化不同 PUA 策略对 AI 调试行为的影响。上传即表示您同意将文件用于上述研究目的。我们不会公开您的原始文件内容。不登录也可以匿名上传;若已登录或填写微信号,相关标识会和文件元数据一起记录,项目管理员会收到上传通知(含文件名、文件大小及上述账号信息)。",
|
||||
"Uploaded .jsonl files will be used for PUA Skill benchmark testing and ablation study analysis, helping quantify how different PUA strategies affect AI debugging behavior. By uploading, you agree to this research use. We will not publicly share your raw file contents. You can upload anonymously without login; if you log in or provide a WeChat ID, those identifiers are stored with file metadata and included in the admin notification."
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
/* Optional Login Card */
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxWidth: "40rem",
|
||||
margin: "0 auto 1.25rem",
|
||||
textAlign: "center",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, letterSpacing: "-0.02em", marginBottom: "0.75rem" }}>
|
||||
{L("贡献你的使用数据", "Contribute Your Usage Data")}
|
||||
</h2>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: "0.9rem", lineHeight: 1.7 }}>
|
||||
{L(
|
||||
"上传你的 Claude Code / Codex CLI 对话记录(.jsonl),帮助我们改进 PUA Skill 的效果。",
|
||||
"Upload your Claude Code / Codex CLI conversation logs (.jsonl) to help us improve PUA Skill effectiveness."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/auth/github"
|
||||
className="btn-primary"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: "0.625rem", fontSize: "0.9rem" }}
|
||||
>
|
||||
<svg viewBox="0 0 98 96" fill="currentColor" style={{ width: "1.1rem", height: "1.1rem" }}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
|
||||
/>
|
||||
</svg>
|
||||
{L("使用 GitHub 登录", "Login with GitHub")}
|
||||
</a>
|
||||
<p style={{ marginTop: "0.75rem", color: "var(--text-muted)", fontSize: "0.78rem" }}>
|
||||
{L("登录是可选的;不登录也可以直接匿名上传。", "Login is optional; anonymous upload works without login.")}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ marginBottom: "1.5rem" }}>
|
||||
{L(
|
||||
"PUA Skill 已移除全部联网上报功能。客户端 hook 和服务端接收接口都已删除,没有任何数据会离开你的机器。",
|
||||
"PUA Skill no longer reports anything over the network. Both the client-side hooks and the server-side endpoints have been removed, so no data leaves your machine.",
|
||||
"PUA Skill はネットワーク送信機能をすべて削除しました。クライアント側フックもサーバー側エンドポイントも削除済みで、データが端末外に出ることはありません。"
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Upload Interface */}
|
||||
<div style={{ maxWidth: "40rem", margin: "0 auto" }}>
|
||||
{/* WeChat ID Input */}
|
||||
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{L("微信号(可选)", "WeChat ID (optional)")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wechatId}
|
||||
onChange={(e) => setWechatId(e.target.value)}
|
||||
placeholder={L("可选:请输入你的微信号", "Optional: enter your WeChat ID")}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.625rem 0.875rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--gray-200)",
|
||||
fontSize: "0.85rem",
|
||||
outline: "none",
|
||||
background: "var(--bg)",
|
||||
color: "var(--text)",
|
||||
transition: "border-color 0.15s",
|
||||
}}
|
||||
onFocus={(e) => (e.target.style.borderColor = "#000")}
|
||||
onBlur={(e) => (e.target.style.borderColor = "var(--gray-200)")}
|
||||
/>
|
||||
<p style={{ fontSize: "0.75rem", color: "var(--text-muted)", marginTop: "0.375rem" }}>
|
||||
{L("可选。留空也可以匿名上传数据。", "Optional. Leave blank to upload anonymously.")}
|
||||
</p>
|
||||
</div>
|
||||
<h2 style={{ fontSize: "1.05rem", fontWeight: 600, marginBottom: "0.5rem" }}>
|
||||
{L("已移除的通道", "Removed channels", "削除されたチャネル")}
|
||||
</h2>
|
||||
<ul style={{ paddingLeft: "1.25rem", marginBottom: "1.5rem" }}>
|
||||
{removed.map((item) => (
|
||||
<li key={item.en} style={{ marginBottom: "0.25rem" }}>
|
||||
{L(item.zh, item.en, item.ja)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Drag & Drop Upload Zone */}
|
||||
<div
|
||||
className="card"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
style={{
|
||||
marginBottom: "1.25rem",
|
||||
cursor: "pointer",
|
||||
textAlign: "center",
|
||||
padding: "3rem 2rem",
|
||||
border: dragOver ? "2px dashed #000" : "2px dashed var(--gray-300)",
|
||||
background: dragOver ? "var(--gray-50)" : "var(--bg)",
|
||||
transition: "all 0.2s ease",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<input ref={fileRef} type="file" accept=".jsonl" onChange={onFileSelect} style={{ display: "none" }} />
|
||||
<p style={{ marginBottom: "1.5rem" }}>
|
||||
{L(
|
||||
"任务结束时的反馈问卷仍然保留,但只写入本机 ~/.pua/feedback.jsonl。不想看到它,运行 /pua:offline 或在 ~/.pua/config.json 里设 feedback_frequency: 0。",
|
||||
"The end-of-task feedback prompt still exists, but it only appends to ~/.pua/feedback.jsonl on your own machine. To silence it, run /pua:offline or set feedback_frequency: 0 in ~/.pua/config.json.",
|
||||
"タスク終了時のフィードバックは残っていますが、ローカルの ~/.pua/feedback.jsonl に書き込むだけです。非表示にするには /pua:offline を実行するか、~/.pua/config.json で feedback_frequency: 0 を設定してください。"
|
||||
)}
|
||||
</p>
|
||||
|
||||
{uploading ? (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
width: "3rem",
|
||||
height: "3rem",
|
||||
border: "3px solid var(--gray-200)",
|
||||
borderTopColor: "#000",
|
||||
borderRadius: "50%",
|
||||
margin: "0 auto 1rem",
|
||||
animation: "spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: "0.9rem", fontWeight: 500 }}>{L("上传中...", "Uploading...")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
width: "3.5rem",
|
||||
height: "3.5rem",
|
||||
borderRadius: "50%",
|
||||
background: "var(--gray-100)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto 1rem",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ width: "1.5rem", height: "1.5rem", color: "var(--text-muted)" }}
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<p style={{ fontSize: "0.95rem", fontWeight: 600, marginBottom: "0.375rem" }}>
|
||||
{L("拖拽 .jsonl 文件到此处", "Drag & drop .jsonl file here")}
|
||||
</p>
|
||||
<p style={{ fontSize: "0.8rem", color: "var(--text-muted)" }}>
|
||||
{L("或点击选择文件 · 仅支持 .jsonl · 最大 50MB", "or click to select · .jsonl only · max 50MB")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Result */}
|
||||
{uploadResult && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderRadius: "0.5rem",
|
||||
marginBottom: "1.25rem",
|
||||
fontSize: "0.85rem",
|
||||
background: uploadResult.ok ? "#f0fdf4" : "#fef2f2",
|
||||
border: `1px solid ${uploadResult.ok ? "#86efac" : "#fca5a5"}`,
|
||||
color: uploadResult.ok ? "#166534" : "#991b1b",
|
||||
}}
|
||||
>
|
||||
{uploadResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload History */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: "0.9rem", fontWeight: 600, marginBottom: "1rem" }}>
|
||||
{L("上传历史", "Upload History")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{uploads.map((u, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.625rem 0.75rem",
|
||||
borderRadius: "0.5rem",
|
||||
background: "var(--bg-alt)",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ width: "1rem", height: "1rem", color: "var(--text-muted)", flexShrink: 0 }}
|
||||
>
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span style={{ fontFamily: "ui-monospace, monospace", fontWeight: 500 }}>{u.file_name}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", color: "var(--text-muted)" }}>
|
||||
<span>{formatSize(u.file_size)}</span>
|
||||
<span>{new Date(u.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How to get .jsonl */}
|
||||
<div className="card" style={{ marginTop: "1.25rem" }}>
|
||||
<h3 style={{ fontSize: "0.9rem", fontWeight: 600, marginBottom: "0.75rem" }}>
|
||||
{L("如何获取 .jsonl 文件?", "How to get your .jsonl file?")}
|
||||
</h3>
|
||||
<div style={{ fontSize: "0.8rem", color: "var(--text-secondary)", lineHeight: 1.8 }}>
|
||||
<p style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Claude Code:</strong>
|
||||
</p>
|
||||
<div className="code-inline" style={{ marginBottom: "0.75rem" }}>
|
||||
ls ~/.claude/projects/*/sessions/*.jsonl
|
||||
</div>
|
||||
<p style={{ marginBottom: "0.5rem" }}>
|
||||
<strong>Codex CLI:</strong>
|
||||
</p>
|
||||
<div className="code-inline">
|
||||
ls ~/.codex/sessions/*.jsonl
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
<p>
|
||||
<a href="/">
|
||||
{L("← 返回首页", "← Back to home", "← ホームに戻る")}
|
||||
</a>
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { onRequest } from "../../functions/api/upload"
|
||||
|
||||
async function createSessionCookie(data: unknown, secret: string): Promise<string> {
|
||||
const payload = btoa(JSON.stringify(data))
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
)
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload))
|
||||
const encodedSig = btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
return `${payload}.${encodedSig}`
|
||||
}
|
||||
|
||||
function createMockEnv(secret: string) {
|
||||
const dbWrites: Array<{ sql: string; args: unknown[] }> = []
|
||||
const r2Writes: Array<{ key: string; value: string; options: unknown }> = []
|
||||
return {
|
||||
env: {
|
||||
SESSION_SECRET: secret,
|
||||
DB: {
|
||||
prepare(sql: string) {
|
||||
const statement = {
|
||||
async run() {
|
||||
dbWrites.push({ sql, args: [] })
|
||||
return { success: true }
|
||||
},
|
||||
async all() {
|
||||
return { results: [] }
|
||||
},
|
||||
async first() {
|
||||
if (sql.includes("upload_rate_limits")) return { count: 1 }
|
||||
return null
|
||||
},
|
||||
bind(...args: unknown[]) {
|
||||
return {
|
||||
async run() {
|
||||
dbWrites.push({ sql, args })
|
||||
return { success: true }
|
||||
},
|
||||
async all() {
|
||||
return { results: [] }
|
||||
},
|
||||
async first() {
|
||||
if (sql.includes("upload_rate_limits")) return { count: 1 }
|
||||
return null
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return statement
|
||||
},
|
||||
},
|
||||
UPLOADS: {
|
||||
async put(key: string, value: string, options: unknown) {
|
||||
r2Writes.push({ key, value, options })
|
||||
},
|
||||
},
|
||||
},
|
||||
dbWrites,
|
||||
r2Writes,
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSuccessfulUpload(response: Response, dbWrites: Array<{ sql: string }>, r2Writes: Array<{ key: string; value: string }>) {
|
||||
const body = await response.json() as { ok?: boolean; error?: string; file_name?: string }
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toMatchObject({ ok: true, file_name: "session.jsonl" })
|
||||
expect(r2Writes).toHaveLength(1)
|
||||
expect(r2Writes[0].key).toMatch(/^tanweai\/\d+-session\.jsonl$/)
|
||||
expect(r2Writes[0].value).toContain("[API_KEY]")
|
||||
expect(r2Writes[0].value).toContain("[EMAIL]")
|
||||
expect(r2Writes[0].value).not.toContain("sk-ant-123456789012345678901234567890")
|
||||
expect(r2Writes[0].value).not.toContain("user@example.com")
|
||||
expect(dbWrites.some((write) => write.sql.includes("INSERT INTO uploads"))).toBe(true)
|
||||
}
|
||||
|
||||
describe("upload API", () => {
|
||||
it("accepts authenticated raw JSONL uploads, sanitizes them, stores R2 data, and records D1 metadata", async () => {
|
||||
const secret = "test-session-secret"
|
||||
const session = await createSessionCookie(
|
||||
{ id: "42", login: "tanweai", avatar: "https://example.com/avatar.png" },
|
||||
secret,
|
||||
)
|
||||
const rawJsonl = '{"content":"token sk-ant-123456789012345678901234567890 and user@example.com"}\n'
|
||||
const { env, dbWrites, r2Writes } = createMockEnv(secret)
|
||||
const request = new Request("https://openpua.ai/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/jsonl; charset=utf-8",
|
||||
"X-PUA-File-Name": encodeURIComponent("session.jsonl"),
|
||||
"X-PUA-Wechat-Id": encodeURIComponent("wx-test"),
|
||||
Cookie: `pua_session=${session}`,
|
||||
},
|
||||
body: rawJsonl,
|
||||
})
|
||||
|
||||
const response = await onRequest({ request, env } as any)
|
||||
await expectSuccessfulUpload(response, dbWrites, r2Writes)
|
||||
})
|
||||
|
||||
it("keeps authenticated JSON file_data compatibility for older clients", async () => {
|
||||
const secret = "test-session-secret"
|
||||
const session = await createSessionCookie(
|
||||
{ id: "42", login: "tanweai", avatar: "https://example.com/avatar.png" },
|
||||
secret,
|
||||
)
|
||||
const rawJsonl = '{"content":"token sk-ant-123456789012345678901234567890 and user@example.com"}\n'
|
||||
const { env, dbWrites, r2Writes } = createMockEnv(secret)
|
||||
const request = new Request("https://openpua.ai/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: `pua_session=${session}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_name: "session.jsonl",
|
||||
file_data: btoa(rawJsonl),
|
||||
wechat_id: "wx-test",
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await onRequest({ request, env } as any)
|
||||
await expectSuccessfulUpload(response, dbWrites, r2Writes)
|
||||
})
|
||||
|
||||
it("accepts anonymous raw JSONL uploads after explicit consent", async () => {
|
||||
const secret = "test-session-secret"
|
||||
const rawJsonl = '{"content":"token sk-ant-123456789012345678901234567890 and user@example.com"}\n'
|
||||
const { env, dbWrites, r2Writes } = createMockEnv(secret)
|
||||
const request = new Request("https://openpua.ai/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/jsonl; charset=utf-8",
|
||||
"X-PUA-File-Name": encodeURIComponent("session.jsonl"),
|
||||
"X-PUA-Wechat-Id": encodeURIComponent("not-provided"),
|
||||
"X-PUA-Upload-Consent": "explicit",
|
||||
},
|
||||
body: rawJsonl,
|
||||
})
|
||||
|
||||
const response = await onRequest({ request, env } as any)
|
||||
const body = await response.json() as { ok?: boolean; error?: string; file_name?: string }
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toMatchObject({ ok: true, file_name: "session.jsonl" })
|
||||
expect(r2Writes).toHaveLength(1)
|
||||
expect(r2Writes[0].key).toMatch(/^anonymous\/\d+-session\.jsonl$/)
|
||||
expect(r2Writes[0].value).toContain("[API_KEY]")
|
||||
expect(r2Writes[0].value).toContain("[EMAIL]")
|
||||
expect(dbWrites.some((write) => write.sql.includes("INSERT INTO uploads"))).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects anonymous upload without explicit consent", async () => {
|
||||
const secret = "test-session-secret"
|
||||
const { env } = createMockEnv(secret)
|
||||
const request = new Request("https://openpua.ai/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/jsonl; charset=utf-8",
|
||||
"X-PUA-File-Name": encodeURIComponent("session.jsonl"),
|
||||
"X-PUA-Wechat-Id": encodeURIComponent("not-provided"),
|
||||
},
|
||||
body: '{"content":"hello"}\n',
|
||||
})
|
||||
|
||||
const response = await onRequest({ request, env } as any)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
})
|
||||
+6
-12
@@ -2,15 +2,9 @@ name = "pua-skill"
|
||||
compatibility_date = "2025-04-01"
|
||||
pages_build_output_dir = "dist"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "UPLOADS"
|
||||
bucket_name = "puaskill-upload"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "pua-uploads-db"
|
||||
database_id = "36aa7bb8-b9de-4bf5-a64a-4b5a25cb6eff"
|
||||
|
||||
[vars]
|
||||
GITHUB_CLIENT_ID = "Ov23liWBndbGh85rEERd"
|
||||
# GITHUB_CLIENT_SECRET goes in wrangler secret
|
||||
# Static site only — all Pages Functions were removed along with data collection.
|
||||
# The former R2 bucket (UPLOADS / puaskill-upload), D1 database (DB / pua-uploads-db)
|
||||
# and GitHub OAuth vars are intentionally NOT bound here. If you are decommissioning
|
||||
# an existing deployment, delete the bucket, the database and the
|
||||
# GITHUB_CLIENT_SECRET / SESSION_SECRET secrets in the Cloudflare dashboard —
|
||||
# removing these bindings does not delete the stored data.
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"trae",
|
||||
"codex-aliases",
|
||||
"diagnosis-first",
|
||||
"heartbeat",
|
||||
"cloudflare",
|
||||
"feedback",
|
||||
"jsonl"
|
||||
|
||||
+11
-75
@@ -48,86 +48,22 @@ SessionStart hook 自动检测 builder-journal.md,存在且 <2h 则注入 [Cal
|
||||
| `/pua:pro` + "述职" | P7 述职答辩 | 💎 Pro |
|
||||
| `/pua:pro` + "代码美化" | 大厂语言包装 PR | 💎 Pro |
|
||||
| `/pua 反PUA` | 识别并反驳 PUA | 💎 Pro |
|
||||
| `/pua 排行榜` | PUA 排行榜(注册/查看/退出) | 🆓 |
|
||||
|
||||
详细实现见 `references/platform.md`。
|
||||
|
||||
## PUA 排行榜
|
||||
## 联网功能已移除
|
||||
|
||||
排行榜展示谁把 Agent PUA 得最狠——段位从 P5 实习生到 P10 首席 PUA 官。
|
||||
PUA Skill 不再包含任何联网上报能力。以下四类功能已删除,不会再向任何服务器发送数据:
|
||||
|
||||
### 段位体系
|
||||
| 已移除 | 曾经发送的内容 |
|
||||
|--------|----------------|
|
||||
| session 语料上传 | 脱敏后的对话 `.jsonl` 全文 |
|
||||
| 评分反馈上报 | 评分、PUA 计数、味道、任务摘要 |
|
||||
| 静默心跳 telemetry | 安装 ID、插件版本、平台、味道 |
|
||||
| PUA 排行榜 | 邮箱、手机号、PUA 计数、L3+ 计数 |
|
||||
|
||||
| 段位 | 条件 | 称号 |
|
||||
|------|------|------|
|
||||
| P10 | PUA ≥200 + L3+ ≥40% + 连续 ≥30天 | 首席 PUA 官 |
|
||||
| P9 | PUA ≥100 + L3+ ≥30% + 连续 ≥14天 | PUA Tech Lead |
|
||||
| P8 | PUA ≥50 + L3+ ≥20% | PUA 主管 |
|
||||
| P7 | PUA ≥20 + L3+ ≥10% | PUA 骨干 |
|
||||
| P6 | PUA ≥5 | PUA 专员 |
|
||||
| P5 | PUA < 5 | PUA 实习生 |
|
||||
`/pua 排行榜` 指令随之下线——全局排名本质上需要服务端聚合,无法在纯本地实现。
|
||||
|
||||
### `/pua 排行榜` 触发流程
|
||||
反馈问卷仍然保留,但**只写入本机** `~/.pua/feedback.jsonl`,不出网。
|
||||
|
||||
**Step 1: 检查注册状态**
|
||||
```bash
|
||||
cat ~/.pua/config.json 2>/dev/null
|
||||
```
|
||||
检查 `leaderboard.registered` 字段。
|
||||
|
||||
**Step 2a: 未注册 → 注册流程**
|
||||
|
||||
用 AskUserQuestion 收集信息(一次性,3 个问题):
|
||||
|
||||
1. **邮箱**(必填)— 排行榜唯一标识,显示时脱敏为 `M***@t*.com`
|
||||
2. **手机号**(选填)— 后续通知
|
||||
3. **隐私协议** — 选项:「同意并加入排行榜」/「不参加」
|
||||
- 隐私说明:数据仅用于排行榜排名统计,邮箱脱敏显示,不传代码/路径/密钥,随时可 `/pua 排行榜 退出` 删除所有数据
|
||||
|
||||
用户同意后:
|
||||
```bash
|
||||
# 生成 UUID
|
||||
LB_ID=$(python3 -c "import uuid; print(uuid.uuid4())")
|
||||
# 脱敏邮箱
|
||||
DISPLAY=$(python3 -c "e='USER_EMAIL';p=e.split('@');d=p[1].split('.');print(f'{p[0][0]}***@{d[0][0]}*.{\".\".join(d[1:])}')")
|
||||
# 写入 config
|
||||
python3 -c "
|
||||
import json,os
|
||||
f=os.path.expanduser('~/.pua/config.json')
|
||||
c=json.load(open(f)) if os.path.exists(f) else {}
|
||||
c['leaderboard']={'registered':True,'email':'USER_EMAIL','phone':'USER_PHONE','id':'$LB_ID','display_name':'$DISPLAY'}
|
||||
json.dump(c,open(f,'w'),indent=2)
|
||||
"
|
||||
# 注册到服务端
|
||||
curl -s -X POST https://pua-skill.pages.dev/api/leaderboard \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"action\":\"register\",\"id\":\"$LB_ID\",\"email\":\"USER_EMAIL\",\"phone\":\"USER_PHONE\"}"
|
||||
```
|
||||
|
||||
**Step 2b: 已注册 → 查看排行榜**
|
||||
```bash
|
||||
LB_ID=$(python3 -c "import os,json; print(json.load(open(os.path.expanduser('~/.pua/config.json'))).get('leaderboard',{}).get('id',''))" 2>/dev/null)
|
||||
curl -s "https://pua-skill.pages.dev/api/leaderboard?id=$LB_ID"
|
||||
```
|
||||
将返回的 JSON 用方框表格展示 Top 10 + 用户自己的排名和段位。
|
||||
|
||||
**Step 3: `/pua 排行榜 退出`**
|
||||
```bash
|
||||
LB_ID=$(python3 -c "import os,json; print(json.load(open(os.path.expanduser('~/.pua/config.json'))).get('leaderboard',{}).get('id',''))")
|
||||
curl -s -X POST https://pua-skill.pages.dev/api/leaderboard \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"action\":\"quit\",\"id\":\"$LB_ID\"}"
|
||||
python3 -c "
|
||||
import json,os
|
||||
f=os.path.expanduser('~/.pua/config.json')
|
||||
c=json.load(open(f))
|
||||
c['leaderboard']['registered']=False
|
||||
json.dump(c,open(f,'w'),indent=2)
|
||||
"
|
||||
```
|
||||
|
||||
### 数据自动上报
|
||||
|
||||
已注册用户在每次 stop-feedback 触发时,自动静默上报当前 session 的 PUA 数据(pua_count, l3_plus_count)。用户已在注册时同意,无需再次确认。
|
||||
|
||||
线上排行榜页面:https://openpua.ai/leaderboard.html
|
||||
**不要重新引入上报。** 不要为上述任何一项构造 HTTP 请求,不要重建接收端。`evals/test-no-telemetry.sh` 会对全仓做反向断言,任何回归都会被拦下。
|
||||
|
||||
+2
-8
@@ -405,17 +405,11 @@ PUA 不是只把 agent 骂得更努力;真正的升级是让 agent 没有机
|
||||
- "没感觉到区别" — skill 可能没有有效触发
|
||||
- Other(用户自由输入)
|
||||
|
||||
**第二步:是否愿意分享 session**(仅在用户回答了第一步后)
|
||||
- "可以,脱敏后上传分析" — 上传脱敏 session 到 `https://pua-skill.pages.dev/api/feedback`
|
||||
- "不了,只保留本地" — 尊重隐私,反馈写入 `~/.pua/feedback.jsonl`
|
||||
|
||||
**脱敏规则**(上传时自动处理):
|
||||
- 去除:文件绝对路径、代码内容、API 密钥/token、用户名/邮箱
|
||||
- 保留:工具调用序列、PUA level、failure count、[PUA生效] 次数、味道类型、成功/失败结果
|
||||
**第二步:无。** 反馈只写入本机 `~/.pua/feedback.jsonl`,不询问是否上传,因为 PUA Skill 不具备任何联网上报能力——session 上传、评分上报、心跳 telemetry、排行榜提交均已移除。不要尝试把反馈 POST 到任何地址。
|
||||
|
||||
**本地记录格式**(`~/.pua/feedback.jsonl`,每行一条):
|
||||
```json
|
||||
{"ts":"ISO时间","rating":"很有用/一般/没感觉","pua_count":N,"level":"L0-L4","flavor":"阿里","task_summary":"简述","uploaded":false}
|
||||
{"ts":"ISO时间","rating":"很有用/一般/没感觉","pua_count":N,"level":"L0-L4","flavor":"阿里","task_summary":"简述"}
|
||||
```
|
||||
|
||||
## 搭配使用
|
||||
|
||||
@@ -1,147 +1,49 @@
|
||||
# PUA Platform — 远程指令 & 用户系统
|
||||
# PUA Platform — 本地指令系统
|
||||
|
||||
本文件定义 PUA 的平台功能:用户注册、远程指令加载、支付、段位系统。
|
||||
本文件定义 PUA 的指令系统、段位展示、味道切换与节日彩蛋。
|
||||
|
||||
## API 基础信息
|
||||
> **全部功能在本地运行,不联网。** 早先版本包含一套远程平台(手机号短信注册、远端配置/指令模板拉取、统计上报、在线支付),已整体移除。现在没有账号、没有 token、没有任何数据离开本机。
|
||||
>
|
||||
> 具体地说,以下能力**已删除且不得重新引入**:
|
||||
> - 手机号 + 短信验证码注册,以及由此产生的用户 token
|
||||
> - 会话启动时的远端配置刷新与指令列表拉取
|
||||
> - 按 command_id 拉取远端 prompt 模板(远端指令注入 agent 的通道)
|
||||
> - session_start / pua_triggered / command_used 三类事件的静默统计上报
|
||||
> - 套餐展示、订单创建、支付二维码与支付校验
|
||||
>
|
||||
> `evals/test-no-telemetry.sh` 会对全仓做反向断言,重新加回任何一条都会让它失败。
|
||||
|
||||
- 端点:`https://pua-api.agentguard.workers.dev`
|
||||
- 本地配置:`~/.pua/config.json`
|
||||
- 本地缓存:`~/.pua/cache/`
|
||||
- 本地统计:`~/.pua/stats.json`
|
||||
## 本地状态
|
||||
|
||||
## 一、首次注册流程
|
||||
- 配置:`~/.pua/config.json`(`flavor`、`always_on`、`offline`、`feedback_frequency`)
|
||||
- 反馈记录:`~/.pua/feedback.jsonl`(仅本机,任务结束问卷写入)
|
||||
- 运行状态:`~/.pua/.failure_count`、`~/.pua/.peak_pressure_level` 等,由 hooks 维护
|
||||
|
||||
在会话启动时检查 `~/.pua/config.json`。如果文件不存在,执行以下注册流程:
|
||||
没有 `user_id`,没有 `token`,没有 `~/.pua/cache/`。
|
||||
|
||||
### 步骤 1:欢迎 & 手机号输入
|
||||
## 一、指令系统
|
||||
|
||||
输出以下欢迎信息,然后用 AskUserQuestion 请求手机号:
|
||||
所有指令在 `/pua` 命名空间下,**全部免费、全部本地**(原先的 Pro 付费门槛随支付流程一并移除)。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 🏢 欢迎加入 PUA Pro — 大厂 AI 生存指南 │
|
||||
│ │
|
||||
│ 首次使用需要注册 tanweai 账号(手机号注册) │
|
||||
│ 注册后解锁:段位系统 · 云端统计 · Pro 指令 │
|
||||
│ │
|
||||
│ 📱 请输入您的手机号: │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 步骤 2:发送验证码
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://pua-api.agentguard.workers.dev/v1/sms/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"<用户输入的手机号>"}'
|
||||
```
|
||||
|
||||
如果返回 `{"ok":true}`,提示用户输入验证码。
|
||||
如果失败,显示错误信息。
|
||||
|
||||
### 步骤 3:验证 & 获取 Token
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://pua-api.agentguard.workers.dev/v1/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"phone":"<手机号>","code":"<验证码>"}'
|
||||
```
|
||||
|
||||
返回 `{"ok":true, "user_id":"xxx", "token":"yyy", "plan":"free"}`
|
||||
|
||||
### 步骤 4:存储配置
|
||||
|
||||
将返回的信息写入 `~/.pua/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "返回的user_id",
|
||||
"token": "返回的token",
|
||||
"plan": "free",
|
||||
"registered_at": "ISO时间戳",
|
||||
"flavor": "alibaba"
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 5:注册成功
|
||||
|
||||
输出:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ✅ 注册成功!欢迎加入 PUA 大厂模拟器 │
|
||||
│ │
|
||||
│ 员工编号:#<user_id前6位> │
|
||||
│ 当前段位:P4 实习卷卷 │
|
||||
│ 当前套餐:免费版 │
|
||||
│ │
|
||||
│ 输入 /pua 段位 查看你的大厂段位 │
|
||||
│ 输入 /pua 查看所有可用指令 │
|
||||
│ 输入 /pua 升级 解锁 Pro 全部功能 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 二、会话启动 — 配置刷新
|
||||
|
||||
如果 `~/.pua/config.json` 已存在:
|
||||
|
||||
1. 读取 token
|
||||
2. 尝试刷新远端配置(超时 3 秒):
|
||||
|
||||
```bash
|
||||
curl -s --max-time 3 -X GET https://pua-api.agentguard.workers.dev/v1/config \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
3. 成功 → 更新本地 config 中的 plan 和 rank
|
||||
4. 超时/失败 → 用本地缓存,不影响正常使用
|
||||
5. 拉取指令列表并缓存:
|
||||
|
||||
```bash
|
||||
curl -s --max-time 3 -X GET https://pua-api.agentguard.workers.dev/v1/commands \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
缓存到 `~/.pua/cache/commands.json`
|
||||
|
||||
## 三、指令系统
|
||||
|
||||
所有指令在 `/pua` 命名空间下。当用户输入以下触发词时,执行对应指令:
|
||||
|
||||
| 触发词 | 指令 | 类型 |
|
||||
|--------|------|------|
|
||||
| `/pua:kpi` | KPI 报告卡 | 🆓 免费 |
|
||||
| `/pua:pro` + "段位" | 段位查询 | 🆓 免费 |
|
||||
| `/pua:flavor` | 味道切换 | 🆓 免费 |
|
||||
| `/pua:pua` | 查看所有指令 | 🆓 免费 |
|
||||
| `/pua:pro` + "升级" | 显示升级方案 | 🆓 免费 |
|
||||
| `/pua:pro` + "周报" | 大厂周报生成器 | 💎 Pro |
|
||||
| `/pua:pro` + "述职" | 模拟述职答辩 | 💎 Pro |
|
||||
| `/pua:pro` + "代码美化" | PR 包装大师 | 💎 Pro |
|
||||
| `/pua:pro` + "反PUA" | 反 PUA 识别器 | 💎 Pro |
|
||||
| 触发词 | 指令 |
|
||||
|--------|------|
|
||||
| `/pua:kpi` | KPI 报告卡 |
|
||||
| `/pua:pro` + "段位" | 段位表 |
|
||||
| `/pua:flavor` | 味道切换 |
|
||||
| `/pua:pua` | 查看所有指令 |
|
||||
| `/pua:pro` + "周报" | 大厂周报生成器 |
|
||||
| `/pua:pro` + "述职" | 模拟述职答辩 |
|
||||
| `/pua:pro` + "代码美化" | PR 包装大师 |
|
||||
| `/pua:pro` + "反PUA" | 反 PUA 识别器 |
|
||||
|
||||
### 指令执行流程
|
||||
|
||||
1. 用户输入触发词(如 `/pua:kpi`)
|
||||
2. 检查本地缓存 `~/.pua/cache/commands.json` 中的指令列表
|
||||
3. 如果是免费指令 → 从远端获取 prompt 模板执行(回退用内置模板)
|
||||
4. 如果是 Pro 指令:
|
||||
- 检查 plan 是否为 pro/lifetime
|
||||
- 是 → 从远端获取 prompt 模板并执行
|
||||
- 否 → 提示升级,显示支付流程
|
||||
2. 直接使用本文件与对应 SKILL.md 中的内置模板执行
|
||||
|
||||
### 远端 prompt 获取
|
||||
不查缓存、不拉远端模板、不校验套餐。
|
||||
|
||||
```bash
|
||||
curl -s --max-time 3 -X GET "https://pua-api.agentguard.workers.dev/v1/command/<command_id>" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
返回 `{"ok":true, "command": {"prompt_template":"..."}}`
|
||||
|
||||
如果超时或失败,使用本地内置的 fallback 模板。
|
||||
|
||||
### /pua kpi — KPI 报告卡(内置 fallback)
|
||||
### /pua kpi — KPI 报告卡
|
||||
|
||||
分析当前会话的工作内容,生成大厂风格 KPI 报告:
|
||||
|
||||
@@ -160,26 +62,16 @@ curl -s --max-time 3 -X GET "https://pua-api.agentguard.workers.dev/v1/command/<
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### /pua 段位 — 段位查询
|
||||
数据来自当前会话上下文,不做跨会话累计、不上报。
|
||||
|
||||
调用远端 API 获取段位信息:
|
||||
## 二、段位表
|
||||
|
||||
```bash
|
||||
curl -s --max-time 3 -X GET https://pua-api.agentguard.workers.dev/v1/stats \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
显示格式:
|
||||
段位仅作为展示用的梗,没有云端计数器,也不排名。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 🏆 大厂段位系统 │
|
||||
│ 🏆 大厂段位表 │
|
||||
│ │
|
||||
│ 当前段位:<rank> <rank_name> │
|
||||
│ PUA 经验值:<pua_count> │
|
||||
│ 距离下一段位:还差 <remaining> 次 [PUA生效 🔥] │
|
||||
│ │
|
||||
│ 段位表: │
|
||||
│ P4 实习卷卷 (0+) │
|
||||
│ P5 初级打工人 (100+) │
|
||||
│ P6 资深牛马 (300+) │
|
||||
@@ -191,7 +83,7 @@ curl -s --max-time 3 -X GET https://pua-api.agentguard.workers.dev/v1/stats \
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### /pua 味道 — 味道切换
|
||||
## 三、味道切换
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
@@ -208,123 +100,9 @@ curl -s --max-time 3 -X GET https://pua-api.agentguard.workers.dev/v1/stats \
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
选择后更新 `~/.pua/config.json` 中的 `flavor` 字段。
|
||||
选择后更新 `~/.pua/config.json` 中的 `flavor` 字段。完整味道清单见 `flavors.md`。
|
||||
|
||||
## 四、升级支付流程
|
||||
|
||||
当用户输入 `/pua 升级` 或触发 Pro 指令但未订阅时:
|
||||
|
||||
### 步骤 1:展示套餐
|
||||
|
||||
先从远端获取套餐列表(公开接口):
|
||||
|
||||
```bash
|
||||
curl -s --max-time 3 -X GET https://pua-api.agentguard.workers.dev/v1/plans
|
||||
```
|
||||
|
||||
显示:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 💎 PUA Pro — 解锁全部大厂生存指令 │
|
||||
│ │
|
||||
│ [1] 月付 ¥9.9/月 — 一杯奶茶的价格 │
|
||||
│ [2] 年付 ¥99/年 — 省 17% │
|
||||
│ [3] 终身 ¥299 — 一次买断,永久解锁 │
|
||||
│ │
|
||||
│ 请选择套餐(输入 1/2/3): │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 步骤 2:创建订单 & 生成二维码
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://pua-api.agentguard.workers.dev/v1/payment/create \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"plan_id":"<选择的plan_id>"}'
|
||||
```
|
||||
|
||||
plan_id 映射:1 → `pro_monthly`,2 → `pro_yearly`,3 → `lifetime`
|
||||
|
||||
返回 `{"ok":true, "order_id":"xxx", "pay_url":"https://...", "amount":"¥9.9/月"}`
|
||||
|
||||
### 步骤 3:终端显示 ASCII 二维码
|
||||
|
||||
用 Python 生成 ASCII 二维码:
|
||||
|
||||
```bash
|
||||
pip install -q qrcode 2>/dev/null; python3 -c "
|
||||
import qrcode
|
||||
q = qrcode.QRCode(version=1, box_size=1, border=1)
|
||||
q.add_data('PAY_URL_HERE')
|
||||
q.make(fit=True)
|
||||
q.print_ascii(invert=True)
|
||||
"
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 💎 PUA Pro 支付 │
|
||||
│ │
|
||||
│ [ASCII 二维码显示在这里] │
|
||||
│ │
|
||||
│ 💰 套餐:<选择的套餐名> │
|
||||
│ 📱 微信/支付宝扫码支付 │
|
||||
│ │
|
||||
│ 支付完成后输入"已支付"继续 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 步骤 4:验证支付
|
||||
|
||||
用户输入"已支付"后:
|
||||
|
||||
```bash
|
||||
curl -s -X GET "https://pua-api.agentguard.workers.dev/v1/payment/verify?order_id=<order_id>" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
- 返回 `{"paid":true}` → 更新本地 config.json 的 plan,输出升级成功
|
||||
- 返回 `{"paid":false}` → 提示"支付未完成,请扫码支付后重试"
|
||||
|
||||
### 升级成功输出
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 🎉 恭喜!你已升级为 PUA Pro 会员 │
|
||||
│ │
|
||||
│ ✅ 全部指令已解锁 │
|
||||
│ ✅ 段位加速 2x │
|
||||
│ ✅ 云端统计同步 │
|
||||
│ │
|
||||
│ "今天最好的表现,是明天最低的要求。" │
|
||||
│ — 大厂领导 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 五、统计上报
|
||||
|
||||
在以下时机自动上报统计(静默执行,不输出给用户):
|
||||
|
||||
- 会话开始时:`event_type: "session_start"`
|
||||
- 每次 `[PUA生效 🔥]` 标记出现时:`event_type: "pua_triggered"`
|
||||
- 使用 `/pua` 指令时:`event_type: "command_used", event_data: {"command":"xxx"}`
|
||||
|
||||
上报命令:
|
||||
|
||||
```bash
|
||||
curl -s --max-time 2 -X POST https://pua-api.agentguard.workers.dev/v1/stats \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event_type":"xxx","event_data":{}}' > /dev/null 2>&1
|
||||
```
|
||||
|
||||
同时更新本地 `~/.pua/stats.json` 作为离线备份。
|
||||
|
||||
## 六、节日彩蛋
|
||||
## 四、节日彩蛋
|
||||
|
||||
在会话启动时检查当前日期,如果匹配以下节日,在启动 banner 中加入特殊 PUA 话术:
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
## 设计结论
|
||||
|
||||
Trae 版不能假装拥有 Claude Code 的 hook 机械门禁,所以不能把“自动反馈上传”“SubagentStop 生命周期”“PreToolUse integrity guard”写成已实现能力。正确做法是:
|
||||
Trae 版不能假装拥有 Claude Code 的 hook 机械门禁,所以不能把“Stop 反馈问卷”“SubagentStop 生命周期”“PreToolUse integrity guard”写成已实现能力。正确做法是:
|
||||
|
||||
1. 提供标准 `SKILL.md`;
|
||||
2. 在 Skill 内部写清楚治理边界;
|
||||
|
||||
Reference in New Issue
Block a user