Merge remote-tracking branch 'origin/main' into codex/guidance-publishing
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 3m28s

# Conflicts:
#	apps/waterfall-wiki/ACCOUNT.md
#	apps/waterfall-wiki/tests/auth.test.ts
This commit is contained in:
2026-10-29 16:55:12 +08:00
4 changed files with 20 additions and 4 deletions
+4 -1
View File
@@ -35,7 +35,10 @@ the global cap to 60 per trusted client address. Do not enable it on the direct
Limits count failures and successes. Registration/resend always give the same success
response for existing/unknown emails, and login uses a dummy KDF for unknown accounts.
The rate limit itself can reveal recent activity for a known email; it does not reveal
whether an account exists. No password recovery or account deletion is included.
whether an account exists. Synchronous SMTP latency can still expose a timing side channel;
a durable asynchronous mail queue would be needed to fully decouple delivery timing.
Logout remains available when login/mail limits are exhausted. No password recovery or
account deletion is included.
## Verification
@@ -14,10 +14,11 @@ export async function POST(request: Request,context: Context) {
const {action} = await context.params;
if (!['register','resend','verify','login','logout'].includes(action)) return json({error:'不存在'},404);
try {
// Revocation remains available even when login or mail limits are exhausted.
if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; }
// Use a global cap by default: forwarding headers are untrusted unless the deployment explicitly enables a trusted ingress.
const ip = process.env.ACCOUNT_TRUST_PROXY === 'true' ? request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'unknown' : 'global';
if (!await rateLimit('request:'+ip,process.env.ACCOUNT_TRUST_PROXY === 'true' ? 60 : 300)) return json({error:'请求过于频繁,请稍后再试'},429);
if (action === 'logout') { await logoutAccount(request); const r=json({ok:true});r.headers.set('Set-Cookie',accountCookie('',true));return r; }
if (Number(request.headers.get('content-length') || 0)>4096) return json({error:'请求过大'},413);
const text=await request.text(); if(text.length>4096)return json({error:'请求过大'},413);
let body;try{body=JSON.parse(text);}catch{return json({error:'请求格式错误'},400);}
+6 -2
View File
@@ -36,12 +36,16 @@ test('proxy protects APIs, data routes, static content and Studio; fails closed'
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
for(const path of ['/library/import','/skills/example.png','/_next/data/build/index.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
for(const path of ['/api/upload','/studio/api/export','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
for(const path of ['/','/write','/documents','/api/documents','/api/documents/preview','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
for(const path of ['/','/write','/documents','/api/documents','/api/documents/preview','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://wiki.test'}})).status,200);
writeFileSync(process.env.AUTH_CONFIG_FILE,'{}'); assert.throws(()=>authConfig()); assert.equal(proxy(new NextRequest('http://wiki.test/api/feed')).status,503);
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{cookie:'wiki_account='+ 'a'.repeat(64)}})).status,401);
assert.equal(proxy(new NextRequest('http://wiki.test/guide/welcome',{method:'POST'})).status,401);
writeFileSync(process.env.AUTH_CONFIG_FILE,'{}'); assert.throws(()=>authConfig()); assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503);
delete process.env.AUTH_CONFIG_FILE;
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503,'Missing admin configuration fails closed');
}finally{if(old===undefined)delete process.env.AUTH_CONFIG_FILE;else process.env.AUTH_CONFIG_FILE=old;rmSync(temp,{recursive:true,force:true});}
});
+8
View File
@@ -36,3 +36,11 @@ Hugging Face 可借鉴:owner/name 身份、标签、概览与文件/社区分
- 文件标签:观察到 references、scripts 文件夹和 SKILL.md、共 4 文件;侧栏有 token 预估、下载收藏、作者、安全报告、内容指纹、数字签名与相关推荐。文件树仍将整个包作为单位。
本项目取舍:保留清晰命名空间、列表可扫描性、阅读与源文档切换;将安装栏改为“采用单篇”,将包文件树改为条件链接和反向引用。暂不复制评分、下载热度、认证徽章,避免没有真实数据的信任暗示。当前暖绿原型是独立视觉探索,需完成参考截图与服务器验收后修订。
## 第二轮截图补充
已在主 chat 逐项截取:文件树与安装侧栏、元数据与签名侧栏、版本历史、手机号登录弹窗、企业作者页、发布访问限制。截图为 Chrome 原生窗口实拍,包含浏览器框;暂未存为仓库图片文件。
版本历史为时间线,显示当前版本标记、日期、说明、展开/下载控件;安装右栏在不同标签间保持一致。登录弹窗区分个人/团队,个人有微信和手机号模式,手机号模式包含验证码输入、协议勾选、主按钮;没有执行登录或同意协议。作者页包含头像/名称/认证/简介、关注、横向统计条、左侧排序分类、右侧搜索与作品目录。发布入口 `/dashboard/publish` 匿名访问明确显示“需要登录才能访问”,无法检查其内部编辑器,不假定内部布局。
待补:详情评论和评测标签、首页下半屏的独立截图、移动端;插件、付费服务、大赛作为非核心周边功能,不进入本次开发首版。第一版发布使用结构化指导编辑器,支持 XML 校验与预览,不沿用未知的 SkillHub 内部发布流程。