docs · docs/20-control-plane/LOCAL_API_SPEC.md · synced 2026-09-10

Local API

the localhost HTTP/JSON contract every client speaks; with contract/openapi.yaml

Purpose: define the single control boundary (ADR-0003). This is a contract sketch, not final IDL — it fixes shape, naming, error model, events, and versioning so CLI/MCP/UI can be designed against it now.

1. Transport — DECIDED (ADR-0006, supersedes ADR-0005)

  • Canonical: localhost HTTP/1.1 + JSON, REST-resource-oriented, bound to 127.0.0.1, documented with OpenAPI 3.1 (contract/openapi.yaml) → codegen CLI/MCP/SDK + Postman. (3.1 is provisional — the codegen spike may down-shift to 3.0.3 for Rust tooling; CODEGEN_PIPELINE §4.)
  • Auth: bearer API token. Port + token published to a per-user discovery file (~/.vflin/local-api.json) — AdsPower-style; clients read it to connect.
  • Events/streaming: WebSocket at /events (subscribe, bidi); SSE one-way fallback.
  • Optional OS-secure channel: the same handlers also served over UDS / Named Pipe for local-only clients (UI, CLI) that want no TCP surface. Handlers stay transport-agnostic.
  • The cloud is reached only by the Core’s Sync Client, never by Local API clients.
  • Full rationale + alternatives: TRANSPORT_COMPARISON.md (internal: docs/20-control-plane/TRANSPORT_COMPARISON.md).

2. Conventions

  • Namespaced methods: domain.action (e.g. profile.start).
  • All methods take a single typed object; all return { ok, data?, error? }.
  • IDs are UUIDv4 strings. Timestamps are RFC3339 UTC.
  • Every mutating call carries an idempotency key and a mode (policy mode).

3. Error model

error: { code: string, message: string }

Stable string codes, not numbers. The complete list, with the HTTP status each maps to, is the table in §8 — it is generated from nothing and checked against the daemon by a test, so it is the one place to read and the one place to edit.

retryable and details were in this shape and are not in the wire envelope: retryable was removed 2026-08-16 because every arm produced it and no client read it, and details was never built. The four codes this paragraph used to name — PROFILE_LOCKED_REMOTE, PROFILE_DIRTY, FINGERPRINT_INVALID, VAULT_LOCKED — were written on 2026-06-17 in the Phase-0 architecture base, before a daemon existed, and none of them was ever implemented. See §8 for what the daemon actually returns.

4. Method catalog (v0 sketch)

profile

  • profile.create({ name, fingerprintRef|fingerprint, proxyRef?, group? }) → { id }
  • profile.list({ filter?, group? }) → { profiles[] }
  • profile.get({ id }) → { profile, state }
  • profile.start({ id, headless?, mode }) → { handle, cdpEndpoint }
  • profile.stop({ id, sync? }) → { state }
  • profile.materialize({ id }) → { path, state }
  • profile.delete({ id, hard?, mode }) → { ok } (hard delete = danger mode)

profile.session (Feature c — Sessions module, ADR-0034)

Bind a saved service session so a profile opens already logged in. Secrets ride the vault by reference; these methods never return one. Operator guide: SESSIONS_MODULE.md (internal: docs/20-control-plane/SESSIONS_MODULE.md).

  • session.recipe.list() → { recipes[] } (green — the supported-service catalog)
  • profile.session.list({ id }) → { sessions[] } · profile.session.get({ id, service }) → { attached, binding? }
  • profile.session.set({ id, service, mode, secrets, label? }) → { attached, binding } (danger)
  • profile.session.clear({ id, service }) → { attached } (danger — also wipes the vaulted secrets)

fingerprint

  • fingerprint.generate({ constraints }) → { config }
  • fingerprint.validate({ config }) → { valid, issues[] }
  • fingerprint.catalog.list() → { presets[] }
  • fingerprint.generateFromHost({ timezone, language, screen* }) → { config } — host-inversion (ADR-0031 B-local): device dims host-real, free dims from the request. NOT_FOUND until the host is characterized.
  • node.characterize() → { NodeCapability } — the B-local host capture through the ONE control boundary (invariant 1): launches the core on THIS device, caches the host fingerprint so generateFromHost / fromHost can derive host-coherent identities (was the standalone capture-profile binary).

proxy

  • proxy.create / proxy.list / proxy.test({ id }) → { ok, ip, latencyMs }
  • proxy.bind({ profileId, proxyId })

vault (all gated by mode + audited)

  • vault.get({ key, mode }) · vault.put({ key, value }) · vault.export({ scope, mode })

automation

  • automation.script.upsert / list
  • automation.run({ scriptId, target: {profileIds|group}, vars? }) → { runId }
  • automation.status({ runId }) · automation.cancel({ runId })
  • automation.group.upsert / schedule

sync

  • sync.push({ id }) · sync.pull({ id }) · sync.status({ id })
  • sync.lock.acquire({ id }) · sync.lock.release({ id })

core

  • core.list() → { providers[], versions[] }
  • core.select({ providerId, version }) · core.diagnose() → { report }

system

  • system.health() · system.policy.get/set({ mode }) · log.tail({ filter })

5. Events (push, over the stream)

profile.state_changed, automation.run_progress, sync.progress, core.crash, policy.violation, proxy.health_changed. Clients subscribe; UI/CLI/MCP render them.

6. Versioning

  • system.health() returns apiVersion (semver). Breaking changes bump major.
  • Additive methods/fields are minor. Clients must ignore unknown fields.

7. Hard rules

  • No method returns a raw filesystem path that bypasses the lifecycle. The one bridge, materialize, returns a raw path only to the local UI/owner; CLI/MCP/script clients get an opaque handle, and any raw-path return is power mode + audited (PROFILE_STORAGE_SYNC §6, SECURITY_POLICY §2).
  • No business logic on the client side of this API. (See MODULE_BOUNDARIES.md §5.)

8. Concrete contract (OpenAPI) — the walking-skeleton surface

The machine-readable source of truth is contract/openapi.yaml (internal: contract/openapi.yaml) (OpenAPI 3.1). Phase 0 models the 6 walking-skeleton methods end-to-end; the rest of §4 is promoted into it one method at a time via the local-api-contract-designer skill.

RPC-over-REST mapping. Logical domain.action names map to REST resource paths:

  • CRUD → resource: profile.createPOST /profiles, profile.listGET /profiles, profile.getGET /profiles/{id}, profile.deleteDELETE /profiles/{id}.
  • Action on a resource → sub-path: profile.startPOST /profiles/{id}/start, profile.stopPOST /profiles/{id}/stop.
  • Singleton/collection reads: core.listGET /cores, system.healthGET /system/health.
  • Base path carries the major version: /v1.

Envelope + status. Success = HTTP 200 + { ok:true, data }. Failure = a mapped HTTP status + { ok:false, error: { code, message } }. contract/openapi.yaml routes every failure through default: $ref: Error and points here for the status, so the table below is the status contract rather than a commentary on one.

It is COMPLETE, and it is checked. Every code the daemon can put on the wire is a row, in both directions: a row naming a code the daemon does not build fails the gate, and a code the daemon builds with no row fails it too. The gate is vflin-localapi’s the_status_table_in_the_spec_is_what_the_daemon_returns, and it gets the daemon’s half by CONSTRUCTING each error and calling into_response() — it reads the answer off the same code path a client does, rather than pattern-matching the source. A new ApiError variant cannot slip past either: the compiler refuses one without a code (ApiError::code() has no _ arm), and the gate counts that function’s arms and requires one sample per arm — because a variant with a code that the gate never CONSTRUCTS would leave it green over a code the daemon can emit and this table does not list.

The source column says where a code originates: daemon = built locally by ApiError; cloud = relayed from the cloud backend by map_cloud_err, preserving the upstream status (ISSUE-014) so a wrong cloud password stays a 401 rather than flattening to a 400; both = the same code and status on either path.

code HTTP source when
INVALID_ARGUMENT 400 both the request is malformed, or a stated value is not usable
UNAUTHENTICATED 401 both missing/invalid bearer token; or the cloud rejected the credential
TOTP_REQUIRED 401 cloud the cloud account has 2FA and no code was supplied
TOTP_INVALID 401 cloud the supplied 2FA code did not verify
POLICY_DENIED 403 daemon refused by policy — the actor is not permitted, ADR-0036
FORBIDDEN 403 cloud the cloud refused this actor
QUOTA_EXCEEDED 403 cloud a plan/seat/storage entitlement limit was reached
NOT_FOUND 404 both the named resource does not exist
CONFLICT 409 both well-formed and refused because of STATE, not permission
CORE_NOT_EXITED 409 daemon a browser refused to close, so its data could not be sealed
ALREADY_EXISTS 409 cloud a duplicate the cloud will not create twice
STALE 409 cloud a version-vector conflict (ADR-0016)
FAILED_PRECONDITION 409 cloud a cloud precondition is unmet — e.g. rotation exact-coverage
LEASE_LOST 409 cloud the sync lock was stolen or fenced (ADR-0015)
LOCKED_REMOTE 409 cloud the sync lock is held by another device
BLOB_UNREADABLE 422 daemon the stored payload will not open with the key this session holds
LOCKED 423 daemon the keyring is sealed — unlock before this call can proceed
RATE_LIMITED 429 cloud back off and retry
CORE_LAUNCH_FAILED 500 daemon the browser core would not start
INTERNAL 500 daemon an unexpected failure; the detail is in daemon.log under the id shown
CLOUD_ERROR 502 cloud the cloud failed in a way with no code of its own
BLOB_IO 503 daemon a file under the live profile dir could not be read/written — usually transient
UNAVAILABLE 503 both a dependency is momentarily not ready (including “cloud is unreachable”)

Two statuses are worth reading twice, because both were wrong here until 2026-08-23. LOCKED_REMOTE is a 409, not a 423: it is a cloud-relayed code and map_cloud_err preserves the cloud’s own status, which is what “relay faithfully” means. 423 is LOCKED — the sealed keyring, a different thing entirely. And CORE_NOT_EXITED is a 409 rather than a 503 (A24, 2026-08-20): the service is healthy and a precondition is unmet, so inviting the caller back would be a lie.

Cross-cutting headers. Mutating calls send Idempotency-Key and X-Vflin-Mode (normal|power|danger). Auth = Authorization: Bearer <token> from ~/.vflin/local-api.json.

Lockstep (one contract → three surfaces). Each operation carries x-vflin-* fields so the CLI, MCP, and SDKs generate from this file (invariant #8; enforced by api-contract-codegen-guard):

domain.action REST CLI MCP tool mode danger
system.health GET /system/health vflin system health vflin_system_health normal 🟢
core.list GET /cores vflin core ls vflin_core_list normal 🟢
profile.list GET /profiles vflin profile ls vflin_profile_list normal 🟢
profile.create POST /profiles vflin profile create vflin_profile_create normal 🟡
profile.start POST /profiles/{id}/start vflin profile start <id> vflin_profile_start normal 🟡
profile.stop POST /profiles/{id}/stop vflin profile stop <id> vflin_profile_stop normal 🟡
profile.session.set POST /profiles/{id}/sessions vflin profile session set <id> vflin_profile_session_set danger 🔴
profile.session.clear DELETE /profiles/{id}/sessions/{service} vflin profile session clear <id> <svc> vflin_profile_session_clear danger 🔴

This table is an EXCERPT, not the catalogue. It was written when the surface was six walking-skeleton methods; the shipped surface is now far larger and every entry here is hand-maintained, so it rots. The authoritative list is contract/openapi.yaml and its generated projection (vflin surface / crates/vflin-client/src/generated.rs). Note also that the mode axis is live: the wallet and session mutators are danger, not normal (docs audit 2026-07-19).

Events stay on the WebSocket /events (§5) — OpenAPI can’t describe WS; an AsyncAPI doc is the planned home (OPEN_QUESTIONS).

Open questions

Event/streaming schema (AsyncAPI?), auth token lifetime + rotation, request batching — OPEN_QUESTIONS.md.