dsh-cindy-host 0.1.20
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.
- package/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-cindy-host",
|
|
3
|
+
"version": "0.1.20",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chanyuenpang/dsh-cindy-host.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"dsh",
|
|
13
|
+
"dsh-plugin",
|
|
14
|
+
"cordis",
|
|
15
|
+
"cindy",
|
|
16
|
+
"device-link"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"//files": "白名单,而非整个目录:.sandbox/(含凭据与会话)、.claw/runtime、验收日志、开发记录都不进包。见 doc/publishing.md §3。",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"lib",
|
|
25
|
+
"cordis.patch.yml",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"doc"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "./src/dsh-plugin.js",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./src/dsh-plugin.js",
|
|
35
|
+
"./client": "./lib/client.js",
|
|
36
|
+
"./src/*": "./src/*",
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"acceptance": "node tools/acceptance.mjs",
|
|
49
|
+
"acceptance:sandbox": "node tools/acceptance.mjs --base http://127.0.0.1:3081",
|
|
50
|
+
"sandbox": "node tools/sandbox.mjs start",
|
|
51
|
+
"sandbox:stop": "node tools/sandbox.mjs stop",
|
|
52
|
+
"restart": "node tools/restart-host.mjs",
|
|
53
|
+
"restart:apply": "node tools/restart-host.mjs --apply",
|
|
54
|
+
"audit:channels": "node tools/channel-audit.mjs",
|
|
55
|
+
"verify": "npm test && npm run audit:channels && npm run acceptance",
|
|
56
|
+
"demo": "node src/demo.js",
|
|
57
|
+
"login": "node src/login.js",
|
|
58
|
+
"logout": "node src/logout.js",
|
|
59
|
+
"host": "node src/host.js",
|
|
60
|
+
"host:demo": "node src/host-demo.js",
|
|
61
|
+
"test": "node --test",
|
|
62
|
+
"test:file-peer": "node --test test/file-peer-rtc.test.js test/host-file-peer.test.js test/host-file-ice.test.js test/host-media-fetch.test.js test/file-peer-integration.test.js",
|
|
63
|
+
"smoke:file-peer": "node tools/file-peer-rtc-smoke.mjs --manager"
|
|
64
|
+
},
|
|
65
|
+
"//dependencies": "只放宿主不提供的库。DSH 自己的包一律不进 dependencies —— 见下面 peerDependencies 的说明。`keytar` 放 optionalDependencies:它是**原生**模块,没有 C++ 工具链的机器上必然编译失败,而 pnpm 会把普通 dependency 的构建失败当成安装失败(exit 1)—— DSH 随后就不会把这个 bundle 写进 `dsh.profile.bundles`,行也就永远不会出现。可选依赖的构建失败是允许的,与我们本来就有的惰性 require + 降级(凭据能力不可用,插件照常 mount)语义一致。",
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"werift": "0.24.4",
|
|
68
|
+
"ws": "^8.21.3"
|
|
69
|
+
},
|
|
70
|
+
"optionalDependencies": {
|
|
71
|
+
"keytar": "^7.9.0"
|
|
72
|
+
},
|
|
73
|
+
"//peerDependencies": "DSH 自己的包必须由宿主提供,绝不能声明成 dependency/optionalDependency:pnpm 会把它们**连同整代依赖图**装进 profile,宿主再组合这个混合体就起不来(实测:`^0.1.1-rc.2` 会把 28 个 0.1.1-rc.2 的包装进一个 0.1.5-rc.2 的宿主,profile 启动失败于 dsh-client-file-upload / dsh-session)。区间必须覆盖宿主那一代(semver 的预发布规则:`^0.1.1-rc.2` 匹配不到 `0.1.5-rc.2`),与生态里 dsh-codex / dsh-cost-meter 的写法一致;`0.2.0-rc.2` 需要 `>=0.2.0-rc.1 <0.3.0-0`。",
|
|
74
|
+
"peerDependencies": {
|
|
75
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2 || ^0.1.5-rc.2 || >=0.2.0-rc.1 <0.3.0-0",
|
|
76
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2 || ^0.1.5-rc.2 || >=0.2.0-rc.1 <0.3.0-0",
|
|
77
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
78
|
+
},
|
|
79
|
+
"//peerDependenciesMeta": "**故意没有这个字段。** `@deepseek-ai/dsh-host-apiproxy` 曾经在这里标 optional,但 0.2 已经删掉了这个包,而 `evaluatePluginCompatibility` 会遍历 `peerDependencies` 的**每一个键且不查 `peerDependenciesMeta`**,所以只要它出现在 peers 里,`dsh plugin add` 就会直接拒绝安装(`installation rejected`),启动时 bundle 也会被静默跳过、`dsh-cindy-host` 行根本不会出现。该适配器本来就走惰性 `require` + 失败降级(见 src/dsh-plugin.js 的 apiProxy 分支),从 peers 里移除不改变任何运行时行为,只是让两道门禁都能正确判定。",
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"react": "^18.3.1",
|
|
82
|
+
"react-dom": "^18.3.1"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle for the Cindy Host.
|
|
3
|
+
*
|
|
4
|
+
* Two callers with different needs share this module:
|
|
5
|
+
* - the Web settings card needs a non-interactive restore (`restoreSession`),
|
|
6
|
+
* because a host with no TTY can never answer a readline prompt;
|
|
7
|
+
* - the `npm run host` CLI keeps the interactive fallback that asks for a
|
|
8
|
+
* phone number and a verification code on the terminal.
|
|
9
|
+
*
|
|
10
|
+
* Tokens only ever travel between the OS credential store and the relay
|
|
11
|
+
* handshake; neither path prints them.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { loginWithPhone } from './cindy-login.js';
|
|
15
|
+
import { refreshStoredSession } from './cindy-login-flow.js';
|
|
16
|
+
import { loadSession, saveSession, credentialStoreAvailable, clearSession as clearStoredSession } from './credential-store.js';
|
|
17
|
+
|
|
18
|
+
// One in-process writer for login/logout and guarded refresh commits. This is
|
|
19
|
+
// deliberately not a cross-process credential-store CAS.
|
|
20
|
+
let credentialRevision = 0;
|
|
21
|
+
let writes = Promise.resolve();
|
|
22
|
+
function serializeWrite(action) {
|
|
23
|
+
const pending = writes.then(action);
|
|
24
|
+
writes = pending.catch(() => {});
|
|
25
|
+
return pending;
|
|
26
|
+
}
|
|
27
|
+
function sameCredential(a, b) {
|
|
28
|
+
return a != null && b != null && ['deviceId', 'authBaseUrl', 'accessToken', 'refreshToken']
|
|
29
|
+
.every((key) => a[key] === b[key]);
|
|
30
|
+
}
|
|
31
|
+
const superseded = () => ({ ok: false, reason: 'OWNER_UNVERIFIED', message: 'Cindy 登录归属已变化,未采用旧操作结果' });
|
|
32
|
+
|
|
33
|
+
/** Refresh only a captured authenticated lease, never import another stored identity. */
|
|
34
|
+
export async function refreshOwnedCredential(captured, ports = {}) {
|
|
35
|
+
const load = ports.loadSession ?? loadSession;
|
|
36
|
+
const save = ports.saveSession ?? saveSession;
|
|
37
|
+
const refresh = ports.refreshStoredSession ?? refreshStoredSession;
|
|
38
|
+
const current = ports.isCurrent ?? (() => false);
|
|
39
|
+
const revision = credentialRevision;
|
|
40
|
+
const snapshot = Object.freeze({ ...captured });
|
|
41
|
+
const stored = await load();
|
|
42
|
+
if (revision !== credentialRevision || !current() || !sameCredential(snapshot, stored)) return superseded();
|
|
43
|
+
const timeoutMs = Number.isSafeInteger(ports.timeoutMs) && ports.timeoutMs > 0 ? Math.min(ports.timeoutMs, 30_000) : 30_000;
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const abort = () => controller.abort();
|
|
46
|
+
ports.signal?.addEventListener('abort', abort, { once: true });
|
|
47
|
+
if (ports.signal?.aborted) abort();
|
|
48
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
49
|
+
let aborted;
|
|
50
|
+
let refreshed;
|
|
51
|
+
try {
|
|
52
|
+
const cancelled = new Promise((resolve) => {
|
|
53
|
+
aborted = () => resolve({ ok: false });
|
|
54
|
+
controller.signal.addEventListener('abort', aborted, { once: true });
|
|
55
|
+
if (controller.signal.aborted) aborted();
|
|
56
|
+
});
|
|
57
|
+
refreshed = await Promise.race([
|
|
58
|
+
Promise.resolve().then(() => {
|
|
59
|
+
controller.signal.throwIfAborted();
|
|
60
|
+
return refresh(snapshot, { signal: controller.signal });
|
|
61
|
+
}), cancelled,
|
|
62
|
+
]);
|
|
63
|
+
} catch { refreshed = { ok: false }; }
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
ports.signal?.removeEventListener('abort', abort);
|
|
67
|
+
controller.signal.removeEventListener('abort', aborted);
|
|
68
|
+
}
|
|
69
|
+
if (!refreshed?.ok) return { ok: false, reason: 'AUTH_FAILED', message: 'Cindy 登录续期失败' };
|
|
70
|
+
const session = { ...snapshot, ...refreshed.session };
|
|
71
|
+
if (session.deviceId !== snapshot.deviceId || session.authBaseUrl !== snapshot.authBaseUrl
|
|
72
|
+
|| typeof session.accessToken !== 'string' || !session.accessToken
|
|
73
|
+
|| typeof session.refreshToken !== 'string' || !session.refreshToken) return superseded();
|
|
74
|
+
return serializeWrite(async () => {
|
|
75
|
+
const stored = await load();
|
|
76
|
+
if (revision !== credentialRevision || !current() || !sameCredential(snapshot, stored)) return superseded();
|
|
77
|
+
await save(session);
|
|
78
|
+
if (revision !== credentialRevision || !current()) return superseded();
|
|
79
|
+
credentialRevision += 1;
|
|
80
|
+
return { ok: true, session };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load the stored session and refresh it.
|
|
86
|
+
*
|
|
87
|
+
* The credential ports are injectable so the **deletion rule** can be tested
|
|
88
|
+
* directly: it is the one behaviour here that is destructive, and it already cost
|
|
89
|
+
* a real login once.
|
|
90
|
+
* @param ports - optional overrides for the credential store and the refresher.
|
|
91
|
+
* @returns `{ ok: true, session }` when a usable session stands, else the reason none does.
|
|
92
|
+
*/
|
|
93
|
+
export async function restoreSession(ports = {}) {
|
|
94
|
+
const load = ports.loadSession ?? loadSession;
|
|
95
|
+
// A missing native dependency says nothing about whether the OS holds a login.
|
|
96
|
+
// Injected stores do not depend on this machine's keytar unless they opt in.
|
|
97
|
+
const available = ports.credentialStoreAvailable ?? (load === loadSession ? credentialStoreAvailable : undefined);
|
|
98
|
+
if (available && !available()) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: 'CREDENTIAL_STORE_UNAVAILABLE',
|
|
102
|
+
message: '本机 Cindy 凭据存储不可用,无法读取已有登录态。请修复当前 DSH 配置中的 keytar 原生模块(允许安装构建脚本或重新构建),然后重启 DSH;请勿因此清除登录态或重复登录。',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const save = ports.saveSession ?? saveSession;
|
|
106
|
+
const refresh = ports.refreshStoredSession ?? refreshStoredSession;
|
|
107
|
+
const revision = credentialRevision;
|
|
108
|
+
const saved = await load();
|
|
109
|
+
if (!saved) return { ok: false, reason: 'missing', message: '本机还没有 Cindy 登录态' };
|
|
110
|
+
|
|
111
|
+
if (revision !== credentialRevision) return superseded();
|
|
112
|
+
const refreshed = await refresh(saved);
|
|
113
|
+
if (revision !== credentialRevision) return superseded();
|
|
114
|
+
if (refreshed.ok) {
|
|
115
|
+
const session = { ...saved, ...refreshed.session };
|
|
116
|
+
return serializeWrite(async () => {
|
|
117
|
+
const stored = await load();
|
|
118
|
+
if (revision !== credentialRevision || !sameCredential(saved, stored)) return superseded();
|
|
119
|
+
await save(session);
|
|
120
|
+
if (revision !== credentialRevision) return superseded();
|
|
121
|
+
credentialRevision += 1;
|
|
122
|
+
return { ok: true, session };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A service that could not be reached is not evidence about the credential at
|
|
127
|
+
// all: keep it and try with what is stored. If the access token has also
|
|
128
|
+
// expired the connection fails visibly, which is recoverable — unlike a
|
|
129
|
+
// credential that no longer exists.
|
|
130
|
+
if (refreshed.transient === true) {
|
|
131
|
+
return { ok: true, session: saved, refreshDeferred: true, message: refreshed.message };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A **refused** session is reported, never deleted.
|
|
135
|
+
//
|
|
136
|
+
// This used to `clearSession()` on any failure, and that is what cost a real
|
|
137
|
+
// login: every Host start refreshes, `refreshStoredSession` used to fold
|
|
138
|
+
// timeouts and 5xx into "expired", and a second instance sharing the credential
|
|
139
|
+
// rotates the refresh token out from under the first — so one hiccup permanently
|
|
140
|
+
// deleted a working credential and forced a fresh login.
|
|
141
|
+
//
|
|
142
|
+
// Keeping it is safe, because the card's login form keys off this result
|
|
143
|
+
// (`resolved.ok`), not off the credential's presence: a rejected session still
|
|
144
|
+
// shows the form. What the user gets back is the ability to recover — a rotation
|
|
145
|
+
// that merely raced works again on the next start instead of demanding a new
|
|
146
|
+
// login. Deleting the credential is now something only an explicit logout does
|
|
147
|
+
// (`forgetSession`).
|
|
148
|
+
return { ok: false, reason: 'expired', message: refreshed.message, rejected: refreshed.rejected === true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Persist a session the card just established.
|
|
153
|
+
* @param session - the session the login flow returned.
|
|
154
|
+
* @param ports - optional credential-store override, for tests.
|
|
155
|
+
*/
|
|
156
|
+
export async function adoptSession(session, ports = {}) {
|
|
157
|
+
const save = ports.saveSession ?? saveSession;
|
|
158
|
+
const stored = { deviceId: session.deviceId ?? randomUUID(), ...session };
|
|
159
|
+
credentialRevision += 1; // Fence refreshes immediately, before waiting for an older writer.
|
|
160
|
+
await serializeWrite(() => save(stored));
|
|
161
|
+
return stored;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Drop the stored session; the next connect starts from the login form.
|
|
166
|
+
*
|
|
167
|
+
* This is the **only** path that deletes the credential. A failed refresh reports
|
|
168
|
+
* itself instead (see {@link restoreSession}).
|
|
169
|
+
* @param ports - optional credential-store override, for tests.
|
|
170
|
+
*/
|
|
171
|
+
export async function forgetSession(ports = {}) {
|
|
172
|
+
const clear = ports.clearSession ?? clearStoredSession;
|
|
173
|
+
credentialRevision += 1;
|
|
174
|
+
await serializeWrite(clear);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* CLI path: reuse a stored session when possible, otherwise ask on the terminal.
|
|
179
|
+
* @returns a usable Cindy session.
|
|
180
|
+
*/
|
|
181
|
+
export async function getAuthenticatedSession() {
|
|
182
|
+
const restored = await restoreSession();
|
|
183
|
+
if (restored.ok) return restored.session;
|
|
184
|
+
const session = await loginWithPhone({ clientType: 'desktop' });
|
|
185
|
+
await adoptSession(session);
|
|
186
|
+
return session;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export { forgetSession as clearSession };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may control this Host.
|
|
3
|
+
*
|
|
4
|
+
* Two facts decide admission, and only two:
|
|
5
|
+
* 1. the user turned the phone connection on (the settings switch), and
|
|
6
|
+
* 2. the device has not been explicitly revoked.
|
|
7
|
+
*
|
|
8
|
+
* There is deliberately no per-device enrollment step. The relay only ever
|
|
9
|
+
* routes frames between devices of ONE Cindy account, so a `link-open` that
|
|
10
|
+
* arrives here is already the same account as the session this Host logged in
|
|
11
|
+
* with; asking the user to additionally bless each device would be an
|
|
12
|
+
* enrollment UI the current scope excludes. Revocation stays available and is
|
|
13
|
+
* honored, which is what makes that omission safe to revisit later.
|
|
14
|
+
*/
|
|
15
|
+
export class AuthorizationPolicy {
|
|
16
|
+
/** @param settings - resolved `dsh-cindy-host` settings section. */
|
|
17
|
+
constructor(settings = {}) {
|
|
18
|
+
this.update(settings);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Re-resolve from settings; called on every settings change. */
|
|
22
|
+
update(settings = {}) {
|
|
23
|
+
// Keep the caller's section verbatim so `revoke` can hand back something
|
|
24
|
+
// `update` accepts — the internal shape below is not the settings shape.
|
|
25
|
+
this.raw = settings;
|
|
26
|
+
this.settings = {
|
|
27
|
+
// `transportEnabled` is the card's switch. `remoteControlEnabled` is the
|
|
28
|
+
// legacy/hand-edited spelling of the same intent and is still honored.
|
|
29
|
+
enabled: settings.transportEnabled === true || settings.remoteControlEnabled === true,
|
|
30
|
+
controllers: settings.controllers && typeof settings.controllers === 'object' ? settings.controllers : {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether this Host is currently willing to be controlled at all. */
|
|
35
|
+
isEnabled() {
|
|
36
|
+
return this.settings.enabled;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether one `link-open`/`invoke` source may be served.
|
|
41
|
+
* @param deviceId - relay-assigned source device id (`Envelope.src`).
|
|
42
|
+
*/
|
|
43
|
+
canAccept(deviceId) {
|
|
44
|
+
if (!this.settings.enabled || typeof deviceId !== 'string' || deviceId === '') return false;
|
|
45
|
+
return this.settings.controllers[deviceId]?.state !== 'revoked';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Produce the settings a revocation would write. Pure: the caller persists it. */
|
|
49
|
+
revoke(deviceId) {
|
|
50
|
+
const entry = this.settings.controllers[deviceId] ?? {};
|
|
51
|
+
return {
|
|
52
|
+
...this.raw,
|
|
53
|
+
controllers: {
|
|
54
|
+
...this.settings.controllers,
|
|
55
|
+
[deviceId]: { ...entry, state: 'revoked', revokedAt: new Date().toISOString(), grantRevision: (entry.grantRevision || 0) + 1 },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|