design phase 8 c1 - sinrimin/harness-nexus GitHub Wiki
Status: implemented (branch
phase-8-c1). Parent design:design-phase-8-client.md(protocol, isolation model, decisions) ā this doc is the concrete C1 development plan. PRD:prd-phase-8-client.md. Verification: shared 12 realtime-schema tests, server 18 tests (presence, machines REST, realtime integration against a live socket), smoke block[8 C1](14 assertions, E2E enroll ā online ā revoke).
C1 delivers the client unification foundation:
-
Machinedomain entity + storage (both drivers, SQLite migration0006). -
Machine identity: a dedicated PAT (scopes
['machine-ctl']) issued once at enrollment, rejected by the REST auth hook (blast radius = realtime only). -
Realtime channel v0: Socket.IO over the server's HTTP port (
@fastify/socket.io), namespaces/ctl(daemon) and/app(browser), per design-doc isolation rules. -
hnx enroll+hnx daemon(CLI grows into the client program);packages/acp-bridgeis deleted. -
Machines web page with honest online/offline via
/apppush.
Not in C1 (per parent plan): jobs/dispatch (C4), MCP shim (C2), inventory (C3),
chat (C5). The job:* dispatch envelope schemas land in shared/realtime.ts
now (v0 shapes) so C4 doesn't touch the protocol file's framing ā but no
handler exists yet.
-
core ā
domain/machine.ts(Machine: id, ownerId, name, hostname/os/ arch/daemonVersion/capabilities reported at hello,remoteChatEnableddefault false,enrollmentPatId, enrolledAt, lastSeenAt; online is derived from socket presence, never stored); portMachineRepository(findById / findByEnrollmentPatId / list({ownerId?}) / save / delete) onUnitOfWork.machines. -
shared ā
schemas/machine.ts(createMachineSchema, updateMachineSchema) andsrc/realtime.ts: handshake auth schemas +machine:hellopayload/ack-
machine:statuspush + v0job:dispatch/jobViewshapes. Export both from index. Unit tests for parse/reject.
-
-
server storage ā SQLite migration
0006(machinestable + owner/pat indexes),sqliteMachineRepositoryinrepos.ts, memory driver repo, both wired into the returnedUnitOfWork. -
server auth ā REST hook rejects PATs whose scopes include
machine-ctl(next to the existingmarketplacerejection). -
server realtime ā
src/plugins/realtime.ts+src/realtime/presence.ts:-
MachinePresenceā pure presence state machine (machineId ā socketId,connected/disconnected(socketId)/forceOffline), unit-testable without sockets. - plugin: register
@fastify/socket.io(explicitmaxHttpBufferSize, websocket-only transport), decorateapp.presence. -
/ctlauth middleware: machine PAT (hash lookup,machine-ctlscope, expiry, user active) +machineIdmust matchenrollmentPatId's machine. Joinmachine:<id>; presence up; broadcastmachine:statusonline touser:<owner>+adminson/app; savelastSeenAt. -
#45 ā one live socket per machine, enforced HERE: after the auth
checks, the middleware refuses the handshake when the machine's room
already holds a live socket (
machine already connectedconnect_error; first-come stays). Rationale:toCtlis a room broadcast ā N sockets mean every prompt bills Nx and every dispatched job runs Nx; the client pid lock is advisory and deletable, so the server carries the real invariant. A post-handshake fence (emitctl:duplicate, drain presence, delayeddisconnect(true)ā the delay lets the event flush, the manual presence drain keeps the KEEPER's channels from being reaped) covers the same-tick double-connect race. Genuine restarts are unaffected (the old socket drops before the replacement dials); a blip recovers because the refused daemon retries with backoff until the stale socket times out (seedesign-cli-tui.md#45 hardening). -
machine:hellohandler: zod-parse, persist daemon metadata, ack{ proto: 1, machineId }. - disconnect: presence down ā broadcast offline + save
lastSeenAt. -
/appauth middleware: JWT or api-PAT (machine/marketplace rejected); joinuser:<id>(+adminsfor admins). No handlers in C1 (push only).
-
-
server routes ā
modules/machines.ts:POST /api/machines(enroll: creates Machine + machine PAT, raw token returned once),GET(own; admin all;onlinefrom presence),GET /:id(owner-or-admin, 404 else),PATCH /:id(name / remoteChatEnabled),DELETE /:id(revoke PAT + force socket offline).machineViewinserialize.ts. -
sdk-ts ā
MachineView+ createMachine / listMachines / getMachine / updateMachine / deleteMachine. -
cli ā
hnx enroll --server <url> --token <userPAT> [--name](writes~/.hnx/config.json0600: server, machineToken, machineId) andhnx daemon(reads config, socket.io-client to/ctl,machine:helloon every (re)connect with os/arch/hostname/version/capabilities, graceful SIGINT).packages/acp-bridgedeleted. -
web ā
realtime.ts(/appsocket singleton, token re-read on reconnect), Machines page (list + online dot via push, enroll dialog with once-only token reveal + ready-to-pastehnx enrollcommand, remote-chat confirm toggle, revoke confirm), route +navItems()entry. -
verify ā
pnpm -r typecheck+ builds; vitest (realtime schemas, presence, machines REST viaapp.inject, machine-token REST rejection); smoke block: enroll ā daemon connect (socket.io-client) ā online ā disconnect ā offline ā revoke ā reconnect rejected.
| Method | Path | Who | Notes |
|---|---|---|---|
POST |
/api/machines |
authenticated user | body { name } ā 201 { machine, token } (once) |
GET |
/api/machines |
own; admin sees all |
{ machines: MachineView[] } incl. derived online |
GET |
/api/machines/:id |
owner or admin (404) | MachineView |
PATCH |
/api/machines/:id |
owner or admin | { name? , remoteChatEnabled? } |
DELETE |
/api/machines/:id |
owner or admin | revokes enrollment PAT + drops the live socket |
Errors: 404 MACHINE_NOT_FOUND (existence-hiding), 400 VALIDATION_ERROR.
/ctl: machine:hello (CāS, ack) ā the only registered handler; anything
else gets no handler (sender ack-times-out).
/app: machine:status push only.
- Enrollment PAT revocation = deleting the machine (or the PAT) ā the
/ctlmiddleware re-verifies the PAT record on every (re)connect, andDELETE /api/machines/:idforce-disconnects the live socket. -
machine-ctlPATs are rejected by the REST API hook (unit-tested). - Raw machine tokens appear only in the enroll response (and the CLI config
file, 0600). Log serializer keeps masking
Authorization.