M9:桌面壳与收口——镜匣最小壳、器谱登记、发布证据落位、重写计划功成身退

- 镜匣 desktop/(Tauri v2 最小壳):WebView 承载琉璃镜(frontend/out 或 devUrl);能力桥最小命令(通知);乾坤通道 HMAC 短期凭证构造(与 sandbox/src/auth.rs 验签同算法,密钥仅经 SANDBOX_AUTH_SECRET 环境注入——二进制零平台密钥,静态审计钉死);hard_stop/resume 本地硬急停原子断路(全部通道命令过 ensure_not_halted,本地最高优先级不经云端);tantivy 依赖与 capabilities/ 目录预留;updater 插件位预留;cargo check 通过
- 器谱域 domains/tianzai(迁移 010):devices/device_grants——一次配对(密钥指纹登记)、逐项授契(可授面显式枚举,未知能力键拒)、撤回立即生效、设备撤销连带全部授契失效且不可再授;/devices 器谱 API 组
- 发布收口:release-evidence.md 落位重写期 RW-* 证据包(M0–M9 十里程碑验收自动化套件与结果物);能力矩阵 M9 行收官;rewrite-plan.md 按计划删除(M0–M9 全部完成,能力矩阵与证据台账接棒),全库引用清理
- 验收(test_m9_jingxia 四用例):器谱未授契 403 留痕/撤回即效/设备撤销连带;镜匣零平台密钥(rg 审计源码与配置为空);硬急停语义钉死(通道命令全检查+resume 唯一恢复+HMAC 无密钥不出凭证)
- 五套件终检全绿:后端 349、前端 203、BFF 4、神识 5 包、镜匣 cargo check(2 个预存环境失败历次 HEAD 复验与本周期无关);Compose nginx profile 配置校验通过
This commit is contained in:
chenxin committed 2026-08-28 09:35:27 +08:00
1 parent c63b75152a
commit 2483a25f63
29 files changed
+10533 -179

No files matched your search

+2
View File
@@ -19,6 +19,7 @@ from app.api.v1.endpoints import (
baigong,
hetu,
soul,
tianzai,
kouxin,
tiangong,
xingyi,
@@ -47,6 +48,7 @@ api_router.include_router(hetu.world_router)
api_router.include_router(soul.soul_router)
api_router.include_router(tiangong.task_router)
api_router.include_router(kouxin.knock_router)
api_router.include_router(tianzai.device_router)
api_router.include_router(xingyi.form_router)
api_router.include_router(yuanqi.cov_router)
for _baigong_r in baigong.routers:
+5
View File
@@ -0,0 +1,5 @@
"""器谱路由(域实现见 app/domains/tianzai/api.py)。"""
from app.domains.tianzai.api import device_router
__all__ = ["device_router"]
+5
View File
@@ -0,0 +1,5 @@
"""天宰域器谱:设备配对与逐项授契(rewrite/1.0 M9)。"""
from app.domains.tianzai import models, service # noqa: F401
__all__ = ["models", "service"]
+66
View File
@@ -0,0 +1,66 @@
"""器谱 API:设备配对/撤销、逐项授契/撤回、可用性裁决。"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.tianzai import service
from app.schemas.auth import CurrentUser
device_router = APIRouter(prefix="/devices", tags=["tianzai"])
class PairRequest(BaseModel):
kind: str = "desktop_shell"
name: str = Field(default="", max_length=120)
key_fingerprint: str = Field(min_length=8, max_length=128)
class GrantRequest(BaseModel):
capability: str
scope: dict = Field(default_factory=dict)
@device_router.post("", status_code=201)
async def pair_device_api(payload: PairRequest, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.pair_device(
owner_scope=user.owner_scope, kind=payload.kind,
name=payload.name, key_fingerprint=payload.key_fingerprint,
)
@device_router.get("")
async def list_devices_api(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_devices(user.owner_scope)
@device_router.post("/{device_id}/revoke")
async def revoke_device_api(device_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.revoke_device(device_id, user.owner_scope)
@device_router.post("/{device_id}/grants", status_code=201)
async def grant_api(
device_id: str, payload: GrantRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.grant(
device_id, user.owner_scope, capability=payload.capability, scope=payload.scope
)
@device_router.get("/{device_id}/grants")
async def list_grants_api(device_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_grants(device_id, user.owner_scope)
@device_router.post("/{device_id}/assert/{capability}")
async def assert_grant_api(
device_id: str, capability: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""能力使用前置裁决(未授契 403)。"""
return await service.assert_granted(device_id, user.owner_scope, capability)
@device_router.post("/grants/{grant_id}/revoke")
async def revoke_grant_api(grant_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.revoke_grant(grant_id, user.owner_scope)
+54
View File
@@ -0,0 +1,54 @@
"""天宰域器谱表:设备配对与逐项授契(PRD §8.3)。
设计依据:
- 设备采用一次配对、设备密钥、可撤销证书;
- 器谱属于镜域,人物逐项授契;撤回立即生效;
- 失联时只允许安全自治与输入暂存,不允许本地人物副本。
"""
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
class Device(Base):
"""已配对设备(镜匣桌面端/移动端):一次配对,可撤销。"""
__tablename__ = "devices"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("dev"))
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 设备形态:desktop_shell(镜匣)/ mobile / other
kind: Mapped[str] = mapped_column(String(30), nullable=False, default="desktop_shell")
name: Mapped[str] = mapped_column(String(120), nullable=False, default="")
# 设备公钥指纹(配对时登记;凭证与通信验签用其派生)
key_fingerprint: Mapped[str] = mapped_column(String(128), nullable=False, default="")
# active / revoked / lost
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class DeviceGrant(Base):
"""器谱授契:设备级能力逐项授权(通知/文件对话框/语音输入/通感…);撤回立即生效。"""
__tablename__ = "device_grants"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("dgrant"))
device_id: Mapped[str] = mapped_column(
String(80), nullable=False, index=True
)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 能力键:notifications / file_dialog / voice_input / camera / screen / location / local_search
capability: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
# 授权范围(时间窗/精度/单次触发等自由结构;敏感设备必须用户主动触发)
scope_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# active / revoked
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+167
View File
@@ -0,0 +1,167 @@
"""器谱应用服务:设备配对、逐项授契、撤回与可用性裁决。
铁律(PRD §8.3):器谱属于镜域、逐项授契;撤回立即生效;
失联设备只允许安全自治与输入暂存(不产生本地人物副本)。
"""
from __future__ import annotations
import json
from sqlalchemy import select
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.domains.tianzai.models import Device, DeviceGrant
# 器谱能力键(可授契面;新增能力必须显式登记于此)
GRANTABLE = {
"notifications", "file_dialog", "voice_input",
"camera", "screen", "location", "local_search",
}
async def pair_device(*, owner_scope: str, kind: str, name: str, key_fingerprint: str) -> dict:
"""配对设备(一次配对;指纹登记后续凭证验签依据)。"""
async with AsyncSessionLocal() as session:
device = Device(
owner_scope=owner_scope, kind=kind or "desktop_shell",
name=name, key_fingerprint=key_fingerprint,
)
session.add(device)
await session.commit()
session.expunge(device)
return _device_dict(device)
async def list_devices(owner_scope: str) -> list[dict]:
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(Device).where(Device.owner_scope == owner_scope)
.order_by(Device.created_at.desc())
)
).scalars().all()
return [_device_dict(r) for r in rows]
async def revoke_device(device_id: str, owner_scope: str) -> dict:
"""撤销设备:立即生效(全部授契随之失效)。"""
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
device.status = "revoked"
grants = (
await session.execute(
select(DeviceGrant).where(DeviceGrant.device_id == device_id, DeviceGrant.status == "active")
)
).scalars().all()
now = utc_now()
for g in grants:
g.status = "revoked"
g.revoked_at = now
await session.commit()
await one_qi.emit(
"device.revoked", character_id=None,
payload={"device_id": device_id}, source="tianzai",
)
return {"device_id": device_id, "status": "revoked", "grants_revoked": len(grants)}
async def grant(device_id: str, owner_scope: str, *, capability: str, scope: dict) -> dict:
"""逐项授契(能力键必须显式登记;设备撤销后不可再授)。"""
if capability not in GRANTABLE:
raise AppError(
"invalid_request",
f"未知器谱能力:{capability}(可授面:{sorted(GRANTABLE)})",
)
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
if device.status != "active":
raise AppError("invalid_request", "设备已撤销,不可授契。", status_code=409)
row = DeviceGrant(
device_id=device_id, owner_scope=owner_scope,
capability=capability, scope_json=json.dumps(scope or {}, ensure_ascii=False),
)
session.add(row)
await session.commit()
session.expunge(row)
return _grant_dict(row)
async def revoke_grant(grant_id: str, owner_scope: str) -> dict:
"""撤回单项授契:立即生效。"""
async with AsyncSessionLocal() as session:
row = await session.get(DeviceGrant, grant_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "授契不存在。", status_code=404)
row.status = "revoked"
row.revoked_at = utc_now()
await session.commit()
return {"grant_id": grant_id, "status": "revoked", "effective": "immediate"}
async def assert_granted(device_id: str, owner_scope: str, capability: str) -> dict:
"""能力使用前置裁决:设备 active 且该能力授契 active(失联= revoked 同拒)。"""
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
if device.status != "active":
raise AppError("invalid_request", "设备已撤销,能力不可用。", status_code=403)
row = (
await session.execute(
select(DeviceGrant).where(
DeviceGrant.device_id == device_id,
DeviceGrant.capability == capability,
DeviceGrant.status == "active",
)
)
).scalars().first()
if row is None:
raise AppError(
"invalid_request", f"能力 {capability} 未授契(器谱逐项授权)。", status_code=403
)
device.last_seen_at = utc_now()
await session.commit()
session.expunge(row)
return _grant_dict(row)
async def list_grants(device_id: str, owner_scope: str) -> list[dict]:
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(DeviceGrant).where(DeviceGrant.device_id == device_id)
.order_by(DeviceGrant.granted_at.desc())
)
).scalars().all()
return [_grant_dict(r) for r in rows]
async def _owned_device(device_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
if device is None or device.owner_scope != owner_scope:
raise AppError("not_found", "设备不存在。", status_code=404)
def _device_dict(d: Device) -> dict:
return {
"id": d.id, "kind": d.kind, "name": d.name,
"key_fingerprint": d.key_fingerprint, "status": d.status,
"created_at": d.created_at.isoformat() if d.created_at else None,
"last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None,
}
def _grant_dict(g: DeviceGrant) -> dict:
return {
"id": g.id, "device_id": g.device_id, "capability": g.capability,
"scope": json.loads(g.scope_json or "{}"), "status": g.status,
"granted_at": g.granted_at.isoformat() if g.granted_at else None,
"revoked_at": g.revoked_at.isoformat() if g.revoked_at else None,
}
+3
View File
@@ -8,6 +8,7 @@ from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
from app.domains.kouxin.models import KnockingCandidate
from app.domains.tiangong.models import BackstageTask
from app.domains.tianzai.models import Device, DeviceGrant
from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile
from app.domains.yuanqi.models import Covenant, CovenantEvent
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
@@ -28,6 +29,8 @@ from app.models.user import User
__all__ = [
"BackstageTask",
"Device",
"DeviceGrant",
"Capability",
"KnockingCandidate",
"Form",