DC-140: network provider abstraction (src/network/) + tailscale provider + registry + tests

This commit is contained in:
Hermes committed 2026-10-03 21:16:20 -07:00
1 parent 420fa2ba2a
commit 7ef3914730
7 files changed
+1308

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
/**
* Network provider registry (DC-140, specs/PRIVATE-NETWORK-SPEC.md §3).
*
* Single resolution point between feature modules and providers: no feature
* code may import a provider module directly — they call getActiveProvider().
*
* State is deliberately process-local and defaults to INACTIVE: no provider
* is active until the setup wizard (DC-152) connects one. Until then,
* getActiveProvider()/getCapabilities() throw ProviderNotActiveError.
*
* registerProvider(factory) — factory is a function ({ getCoordClient,
* localManager, execFileImpl } = {}) → provider instance. The tailscale
* factory defaults to lazily binding the existing singletons
* (src/context getCoordClient pattern + src/managers/tailscale-manager),
* but every dependency is injectable so tests stay hermetic.
*/
'use strict';
const { ProviderNotActiveError } = require('./errors');
const { CONTRACT_METHODS } = require('./provider');
// id → factory(provider deps) → provider instance
const _factories = new Map();
let _activeId = null;
let _activeProvider = null;
/** Lately-bound defaults (required lazily so jest.mock stays effective). */
function _defaultDeps() {
/* eslint-disable global-require -- lazy on purpose: keeps registry require-time
side effects at zero and respects jest module mocks. */
return {
localManager: require('../managers/tailscale-manager'),
execFileImpl: null,
};
}
/**
* Register a provider factory under its id.
* @param {string} id - provider id, e.g. 'tailscale' (must match provider.id())
* @param {Function} factory - (deps) => provider instance
* @returns {void}
*/
function registerProvider(id, factory) {
if (typeof id !== 'string' || !id) {
throw new TypeError('registerProvider: id must be a non-empty string');
}
if (typeof factory !== 'function') {
throw new TypeError('registerProvider: factory must be a function');
}
_factories.set(id, factory);
// If this id is currently active, drop the cached instance so the next
// getActiveProvider() rebuilds from the (possibly new) factory.
if (_activeId === id) _activeProvider = null;
}
/** Build (or rebuild) the instance for `_activeId` from its factory. */
function _buildActive() {
const factory = _factories.get(_activeId);
const provider = factory ? factory(_defaultDeps()) : null;
if (!_isContractProvider(provider)) {
throw new Error('Provider "' + _activeId + '" does not satisfy the NetworkProvider contract');
}
_activeProvider = provider;
return _activeProvider;
}
/**
* True when the id refers to a registered provider whose instances satisfy
* the NetworkProvider contract surface (all CONTRACT_METHODS present).
*/
function _isContractProvider(p) {
if (!p) return false;
return CONTRACT_METHODS.every((m) => typeof p[m] === 'function');
}
/**
* Activate a registered provider. Rebuilding the instance resets any state
* the previous one carried.
* @param {string} id
* @returns {object} the newly active provider instance
* @throws {Error} when the id is not registered, or the instance built from
* it does not satisfy the documented contract.
*/
function setActiveProvider(id) {
const factory = _factories.get(id);
if (!factory) {
throw new Error('Unknown network provider: ' + String(id));
}
const provider = factory(_defaultDeps());
if (!_isContractProvider(provider)) {
throw new Error('Provider "' + id + '" does not satisfy the NetworkProvider contract');
}
if (typeof provider.id !== 'function' || provider.id() !== id) {
throw new Error('Provider "' + id + '" reports a different id');
}
_activeId = id;
_activeProvider = provider;
return _activeProvider;
}
/**
* The active provider instance. Feature modules' ONLY entry point into
* provider functionality.
* @throws {ProviderNotActiveError} when no provider has been activated
*/
function getActiveProvider() {
if (!_activeId) {
throw new ProviderNotActiveError(
'No active network provider. Complete the Private Network setup wizard first.'
);
}
// Factory may have been re-registered since activation → rebuild lazily.
return _activeProvider || _buildActive();
}
/**
* Capability flags of the active provider (spec §3): feature modules check
* these and degrade cleanly when a capability is absent.
* @returns {{invites:boolean, splitDnsApi:boolean, perPeerFirewall:boolean, selfHosted:boolean}}
* @throws {ProviderNotActiveError} when no provider has been activated
*/
function getCapabilities() {
return getActiveProvider().capabilities();
}
/** List registered provider ids (setup wizard mode picker). */
function listProviderIds() {
return [..._factories.keys()];
}
/** Deactivate (setup wizard disconnect / tests). Next getActiveProvider() throws. */
function reset() {
_activeId = null;
_activeProvider = null;
}
/** Currently active provider id, or null — read-only introspection. */
function getActiveProviderId() {
return _activeId;
}
module.exports = {
registerProvider,
setActiveProvider,
getActiveProvider,
getCapabilities,
listProviderIds,
getActiveProviderId,
reset,
};