一句话架构
Tunly 以 Namespace 为唯一归属边界,以 Link 为长期分享对象,以 Route 提供稳定地址,以 Revision 表达每次发布版本;所有公开请求都按 Route → Link → Access → Revision 的顺序确定性解析。
管理长期事实
tunlyctl 提供认证、API Key、发布、成员、访问策略、Review API 和 Dashboard。
处理公开流量
tunlyd 接受 HTTP 与 WebTransport/QUIC,解析公共 Route,转发 live 流量或读取 artifact。
按生命周期分层
Postgres 存产品事实,Redis 存短期运行态,ArtifactStore 存不可变发布内容。
目标、非目标与所有权边界
系统目标
- 一条命令把本地服务或 artifact 变成稳定公共 URL。
- Latest URL 可更新,Version URL 保持不可变。
- 公开、账号、密码、邮箱、域名和 Guest Token 访问控制行为一致。
- Agent 获得确定的 JSON/NDJSON 协议,而不是解析人类日志。
- HTML 文档可按 Revision 做锚点评论、回复、解决与历史回看。
明确非目标
- 不引入 Organization、Workspace、Account、Business 或 Project 作为平行所有权模型。
- P0 不为 webhook、OAuth、MCP、snapshot 或 runtime session 建独立产品表。
- 不让 legacy
/pub或磁盘路径绕过 PublicResolver。 - 不把高频心跳、live lease、viewer session 或评论事件流写入 Postgres。
唯一根边界:Namespace
Namespace ├── Memberships (owner / member) ├── API Keys ├── Links │ ├── Routes │ ├── Revisions │ └── Review Annotations (P1, pinned to one Revision) └── Runtime state (Redis keys scoped by Namespace / Link / Revision)
任何请求体里出现的 ID 都不是授权事实。服务端必须从认证身份、API Key 或已验证 viewer grant 得到 Namespace,再用数据库中的复合关系验证 Link 和 Revision 属于同一边界。
系统上下文与信任边界
Agent / Human CLI Browser Reviewer
│ API key / attach grant │ HTTPS / session
▼ ▼
┌──────────────────────────────────────────────────────────────┐
│ Caddy · TLS edge │
│ control paths ───────────────┐ public paths ────────────┐ │
└───────────────────────────────┼────────────────────────────┼──┘
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ tunlyctl │ │ tunlyd │
│ control plane │ │ edge + live proxy │
│ publish / review │ │ PublicResolver │
└──────┬─────┬───────┘ └──────┬─────┬───────┘
│ │ │ │
┌─────▼─┐ ┌─▼───────────┐ ┌─────▼─┐ ┌─▼───────────┐
│Redis │ │Postgres │ │Redis │ │ArtifactStore│
│runtime│ │product facts│ │leases │ │manifests │
└───────┘ └─────────────┘ └───────┘ └─────────────┘
▲
│ same durable repository contract
└─────────────────────────────────────────
| 边界 | 可信输入 | 不可信输入 | 强制检查 |
|---|---|---|---|
| CLI → Control | 经 hash 验证且未撤销的 API Key | Link ID、route hint、文件名、metadata | Namespace membership、配额、幂等键、artifact 扫描 |
| Browser → Public | 服务端 session 或已签发 viewer grant | Host、Path、query、cookie、guest token | Route 状态、Link 策略、AccessEvaluator、conceal 规则 |
| Edge → Local Agent | 一次性 attach grant 与已注册连接 | 来源声明、local target、重连顺序 | Namespace scope、generation、readiness proof、lease |
| Review iframe → API | 精确范围 HMAC read capability | postMessage、selector、annotation ID | origin/schema/Link/Revision、账号写权限、乐观锁 |
持久领域模型
P0 长期产品事实保持七张表;P1 Review 只增加一张 revision-pinned annotation 表和一个 Link 开关。
| 实体 | 职责 | 关键约束 | 删除/失效语义 |
|---|---|---|---|
users | 账号身份 | Email 唯一;密码只存强 hash | disabled_at 使认证失效 |
namespaces | 根所有权边界 | 所有长期资源最终归属它 | 级联清理 namespace 产品事实 |
namespace_memberships | owner/member RBAC | Namespace + User 唯一 | 移除成员后 API Key attach 也必须失败 |
api_keys | Agent/CLI 身份 | 只存 prefix + hash;支持过期与撤销 | 撤销后控制面和 attach 都拒绝 |
links | 长期分享对象与 Latest 指针 | Namespace 复合约束;CAS lock_version | disabled 后公共解析失败 |
routes | Host + Path → Link | 活动地址唯一;同 Namespace 目标 | reserved/active/disabled 状态机 |
revisions | 版本边界 | Link 内版本号唯一;ready artifact 元数据完整 | 失败或隔离版本不可提升 |
review_annotations | 根评论与一级回复 | Namespace + Link + Revision + self FK | 软删除保留 tombstone;Revision 删除级联 |
Link 与 Revision 的指针语义
Latest
links.current_revision_id 是明确提升出来的当前版本。新的 publish 只有通过完整准备和 CAS 后才移动它。
Version
/versions/{revision_id}/ 精确解析某个 artifact Revision,不跟随 Latest 变化,适合审计、引用与评论历史。
组件职责与依赖方向
apps/tunly · CLI
publish:目录/HTML artifact 或 live port。preview:短生命周期 Agent Preview Session。share/http/tcp:建立 live transport。tunly.agent.v1:稳定 JSON/NDJSON 生命周期事件。
apps/tunlyctl · Control
- 认证、浏览器 session、API Key、成员、配额。
- artifact publish pipeline 与 manifest 写入。
- Review API、SSE、sidebar SPA。
- 签发一次性 attach grant 与 Review read capability。
apps/tunlyd · Edge
- HTTP public ingress 与 WebTransport/QUIC control。
- live connection manager、readiness probe、generation/lease。
- 通过 PublicResolver 统一服务 live 与 artifact。
- 在生产配置下禁止绕过 durable resolver。
Shared infrastructure
- Postgres repository:长期事实和事务状态机。
- Redis stores:viewer grant、session、live lease、SSE invalidation、rate limit。
- ArtifactStore:LocalStore/PVC 或 S3-compatible provider。
- Caddy:TLS、控制面路径与 public path 分流。
依赖原则
HTTP/CLI adapter
│
▼
Narrow domain interface ──► service / policy ──► repository interface
│
▼
Postgres / Redis / ArtifactStore
规则:Public resolver 只有读路由能力;普通 Route admin 不能获得 publish/promote 能力。
核心请求流程
A. Artifact 发布与原子提升
--link、project ref 或 route hint 复用/创建 Link 与 Route。pending → uploading → processing,记录服务端可信扫描结果。lock_version 未变化时才写 current revision;并发慢发布返回 409。B. 公共请求解析
HTTP request │ ├─ 1. normalize Host + Path ├─ 2. resolve the most specific active Route ├─ 3. load active Link inside the same Namespace ├─ 4. evaluate Link access policy (fail closed) ├─ 5. choose exact Version or Link.current_revision_id ├─ 6. require a servable Revision │ ├─ live ─────► validate active Redis lease/generation ─► proxy local origin └─ artifact ─► read manifest + file from ArtifactStore ─► apply profile headers
Latest 响应使用可重新验证的 cache 语义;Version artifact 可使用 immutable cache。私有内容、Review rendition 和访问挑战必须使用 private/no-store,避免代理缓存跨用户泄露。
C. Live attach 与 Latest
- CLI 先通过控制面用 API Key 请求一次性、短 TTL、精确 Namespace/Link scope 的 attach grant。
- CLI 与 edge 建立 WebTransport/QUIC;edge 消费 attach grant,并检查 API Key 所有者仍是活跃成员。
- edge 建立带 generation 的 Redis live lease,旧连接不能覆盖新 generation。
- 生产环境从 edge 往本地 origin 做真实 readiness round trip。
- 只有探针通过才原子提升 live Revision;失败则清理 lease,Latest 保持不动。
HTML Review:Revision 上的协作层
Review 不修改原始 artifact,也不创建新的文档模型。它是 Link 开启的策略,并把每条 annotation 固定在一个 Namespace + Link + Revision 元组上。
Rendition 与隔离
返回发布时的原始 HTML,不注入 bridge,保留正常 Latest/Version cache 语义。
带 ?__tunly_review=1,只对 document profile 的 HTML entrypoint 动态注入 bridge 与 iframe。
React UI 由控制面同源提供;artifact bridge 不接触账号 token、评论正文或 API credentials。
锚点定位策略
根评论同时保存 W3C 风格的 TextQuoteSelector 与 TextPositionSelector。渲染时先用位置快速验证,再用 exact + prefix + suffix 回退;匹配不唯一或找不到时显示 orphan,而不是猜测错误位置。
{
"type": "Text",
"refinedBy": [
{ "type": "TextQuoteSelector", "exact": "selected text", "prefix": "before ", "suffix": " after" },
{ "type": "TextPositionSelector", "start": 120, "end": 133 }
]
}
读写权限与实时更新
| 能力 | 匿名 capability | 登录账号 | 一致性 |
|---|---|---|---|
| 读取线程 | 允许,15 分钟 HMAC,精确 scope | 通过 Link access 后允许 | 每次返回完整逻辑线程 |
| 创建/回复 | 拒绝 | 允许 | 提交数据库后发布 invalidation |
| 编辑/删除 | 拒绝 | 作者或 moderator | expected_updated_at,冲突返回 409 |
| 解决/重开 | 拒绝 | 根作者、成员或 admin | 只允许操作 root |
| SSE | 允许只读 | 允许 | 先发 reconcile;Redis 不是 durable event log |
安全模型
访问模式
| 模式 | 认证事实 | 运行态 | 关键风险控制 |
|---|---|---|---|
| public | 无 | 无 | 仍执行 Route/Link/Revision 状态检查 |
| account | 有效账号 session + 活跃 membership | Redis auth session | 成员移除立即失效 |
| password | bcrypt hash 比对 | viewer grant | 明文不持久化;grant 有 TTL |
| email/domain | OTP 验证身份 | challenge、identity、grant | 绑定 Link、过期、尝试限制 |
| guest | 常量时间比较的 token hash | viewer grant | 支持过期/撤销;URL 清理 |
Artifact 安全 profile
static_app
允许受约束的静态应用资源,PublicResolver 统一施加 CSP、nosniff、frame 与 cache 头。不会自动获得 Review 注入。
document
偏保守的文档 sandbox。Review rendition 生成 nonce,限制 frame-src 到 sidebar origin,并使用 no-store/noindex。
隐私原则
- 结构化日志记录 method、URL、status、bytes、duration,不记录评论正文、选择文本、cookie、Bearer token 或 capability。
- Review capability 放在 iframe URL fragment,不进入 HTTP access log、Referer 或 query。
- 软删除评论清空正文并保留 tombstone,使会话结构可理解但不继续暴露删除内容。
- conceal unauthorized 的 Link 对无权限请求返回不可区分的 not found,避免资源枚举。
一致性、失败模式与恢复
| 失败 | 用户可见行为 | 系统保证 | 恢复方式 |
|---|---|---|---|
| Artifact 上传中断 | Publish 失败;Latest 不变 | Pending/failed Revision 不可服务 | 用相同或新 idempotency key 重试 |
| 扫描拒绝 | Revision quarantined/failed | 不会被 promote | 修复源文件并重新发布 |
| 并发 Publish | 慢请求收到 409 | CAS 防止旧内容覆盖新 Latest | 重新读取 Link 后显式重试 |
| Redis 不可用 | live/OTP/SSE 降级;artifact 仍可按策略读取 | 评论数据库提交不因事件发布失败而回滚 | SSE reconnect + polling reconcile |
| Live agent 断开 | 在线内容不可用或 fallback | generation 防止旧连接复活 | 客户端重连并获得新 lease |
| Postgres 不可用 | 控制与新解析 fail closed | 不使用 Redis 缓存绕过授权事实 | 恢复数据库;readiness 反映依赖失败 |
| ArtifactStore 不可用 | artifact not ready/unavailable | 不返回部分文件或错误版本 | 对象存储恢复后无需改数据库指针 |
| Review anchor 漂移 | 显示 orphan | 不做错误高亮 | 在原 Revision 处理或新建评论 |
备份与回滚
- 数据库迁移前生成可由同版本
pg_restore -l解析的备份,并记录 SHA-256。 - 应用发布使用不可变 tag/digest;Deployment rollout undo 不依赖重新构建镜像。
- additive migration 的应用回滚通常不要求立即 down migration,避免数据丢失。
- Artifact objects 使用不可变 key;回滚 Latest 只移动 Link 指针,不覆盖旧 bytes。
当前部署与扩展路径
当前单节点生产拓扑
Internet :80/:443 TCP ──► Caddy LoadBalancer
│
├─ /api, /app, /login, /review/sidebar ─► tunlyctl:8080
├─ /wt (h2c fallback) ──────────────────► tunlyd-control:8081
└─ all public Link paths ───────────────► tunlyd:80
Internet :443 UDP ───────────────────────────────────────────────────► tunlyd:443 QUIC
tunlyctl + tunlyd ─► Postgres service
├► dedicated Tunly Redis
├► shared ArtifactStore provider
└► OTLP logs collector
Persistent volumes: artifact bytes, Postgres data, Redis data, Caddy state
横向扩展目标
Object Storage
从 LocalStore/PVC 切换到 Hetzner S3-compatible provider;发布和读取接口不变,只迁移 objects 与 secrets。
Multi-edge
Route/Revision 来自共享 Postgres,live binding 用 Redis 指向 edge/generation;artifact 可由任一 edge 读取。
Independent scaling
控制面按 API/Review 负载扩展,edge 按 public traffic 与 live connection 数扩展,两者不共享进程状态。
CLI、HTTP API 与可观测性
Agent-facing CLI contract
tunly publish ./design.html --review --public --route system-design --json
{
"schema": "tunly.agent.v1",
"event": "completed",
"payload": {
"namespace_id": "...",
"link_id": "...",
"revision_id": "...",
"latest_url": "https://tunly.io/system-design/",
"version_url": "https://tunly.io/versions/.../",
"review_url": "https://tunly.io/system-design/?__tunly_review=1",
"state": "ready"
}
}
API 设计原则
- 资源 API 以 Namespace 派生授权,不信任客户端提交的 namespace ID。
- 错误使用稳定 problem JSON 与明确 HTTP 状态:400、401、403、404、409、429、500。
- 长期操作使用 idempotency key;并发状态更新使用 CAS 或时间戳 precondition。
- SSE 只传 invalidation,不把 Redis 当可回放事件日志;客户端收到事件后重新读取 canonical state。
观测信号
| 层 | 信号 | 不允许出现 |
|---|---|---|
| HTTP | method、normalized path、status、bytes、duration、request ID | Authorization、cookie、OTP、comment body |
| Publish | namespace/link/revision、状态迁移、文件数、总字节、失败分类 | artifact 原始内容、API Key |
| Live | connection ID、edge、generation、readiness、lease transition | 代理请求私密 body |
| Review | 操作类型、scope IDs、status、SSE reconnect | 选择文本、评论正文、capability |
演进路线与容量触发器
| 阶段 | 保持不变 | 允许演进 | 触发条件 |
|---|---|---|---|
| P0/P1 当前 | Namespace/Link/Route/Revision、PublicResolver、ArtifactStore contract | 单节点 K3s、本地 PVC、单 Redis | 当前产品验证 |
| 存储扩展 | Manifest layout 与发布状态机 | S3 provider、生命周期清理、跨区复制 | PVC 容量/可靠性成为风险 |
| Edge 扩展 | Route 解析与 generation lease | 多 edge 调度、区域亲和、连接迁移 | 连接数或网络延迟要求 |
| Review 扩展 | Revision-pinned annotation 与 selector contract | mentions、通知、批量 resolve、导出 | Review 使用量证明价值 |
| 新集成 | Link + Route + Revision 复用原则 | 协议 adapter 与 metadata | 已批准 ADR 证明独立表必要 |
关键决策记录
本轮建议重点评审
- Link 是否足以成为 live、static、evidence、design doc 的统一产品对象?
- 访问策略只挂 Link,是否需要近期支持 revision-specific audience?
- 两阶段 publish/promote 对 Agent 高频发布是否需要 cancel 或 manual promote?
- 新 Revision 不复制评论,只显示 prior unresolved count,是否符合设计评审习惯?
- 从 LocalStore 切换对象存储前,哪些 SLO、容量或恢复指标应作为硬触发器?