feat(device-manager): add safe B2 service ping command

This commit is contained in:
Codex
2026-08-13 11:38:26 +03:00
parent 19f0d97e23
commit 2fa1951f51
8 changed files with 161 additions and 12 deletions
@@ -22,6 +22,7 @@ const commandRoutes = new Map([
"device-configurations:set-desired",
"/internal/v1/management/device-configurations:set-desired",
],
["commands:service-ping", "/internal/v1/commands:service-ping"],
]);
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
@@ -97,6 +98,7 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
const configurationRevisions = new Map();
const configurationStates = new Map();
const auditEvents = [];
const commands = new Map();
function now() {
return new Date().toISOString();
@@ -152,12 +154,14 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
bindings: projectValues(bindings, projectRef),
configurationRevisions: projectValues(configurationRevisions, projectRef),
configurationStates: projectValues(configurationStates, projectRef),
commands: [],
commands: projectValues(commands, projectRef),
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
grants: projectValues(grants, projectRef),
policies: {
commandTransport: "disabled",
commandPlanningApi: "disabled",
commandTransport: fixture === "arusnavi-b2"
? "typed-service-ping-v1"
: "disabled",
commandPlanningApi: fixture === "arusnavi-b2" ? "enabled" : "disabled",
identifierProjection: "masked-only",
auditPayloadProjection: "metadata-only",
},
@@ -187,6 +191,42 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
return workspace(projectRef);
},
async execute(command, actor, input) {
if (command === "commands:service-ping") {
if (fixture !== "arusnavi-b2") {
throw serviceError("device_command_transport_disabled", 409);
}
const device = devices.get(input.deviceRef);
if (!device || device.projectRef !== input.projectRef) {
throw serviceError("device_command_route_unavailable", 409);
}
if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) {
throw serviceError("device_service_ping_access_code_invalid", 400);
}
const commandRef = `command:${randomUUID()}`;
const at = now();
const view = {
commandRef,
projectRef: input.projectRef,
deviceRef: input.deviceRef,
deviceName: device.displayName,
commandKey: `preview-service-ping-${randomUUID()}`,
commandCatalogRef: "arusnavi.b2.internal.v1:service-ping",
commandType: "service.ping",
riskClass: "low",
lifecycleState: "queued",
plannedAt: at,
expiresAt: new Date(Date.now() + Number(input.expiresInSeconds) * 1000).toISOString(),
confirmedAt: null,
dispatchedAt: null,
acknowledgedAt: null,
terminalAt: null,
terminalReasonCode: null,
createdAt: at,
updatedAt: at,
};
commands.set(commandRef, view);
return { replayed: false, result: view };
}
if (command === "owner-scopes:ensure") {
const key = `${input.scopeKind}:${input.ownerRef}`;
const created = !ownerScopes.has(key);
@@ -580,6 +620,7 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
grants,
configurationRevisions,
configurationStates,
commands,
auditEvents,
};
},
@@ -229,7 +229,7 @@ test("explicit B2 preview fixture is isolated from the empty canonical preview",
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
assert.equal(workspace.sessions[0].lifecycleState, "online");
assert.equal(workspace.policies.commandTransport, "disabled");
assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1");
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
assert.throws(
@@ -33,6 +33,7 @@ const mutationRoutes = new Map([
"/api/device-manager/device-configurations:set-desired",
"device-configurations:set-desired",
],
["/api/device-manager/commands:service-ping", "commands:service-ping"],
]);
export function createDeviceManagerServer({
+89 -6
View File
@@ -21,6 +21,7 @@ import {
registerAdapterVersion,
registerModelProfile,
revokeDeviceBinding,
sendServicePing,
setDesiredConfiguration,
upsertProjectGrant,
} from "./api";
@@ -157,7 +158,14 @@ export function DeviceControlView({
}).then(onRefresh).catch(onError)}
/>
) : null}
{view === "commands" ? <CommandsView workspace={workspace} /> : null}
{view === "commands" ? (
<CommandsView
workspace={workspace}
canDispatch={capabilities.has("command.plan") && capabilities.has("command.dispatch")}
onRefresh={onRefresh}
onError={onError}
/>
) : null}
{view === "audit" ? <AuditView workspace={workspace} /> : null}
{view === "access" ? (
<AccessView
@@ -412,17 +420,92 @@ function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
);
}
function CommandsView({ workspace }: { workspace: ProjectWorkspace }) {
function CommandsView({ workspace, canDispatch, onRefresh, onError }: {
workspace: ProjectWorkspace;
canDispatch: boolean;
onRefresh: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const supportedDevices = workspace.devices.filter(
(device) => device.modelProfileRef === "arusnavi.b2.internal.v1"
&& !["suspended", "retired"].includes(device.lifecycleState),
);
const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? "");
const [accessCode, setAccessCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const enabled = workspace.policies.commandTransport === "typed-service-ping-v1";
useEffect(() => {
if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) {
setDeviceRef(supportedDevices[0]?.deviceRef ?? "");
}
}, [deviceRef, supportedDevices]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return;
setSubmitting(true);
try {
await sendServicePing({
projectRef: workspace.project.projectRef,
deviceRef,
accessCode,
expiresInSeconds: 300,
});
setAccessCode("");
await onRefresh();
} catch (reason) {
onError(reason);
} finally {
setSubmitting(false);
}
};
return (
<ControlStack>
<div className="device-control-command-policy">
<Icon name="lock" />
<Icon name={enabled ? "check" : "lock"} />
<div>
<strong>Command transport выключен</strong>
<p>Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified отдельное доказательство состояния.</p>
<strong>{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}</strong>
<p>{enabled
? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
: "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}</p>
</div>
<StatusBadge tone="warning">{workspace.policies.commandTransport}</StatusBadge>
<StatusBadge tone={enabled ? "success" : "warning"}>{workspace.policies.commandTransport}</StatusBadge>
</div>
{enabled ? (
<form className="device-control-command-form" onSubmit={submit}>
<Select
label="B2 трекер"
value={deviceRef}
onChange={setDeviceRef}
options={supportedDevices.map((device) => ({
value: device.deviceRef,
label: device.displayName,
description: device.session?.state || device.lifecycleState,
}))}
disabled={!canDispatch || supportedDevices.length === 0 || submitting}
/>
<TextField
label="Код устройства"
type="password"
inputMode="numeric"
autoComplete="off"
value={accessCode}
onChange={(event) => setAccessCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
pattern="[0-9]{6}"
minLength={6}
maxLength={6}
required
disabled={!canDispatch || submitting}
description="Ровно 6 цифр. Код не сохраняется и не попадает в журнал. Команда истечёт через 5 минут."
/>
<Button
type="submit"
variant="primary"
disabled={!canDispatch || !deviceRef || accessCode.length !== 6 || submitting}
>
{submitting ? "Ставим в очередь…" : "Проверить сервис"}
</Button>
</form>
) : null}
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
{workspace.commands.map((command) => (
<ResourceRow
@@ -272,7 +272,7 @@ function AccessNotice({
if (access === "protected") {
return <div className="device-detail-notice" data-access="protected"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></div>;
}
return <div className="device-detail-notice" data-access="managed"><Icon name="settings" size={15} /><span>{commandTransport === "enabled" ? "Настройка управляется через command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></div>;
return <div className="device-detail-notice" data-access="managed"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Настройка управляется через типизированный command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></div>;
}
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
+9
View File
@@ -216,6 +216,15 @@ export async function setDesiredConfiguration(input: {
return mutate("/api/device-manager/device-configurations:set-desired", input);
}
export async function sendServicePing(input: {
projectRef: string;
deviceRef: string;
accessCode: string;
expiresInSeconds: number;
}) {
return mutate("/api/device-manager/commands:service-ping", input);
}
async function mutate<T = unknown>(path: string, input: unknown) {
return requestJson<{ ok: true; replayed: boolean; result: T }>(path, {
method: "POST",
+15
View File
@@ -92,6 +92,17 @@
margin: 0;
}
.device-control-command-form {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
gap: 14px;
padding: 18px;
border: 1px solid rgba(181, 255, 44, 0.18);
border-radius: 22px;
background: rgba(181, 255, 44, 0.045);
}
.device-control-command-policy p {
margin-top: 5px;
color: rgba(255, 255, 255, 0.58);
@@ -143,6 +154,10 @@
grid-template-columns: auto 1fr;
}
.device-control-command-form {
grid-template-columns: 1fr;
}
.device-control-command-policy > .nodedc-status {
grid-column: 2;
justify-self: start;
+1 -1
View File
@@ -279,7 +279,7 @@ export interface ProjectWorkspace {
auditEvents: AuditEventView[];
grants: ProjectGrantView[];
policies: {
commandTransport: "disabled" | "enabled";
commandTransport: "disabled" | "typed-service-ping-v1";
commandPlanningApi: "disabled" | "enabled";
identifierProjection: string;
auditPayloadProjection: string;