# 桌面永久授权与单设备在线租约（v1）

对应 #16；9Star 客户端对应 mllt992/9star#1。本协议不增加支付业务，不改真实商品价格、应用、OAuth 凭据或部署配置。9Star 产品规则：一次性人民币 69 元、永久授权、全部未来版本更新，同一授权同时仅一个设备/运行实例。永久权益与短期运行许可是两个不同概念。

## 兼容与接入前提

- 迁移 `027_authorization_leases.sql` 只增加两张表，不修改现有授权期限、商品、应用或凭据；按既有 migrate 流程执行。没有生产迁移或部署已被执行。
- `duration_days=0` 仍发放 `expires_at=NULL` 授权；后续版本继续使用同一 application/client 身份，不提交版本截止日期。恢复接口的 `perpetual` / `all_future_updates` 在 expires_at 为空时为 true。
- 新接口显式采用单实例协议；旧 `/auth/check`、API Key 和其他授权类型保持原语义，它们不提供运行期互斥。不能将支持离线永久缓存的旧客户端与新客户端混用并声称严格单设备；上线前需评估旧客户端准入。
- 管理员另行配置现有 PublicClient、精确登记 HTTP loopback 回调（例如 `http://127.0.0.1:PORT/callback/`）及所需 scopes。此变更不注册客户端、不产生密钥、不改真实应用。
- 系统浏览器 OAuth `/oauth/authorize` + S256 PKCE；`state` 必须随机并校验，openid 使用随机 nonce。建议 scopes：`openid profile offline_access license:read license:activate`。`/oauth/token` 用表单提交 code/verifier 或 refresh_token，不内置 client_secret。沿用现有短期 access token 和 refresh rotation。

## HTTP / JSON

所有新接口仅接受 `Authorization: Bearer <access_token>`，不接受 API Key 或浏览器 Cookie 身份。新 POST 接口免 CSRF，但缺少有效 Bearer 仍返回 401。所有请求都校验用户、应用、授权归属及授权状态。授权 ID 不等于凭据。

成功沿用 `{ "code": 0, "message": "success", "data": ... }`；时间为 UTC RFC3339。错误 `{ "code": HTTP状态*100, "message": "稳定错误码", "error": "稳定错误码" }`，客户端按 `error` 分支。

### 恢复与激活

`GET /api/v1/auth/entitlements`（license:read）

返回 data：

```json
{"server_time":"2026-10-01T12:00:00Z","authorizations":[{"authorization_id":"uuid","application_id":"uuid","auth_type":"device","status":"active","starts_at":"2026-10-01T00:00:00Z","expires_at":null,"perpetual":true,"all_future_updates":true}]}
```

只返回当前用户、当前 token 应用的授权，包含失效状态用于解释问题。空列表为没有该产品授权。权益不包含其他设备标识、激活码或其他产品信息。列出权益本身不能解锁运行。

`POST /api/v1/authorizations/activate` 继续使用 `{ "code": "激活码" }` 与 license:activate。首次激活锁住激活码；同一用户和应用的 Bearer 设备授权重试返回原 authorization_id，不再创建授权；浏览器及非设备授权重复兑换仍返回原有 409 行为。恢复返回原授权策略，不依赖后来被修改的商品策略。其他用户不能重领，其他应用不能探测此码。已失效授权不会被重启。Bearer 设备绑定接口同一授权/设备重复操作也返回已有绑定；浏览器接口保留原行为。

### 租约

- `POST /api/v1/auth/leases/acquire`：同时需要 license:read 与 license:activate
- `POST /api/v1/auth/leases/heartbeat`、`release`：license:read
- `POST /api/v1/auth/leases/transfer`：license:activate

Acquire body：

```json
{"authorization_id":"uuid","device_id":"稳定随机安装标识，16至255字符","instance_id":"每次获取尝试新建的至少32字符随机值"}
```

device_id 不是用户名、机器名或硬件序列号，建议本机安全保存的随机安装标识。instance_id 是运行实例所有权，不应展示/日志记录/跨进程共用。进程通过本地单实例机制避免重复工作；另一进程即使 device_id 一样也不能接管现有租约。同一获取操作重试必须复用 instance_id；成功租约被释放或过期后必须使用新的 instance_id。服务端只保存 device/instance 的哈希用于租约匹配；既有绑定表保留该随机 device_id。

成功 data：

```json
{"lease_id":"随机值","generation":1,"server_time":"2026-10-01T12:00:00Z","expires_at":"2026-10-01T12:02:00Z","heartbeat_after_seconds":30,"lease_ttl_seconds":120}
```

Heartbeat / release body 必须携带 acquisition 的全部五个字段：authorization_id、device_id、instance_id、lease_id、generation。心跳只可延长尚未过期、未撤销且绑定仍有效的同一租约。重复 acquire 返回已有 deadline，不续期；已成功过的过期/释放 instance_id 永远不能复活。release 返回相同数据并增加 `status:"released"`，只有精确原实例重复 release 才幂等，旧 generation 不能释放继任者。

默认 TTL 120 秒、建议心跳 30 秒，为可配置工程值，不是产品承诺。可在服务器启动环境指定 `LICENSE_LEASE_TTL_SECONDS`（60–600）和 `LICENSE_LEASE_HEARTBEAT_SECONDS`（至少5，严格小于 TTL/2）。无效参数拒绝启动。所有进程应使用同样配置；期限由 PostgreSQL clock_timestamp 决定，不能信任客户端时间。

首次 acquire 在事务内创建已验证设备绑定。达到绑定上限时使用下面的显式换机，不擅自删除旧绑定。

### 严格换机

transfer body：`{"authorization_id":"uuid","device_id":"目标安装标识"}`。

返回 data：`{"status":"transfer_pending","server_time":"...","retry_after":"...","heartbeat_after_seconds":30,"lease_ttl_seconds":120}`。已是当前目标且没有撤销/待转移时可返回 `transfer_ready`，重试不会撤销继任者。

换机立即禁止原租约续期，但绝不缩短它曾经收到的 expires_at。旧设备先停止受限工作并 release，或者等其实际到期，目标设备才可 acquire。transfer 不会直接授予新租约。只有排除窗口结束后，目标 acquire 才事务性替换旧设备绑定并增大 generation。客户端必须再次成功 acquire 才解锁。

禁用用户/应用、撤销 OAuth/session/consent、取消或退款授权都拒绝后续续期。解绑将租约标为 revoked，即使后来重新绑定同一 device_id 也不能恢复原租约。任何撤销后的离线剩余许可最多持续到原截止时间；不能保证瞬时停止已离线客户端。授权禁用/恢复不应被当作缩短旧截止时间的换机手段。

### 客户端时钟与失败原则

记录请求开始时的单调计时值，收到成功后计算保守本地截止值：`request_start_monotonic + (expires_at - server_time)`，不要用响应接收时间加 TTL。慢响应已超过这个截止值时不能解锁。忽略在更新状态之后返回的旧响应。睡眠/恢复时应主动重新校验，或使用包含休眠时间的单调计时器；无法证明期限有效时停止受限工作。

网络错误可在已确认租约剩余时间内重试，不延长本地期限。到期、注销、换机、授权拒绝时停止受限工作；永久 entitlement 的本地缓存仅供显示，不能替代租约。客户端停止工作后才发送 release。服务端无法防止被修改的恶意离线客户端，受保护的远程资源应同时校验 generation。

## 稳定错误

- 400：invalid_request、unsupported_authorization_type（仅 device 类型使用本协议）
- 401：invalid_token（缺失、过期、撤销、用户/应用/session 不可用）
- 403：insufficient_scope、authorization_inactive、lease_revoked、transfer_target_mismatch
- 404：authorization_not_found（包括错误用户或应用，无信息泄漏）
- 409：lease_conflict、lease_stale、lease_expired、binding_limit_reached
- 500：server_error（不返回数据库细节）

lease_conflict 额外返回顶层 server_time 与 retry_after（旧租约截止时间），不泄露持有设备/进程身份。等待是建议时间，成功 acquire 仍是唯一运行许可。

## 并发与保留策略

应用/用户/session/access-token 按固定顺序持有共享锁，与撤销串行化；授权行 FOR UPDATE 是跨进程租约互斥锁。slot 主键保证一授权一行，generation 只增不减。获取实例表唯一约束和持久墓碑防止旧请求重放成为新租约。不能通过删除 slot 或清理 instance 墓碑“重置”租约；此版本不自动清理，以避免改变重放边界。大量正常会话的存储增长需观测后设计有证明的保留策略。

## 验证范围

新增单元测试覆盖永久权益、截止边界、generation/instance fencing、撤销保留排除窗口及配置边界。PostgreSQL 集成测试覆盖两个独立连接池并发获取、同设备多实例、重复 acquire/release、显式换机、过期、旧代请求、解绑重绑定、禁用/撤销、OAuth 用户/应用隔离、永久激活重试与恢复及撤销/解绑竞态。

本轮云端工作区无 Go 编译器或已核验 PostgreSQL，未创建容器或安装工具链。执行结果以此 PR 对应提交的现有 Verify CI（Go test、go vet 及 PostgreSQL 集成）为准。尚未进行真实 Windows 客户端联调、生产数据库迁移或上线，不能将 CI 通过视为这些项目已完成。

### CI 资源与存储验证范围

常规 Verify 在 PR 和 main 分支提交时运行，不再对同一 PR 分支的 push 重复运行；同一事件/分支的旧运行会被新提交取消。Go/PostgreSQL/Redis、web、miniapp、smoke、platform、role_workspace、overview 测试继续执行，不默认启动 MinIO/RustFS 对象存储容器。

- 图片/附件已有真实本地文件后端：TestImageStorageLocalRoundTrip 和 TestImageStorageIntegration 使用临时目录验证写入、读取、删除、存储切换与旧文件保留。
- 版本元数据、状态、权限、幂等/并发、历史清理仍由 PostgreSQL 集成测试验证；既有轻量进程内 S3 协议 fixture 也保留。fixture 测试不能证明真实存储厂商兼容。
- 发布安装包当前仅支持 S3，没有本地文件发布后端；本次不新增后端，也不声称本地目录覆盖了真实 S3 分片上传、断点续传、校验和或下载。
- 真实 S3 字节链路仍保留原 scripts/release_tests.py。手动运行 Verify（workflow_dispatch），明确选择 run_s3=true 后才启动 compose.test.yaml 的 s3 profile，创建隔离测试 bucket 并执行该套件。默认 false，步骤摘要明确显示未运行。

原 MinIO/mc 固定版本镜像在本轮验证中无法匿名拉取，因此可选真实 S3 套件仍有 fixture 可用性限制；启用后拉取失败会使运行失败，不会伪装成通过。未引入源码构建、RustFS 替换、镜像凭据或生产存储配置改动。未来需单独确认可用的测试 fixture 后再验证真实兼容性。

另补入 Dockerfile 中遗漏的既有 check-notifications.cjs COPY，避免 npm prebuild 在隔离 CI 应用构建时找不到检查脚本。
