dsh-mizar 0.0.0-stage → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -2
  3. package/cordis.patch.yml +12 -0
  4. package/lib/auth/devices.d.ts +179 -0
  5. package/lib/auth/devices.js +157 -0
  6. package/lib/auth/pairing.d.ts +133 -0
  7. package/lib/auth/pairing.js +196 -0
  8. package/lib/auth/store.d.ts +108 -0
  9. package/lib/auth/store.js +434 -0
  10. package/lib/bridge/answerers.d.ts +68 -0
  11. package/lib/bridge/answerers.js +138 -0
  12. package/lib/bridge/errors.d.ts +8 -0
  13. package/lib/bridge/errors.js +47 -0
  14. package/lib/bridge/index.d.ts +45 -0
  15. package/lib/bridge/index.js +343 -0
  16. package/lib/bridge/interactions.d.ts +219 -0
  17. package/lib/bridge/interactions.js +510 -0
  18. package/lib/bridge/invoke.d.ts +20 -0
  19. package/lib/bridge/invoke.js +125 -0
  20. package/lib/bridge/method-error.d.ts +68 -0
  21. package/lib/bridge/method-error.js +88 -0
  22. package/lib/bridge/models.d.ts +91 -0
  23. package/lib/bridge/models.js +144 -0
  24. package/lib/bridge/permissions.d.ts +72 -0
  25. package/lib/bridge/permissions.js +81 -0
  26. package/lib/bridge/sessions.d.ts +176 -0
  27. package/lib/bridge/sessions.js +639 -0
  28. package/lib/bridge/stats.d.ts +170 -0
  29. package/lib/bridge/stats.js +199 -0
  30. package/lib/bridge/workspaces.d.ts +76 -0
  31. package/lib/bridge/workspaces.js +329 -0
  32. package/lib/client.js +2144 -0
  33. package/lib/config.d.ts +96 -0
  34. package/lib/config.js +58 -0
  35. package/lib/dsh.d.ts +3 -0
  36. package/lib/dsh.js +1 -0
  37. package/lib/index.d.ts +21 -0
  38. package/lib/index.js +488 -0
  39. package/lib/net/interfaces.d.ts +48 -0
  40. package/lib/net/interfaces.js +112 -0
  41. package/lib/net/watch.d.ts +76 -0
  42. package/lib/net/watch.js +66 -0
  43. package/lib/panel/api.d.ts +90 -0
  44. package/lib/panel/api.js +106 -0
  45. package/lib/panel/status.d.ts +111 -0
  46. package/lib/panel/status.js +103 -0
  47. package/lib/protocol/frames.d.ts +211 -0
  48. package/lib/protocol/frames.js +195 -0
  49. package/lib/protocol/pairing-uri.d.ts +86 -0
  50. package/lib/protocol/pairing-uri.js +119 -0
  51. package/lib/push/binding.d.ts +85 -0
  52. package/lib/push/binding.js +86 -0
  53. package/lib/push/notifier.d.ts +198 -0
  54. package/lib/push/notifier.js +313 -0
  55. package/lib/push/scope.d.ts +68 -0
  56. package/lib/push/scope.js +66 -0
  57. package/lib/push/transport.d.ts +169 -0
  58. package/lib/push/transport.js +113 -0
  59. package/lib/ready.d.ts +50 -0
  60. package/lib/ready.js +64 -0
  61. package/lib/relay/client.d.ts +124 -0
  62. package/lib/relay/client.js +446 -0
  63. package/lib/relay/identity.d.ts +18 -0
  64. package/lib/relay/identity.js +90 -0
  65. package/lib/relay/protocol.d.ts +112 -0
  66. package/lib/relay/protocol.js +45 -0
  67. package/lib/server/listener.d.ts +125 -0
  68. package/lib/server/listener.js +664 -0
  69. package/lib/spike.d.ts +29 -0
  70. package/lib/spike.js +176 -0
  71. package/package.json +105 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShirazuNagisa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,224 @@
1
- # Temporary Holding Version
1
+ # DSH-Mizar 插件
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 用手机看 DSH Agent 干活,也能给它发消息、回答审批。
4
+
5
+ 这个仓库是 **Mac 侧插件**(DSH 的一个 Cordis 插件)。另外两个仓库:
6
+
7
+ | | |
8
+ |---|---|
9
+ | **DSH-Mizar-iOS** | 手机 App(iOS / iPadOS) |
10
+ | **DSH-Mizar-Relay** | 中继服务器,让手机从任何网络连上你的 Mac |
11
+
12
+ ## 一、装插件
13
+
14
+ **推荐用 npm 装**:插件已发布到 npm,包名 **`dsh-mizar`**,与当前 DSH 运行时(0.2.1-rc.2)对齐。
15
+ 装完都要**重启 DSH** —— 插件只在启动时加载,不重启等于没装。
16
+
17
+ ### 推荐:图形界面(不碰终端)
18
+
19
+ 1. 打开 DSH,左侧栏点 **插件(Plugins)**
20
+ 2. 安装输入框里填 **`dsh-mizar`**,点安装
21
+ 3. 确认它弹出来的包名 / 版本 / 描述
22
+ 4. 重启 DSH
23
+
24
+ ### 推荐:命令行
25
+
26
+ ```sh
27
+ dsh plugin --profile desktop add dsh-mizar # 跟随 npm 最新版
28
+ dsh plugin --profile desktop add dsh-mizar@0.8.2 # 锁定某个版本
29
+ ```
30
+
31
+ 两种装法都会把依赖写成 `dsh-mizar@^x.y.z`。升级只要再跑一次 `add dsh-mizar@latest`。
32
+
33
+ 重启后侧边栏会出现 **DSH-Mizar** 面板。
34
+
35
+ ### 为什么是 npm,而不是 GitHub 直装
36
+
37
+ | | **npm(推荐)** | GitHub 直装 |
38
+ |---|---|---|
39
+ | 版本 | 有版本号,可锁 `@0.8.2` | 跟随 `main` 最新提交,不可锁 |
40
+ | 可追溯 | `npm view dsh-mizar versions`、`npm outdated` | 只有 commit hash |
41
+ | 完整性 | registry **sha512 校验** | 无包完整性校验 |
42
+ | 前提 | 只要能访问 registry | 需要 git + pnpm 的 git 支持 |
43
+ | 适合 | **所有人** | 只适合尝鲜未发布的提交 |
44
+
45
+ ### 备选:GitHub / 本地 tarball
46
+
47
+ ```sh
48
+ # 尝鲜:装 main 上还没发版的提交
49
+ dsh plugin --profile desktop add ShirazuNagisa/DSH-Mizar
50
+
51
+ # 离线 / 内网:在有网的机器上 npm pack,再把 .tgz 传过去
52
+ npm pack dsh-mizar
53
+ dsh plugin --profile desktop add /path/to/dsh-mizar-0.8.2.tgz
54
+ ```
55
+
56
+ ### 装不到最新版 / 镜像同步延迟
57
+
58
+ 插件发布在 npm 官方源(`registry.npmjs.org`)。国内镜像(如 `registry.npmmirror.com`)偶尔滞后,
59
+ 表现是 `pnpm` 抓到一个旧的或占位版本。两种情况:
60
+
61
+ - **加 `--registry` 直连官方源**:
62
+ ```sh
63
+ dsh plugin --profile desktop add dsh-mizar@0.8.2 --registry=https://registry.npmjs.org
64
+ ```
65
+ - 或者去 [npmmirror 同步页](https://npmmirror.com/sync/dsh-mizar) 点一次「同步」,等镜像更新。
66
+
67
+ > GUI 的安装框走的是本机默认 registry;镜像没同步时,用上面的 CLI 命令更稳。
68
+
69
+ ### 版本必须与 DSH 对齐
70
+
71
+ DSH 会**精确比对**插件声明的 `@deepseek-ai/dsh-*` 与运行时版本,不匹配时安装会被拒绝:
72
+
73
+ ```
74
+ Plugin dsh-mizar@0.8.2 is incompatible with dsh 0.2.1-rc.2
75
+ ```
76
+
77
+ - 正常做法:升级到匹配的插件版本(DSH 每个 rc 发布后,插件跟一个 `+0.0.1`);
78
+ - 临时跑起来(**有风险**,只在确认没有 API 变更时用):
79
+ ```sh
80
+ dsh plugin --profile desktop allow-version dsh-mizar@0.8.2 --dsh-version 0.2.1-rc.2 --accept-risk
81
+ ```
82
+
83
+ ## 二、配对
84
+
85
+ 1. Mac 上打开 DSH,点侧边栏的 **DSH-Mizar**
86
+ 2. 面板里会出现二维码
87
+ 3. 手机装好 App 后扫码
88
+
89
+ **没有别的步骤。** 不用填 token,不用填地址,不用改文件。
90
+
91
+ ## 三、插件做什么
92
+
93
+ - 把会话列表、对话正文、用量、审批和提问转给手机
94
+ - 手机可以发消息、打断正在跑的 turn、回答审批和提问、切换模型与权限
95
+ - 推送(审批 / 提问 / 跑完)经中继转给 Apple
96
+ - 手机和 Mac 在同一个 Wi-Fi 时也可以直连,二维码里两条路都带着
97
+
98
+ ### 对话只传总结
99
+
100
+ 一页历史里只保留这些:你的提问、每一轮的 `turn/start` 和 `turn/end`、每个 `step/start`
101
+ (只用来数「几步」),以及**每一轮里模型最后那条有正文的消息**。
102
+
103
+ 逐步叙述、工具调用与结果、流式过程,以及**还没结束的那一轮的中间回复**,一律不发。
104
+ 手机上那两行状态 —— 「已完成,用时 xx」和「深度求索中,用时 xx」—— 都不能展开,
105
+ 下面直接就是这一轮的总结。全量历史经中继是服务器上最大的一块流量,这是为了把它压下来。
106
+
107
+ 分页切点取自 `session/follow` 首帧的 `cursor`(DSH 自己对 `throughSeq` 的定义)。
108
+ 用 `session/projections` 的 `asOfSeq` 会滞后于日志头,刚发出的那一轮就不在页里 ——
109
+ 手机上表现为「AI 明明在干活,却没有正在工作的提示」。
110
+
111
+ ## 四、通知
112
+
113
+ 三种情况手机会收到推送,插件通过中继发给 Apple:
114
+
115
+ - Agent 卡在**权限审批**上,等你点头
116
+ - Agent 卡在**提问**上,等你回答
117
+ - 某个会话**跑完了**(跑挂/中断了会说挂掉,不会谎报完成)
118
+
119
+ **一轮结束不会立刻推。** DSH 里一个指令常常连着跑好几个 turn,每个 turn 都推一次等于一个
120
+ 指令三条通知 —— 所以插件会等这个会话安静 15 秒才发。正在看着 App 的时候什么都不发。
121
+
122
+ **锁屏上的 Live Activity 会跟着结束。** iPhone 把正在跑的会话画在锁屏 / 灵动岛上计时;
123
+ App 被挂起时它自己没机会收尾。所以 App 会把这次活动的推送令牌随注册交给插件,插件存下来、
124
+ 转给中继;会话跑完、且手机上还看得到的会话一个都不在跑时,那条「跑完了」的通知会让中继
125
+ 顺手结束 Live Activity —— 任务结束后计时不会一直涨。
126
+
127
+ 推送要 Apple 的签名密钥,密钥只能放在服务器上(放在插件里就等于公开)。**用官方中继
128
+ 什么都不用做**;自建中继需要配 `apns`,见
129
+ [Relay 仓库的部署文档第 10 节](https://github.com/ShirazuNagisa/DSH-Mizar-Relay/blob/main/docs/deployment.md#10-push-notifications)。
130
+
131
+ ## 四·五、发布(维护者)
132
+
133
+ 发布走 **trusted publishing(OIDC)**:GitHub Actions 用一次性的 OIDC 身份向 npm 换发布凭证,
134
+ **没有长期令牌,也没有验证码要输**。发布就是推一个版本 tag。
135
+
136
+ 一次性配置(npmjs.com,用账号的安全密钥验证):
137
+
138
+ 1. 打开 `dsh-mizar` 包页面 → **Settings** → **Publishing access** → **Trusted publisher** →
139
+ **GitHub Actions**;
140
+ 2. 填:
141
+ - Organization or user:`ShirazuNagisa`
142
+ - Repository:`DSH-Mizar`
143
+ - Workflow filename:`publish.yml`
144
+ - Environment name:留空
145
+ - **Allowed actions**:勾上 **`npm publish`**(直接发布;只勾暂存的话每次发布还要人工批准);
146
+ 3. 保存。
147
+
148
+ 之后发布:
149
+
150
+ ```sh
151
+ cd dsh
152
+ npm version patch # 或手改 package.json 的 version
153
+ git push # 版本号先上 main
154
+ git tag vX.Y.Z && git push origin vX.Y.Z
155
+ ```
156
+
157
+ workflow(`.github/workflows/publish.yml`)会:**校验 tag 与 package.json 版本一致** →
158
+ `npm ci` → typecheck → 全部测试 → build → `npm publish --provenance`。任何一步失败都不会发布。
159
+
160
+ 为什么不用手工 `npm publish`:这个账号的第二因子是**安全密钥(WebAuthn)**,npm CLI 不支持
161
+ WebAuthn,`Enter OTP:` 永远过不去;trusted publishing 是 npm 对 CI 发布的官方方案,还顺带生成
162
+ 来源证明(provenance)。
163
+
164
+ > 兜底:`npm publish --registry=https://registry.npmjs.org` 仍可用,但需要账号里有可用的
165
+ > 验证器 App(TOTP)或一次性密码;若走 `auth-type=web` 会进入暂存发布,registry 上出现
166
+ > `0.0.0-stage` 占位版本时用
167
+ > `npm unpublish dsh-mizar@0.0.0-stage --registry=https://registry.npmjs.org` 清掉。
168
+
169
+ ## 五、开发
170
+
171
+ ```sh
172
+ npm install # 依赖(npm cache 建议指到本地:npm_config_cache=$PWD/.devcache/npm)
173
+ npm test # 全部测试
174
+ npm run typecheck
175
+ npm run build # tsc + 浏览器半边;lib/ 是提交进仓库的,必须与全新构建一致
176
+ ```
177
+
178
+ `lib/` **故意提交进仓库**:`dsh plugin add` 装的是 git 检出,没有构建步骤,缺了 `lib/`
179
+ 等于插件装不上。所以改完 `src/` 一定要 `npm run build` 再提交。
180
+
181
+ 跑一个真的开发实例(用仓库内的 `.devhome`,不碰你 `~/.dsh` 的正式配置):
182
+
183
+ ```sh
184
+ node scripts/dev.mjs install # 构建并装进 mizar-dev profile
185
+ node scripts/dev.mjs boot # 起一个真 DSH;日志 .devstate/dev.log
186
+ node scripts/dev.mjs stop
187
+ ```
188
+
189
+ 对着它跑一次真实链路的冒烟测试(会打印配对码):
190
+
191
+ ```sh
192
+ MIZAR_CODE=XXXX-XXXX node scripts/smoke-listener.mjs
193
+ ```
194
+
195
+ **开发实例不要连中继。** `.devhome` 是从正式配置里拷出来的,会拿到同一个中继房间号,
196
+ 两边一抢就会把正式那条隧道踢掉 —— `.devhome/profiles/mizar-dev/cordis.patch.yml` 里
197
+ 已经写了 `relay: enabled: false`。
198
+
199
+ 归档:`archive/full-message-transport/` 是 0.7.2 之前那套「全量传输」的实现,**不参与
200
+ 编译**,留着以便以后要用(见该目录的 README)。
201
+
202
+ ## 常见问题
203
+
204
+ **手机连不上。**
205
+ 面板顶部会写原因。默认走中继,只要 Mac 和手机都能上网就行,不需要在同一个 Wi-Fi。
206
+
207
+ **面板里没有二维码。**
208
+ 重启 DSH。装完不重启,插件不会加载。
209
+
210
+ **想改成局域网直连。**
211
+ 默认是「有中继就走中继」。二维码里同时带直连地址,App 会先试直连、失败再走中继。
212
+
213
+ **插件版本对不对。**
214
+ DSH 启动日志里有 `[mizar] dsh-mizar <版本号> · state: … · devices: N live` 这一行。
215
+
216
+ **收不到通知。**
217
+ 按顺序查:App 里「设置 → 通知」有没有允许;插件日志里有没有 `push: registered …`
218
+ (说明手机把推送令牌交上来了);中继上 `curl localhost:8787/healthz` 的
219
+ `pushDevices` 是不是大于 0。三条都对还是没有,看中继日志里 Apple 回的是什么 ——
220
+ `BadDeviceToken` 基本就是环境选错了。
221
+
222
+ ---
223
+
224
+ 协议、架构决策、自建中继、研究记录:[docs/](docs/)
@@ -0,0 +1,12 @@
1
+ # dsh-mizar bundle patch.
2
+ #
3
+ # Inserts the Mizar host row into the profile tree. The row resolves from the
4
+ # profile's own node_modules (this package is installed as a profile
5
+ # dependency), so no installation-anchor package is required.
6
+ #
7
+ # Every field of this row's `config` belongs to the profile's own patch layer
8
+ # (@see ~/.dsh/profiles/<profile>/cordis.patch.yml), not here, so two profiles
9
+ # can configure Mizar independently.
10
+ - insert:
11
+ - id: mizar
12
+ name: 'dsh-mizar'
@@ -0,0 +1,179 @@
1
+ /** Prefix identifying a Mizar device token in logs and support requests. */
2
+ export declare const TOKEN_PREFIX = "mz_";
3
+ /** Longest accepted device name, in characters. */
4
+ export declare const MAX_DEVICE_NAME = 64;
5
+ /** Longest accepted Live Activity push token, in characters. */
6
+ export declare const MAX_LIVE_ACTIVITY_TOKEN = 200;
7
+ /**
8
+ * Whether a value is an APNs Live Activity push token.
9
+ *
10
+ * Lowercase hex, because that is what APNs issues and therefore what the app
11
+ * reports. The length bound is the wire contract's: wide enough for any token
12
+ * APNs has ever issued, narrow enough that a stored file cannot be made to hold
13
+ * an unbounded string.
14
+ *
15
+ * @param value - the candidate, normally straight from a client payload or from state.json.
16
+ * @returns whether it is usable.
17
+ */
18
+ export declare function isLiveActivityToken(value: unknown): value is string;
19
+ /** One paired device. */
20
+ export interface DeviceRecord {
21
+ /** Stable identity, generated at pairing time. */
22
+ readonly deviceId: string;
23
+ /** Operator-visible name, defaulted from the device model. */
24
+ readonly name: string;
25
+ /** Device model reported by the app, when it sent one. */
26
+ readonly model?: string;
27
+ /** App version reported at pairing time. */
28
+ readonly appVersion?: string;
29
+ /** SHA-256 of the token, hex encoded. */
30
+ readonly tokenHash: string;
31
+ /** Short, non-secret display id derived from the token. */
32
+ readonly tokenPrefix: string;
33
+ /** When the device paired, in epoch milliseconds. */
34
+ readonly createdAt: number;
35
+ /** Last successful authentication, in epoch milliseconds. */
36
+ readonly lastSeenAt: number;
37
+ /** When the device was revoked; absent while it is valid. */
38
+ readonly revokedAt?: number;
39
+ /**
40
+ * Where to send a notification for this device, once its app has asked.
41
+ *
42
+ * Kept on the device rather than in a store of its own, because that is what
43
+ * it is: a property of a paired phone. Revoking a device drops it with the
44
+ * record, and there is no second roster to keep in step with this one.
45
+ *
46
+ * Absent until the app registers, which it does on its own after the operator
47
+ * grants notification permission -- so a host whose phone never asked simply
48
+ * never grows this field.
49
+ */
50
+ readonly push?: DevicePush;
51
+ }
52
+ /** A device's push endpoint. */
53
+ export interface DevicePush {
54
+ /** The device token: hex for APNs, an opaque registration token for FCM. */
55
+ readonly token: string;
56
+ /**
57
+ * Which push service the token belongs to.
58
+ *
59
+ * Stored rather than inferred from the token's shape, because a relay that
60
+ * guessed would send an Android token to Apple on the strength of it looking
61
+ * unusual. Defaulted to `'ios'` when a record predates the field, which is
62
+ * what every stored record does.
63
+ */
64
+ readonly platform: 'ios' | 'android';
65
+ /**
66
+ * Which APNs environment issued it, or `null` on Android.
67
+ *
68
+ * Not inferable and not interchangeable: a token minted for the sandbox is
69
+ * rejected by the production endpoint with `BadDeviceToken`, which says
70
+ * nothing about why. FCM has one service, so an Android registration has no
71
+ * environment to record.
72
+ */
73
+ readonly environment: 'sandbox' | 'production' | null;
74
+ /** The language the app is running in, so notifications match it. */
75
+ readonly locale: string;
76
+ /**
77
+ * The APNs push token of the Live Activity currently on screen.
78
+ *
79
+ * Not the same token as {@link DevicePush.token}, and not interchangeable: a
80
+ * Live Activity is addressed by a per-activity token, so an end push sent to
81
+ * the app's device token would reach the app rather than the activity.
82
+ *
83
+ * Absent means no Live Activity is up. That is why registering without it has
84
+ * to *clear* the stored one rather than leave it alone: a token held past its
85
+ * activity is a credential for ending a countdown that no longer exists.
86
+ */
87
+ readonly liveActivityToken?: string;
88
+ /** When the app last told this host where it is. */
89
+ readonly updatedAt: number;
90
+ }
91
+ /** A freshly minted token and the record material derived from it. */
92
+ export interface IssuedToken {
93
+ /** The secret handed to the phone exactly once. */
94
+ readonly token: string;
95
+ /** The record fields to persist. */
96
+ readonly tokenHash: string;
97
+ /** The short display id. */
98
+ readonly tokenPrefix: string;
99
+ }
100
+ /**
101
+ * Hash a token for storage or comparison.
102
+ * @param token - the token.
103
+ * @returns the hex-encoded SHA-256 digest.
104
+ */
105
+ export declare function hashToken(token: string): string;
106
+ /**
107
+ * Mint a new device token.
108
+ * @returns the token plus the material to persist.
109
+ */
110
+ export declare function issueToken(): IssuedToken;
111
+ /**
112
+ * Build a device record for a pairing request.
113
+ * @param issued - the minted token material.
114
+ * @param details - what the phone reported about itself.
115
+ * @param now - current epoch milliseconds.
116
+ * @returns the record to persist.
117
+ */
118
+ export declare function createDeviceRecord(issued: IssuedToken, details: {
119
+ name?: string;
120
+ model?: string;
121
+ appVersion?: string;
122
+ }, now?: number): DeviceRecord;
123
+ /**
124
+ * A token's record, and whether that record has been revoked.
125
+ *
126
+ * Both are needed because the two are different answers for the client:
127
+ * "this host has never seen you" sends the app back to pairing, while "you were
128
+ * removed on purpose" also makes it clear its stored token is dead.
129
+ */
130
+ export interface TokenMatch {
131
+ readonly record: DeviceRecord;
132
+ readonly revoked: boolean;
133
+ }
134
+ /**
135
+ * Find the record a token belongs to, revoked or not.
136
+ *
137
+ * Every record is compared without an early exit, so a caller cannot time which
138
+ * record matched or how close a wrong token was. A live record wins over a
139
+ * revoked one when a token somehow appears twice, which is the order the roster
140
+ * itself is kept in.
141
+ *
142
+ * @param devices - the known device records.
143
+ * @param token - the token presented by the client.
144
+ * @returns the match, or `null` when no record holds this token.
145
+ */
146
+ export declare function matchToken(devices: readonly DeviceRecord[], token: string): TokenMatch | null;
147
+ /**
148
+ * Find the live device a token belongs to.
149
+ *
150
+ * @param devices - the known device records.
151
+ * @param token - the token presented by the client.
152
+ * @returns the matching live record, or `null`.
153
+ */
154
+ export declare function verifyToken(devices: readonly DeviceRecord[], token: string): DeviceRecord | null;
155
+ /**
156
+ * Mark a device revoked, preserving its record for the roster.
157
+ * @param devices - the known records.
158
+ * @param deviceId - the device to revoke.
159
+ * @param now - current epoch milliseconds.
160
+ * @returns the updated records, or the input when the id is unknown.
161
+ */
162
+ export declare function revokeDevice(devices: readonly DeviceRecord[], deviceId: string, now?: number): readonly DeviceRecord[];
163
+ /**
164
+ * Record a successful authentication against a device.
165
+ * @param devices - the known records.
166
+ * @param deviceId - the authenticated device.
167
+ * @param now - current epoch milliseconds.
168
+ * @returns the updated records.
169
+ */
170
+ export declare function touchDevice(devices: readonly DeviceRecord[], deviceId: string, now?: number): readonly DeviceRecord[];
171
+ /**
172
+ * Split the roster into live and revoked devices, newest first.
173
+ * @param devices - the known records.
174
+ * @returns the two lists, each ordered by creation time descending.
175
+ */
176
+ export declare function partitionDevices(devices: readonly DeviceRecord[]): {
177
+ live: readonly DeviceRecord[];
178
+ revoked: readonly DeviceRecord[];
179
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Device credentials.
3
+ *
4
+ * A device token is the long-lived credential the phone stores in the Keychain.
5
+ * It is opaque, 256-bit random, and never persisted on the host — only its
6
+ * SHA-256 hash is, so a leaked `devices.json` cannot be replayed against the
7
+ * listener. Verification walks every record without an early exit so the time it
8
+ * takes does not reveal which record matched.
9
+ *
10
+ * @module dsh-mizar/auth/devices
11
+ */
12
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
13
+ import { base32 } from './pairing.js';
14
+ /** Prefix identifying a Mizar device token in logs and support requests. */
15
+ export const TOKEN_PREFIX = 'mz_';
16
+ /** Random bytes behind one token. 32 bytes is 256 bits. */
17
+ const TOKEN_BYTES = 32;
18
+ /** Longest accepted device name, in characters. */
19
+ export const MAX_DEVICE_NAME = 64;
20
+ /** Longest accepted Live Activity push token, in characters. */
21
+ export const MAX_LIVE_ACTIVITY_TOKEN = 200;
22
+ /** APNs issues lowercase hex tokens and nothing else. */
23
+ const LIVE_ACTIVITY_TOKEN_PATTERN = new RegExp(`^[0-9a-f]{1,${MAX_LIVE_ACTIVITY_TOKEN}}$`);
24
+ /**
25
+ * Whether a value is an APNs Live Activity push token.
26
+ *
27
+ * Lowercase hex, because that is what APNs issues and therefore what the app
28
+ * reports. The length bound is the wire contract's: wide enough for any token
29
+ * APNs has ever issued, narrow enough that a stored file cannot be made to hold
30
+ * an unbounded string.
31
+ *
32
+ * @param value - the candidate, normally straight from a client payload or from state.json.
33
+ * @returns whether it is usable.
34
+ */
35
+ export function isLiveActivityToken(value) {
36
+ return typeof value === 'string' && LIVE_ACTIVITY_TOKEN_PATTERN.test(value);
37
+ }
38
+ /**
39
+ * Hash a token for storage or comparison.
40
+ * @param token - the token.
41
+ * @returns the hex-encoded SHA-256 digest.
42
+ */
43
+ export function hashToken(token) {
44
+ return createHash('sha256').update(token, 'utf8').digest('hex');
45
+ }
46
+ /**
47
+ * Mint a new device token.
48
+ * @returns the token plus the material to persist.
49
+ */
50
+ export function issueToken() {
51
+ const token = `${TOKEN_PREFIX}${base32(new Uint8Array(randomBytes(TOKEN_BYTES)))}`;
52
+ const tokenHash = hashToken(token);
53
+ return { token, tokenHash, tokenPrefix: tokenHash.slice(0, 8) };
54
+ }
55
+ /**
56
+ * Build a device record for a pairing request.
57
+ * @param issued - the minted token material.
58
+ * @param details - what the phone reported about itself.
59
+ * @param now - current epoch milliseconds.
60
+ * @returns the record to persist.
61
+ */
62
+ export function createDeviceRecord(issued, details, now = Date.now()) {
63
+ const rawName = details.name?.trim() ?? '';
64
+ const model = details.model?.trim();
65
+ const name = rawName !== '' ? rawName : (model ?? '').trim() !== '' ? model : 'Unnamed device';
66
+ return {
67
+ deviceId: randomUUID(),
68
+ name: name.slice(0, MAX_DEVICE_NAME),
69
+ ...(model !== undefined && model !== '' ? { model: model.slice(0, MAX_DEVICE_NAME) } : {}),
70
+ ...(details.appVersion !== undefined && details.appVersion !== ''
71
+ ? { appVersion: details.appVersion.slice(0, 32) }
72
+ : {}),
73
+ tokenHash: issued.tokenHash,
74
+ tokenPrefix: issued.tokenPrefix,
75
+ createdAt: now,
76
+ lastSeenAt: now,
77
+ };
78
+ }
79
+ /**
80
+ * Find the record a token belongs to, revoked or not.
81
+ *
82
+ * Every record is compared without an early exit, so a caller cannot time which
83
+ * record matched or how close a wrong token was. A live record wins over a
84
+ * revoked one when a token somehow appears twice, which is the order the roster
85
+ * itself is kept in.
86
+ *
87
+ * @param devices - the known device records.
88
+ * @param token - the token presented by the client.
89
+ * @returns the match, or `null` when no record holds this token.
90
+ */
91
+ export function matchToken(devices, token) {
92
+ const presented = Buffer.from(hashToken(token), 'utf8');
93
+ let match = null;
94
+ for (const device of devices) {
95
+ const stored = Buffer.from(device.tokenHash, 'utf8');
96
+ if (stored.length !== presented.length || !timingSafeEqual(stored, presented))
97
+ continue;
98
+ if (device.revokedAt === undefined) {
99
+ if (match === null || match.revoked)
100
+ match = { record: device, revoked: false };
101
+ }
102
+ else if (match === null) {
103
+ match = { record: device, revoked: true };
104
+ }
105
+ }
106
+ return match;
107
+ }
108
+ /**
109
+ * Find the live device a token belongs to.
110
+ *
111
+ * @param devices - the known device records.
112
+ * @param token - the token presented by the client.
113
+ * @returns the matching live record, or `null`.
114
+ */
115
+ export function verifyToken(devices, token) {
116
+ const match = matchToken(devices, token);
117
+ return match !== null && !match.revoked ? match.record : null;
118
+ }
119
+ /**
120
+ * Mark a device revoked, preserving its record for the roster.
121
+ * @param devices - the known records.
122
+ * @param deviceId - the device to revoke.
123
+ * @param now - current epoch milliseconds.
124
+ * @returns the updated records, or the input when the id is unknown.
125
+ */
126
+ export function revokeDevice(devices, deviceId, now = Date.now()) {
127
+ let changed = false;
128
+ const next = devices.map((device) => {
129
+ if (device.deviceId !== deviceId || device.revokedAt !== undefined)
130
+ return device;
131
+ changed = true;
132
+ return { ...device, revokedAt: now };
133
+ });
134
+ return changed ? next : devices;
135
+ }
136
+ /**
137
+ * Record a successful authentication against a device.
138
+ * @param devices - the known records.
139
+ * @param deviceId - the authenticated device.
140
+ * @param now - current epoch milliseconds.
141
+ * @returns the updated records.
142
+ */
143
+ export function touchDevice(devices, deviceId, now = Date.now()) {
144
+ return devices.map((device) => device.deviceId === deviceId ? { ...device, lastSeenAt: now } : device);
145
+ }
146
+ /**
147
+ * Split the roster into live and revoked devices, newest first.
148
+ * @param devices - the known records.
149
+ * @returns the two lists, each ordered by creation time descending.
150
+ */
151
+ export function partitionDevices(devices) {
152
+ const byNewest = [...devices].sort((a, b) => b.createdAt - a.createdAt);
153
+ return {
154
+ live: byNewest.filter((device) => device.revokedAt === undefined),
155
+ revoked: byNewest.filter((device) => device.revokedAt !== undefined),
156
+ };
157
+ }