# 变更与 Webhook

每个空间都有一份按顺序的变更日志。可以直接读，可以通过 WebSocket 收到推送信号，也可以让它按顺序、带签名地 POST 到你的地址。

## 变更日志

空间里的每一次变化都按序号追加到它的变更日志（journal）里：没有空缺，不会乱序。同步客户端、流水线、Webhook 和 SDK 的 `changes()` 都是它的读者。

```sh
curl -H "Authorization: Bearer $SIDESPACE_TOKEN" \
  "https://side.example.com/v1/spaces/$SPACE/journal?after=1040&limit=200"
```

```json
{
  "entries": [
    {
      "seq": 1041,
      "op": "version.created",
      "node_id": "nod_…",
      "version_id": "ver_…",
      "actor_id": "usr_…",
      "actor_kind": "agent",
      "actor_via": "tok_…",
      "ts": "2026-11-14T09:30:00Z",
      "node": { "id": "nod_…", "name": "A001_C003.mov", "parent_id": "nod_…", "head_version_id": "ver_…" }
    }
  ],
  "last_seq": 1041,
  "has_more": false
}
```

记下处理过的最后一个 `seq`，下次用 `after=` 接着读。令牌需要 `files:read` 或 `events:read`。

| `op` | 什么时候 |
|---|---|
| `node.created`、`node.renamed`、`node.moved`、`node.deleted`、`node.restored` | 目录结构变了 |
| `version.created` | 有人保存了；`version_id` 是新版本 |
| `version.labeled` | 某个版本起了名或去掉了名字 |
| `sidecar.updated` | 某个版本的元数据变了 |
| `rendition.updated` | 缩略图或代理生成好了 |
| `incoming.started`、`incoming.stopped` | 有人开始或停止上传这个文件（应用里显示“正在传入”） |
| `comment.created`、`comment.updated` | 审阅评论 |

`actor_kind` 是 `human`、`agent` 或 `plugin`，可以用来跳过你自己工具的改动，或者显示是谁做的。

```ts tab=TypeScript
for await (const change of side.changes("Film A", { after: lastSeq })) {
  console.log(change.seq, change.op, change.node.name);
}
```

```python tab=Python
for change in side.changes("Film A", after=last_seq):
    print(change["seq"], change["op"], change["node"]["name"])
```

```sh tab=CLI
sidespace events tail "Film A" --json            # 每行一个 JSON，直到 Ctrl-C
sidespace events tail "Film A" --after 1040 --no-follow
```

## 实时信号

不想轮询的话，打开 `GET /v1/ws`（WebSocket；令牌放在 Bearer 请求头里，不能设请求头时用 `?access_token=`），然后订阅：

```json
{ "type": "subscribe", "spaces": ["spc_…"] }
```

空间的变更日志有新内容时，服务器发 `{"type": "journal", "space_id": "spc_…", "seq": 1042}`；有人打开文件时发 `{"type": "presence", …}`；给你的通知是 `{"type": "notification", …}`。它从不直接发变更内容：收到信号后，从你的游标往后读变更日志。每 25 秒 ping 一次，60 秒没动静的连接会被关闭。信号可能重复或乱序，用 `seq` 和你的游标比较即可。

## Webhook

空间管理员可以把变更日志推送到一个 URL（令牌需要 `webhooks:manage`）。

```sh
sidespace webhook create "Film A" https://hooks.example.com/side \
  --event version.created --path "/dailies/**" --mime video/
# whsec_…  ← 签名密钥，只显示这一次
sidespace webhook list "Film A"
sidespace webhook ping "Film A" whk_…
sidespace webhook deliveries "Film A" whk_…
sidespace webhook replay "Film A" whk_… --from 120
sidespace webhook enable "Film A" whk_… [--off]
sidespace webhook rm "Film A" whk_…
```

用 API：`POST /v1/spaces/{sid}/webhooks`，带 `url`、`events`、`path_glob` 和 `mime_prefix`。

### 收到什么

每个事件一个 POST，按变更日志的顺序，只带引用，不带文件内容：

```json
{
  "id": "spc_….1042",
  "type": "version.created",
  "space_id": "spc_…",
  "seq": 1042,
  "time": "2026-11-14T09:30:00Z",
  "node_id": "nod_…",
  "version_id": "ver_…",
  "path": "/dailies/A001_C003.mov",
  "node": { "name": "A001_C003.mov", "mime": "video/quicktime", "size": 734003200 },
  "actor": { "id": "usr_…", "kind": "human" }
}
```

过滤条件：事件类型（`--event`，可重复）、路径 glob（`/dailies/**`、`**/*.mov`）、MIME 前缀（`video/`）。`comment.*` 事件还会带上评论内容。

### 投递规则

- 10 秒内返回 `2xx`。其他情况都算失败，这个订阅后面的事件会等它成功，以保证顺序。
- 重试从 10 秒开始翻倍，最长 1 小时。连续失败 24 次（约 16 小时）后订阅自动停用，并写明原因。`enable` 或 `replay` 会清掉失败计数。
- 创建它的成员不再是空间管理员时，订阅停用。
- 生产环境只允许 https，且不能指向内网、本机或链路本地地址。

### 验证签名

每次投递带 `Side-Signature: t=<unix 秒>,v1=<HMAC-SHA256(密钥, "<t>.<原始 body>") 的十六进制>` 和 `Side-Event-Id`（等于 body 里的 `id`，重试和重放时不变）。用原始 body 验签，拒绝时间戳偏差超过 5 分钟的请求，并按事件 id 去重。

```ts tab=TypeScript
import { verifyWebhookSignature } from "@sidespace/sdk/webhooks";

app.post("/hooks/side", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyWebhookSignature(process.env.SIDE_WEBHOOK_SECRET!, req.get("Side-Signature"), req.body)) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString());
  queue.add(event.id, event); // 按 id 去重
  res.sendStatus(204);
});
```

```python tab=Python
from sidespace import verify_webhook_signature

@app.post("/hooks/side")
def hook():
    if not verify_webhook_signature(SECRET, request.headers.get("Side-Signature"), request.get_data()):
        abort(401)
    event = request.get_json()
    return "", 204
```

```python tab="Python（不用 SDK）"
import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    want = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts["v1"])
```
