dsh-mizar 0.0.0-stage → 0.8.2

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 +157 -2
  3. package/cordis.patch.yml +12 -0
  4. package/lib/auth/devices.d.ts +153 -0
  5. package/lib/auth/devices.js +139 -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 +421 -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 +304 -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 +463 -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 +202 -0
  48. package/lib/protocol/frames.js +186 -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 +77 -0
  52. package/lib/push/binding.js +78 -0
  53. package/lib/push/notifier.d.ts +185 -0
  54. package/lib/push/notifier.js +279 -0
  55. package/lib/push/scope.d.ts +45 -0
  56. package/lib/push/scope.js +44 -0
  57. package/lib/push/transport.d.ts +146 -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,158 @@
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 上叫 **`dsh-mizar`**。命令行和图形界面两种装法,装完都要**重启 DSH** ——
15
+ 插件只在启动时加载,不重启等于没装。
16
+
17
+ ### 方式 A:图形界面(不想开终端就用这个)
18
+
19
+ 1. 打开 DSH,左侧栏点 **插件(Plugins)**
20
+ 2. 安装输入框里填 **`dsh-mizar`**,点安装
21
+ 3. 重启 DSH
22
+
23
+ 安装框同时接受 **npm 包名**(`dsh-mizar`)、**GitHub 地址**(`ShirazuNagisa/DSH-Mizar`)、
24
+ **本地 `.tgz`** 或**目录路径**;装之前它会先把包名、版本、描述报给你确认。
25
+
26
+ ### 方式 B:命令行
27
+
28
+ ```sh
29
+ dsh plugin --profile desktop add dsh-mizar # npm 正式版
30
+ dsh plugin --profile desktop add dsh-mizar@0.8.2 # 锁定版本
31
+ ```
32
+
33
+ 尝鲜(还没发版的提交)用 GitHub 直装:
34
+
35
+ ```sh
36
+ dsh plugin --profile desktop add ShirazuNagisa/DSH-Mizar
37
+ ```
38
+
39
+ 离线 / 内网就传一个 `.tgz` 过去:
40
+
41
+ ```sh
42
+ dsh plugin --profile desktop add /path/to/dsh-mizar-0.8.2.tgz
43
+ ```
44
+
45
+ 重启后侧边栏会出现 **DSH-Mizar** 面板。
46
+
47
+ ### 版本必须与 DSH 对齐
48
+
49
+ DSH 会**精确比对**插件声明的 `@deepseek-ai/dsh-*` 与运行时版本,不匹配时安装会被拒绝:
50
+
51
+ ```
52
+ Plugin dsh-mizar@0.8.2 is incompatible with dsh 0.2.1-rc.2
53
+ ```
54
+
55
+ - 正常做法:升级到匹配的插件版本(DSH 每个 rc 发布后,插件跟一个 `+0.0.1`);
56
+ - 临时跑起来(**有风险**,只在确认没有 API 变更时用):
57
+ ```sh
58
+ dsh plugin --profile desktop allow-version dsh-mizar@0.8.2 --dsh-version 0.2.1-rc.2 --accept-risk
59
+ ```
60
+
61
+ ## 二、配对
62
+
63
+ 1. Mac 上打开 DSH,点侧边栏的 **DSH-Mizar**
64
+ 2. 面板里会出现二维码
65
+ 3. 手机装好 App 后扫码
66
+
67
+ **没有别的步骤。** 不用填 token,不用填地址,不用改文件。
68
+
69
+ ## 三、插件做什么
70
+
71
+ - 把会话列表、对话正文、用量、审批和提问转给手机
72
+ - 手机可以发消息、打断正在跑的 turn、回答审批和提问、切换模型与权限
73
+ - 推送(审批 / 提问 / 跑完)经中继转给 Apple
74
+ - 手机和 Mac 在同一个 Wi-Fi 时也可以直连,二维码里两条路都带着
75
+
76
+ ### 对话只传总结
77
+
78
+ 一页历史里只保留这些:你的提问、每一轮的 `turn/start` 和 `turn/end`、每个 `step/start`
79
+ (只用来数「几步」),以及**每一轮里模型最后那条有正文的消息**。
80
+
81
+ 逐步叙述、工具调用与结果、流式过程,以及**还没结束的那一轮的中间回复**,一律不发。
82
+ 手机上那两行状态 —— 「已完成,用时 xx」和「深度求索中,用时 xx」—— 都不能展开,
83
+ 下面直接就是这一轮的总结。全量历史经中继是服务器上最大的一块流量,这是为了把它压下来。
84
+
85
+ 分页切点取自 `session/follow` 首帧的 `cursor`(DSH 自己对 `throughSeq` 的定义)。
86
+ 用 `session/projections` 的 `asOfSeq` 会滞后于日志头,刚发出的那一轮就不在页里 ——
87
+ 手机上表现为「AI 明明在干活,却没有正在工作的提示」。
88
+
89
+ ## 四、通知
90
+
91
+ 三种情况手机会收到推送,插件通过中继发给 Apple:
92
+
93
+ - Agent 卡在**权限审批**上,等你点头
94
+ - Agent 卡在**提问**上,等你回答
95
+ - 某个会话**跑完了**(跑挂/中断了会说挂掉,不会谎报完成)
96
+
97
+ **一轮结束不会立刻推。** DSH 里一个指令常常连着跑好几个 turn,每个 turn 都推一次等于一个
98
+ 指令三条通知 —— 所以插件会等这个会话安静 15 秒才发。正在看着 App 的时候什么都不发。
99
+
100
+ 推送要 Apple 的签名密钥,密钥只能放在服务器上(放在插件里就等于公开)。**用官方中继
101
+ 什么都不用做**;自建中继需要配 `apns`,见 [relay-deployment.md 第 10 节](docs/relay-deployment.md)。
102
+
103
+ ## 五、开发
104
+
105
+ ```sh
106
+ npm install # 依赖(npm cache 建议指到本地:npm_config_cache=$PWD/.devcache/npm)
107
+ npm test # 全部测试
108
+ npm run typecheck
109
+ npm run build # tsc + 浏览器半边;lib/ 是提交进仓库的,必须与全新构建一致
110
+ ```
111
+
112
+ `lib/` **故意提交进仓库**:`dsh plugin add` 装的是 git 检出,没有构建步骤,缺了 `lib/`
113
+ 等于插件装不上。所以改完 `src/` 一定要 `npm run build` 再提交。
114
+
115
+ 跑一个真的开发实例(用仓库内的 `.devhome`,不碰你 `~/.dsh` 的正式配置):
116
+
117
+ ```sh
118
+ node scripts/dev.mjs install # 构建并装进 mizar-dev profile
119
+ node scripts/dev.mjs boot # 起一个真 DSH;日志 .devstate/dev.log
120
+ node scripts/dev.mjs stop
121
+ ```
122
+
123
+ 对着它跑一次真实链路的冒烟测试(会打印配对码):
124
+
125
+ ```sh
126
+ MIZAR_CODE=XXXX-XXXX node scripts/smoke-listener.mjs
127
+ ```
128
+
129
+ **开发实例不要连中继。** `.devhome` 是从正式配置里拷出来的,会拿到同一个中继房间号,
130
+ 两边一抢就会把正式那条隧道踢掉 —— `.devhome/profiles/mizar-dev/cordis.patch.yml` 里
131
+ 已经写了 `relay: enabled: false`。
132
+
133
+ 归档:`archive/full-message-transport/` 是 0.7.2 之前那套「全量传输」的实现,**不参与
134
+ 编译**,留着以便以后要用(见该目录的 README)。
135
+
136
+ ## 常见问题
137
+
138
+ **手机连不上。**
139
+ 面板顶部会写原因。默认走中继,只要 Mac 和手机都能上网就行,不需要在同一个 Wi-Fi。
140
+
141
+ **面板里没有二维码。**
142
+ 重启 DSH。装完不重启,插件不会加载。
143
+
144
+ **想改成局域网直连。**
145
+ 默认是「有中继就走中继」。二维码里同时带直连地址,App 会先试直连、失败再走中继。
146
+
147
+ **插件版本对不对。**
148
+ DSH 启动日志里有 `[mizar] dsh-mizar <版本号> · state: … · devices: N live` 这一行。
149
+
150
+ **收不到通知。**
151
+ 按顺序查:App 里「设置 → 通知」有没有允许;插件日志里有没有 `push: registered …`
152
+ (说明手机把推送令牌交上来了);中继上 `curl localhost:8787/healthz` 的
153
+ `pushDevices` 是不是大于 0。三条都对还是没有,看中继日志里 Apple 回的是什么 ——
154
+ `BadDeviceToken` 基本就是环境选错了。
155
+
156
+ ---
157
+
158
+ 协议、架构决策、自建中继:[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,153 @@
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
+ /** One paired device. */
6
+ export interface DeviceRecord {
7
+ /** Stable identity, generated at pairing time. */
8
+ readonly deviceId: string;
9
+ /** Operator-visible name, defaulted from the device model. */
10
+ readonly name: string;
11
+ /** Device model reported by the app, when it sent one. */
12
+ readonly model?: string;
13
+ /** App version reported at pairing time. */
14
+ readonly appVersion?: string;
15
+ /** SHA-256 of the token, hex encoded. */
16
+ readonly tokenHash: string;
17
+ /** Short, non-secret display id derived from the token. */
18
+ readonly tokenPrefix: string;
19
+ /** When the device paired, in epoch milliseconds. */
20
+ readonly createdAt: number;
21
+ /** Last successful authentication, in epoch milliseconds. */
22
+ readonly lastSeenAt: number;
23
+ /** When the device was revoked; absent while it is valid. */
24
+ readonly revokedAt?: number;
25
+ /**
26
+ * Where to send a notification for this device, once its app has asked.
27
+ *
28
+ * Kept on the device rather than in a store of its own, because that is what
29
+ * it is: a property of a paired phone. Revoking a device drops it with the
30
+ * record, and there is no second roster to keep in step with this one.
31
+ *
32
+ * Absent until the app registers, which it does on its own after the operator
33
+ * grants notification permission -- so a host whose phone never asked simply
34
+ * never grows this field.
35
+ */
36
+ readonly push?: DevicePush;
37
+ }
38
+ /** A device's push endpoint. */
39
+ export interface DevicePush {
40
+ /** The device token: hex for APNs, an opaque registration token for FCM. */
41
+ readonly token: string;
42
+ /**
43
+ * Which push service the token belongs to.
44
+ *
45
+ * Stored rather than inferred from the token's shape, because a relay that
46
+ * guessed would send an Android token to Apple on the strength of it looking
47
+ * unusual. Defaulted to `'ios'` when a record predates the field, which is
48
+ * what every stored record does.
49
+ */
50
+ readonly platform: 'ios' | 'android';
51
+ /**
52
+ * Which APNs environment issued it, or `null` on Android.
53
+ *
54
+ * Not inferable and not interchangeable: a token minted for the sandbox is
55
+ * rejected by the production endpoint with `BadDeviceToken`, which says
56
+ * nothing about why. FCM has one service, so an Android registration has no
57
+ * environment to record.
58
+ */
59
+ readonly environment: 'sandbox' | 'production' | null;
60
+ /** The language the app is running in, so notifications match it. */
61
+ readonly locale: string;
62
+ /** When the app last told this host where it is. */
63
+ readonly updatedAt: number;
64
+ }
65
+ /** A freshly minted token and the record material derived from it. */
66
+ export interface IssuedToken {
67
+ /** The secret handed to the phone exactly once. */
68
+ readonly token: string;
69
+ /** The record fields to persist. */
70
+ readonly tokenHash: string;
71
+ /** The short display id. */
72
+ readonly tokenPrefix: string;
73
+ }
74
+ /**
75
+ * Hash a token for storage or comparison.
76
+ * @param token - the token.
77
+ * @returns the hex-encoded SHA-256 digest.
78
+ */
79
+ export declare function hashToken(token: string): string;
80
+ /**
81
+ * Mint a new device token.
82
+ * @returns the token plus the material to persist.
83
+ */
84
+ export declare function issueToken(): IssuedToken;
85
+ /**
86
+ * Build a device record for a pairing request.
87
+ * @param issued - the minted token material.
88
+ * @param details - what the phone reported about itself.
89
+ * @param now - current epoch milliseconds.
90
+ * @returns the record to persist.
91
+ */
92
+ export declare function createDeviceRecord(issued: IssuedToken, details: {
93
+ name?: string;
94
+ model?: string;
95
+ appVersion?: string;
96
+ }, now?: number): DeviceRecord;
97
+ /**
98
+ * A token's record, and whether that record has been revoked.
99
+ *
100
+ * Both are needed because the two are different answers for the client:
101
+ * "this host has never seen you" sends the app back to pairing, while "you were
102
+ * removed on purpose" also makes it clear its stored token is dead.
103
+ */
104
+ export interface TokenMatch {
105
+ readonly record: DeviceRecord;
106
+ readonly revoked: boolean;
107
+ }
108
+ /**
109
+ * Find the record a token belongs to, revoked or not.
110
+ *
111
+ * Every record is compared without an early exit, so a caller cannot time which
112
+ * record matched or how close a wrong token was. A live record wins over a
113
+ * revoked one when a token somehow appears twice, which is the order the roster
114
+ * itself is kept in.
115
+ *
116
+ * @param devices - the known device records.
117
+ * @param token - the token presented by the client.
118
+ * @returns the match, or `null` when no record holds this token.
119
+ */
120
+ export declare function matchToken(devices: readonly DeviceRecord[], token: string): TokenMatch | null;
121
+ /**
122
+ * Find the live device a token belongs to.
123
+ *
124
+ * @param devices - the known device records.
125
+ * @param token - the token presented by the client.
126
+ * @returns the matching live record, or `null`.
127
+ */
128
+ export declare function verifyToken(devices: readonly DeviceRecord[], token: string): DeviceRecord | null;
129
+ /**
130
+ * Mark a device revoked, preserving its record for the roster.
131
+ * @param devices - the known records.
132
+ * @param deviceId - the device to revoke.
133
+ * @param now - current epoch milliseconds.
134
+ * @returns the updated records, or the input when the id is unknown.
135
+ */
136
+ export declare function revokeDevice(devices: readonly DeviceRecord[], deviceId: string, now?: number): readonly DeviceRecord[];
137
+ /**
138
+ * Record a successful authentication against a device.
139
+ * @param devices - the known records.
140
+ * @param deviceId - the authenticated device.
141
+ * @param now - current epoch milliseconds.
142
+ * @returns the updated records.
143
+ */
144
+ export declare function touchDevice(devices: readonly DeviceRecord[], deviceId: string, now?: number): readonly DeviceRecord[];
145
+ /**
146
+ * Split the roster into live and revoked devices, newest first.
147
+ * @param devices - the known records.
148
+ * @returns the two lists, each ordered by creation time descending.
149
+ */
150
+ export declare function partitionDevices(devices: readonly DeviceRecord[]): {
151
+ live: readonly DeviceRecord[];
152
+ revoked: readonly DeviceRecord[];
153
+ };
@@ -0,0 +1,139 @@
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
+ /**
21
+ * Hash a token for storage or comparison.
22
+ * @param token - the token.
23
+ * @returns the hex-encoded SHA-256 digest.
24
+ */
25
+ export function hashToken(token) {
26
+ return createHash('sha256').update(token, 'utf8').digest('hex');
27
+ }
28
+ /**
29
+ * Mint a new device token.
30
+ * @returns the token plus the material to persist.
31
+ */
32
+ export function issueToken() {
33
+ const token = `${TOKEN_PREFIX}${base32(new Uint8Array(randomBytes(TOKEN_BYTES)))}`;
34
+ const tokenHash = hashToken(token);
35
+ return { token, tokenHash, tokenPrefix: tokenHash.slice(0, 8) };
36
+ }
37
+ /**
38
+ * Build a device record for a pairing request.
39
+ * @param issued - the minted token material.
40
+ * @param details - what the phone reported about itself.
41
+ * @param now - current epoch milliseconds.
42
+ * @returns the record to persist.
43
+ */
44
+ export function createDeviceRecord(issued, details, now = Date.now()) {
45
+ const rawName = details.name?.trim() ?? '';
46
+ const model = details.model?.trim();
47
+ const name = rawName !== '' ? rawName : (model ?? '').trim() !== '' ? model : 'Unnamed device';
48
+ return {
49
+ deviceId: randomUUID(),
50
+ name: name.slice(0, MAX_DEVICE_NAME),
51
+ ...(model !== undefined && model !== '' ? { model: model.slice(0, MAX_DEVICE_NAME) } : {}),
52
+ ...(details.appVersion !== undefined && details.appVersion !== ''
53
+ ? { appVersion: details.appVersion.slice(0, 32) }
54
+ : {}),
55
+ tokenHash: issued.tokenHash,
56
+ tokenPrefix: issued.tokenPrefix,
57
+ createdAt: now,
58
+ lastSeenAt: now,
59
+ };
60
+ }
61
+ /**
62
+ * Find the record a token belongs to, revoked or not.
63
+ *
64
+ * Every record is compared without an early exit, so a caller cannot time which
65
+ * record matched or how close a wrong token was. A live record wins over a
66
+ * revoked one when a token somehow appears twice, which is the order the roster
67
+ * itself is kept in.
68
+ *
69
+ * @param devices - the known device records.
70
+ * @param token - the token presented by the client.
71
+ * @returns the match, or `null` when no record holds this token.
72
+ */
73
+ export function matchToken(devices, token) {
74
+ const presented = Buffer.from(hashToken(token), 'utf8');
75
+ let match = null;
76
+ for (const device of devices) {
77
+ const stored = Buffer.from(device.tokenHash, 'utf8');
78
+ if (stored.length !== presented.length || !timingSafeEqual(stored, presented))
79
+ continue;
80
+ if (device.revokedAt === undefined) {
81
+ if (match === null || match.revoked)
82
+ match = { record: device, revoked: false };
83
+ }
84
+ else if (match === null) {
85
+ match = { record: device, revoked: true };
86
+ }
87
+ }
88
+ return match;
89
+ }
90
+ /**
91
+ * Find the live device a token belongs to.
92
+ *
93
+ * @param devices - the known device records.
94
+ * @param token - the token presented by the client.
95
+ * @returns the matching live record, or `null`.
96
+ */
97
+ export function verifyToken(devices, token) {
98
+ const match = matchToken(devices, token);
99
+ return match !== null && !match.revoked ? match.record : null;
100
+ }
101
+ /**
102
+ * Mark a device revoked, preserving its record for the roster.
103
+ * @param devices - the known records.
104
+ * @param deviceId - the device to revoke.
105
+ * @param now - current epoch milliseconds.
106
+ * @returns the updated records, or the input when the id is unknown.
107
+ */
108
+ export function revokeDevice(devices, deviceId, now = Date.now()) {
109
+ let changed = false;
110
+ const next = devices.map((device) => {
111
+ if (device.deviceId !== deviceId || device.revokedAt !== undefined)
112
+ return device;
113
+ changed = true;
114
+ return { ...device, revokedAt: now };
115
+ });
116
+ return changed ? next : devices;
117
+ }
118
+ /**
119
+ * Record a successful authentication against a device.
120
+ * @param devices - the known records.
121
+ * @param deviceId - the authenticated device.
122
+ * @param now - current epoch milliseconds.
123
+ * @returns the updated records.
124
+ */
125
+ export function touchDevice(devices, deviceId, now = Date.now()) {
126
+ return devices.map((device) => device.deviceId === deviceId ? { ...device, lastSeenAt: now } : device);
127
+ }
128
+ /**
129
+ * Split the roster into live and revoked devices, newest first.
130
+ * @param devices - the known records.
131
+ * @returns the two lists, each ordered by creation time descending.
132
+ */
133
+ export function partitionDevices(devices) {
134
+ const byNewest = [...devices].sort((a, b) => b.createdAt - a.createdAt);
135
+ return {
136
+ live: byNewest.filter((device) => device.revokedAt === undefined),
137
+ revoked: byNewest.filter((device) => device.revokedAt !== undefined),
138
+ };
139
+ }
@@ -0,0 +1,133 @@
1
+ /** Characters in a pairing code. 8 base32 characters carry 40 bits. */
2
+ export declare const CODE_LENGTH = 8;
3
+ /** Default lifetime of an issued code, in seconds. */
4
+ export declare const DEFAULT_CODE_TTL_SECONDS = 300;
5
+ /** The persisted pairing slot. `expiresAt: 0` means no code is outstanding. */
6
+ export interface PairingSlot {
7
+ /** Counter folded into the code derivation; advances on issue and on redeem. */
8
+ readonly epoch: number;
9
+ /**
10
+ * Epoch milliseconds at which the outstanding code stops being accepted, or
11
+ * `0` when none is outstanding.
12
+ *
13
+ * The deadline lives in the slot rather than being passed to
14
+ * {@link redeemCode} as a TTL. Storing it once removes any way for the
15
+ * issuing and redeeming sides to disagree and silently extend a code's life.
16
+ */
17
+ readonly expiresAt: number;
18
+ }
19
+ /** An outstanding pairing code. */
20
+ export interface IssuedCode {
21
+ /** The display form, grouped for reading aloud. */
22
+ readonly display: string;
23
+ /** The canonical form to compare against, uppercase without separators. */
24
+ readonly code: string;
25
+ /** The slot to persist; carries the deadline in `expiresAt`. */
26
+ readonly slot: PairingSlot;
27
+ }
28
+ /** Outcome of redeeming a code. */
29
+ export type RedeemResult = {
30
+ readonly ok: true;
31
+ readonly slot: PairingSlot;
32
+ } | {
33
+ readonly ok: false;
34
+ readonly reason: 'no-code' | 'consumed' | 'expired' | 'mismatch';
35
+ };
36
+ /**
37
+ * Encode bytes as Crockford base32.
38
+ * @param bytes - the bytes to encode.
39
+ * @returns the encoded string.
40
+ */
41
+ export declare function base32(bytes: Uint8Array): string;
42
+ /**
43
+ * Normalise a user-entered code.
44
+ *
45
+ * Accepts lowercase, spaces, and hyphens; maps the characters Crockford treats
46
+ * as equivalent (`I`/`L` to `1`, `O` to `0`).
47
+ *
48
+ * @param input - the raw text.
49
+ * @returns the canonical uppercase form.
50
+ */
51
+ export declare function normalizeCode(input: string): string;
52
+ /**
53
+ * Derive the code for one host secret and epoch.
54
+ * @param hostSecret - the per-installation secret.
55
+ * @param epoch - the pairing counter.
56
+ * @returns a {@link CODE_LENGTH}-character code.
57
+ */
58
+ export declare function deriveCode(hostSecret: Uint8Array, epoch: number): string;
59
+ /**
60
+ * Group a code for display, four characters at a time.
61
+ * @param code - the canonical code.
62
+ * @returns the display form, for example `AB2C-D3EF`.
63
+ */
64
+ export declare function displayCode(code: string): string;
65
+ /**
66
+ * Issue a fresh pairing code.
67
+ *
68
+ * Issuing invalidates any outstanding code: the epoch advances, so a code handed
69
+ * out a moment ago stops working the instant a new one is generated.
70
+ *
71
+ * @param hostSecret - the per-installation secret.
72
+ * @param previous - the outstanding slot, if any.
73
+ * @param now - current epoch milliseconds.
74
+ * @param ttlSeconds - how long the code stays valid.
75
+ * @returns the issued code and the slot to persist.
76
+ */
77
+ export declare function issueCode(hostSecret: Uint8Array, previous: PairingSlot | null, now?: number, ttlSeconds?: number): IssuedCode;
78
+ /**
79
+ * Redeem a pairing code.
80
+ *
81
+ * Comparison is constant-time over equal-length buffers, so the response time
82
+ * does not reveal how much of a wrong guess was correct.
83
+ *
84
+ * `consumed` and `expired` are reported separately here because the audit log is
85
+ * the operator's only forensic trail and "someone replayed a code" is a
86
+ * different story from "the code timed out". Callers must still answer both with
87
+ * the same client-facing code — telling a caller which of the two it hit turns
88
+ * the endpoint into an oracle.
89
+ *
90
+ * @param hostSecret - the per-installation secret.
91
+ * @param slot - the outstanding pairing slot, carrying its own deadline.
92
+ * @param input - the code the phone sent.
93
+ * @param now - current epoch milliseconds.
94
+ * @returns whether the code was redeemed, and the cleared slot when it was.
95
+ */
96
+ export declare function redeemCode(hostSecret: Uint8Array, slot: PairingSlot | null, input: string, now?: number): RedeemResult;
97
+ /**
98
+ * Generate a host secret with full entropy.
99
+ * @returns 32 random bytes.
100
+ */
101
+ export declare function generateHostSecret(): Uint8Array;
102
+ /**
103
+ * A counted, time-windowed limiter for authentication attempts.
104
+ *
105
+ * Pairing is the only unauthenticated endpoint, so it is the only place an
106
+ * attacker can guess. A fixed window is deliberate: it must be cheap enough to
107
+ * run on every attempt and simple enough to reason about.
108
+ */
109
+ export declare class RateLimiter {
110
+ #private;
111
+ private readonly limit;
112
+ private readonly windowMs;
113
+ /**
114
+ * @param limit - attempts allowed per window, per key.
115
+ * @param windowMs - window length in milliseconds.
116
+ */
117
+ constructor(limit: number, windowMs: number);
118
+ /**
119
+ * Record one attempt and report whether it may proceed.
120
+ * @param key - the caller identity, for example a remote address.
121
+ * @param now - current epoch milliseconds.
122
+ * @returns whether the attempt is within the limit, and how long to wait when it is not.
123
+ */
124
+ check(key: string, now?: number): {
125
+ allowed: boolean;
126
+ retryAfterMs: number;
127
+ };
128
+ /**
129
+ * Drop windows that have expired.
130
+ * @param now - current epoch milliseconds.
131
+ */
132
+ prune(now?: number): void;
133
+ }