Merge branch 'dc/DC-140' into dc/DC-148

This commit is contained in:
Hermes committed 2026-10-03 21:20:01 -07:00
commit b7df8f5fe0
7 files changed
+1308

No files matched your search

@@ -0,0 +1,444 @@
/**
* Tests for src/network/providers/tailscale.js (DC-140).
*
* Hermetic: the coordination client is a jest.fn() stub object, the local
* manager is a jest.mock of ../managers/tailscale-manager (same rigor as
* __tests__/tailscale-manager.test.js), and the whois execFile call is a
* plain stub — no network, no child processes.
*/
'use strict';
jest.mock('../src/managers/tailscale-manager', () => ({
getSummary: jest.fn(),
getStatus: jest.fn(),
}));
const tailscaleManager = require('../src/managers/tailscale-manager');
const { createTailscaleProvider, toProviderError } = require('../src/network/providers/tailscale');
const { ProviderError } = require('../src/network/errors');
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
/** Coord client stub factory: every method is a jest.fn resolving []. */
function makeCoord(overrides = {}) {
return {
listDevices: jest.fn(async () => []),
listUsers: jest.fn(async () => []),
deleteDevice: jest.fn(async () => ({ success: true })),
request: jest.fn(),
...overrides,
};
}
function devicesFixture() {
return [
{ id: 'dev1', name: 'laptop.tail3e209.ts.net', addresses: ['100.91.55.51'], user: '1001', os: 'linux' },
{ id: 'dev2', name: 'phone.tail3e209.ts.net', addresses: ['100.92.60.10'], user: '1001', os: 'android' },
{ id: 'dev3', name: 'nas.tail3e209.ts.net', addresses: ['100.93.7.7'], user: '2002', os: 'linux' },
];
}
/** execFile stub (promise-style, matching promisify(execFile)) with canned stdout. */
function makeExec(stdoutByIp = {}, { failAll = false } = {}) {
const calls = [];
const fn = (bin, args) => {
calls.push({ bin, args });
const ip = args && args[1];
if (failAll) return Promise.reject(new Error('tailscale CLI not available'));
return Promise.resolve({ stdout: stdoutByIp[ip] || '' });
};
fn.calls = calls;
return fn;
}
beforeEach(() => {
jest.clearAllMocks();
tailscaleManager.getSummary.mockResolvedValue({ installed: true, connected: true, peerCount: 1 });
tailscaleManager.getStatus.mockResolvedValue(null);
});
describe('tailscale provider: id + capabilities', () => {
test('id() returns "tailscale"', () => {
expect(createTailscaleProvider().id()).toBe('tailscale');
});
test('capabilities() flags invites + splitDnsApi, no perPeerFirewall, not selfHosted', () => {
expect(createTailscaleProvider().capabilities()).toEqual({
invites: true,
splitDnsApi: true,
perPeerFirewall: false,
selfHosted: false,
});
});
});
describe('tailscale provider: getStatus()', () => {
test('wraps the local manager summary with provider framing', async () => {
tailscaleManager.getSummary.mockResolvedValue({ installed: true, connected: true, peerCount: 4 });
const p = createTailscaleProvider({ localManager: tailscaleManager });
const status = await p.getStatus();
expect(status).toMatchObject({ provider: 'tailscale', installed: true, connected: true });
expect(status.summary).toEqual({ installed: true, connected: true, peerCount: 4 });
});
test('degrades to installed:false when the CLI is missing', async () => {
tailscaleManager.getSummary.mockResolvedValue({ installed: false, connected: false });
const p = createTailscaleProvider({ localManager: tailscaleManager });
const status = await p.getStatus();
expect(status.installed).toBe(false);
expect(status.connected).toBe(false);
});
});
describe('tailscale provider: listDevices()', () => {
test('merges API devices with local online state by tailnet IP', async () => {
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
tailscaleManager.getStatus.mockResolvedValue({
Peer: {
'nodekey:laptop': { TailscaleIPs: ['100.91.55.51'], Online: true },
},
});
const p = createTailscaleProvider({ coordClient: coord, localManager: tailscaleManager });
const devices = await p.listDevices();
expect(devices).toHaveLength(3);
expect(devices.find((d) => d.id === 'dev1')).toMatchObject({ ip: '100.91.55.51', online: true, userId: '1001' });
expect(devices.find((d) => d.id === 'dev3')).toMatchObject({ online: false });
});
test('works when local status is unavailable (all online false, no throw)', async () => {
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
tailscaleManager.getStatus.mockResolvedValue(null);
const p = createTailscaleProvider({ coordClient: coord, localManager: tailscaleManager });
const devices = await p.listDevices();
expect(devices).toHaveLength(3);
expect(devices.every((d) => d.online === false)).toBe(true);
});
test('surfaces coord not-configured as ProviderError not_configured', async () => {
const p = createTailscaleProvider(); // no coord client attached
await expect(p.listDevices()).rejects.toMatchObject({
name: 'ProviderError',
code: 'not_configured',
retryable: false,
});
});
});
describe('tailscale provider: listPeople()', () => {
test('projects API users to { id, displayName, email, createdAt }', async () => {
const coord = makeCoord({
listUsers: jest.fn(async () => [
{ id: '1001', loginName: 'sami@example.com', displayName: 'Sami', created: '2026-01-01T00:00:00Z' },
{ id: '2002', loginName: 'guest-user', created: '2026-02-02T00:00:00Z' },
]),
});
const p = createTailscaleProvider({ coordClient: coord });
const people = await p.listPeople();
expect(people).toEqual([
{ id: '1001', displayName: 'Sami', email: 'sami@example.com', createdAt: '2026-01-01T00:00:00Z' },
{ id: '2002', displayName: 'guest-user', email: null, createdAt: '2026-02-02T00:00:00Z' },
]);
});
});
describe('tailscale provider: createInvite()', () => {
test('with email: POSTs user-invites including the email field', async () => {
const coord = makeCoord({
request: jest.fn(async () => ({ id: 'inv1', inviteUrl: 'https://login.tailscale.com/a/abc123' })),
});
const p = createTailscaleProvider({ coordClient: coord });
const invite = await p.createInvite({ role: 'member', email: 'friend@example.com' });
expect(invite).toEqual({ inviteUrl: 'https://login.tailscale.com/a/abc123', id: 'inv1' });
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/user-invites', {
body: { inviteTailnet: true, email: 'friend@example.com' },
});
});
test('without email: body omits the email field entirely (link-first)', async () => {
const coord = makeCoord({
request: jest.fn(async () => ({ id: 'inv2', inviteUrl: 'https://login.tailscale.com/a/nomail' })),
});
const p = createTailscaleProvider({ coordClient: coord });
const invite = await p.createInvite({ role: 'member' });
expect(invite.inviteUrl).toBe('https://login.tailscale.com/a/nomail');
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/user-invites', {
body: { inviteTailnet: true },
});
});
test('missing inviteUrl in the response maps to a ProviderError', async () => {
const coord = makeCoord({ request: jest.fn(async () => ({ id: 'inv3' })) });
const p = createTailscaleProvider({ coordClient: coord });
await expect(p.createInvite({ role: 'member' })).rejects.toMatchObject({
name: 'ProviderError',
code: 'provider_error',
});
});
test('invalid email input is rejected as bad_input before any API call', async () => {
const coord = makeCoord();
const p = createTailscaleProvider({ coordClient: coord });
await expect(p.createInvite({ role: 'member', email: 'not-an-email' })).rejects.toMatchObject({
code: 'bad_input',
});
expect(coord.request).not.toHaveBeenCalled();
});
test('missing role is rejected as bad_input', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.createInvite({})).rejects.toMatchObject({ code: 'bad_input' });
});
});
describe('tailscale provider: revokeInvite()', () => {
test('DELETEs the user-invite by id', async () => {
const coord = makeCoord({ request: jest.fn(async () => ({})) });
const p = createTailscaleProvider({ coordClient: coord });
const res = await p.revokeInvite('inv1');
expect(res).toEqual({ revoked: true });
expect(coord.request).toHaveBeenCalledWith('DELETE', '/tailnet/-/user-invites/inv1');
});
test('requires an id', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.revokeInvite()).rejects.toMatchObject({ code: 'bad_input' });
});
});
describe('tailscale provider: blockPerson()', () => {
test('expires the key of EVERY device owned by the user', async () => {
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
const p = createTailscaleProvider({ coordClient: coord });
const res = await p.blockPerson('1001');
expect(res).toEqual({ expired: ['dev1', 'dev2'], count: 2 });
const expireCalls = coord.request.mock.calls.filter((c) => c[1].endsWith('/expire'));
expect(expireCalls.map((c) => c[1])).toEqual(['/device/dev1/expire', '/device/dev2/expire']);
expireCalls.forEach((c) => expect(c[0]).toBe('POST'));
// no expiry for other users' devices
expect(coord.request.mock.calls.some((c) => c[1] === '/device/dev3/expire')).toBe(false);
});
test('user with zero devices → empty result, no expire calls', async () => {
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
const p = createTailscaleProvider({ coordClient: coord });
const res = await p.blockPerson('9999');
expect(res).toEqual({ expired: [], count: 0 });
expect(coord.request).not.toHaveBeenCalled();
});
test('matches userId numerically-or-stringly (API returns numbers)', async () => {
const coord = makeCoord({
listDevices: jest.fn(async () => devicesFixture().map((d) => ({ ...d, user: Number(d.user) }))),
});
const p = createTailscaleProvider({ coordClient: coord });
const res = await p.blockPerson('1001');
expect(res.count).toBe(2);
});
test('requires a userId', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.blockPerson()).rejects.toMatchObject({ code: 'bad_input' });
});
});
describe('tailscale provider: unblockPerson()', () => {
test('is a no-op at the Tailscale level and returns { restored: true }', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.unblockPerson('1001')).resolves.toEqual({ restored: true });
});
});
describe('tailscale provider: removePerson()', () => {
test('DELETEs every device owned by the user', async () => {
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
const p = createTailscaleProvider({ coordClient: coord });
const res = await p.removePerson('1001');
expect(res).toEqual({ deleted: ['dev1', 'dev2'], count: 2 });
expect(coord.deleteDevice).toHaveBeenCalledTimes(2);
expect(coord.deleteDevice).toHaveBeenCalledWith('dev1');
expect(coord.deleteDevice).toHaveBeenCalledWith('dev2');
expect(coord.deleteDevice).not.toHaveBeenCalledWith('dev3');
});
test('requires a userId', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.removePerson()).rejects.toMatchObject({ code: 'bad_input' });
});
});
describe('tailscale provider: revokeDevice()', () => {
test('expires the single device key via POST /device/{id}/expire', async () => {
const coord = makeCoord({ request: jest.fn(async () => ({})) });
const p = createTailscaleProvider({ coordClient: coord });
const res = await p.revokeDevice('dev2');
expect(res).toEqual({ expired: 'dev2' });
expect(coord.request).toHaveBeenCalledWith('POST', '/device/dev2/expire', { body: {} });
});
test('requires a deviceId', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.revokeDevice()).rejects.toMatchObject({ code: 'bad_input' });
});
});
describe('tailscale provider: syncAccess()', () => {
test('throws ProviderError code=not_implemented_in_provider (DC-148 lands later)', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.syncAccess({}, [])).rejects.toMatchObject({
name: 'ProviderError',
code: 'not_implemented_in_provider',
retryable: false,
});
});
});
describe('tailscale provider: whois()', () => {
test('resolves identity via the local tailscale CLI', async () => {
const exec = makeExec({
'100.91.55.51': 'user: sami@example.com\nnodeKey: nodekey:abc123\nnode: laptop.tail3e209.ts.net\n',
});
const p = createTailscaleProvider({ coordClient: makeCoord(), execFileImpl: exec });
const who = await p.whois('100.91.55.51');
expect(exec.calls).toEqual([{ bin: 'tailscale', args: ['whois', '100.91.55.51'] }]);
expect(who).toEqual({
nodeKey: 'nodekey:abc123',
userId: null,
loginName: 'sami@example.com',
hostname: 'laptop.tail3e209.ts.net',
});
});
test('captures userid when present', async () => {
const exec = makeExec({ '1.2.3.4': 'userid: 1001\nuser: sami@example.com\n' });
const p = createTailscaleProvider({ execFileImpl: exec });
await expect(p.whois('1.2.3.4')).resolves.toMatchObject({ userId: 1001 });
});
test('CLI failure resolves to null (unknown identity is not an error)', async () => {
const exec = makeExec({}, { failAll: true });
const p = createTailscaleProvider({ execFileImpl: exec });
await expect(p.whois('100.0.0.1')).resolves.toBeNull();
});
test('empty CLI output resolves to null', async () => {
const exec = makeExec({});
const p = createTailscaleProvider({ execFileImpl: exec });
await expect(p.whois('100.0.0.2')).resolves.toBeNull();
});
test('missing ip is bad_input', async () => {
const p = createTailscaleProvider();
await expect(p.whois()).rejects.toMatchObject({ code: 'bad_input' });
});
test('never touches the coordination client', async () => {
const coord = makeCoord();
const exec = makeExec({ '9.9.9.9': 'user: x@example.com\n' });
const p = createTailscaleProvider({ coordClient: coord, execFileImpl: exec });
await p.whois('9.9.9.9');
expect(coord.request).not.toHaveBeenCalled();
expect(coord.listDevices).not.toHaveBeenCalled();
});
});
describe('tailscale provider: configureDns()', () => {
test('POSTs split-DNS nameservers for the zone (capability-flagged)', async () => {
const coord = makeCoord({ request: jest.fn(async () => ({})) });
const p = createTailscaleProvider({ coordClient: coord });
const res = await p.configureDns({ zone: 'internal', server: '100.100.100.10' });
expect(res).toEqual({ configured: true, zone: 'internal', server: '100.100.100.10' });
expect(coord.request).toHaveBeenCalledWith('POST', '/tailnet/-/dns/nameservers', {
body: { dns: ['100.100.100.10'], domains: ['internal'] },
});
});
test('requires both zone and server', async () => {
const p = createTailscaleProvider({ coordClient: makeCoord() });
await expect(p.configureDns({ zone: 'internal' })).rejects.toMatchObject({ code: 'bad_input' });
await expect(p.configureDns({ server: '1.1.1.1' })).rejects.toMatchObject({ code: 'bad_input' });
});
});
describe('tailscale provider: error mapping', () => {
test('listPeople surfaces a coord error as ProviderError', async () => {
const coord = makeCoord({
listUsers: jest.fn(async () => {
throw new TailscaleCoordError('Tailscale API 404: no users', { status: 404 });
}),
});
const p = createTailscaleProvider({ coordClient: coord });
await expect(p.listPeople()).rejects.toBeInstanceOf(ProviderError);
await expect(p.listPeople()).rejects.toMatchObject({ code: 'not_found' });
});
test('unauthorized coord error → ProviderError code unauthorized, not retryable', async () => {
const coord = makeCoord({
listUsers: jest.fn(async () => {
throw new TailscaleCoordError('Tailscale API 401: bad token', { status: 401 });
}),
});
const p = createTailscaleProvider({ coordClient: coord });
await expect(p.listPeople()).rejects.toMatchObject({
name: 'ProviderError',
code: 'unauthorized',
retryable: false,
});
});
test('rate_limited coord error → ProviderError retryable, retryAfter preserved', async () => {
const coord = makeCoord({
listUsers: jest.fn(async () => {
throw new TailscaleCoordError('Tailscale API 429: slow down', {
status: 429,
retryAfter: '30',
});
}),
});
const p = createTailscaleProvider({ coordClient: coord });
await expect(p.listPeople()).rejects.toMatchObject({
code: 'rate_limited',
retryable: true,
retryAfter: '30',
});
});
test('server_error coord error → ProviderError retryable', async () => {
const coord = makeCoord({
listUsers: jest.fn(async () => {
throw new TailscaleCoordError('Tailscale API 502: bad gateway', { status: 502 });
}),
});
const p = createTailscaleProvider({ coordClient: coord });
await expect(p.listPeople()).rejects.toMatchObject({ code: 'server_error', retryable: true });
});
test('generic thrown errors become retryable provider_error', () => {
const mapped = toProviderError(new Error('boom'));
expect(mapped).toBeInstanceOf(ProviderError);
expect(mapped.code).toBe('provider_error');
expect(mapped.retryable).toBe(true);
});
test('ProviderError passes through toProviderError unchanged', () => {
const original = new ProviderError('keep me', { code: 'bad_input' });
expect(toProviderError(original)).toBe(original);
});
test('coord errors thrown mid-block propagate as ProviderError', async () => {
const coord = makeCoord({ listDevices: jest.fn(async () => devicesFixture()) });
coord.request = jest.fn(async () => {
throw new TailscaleCoordError('Tailscale API 403: forbidden', { status: 403 });
});
const p = createTailscaleProvider({ coordClient: coord });
await expect(p.blockPerson('1001')).rejects.toMatchObject({ code: 'unauthorized' });
});
});
describe('tailscale provider: attachCoordClient()', () => {
test('late-binding a client flips not_configured → working', async () => {
const p = createTailscaleProvider();
await expect(p.listPeople()).rejects.toMatchObject({ code: 'not_configured' });
p.attachCoordClient(makeCoord({ listUsers: jest.fn(async () => []) }));
await expect(p.listPeople()).resolves.toEqual([]);
});
});
@@ -0,0 +1,166 @@
/**
* Tests for src/network/registry.js (DC-140).
*
* Hermetic: no network, no filesystem. Providers are stub factories whose
* instances are plain objects implementing the documented contract surface
* (see src/network/provider.js).
*/
'use strict';
const { registry } = require('../src/network');
const { ProviderNotActiveError, ProviderError } = require('../src/network/errors');
const { CONTRACT_METHODS } = require('../src/network/provider');
/** Build a minimal contract-satisfying stub provider. */
function stubProvider(id, caps = {}) {
const p = {};
for (const m of CONTRACT_METHODS) {
p[m] = m === 'id' ? () => id : m === 'capabilities' ? () => ({
invites: false,
splitDnsApi: false,
perPeerFirewall: false,
selfHosted: false,
...caps,
}) : async () => ({});
}
return p;
}
beforeEach(() => {
registry.reset();
});
describe('network registry: inactive default', () => {
test('defaults to inactive — getActiveProvider() throws ProviderNotActiveError', () => {
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
});
test('getCapabilities() throws ProviderNotActiveError when unset', () => {
expect(() => registry.getCapabilities()).toThrow(ProviderNotActiveError);
});
test('active id is null before activation', () => {
expect(registry.getActiveProviderId()).toBeNull();
});
});
describe('network registry: registration', () => {
test('registerProvider stores a factory under its id', () => {
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
expect(registry.listProviderIds()).toContain('stub-a');
});
test('registerProvider rejects a non-function factory', () => {
expect(() => registry.registerProvider('bad', 'not-a-function')).toThrow(TypeError);
});
test('registerProvider rejects an empty id', () => {
expect(() => registry.registerProvider('', () => stubProvider('x'))).toThrow(TypeError);
});
test('registerProvider re-registers over an existing id', () => {
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
expect(registry.listProviderIds().filter((i) => i === 'stub-a')).toHaveLength(1);
});
});
describe('network registry: activation + resolution', () => {
test('setActiveProvider(id) resolves the instance via the factory', () => {
const sentinel = stubProvider('stub-a');
registry.registerProvider('stub-a', () => sentinel);
const active = registry.setActiveProvider('stub-a');
expect(active).toBe(sentinel);
expect(registry.getActiveProvider()).toBe(sentinel);
expect(registry.getActiveProviderId()).toBe('stub-a');
});
test('setActiveProvider throws for an unregistered id', () => {
expect(() => registry.setActiveProvider('does-not-exist')).toThrow(/Unknown network provider/);
});
test('setActiveProvider rebuilds the instance when the factory is replaced', () => {
const first = stubProvider('stub-a');
const second = stubProvider('stub-a');
registry.registerProvider('stub-a', () => first);
registry.setActiveProvider('stub-a');
registry.registerProvider('stub-a', () => second);
expect(registry.getActiveProvider()).toBe(second);
});
test('setActiveProvider rejects an instance missing contract methods', () => {
registry.registerProvider('incomplete', () => ({ id: () => 'incomplete' }));
expect(() => registry.setActiveProvider('incomplete')).toThrow(/contract/i);
});
test('setActiveProvider rejects an instance whose id() mismatches', () => {
registry.registerProvider('stub-a', () => stubProvider('stub-b'));
expect(() => registry.setActiveProvider('stub-a')).toThrow(/different id/i);
});
test('reset() returns the registry to inactive', () => {
registry.registerProvider('stub-a', () => stubProvider('stub-a'));
registry.setActiveProvider('stub-a');
registry.reset();
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
expect(registry.getActiveProviderId()).toBeNull();
});
});
describe('network registry: capability flags', () => {
test('getCapabilities() returns the active provider capabilities', () => {
registry.registerProvider(
'stub-ts',
() => stubProvider('stub-ts', { invites: true, splitDnsApi: true })
);
registry.setActiveProvider('stub-ts');
expect(registry.getCapabilities()).toEqual({
invites: true,
splitDnsApi: true,
perPeerFirewall: false,
selfHosted: false,
});
});
});
describe('network errors', () => {
test('ProviderNotActiveError is a ProviderError with code provider_not_active', () => {
const e = new ProviderNotActiveError();
expect(e).toBeInstanceOf(ProviderError);
expect(e.code).toBe('provider_not_active');
expect(e.retryable).toBe(false);
});
test('ProviderError derives retryable from code when not given', () => {
expect(new ProviderError('x', { code: 'rate_limited' }).retryable).toBe(true);
expect(new ProviderError('x', { code: 'server_error' }).retryable).toBe(true);
expect(new ProviderError('x', { code: 'network_error' }).retryable).toBe(true);
expect(new ProviderError('x', { code: 'unauthorized' }).retryable).toBe(false);
expect(new ProviderError('x', { code: 'bad_input' }).retryable).toBe(false);
});
test('ProviderError honors an explicit retryable override', () => {
expect(new ProviderError('x', { code: 'rate_limited', retryable: false }).retryable).toBe(false);
expect(new ProviderError('x', { code: 'unknown', retryable: true }).retryable).toBe(true);
});
});
describe('network index: built-in tailscale registration', () => {
test('tailscale is registered and activatable via the package entry point', () => {
// The package entry registers the real tailscale factory; activation
// builds a real provider instance (lazy deps, no network at build time).
expect(registry.listProviderIds()).toContain('tailscale');
const provider = registry.setActiveProvider('tailscale');
expect(provider.id()).toBe('tailscale');
expect(provider.capabilities()).toEqual({
invites: true,
splitDnsApi: true,
perPeerFirewall: false,
selfHosted: false,
});
// And it deactivates cleanly again.
registry.reset();
expect(() => registry.getActiveProvider()).toThrow(ProviderNotActiveError);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Provider error types for the DashCaddy Private Network abstraction
* (DC-140, specs/PRIVATE-NETWORK-SPEC.md §3).
*
* Every provider-facing failure surfaces as a ProviderError carrying a
* machine-readable `code` and a `retryable` hint, so feature modules can
* degrade cleanly without knowing which provider is active.
*
* Canonical codes (providers may add, but these are the shared vocabulary):
* - not_configured provider has no credentials yet
* - unauthorized token rejected (401/403)
* - not_found entity does not exist upstream
* - rate_limited upstream 429 (retryable, honor retryAfter)
* - server_error upstream 5xx (retryable)
* - network_error transport failure (retryable)
* - bad_input caller passed invalid arguments
* - not_implemented_in_provider capability exists but lands in a later DC
* - provider_not_active registry has no active provider
*/
'use strict';
/** Codes for which a retry (possibly after a backoff) is reasonable. */
const RETRYABLE_CODES = new Set(['rate_limited', 'server_error', 'network_error']);
class ProviderError extends Error {
/**
* @param {string} message - human-readable description
* @param {object} [opts]
* @param {string} [opts.code='unknown'] - machine-readable error code
* @param {boolean} [opts.retryable] - defaults to derived-from-code
* @param {*} [opts.cause] - original error (e.g. TailscaleCoordError)
* @param {*} [opts.retryAfter] - upstream retry-after hint, seconds
*/
constructor(message, { code, retryable, cause, retryAfter } = {}) {
super(message);
this.name = 'ProviderError';
this.code = code || 'unknown';
this.retryable = retryable !== undefined ? !!retryable : RETRYABLE_CODES.has(this.code);
if (retryAfter !== undefined) this.retryAfter = retryAfter;
if (cause !== undefined) this.cause = cause;
}
}
/**
* Thrown by the registry when no provider has been activated yet
* (default state until the setup wizard connects one — DC-152).
*/
class ProviderNotActiveError extends ProviderError {
constructor(message) {
super(message || 'No active network provider configured', {
code: 'provider_not_active',
retryable: false,
});
this.name = 'ProviderNotActiveError';
}
}
module.exports = { ProviderError, ProviderNotActiveError };
+24
View File
@@ -0,0 +1,24 @@
/**
* src/network entry point (DC-140).
*
* Registers the built-in providers with the registry on first require.
* The WireGuard provider (DC-149) registers itself here when it lands.
*
* Usage (feature modules — the ONLY sanctioned import):
* const { registry } = require('../network');
* const provider = registry.getActiveProvider(); // throws if unset
* if (registry.getCapabilities().invites) { ... }
*/
'use strict';
const registry = require('./registry');
const { createTailscaleProvider } = require('./providers/tailscale');
registry.registerProvider('tailscale', createTailscaleProvider);
module.exports = {
registry,
errors: require('./errors'),
CONTRACT_METHODS: require('./provider').CONTRACT_METHODS,
};
+75
View File
@@ -0,0 +1,75 @@
/**
* NetworkProvider interface — the documented contract for DashCaddy
* Private Network providers (DC-140, specs/PRIVATE-NETWORK-SPEC.md §3).
*
* Everything above the provider (people, groups, resources, access map,
* forward_auth, Technitium names, the CA, the UI) is shared and talks to
* the network ONLY through this contract — never to a provider module
* directly. Resolution goes through src/network/registry.js.
*
* ## Capability flags
*
* `capabilities()` returns:
* - invites provider can mint invite URLs itself (TS user-invites /
* WG one-time token). When false, DashCaddy mints its own.
* - splitDnsApi provider exposes a split-DNS API (Tailscale does;
* WireGuard writes Technitium directly instead).
* - perPeerFirewall provider enforces per-peer rules natively (WG nftables;
* Tailscale relies on ACLs, so false).
* - selfHosted no outside control-plane dependency (WG true, TS false).
*
* Feature modules MUST check capabilities and degrade cleanly.
*
* ## Methods (all async, all return plain JSON, all validated)
*
* id() → 'tailscale' | 'wireguard'
* capabilities() → { invites, splitDnsApi, perPeerFirewall, selfHosted }
* getStatus() → provider health/summary (TS: local `tailscale status`;
* WG: `wg show dump` + iface state)
* listDevices() → device list merged with local online state
* listPeople() → provider users projected to
* { id, displayName, email, createdAt }
* createInvite({ role, email? }) → { inviteUrl, id } — email is OPTIONAL;
* when omitted, the provider returns a link without sending
* (link-first, DC-085 pattern; never log inviteUrl raw)
* revokeInvite(id) → { revoked: true }
* blockPerson(userId) → reversible network-level block (TS: expire every
* device key owned by the user; WG: drop peer + deny set)
* unblockPerson(userId) → reverse of blockPerson (TS: no-op at provider
* level, DashCaddy re-grants via syncAccess)
* removePerson(userId) → delete provider devices + revoke invites
* revokeDevice(deviceId) → expire/remove a single device's access
* syncAccess(accessMap, resources) → compile + dry-run + push provider
* rules (hash-guarded). Injected per provider in later DCs
* (TS ACL compiler = DC-148; WG nftables = DC-150).
* whois(ip) → which person owns the connecting device
* configureDns({ zone, server }) → split-DNS setup when splitDnsApi
*
* ## Error contract
*
* All failures throw ProviderError (src/network/errors.js) with a `code`
* and `retryable` hint. Providers map their native errors at the boundary
* so no provider-specific error type ever escapes this package.
*/
'use strict';
module.exports = {
/** Method names every provider must implement (documented contract). */
CONTRACT_METHODS: [
'id',
'capabilities',
'getStatus',
'listDevices',
'listPeople',
'createInvite',
'revokeInvite',
'blockPerson',
'unblockPerson',
'removePerson',
'revokeDevice',
'syncAccess',
'whois',
'configureDns',
],
};
@@ -0,0 +1,389 @@
/**
* Tailscale provider for the DashCaddy Private Network (DC-140,
* specs/PRIVATE-NETWORK-SPEC.md §3).
*
* Wraps — and does not duplicate — the two existing tailscale modules:
* - src/managers/tailscale-coord.js — cloud control-plane client
* (api.tailscale.com: devices, users, user-invites, device expire,
* device delete, split-DNS preferences)
* - src/managers/tailscale-manager.js — local CLI daemon (status, whois)
*
* This adapter adds the provider-contract semantics on top: people
* projection, device key expiry as "block", error normalization into
* ProviderError, and capability flags.
*
* API facts (Tailscale API v2, Oct 2026):
* POST /tailnet/{t}/user-invites email optional → { inviteUrl, id }
* DELETE /tailnet/{t}/user-invites/{id}
* GET /tailnet/{t}/user-invites
* POST /device/{id}/expire expire device key (reversible: user re-auths)
* DELETE /device/{id} remove device
* POST /tailnet/{t}/dns/nameservers split-DNS search domains
* POST /tailnet/{t}/preferences feature prefs incl. MagicDNS
* Free-plan fact: ACL groups capped at 3, so compiled ACLs must reference
* users directly — handled by the ACL compiler (DC-148), not here.
*/
'use strict';
const { execFile } = require('child_process');
const { promisify } = require('util');
const { ProviderError } = require('../errors');
const execFileAsyncDefault = promisify(execFile);
const CLI_TIMEOUT_MS = 5000;
/** Map a TailscaleCoordError code to the shared ProviderError vocabulary. */
const COORD_CODE_MAP = {
not_configured: 'not_configured',
unauthorized: 'unauthorized',
not_found: 'not_found',
rate_limited: 'rate_limited',
server_error: 'server_error',
network_error: 'network_error',
bad_input: 'bad_input',
};
/**
* Normalize any thrown value into a ProviderError. Coord errors keep their
* code (mapped) + retryAfter; unknown errors land as retryable
* 'provider_error' so callers can distinguish transport-ish failures from
* contract violations.
* @returns {ProviderError}
*/
function toProviderError(err) {
if (err instanceof ProviderError) return err;
if (err && err.name === 'TailscaleCoordError') {
const code = COORD_CODE_MAP[err.code] || 'provider_error';
const opts = { code, cause: err };
if (err.retryAfter !== undefined) opts.retryAfter = err.retryAfter;
return new ProviderError('tailscale: ' + err.message, opts);
}
const message = (err && err.message) ? err.message : String(err);
return new ProviderError('tailscale: ' + message, {
code: 'provider_error',
retryable: true,
cause: err,
});
}
/**
* Build the Tailscale NetworkProvider.
*
* @param {object} deps - all injectable, all optional
* @param {object} deps.coordClient - object with the TailscaleCoordClient
* method surface. When omitted the provider expects the registry/caller
* to supply one via `provider.attachCoordClient(client)` (wiring to the
* context singleton lands with routes/network.js — keeps this module
* free of app-context requires).
* @param {object} deps.localManager - tailscale-manager module (default:
* require it lazily). Used for getStatus(); may be a jest mock.
* @param {Function} deps.execFileImpl - optional execFile(cb) override for
* the whois CLI call (tests inject a stub instead of spawning processes).
*/
function createTailscaleProvider({
coordClient = null,
localManager,
execFileImpl,
} = {}) {
// Lazy default require keeps jest.mock('.../tailscale-manager') effective.
/* eslint-disable global-require -- see above */
const manager = localManager || require('../../managers/tailscale-manager');
const execFn = execFileImpl || execFileAsyncDefault;
let coord = coordClient;
function requireCoord() {
if (!coord) {
throw new ProviderError(
'Tailscale coordination client not attached (API token not configured?)',
{ code: 'not_configured', retryable: false }
);
}
return coord;
}
/** Normalized single-flight wrapper for coord calls. */
async function coordCall(_methodName, fn) {
try {
return await fn(requireCoord(_methodName));
} catch (err) {
throw toProviderError(err);
}
}
const provider = {
id() {
return 'tailscale';
},
capabilities() {
return {
invites: true,
splitDnsApi: true,
perPeerFirewall: false,
selfHosted: false,
};
},
/**
* Health/summary from the LOCAL daemon (existing manager, cached there).
* Returns null-ish manager output unchanged — the manager already
* degrades gracefully ({ installed: false } / { connected: false }).
*/
async getStatus() {
try {
const summary = await manager.getSummary();
return {
provider: 'tailscale',
installed: !!(summary && summary.installed),
connected: !!(summary && summary.connected),
summary,
};
} catch (err) {
throw toProviderError(err, 'getStatus');
}
},
/** Devices from the API, online state merged from local status. */
async listDevices() {
try {
const [devices, local] = await Promise.all([
coordCall('listDevices', (c) => c.listDevices({ skipCache: false })),
Promise.resolve(manager.getStatus()).catch(() => null),
]);
const localPeers = {};
if (local && local.Peer && typeof local.Peer === 'object') {
for (const [nodeKey, peer] of Object.entries(local.Peer)) {
if (peer && Array.isArray(peer.TailscaleIPs) && peer.TailscaleIPs[0]) {
localPeers[peer.TailscaleIPs[0]] = { nodeKey, online: !!peer.Online };
}
}
}
return (devices || []).map((d) => {
const addr = Array.isArray(d.addresses) ? d.addresses[0] : null;
const localInfo = addr ? localPeers[addr] : undefined;
return {
id: d.id,
name: d.name || d.hostname || null,
hostname: d.hostname || null,
ip: addr,
os: d.os || null,
online: localInfo ? localInfo.online : !!d.online,
lastSeen: d.lastSeen || null,
userId: d.user !== undefined ? d.user : null,
tags: d.tags || [],
keyExpiry: d.keyExpiry || null,
};
});
} catch (err) {
throw err instanceof ProviderError ? err : toProviderError(err, 'listDevices');
}
},
/** API users → people projection { id, displayName, email, createdAt }. */
listPeople() {
return coordCall('listPeople', async (c) => {
const users = await c.listUsers({ skipCache: false });
return (users || []).map((u) => ({
id: u.id,
displayName: u.displayName || u.loginName || null,
email: u.loginName && u.loginName.includes('@') ? u.loginName : null,
createdAt: u.created || null,
}));
});
},
/**
* Create a user invite. Email is OPTIONAL — when omitted, Tailscale
* returns an inviteUrl WITHOUT sending email (link-first, spec §5).
* The inviteUrl is the artifact; never log it raw (routes enforce).
* @returns {{ inviteUrl: string, id: string }}
*/
async createInvite({ role, email } = {}) {
if (!role || typeof role !== 'string') {
throw new ProviderError('createInvite: role is required', { code: 'bad_input' });
}
if (email !== undefined && (typeof email !== 'string' || !email.includes('@'))) {
throw new ProviderError('createInvite: email must be a valid address when provided', {
code: 'bad_input',
});
}
// Input validation above throws synchronously by design (bad_input);
// everything below is async and normalized through coordCall.
const invitePromise = coordCall('createInvite', async (c) => {
const body = { inviteTailnet: true };
if (email) body.email = email;
const res = await c.request('POST', '/tailnet/-/user-invites', { body });
if (!res || !res.inviteUrl) {
throw new ProviderError('Tailscale user-invite response missing inviteUrl', {
code: 'provider_error',
retryable: true,
});
}
return { inviteUrl: res.inviteUrl, id: res.id || null };
});
return await invitePromise.catch((err) => Promise.reject(toProviderError(err)));
},
/** Revoke a user invite: DELETE /tailnet/{t}/user-invites/{id}. */
async revokeInvite(id) {
if (!id) {
throw new ProviderError('revokeInvite: id is required', { code: 'bad_input' });
}
return await coordCall('revokeInvite', async (c) => {
await c.request('DELETE', '/tailnet/-/user-invites/' + encodeURIComponent(id));
return { revoked: true };
});
},
/**
* Block a person at the network level: expire the key of every device
* they own (POST /device/{id}/expire). Reversible — unexpired devices
* re-authenticate; DashCaddy re-grants via syncAccess on unblock.
* @returns {{ expired: string[], count: number }}
*/
async blockPerson(userId) {
if (!userId) {
throw new ProviderError('blockPerson: userId is required', { code: 'bad_input' });
}
return await coordCall('blockPerson', async (c) => {
const devices = await c.listDevices({ skipCache: true });
const owned = (devices || []).filter((d) => String(d.user) === String(userId));
const expired = [];
for (const d of owned) {
await c.request('POST', '/device/' + encodeURIComponent(d.id) + '/expire', { body: {} });
expired.push(d.id);
}
return { expired, count: expired.length };
});
},
/**
* Unblock is a no-op at the Tailscale level: key expiry is reversed by
* the user re-authenticating, and access restoration happens when
* DashCaddy re-syncs the ACL (syncAccess). Kept in the contract so the
* orchestration layer (DC-143) can call it uniformly across providers.
*/
async unblockPerson() {
await Promise.resolve(); // uniform async surface (contract: all async)
return { restored: true };
},
/**
* Remove a person permanently: DELETE every device they own. (Invite
* revocation and the DashCaddy person record are handled by the
* orchestration layer, which owns that state.)
* @returns {{ deleted: string[], count: number }}
*/
async removePerson(userId) {
if (!userId) {
throw new ProviderError('removePerson: userId is required', { code: 'bad_input' });
}
return await coordCall('removePerson', async (c) => {
const devices = await c.listDevices({ skipCache: true });
const owned = (devices || []).filter((d) => String(d.user) === String(userId));
const deleted = [];
for (const d of owned) {
await c.deleteDevice(d.id);
deleted.push(d.id);
}
return { deleted, count: deleted.length };
});
},
/** Expire one device's key (kick a single machine, keep the person). */
async revokeDevice(deviceId) {
if (!deviceId) {
throw new ProviderError('revokeDevice: deviceId is required', { code: 'bad_input' });
}
return await coordCall('revokeDevice', async (c) => {
await c.request('POST', '/device/' + encodeURIComponent(deviceId) + '/expire', { body: {} });
return { expired: deviceId };
});
},
/**
* Compile + push ACLs. NOT in this provider yet — the ACL compiler is
* DC-148 and will be injected (hash-guarded push, lockout guard, dry-run
* preview). Feature modules must treat this code as "coming soon", not
* as a silent no-op.
*/
async syncAccess() {
await Promise.reject(
new ProviderError('syncAccess lands with the ACL compiler (DC-148)', {
code: 'not_implemented_in_provider',
retryable: false,
})
);
},
/**
* Who owns the device at this tailnet IP? Local CLI only — never the
* cloud API — because forward_auth sits on the same host as tailscaled.
* Returns null when the CLI cannot resolve the ip (offline peer,
* unknown address) rather than throwing.
* @returns {Promise<{nodeKey:string, userId:number|null, loginName:string|null, hostname:string|null}|null>}
*/
async whois(ip) {
if (!ip || typeof ip !== 'string') {
throw new ProviderError('whois: ip is required', { code: 'bad_input' });
}
let out;
try {
out = await execFn('tailscale', ['whois', ip], { timeout: CLI_TIMEOUT_MS });
} catch (err) {
// CLI failure (not installed / daemon down / unknown ip): identity
// unknown is a legitimate answer for forward_auth, not an error.
return null;
}
const stdout = (out && out.stdout) || '';
if (!stdout.trim()) return null;
// `tailscale whois` prints lines "key=value" per detail, e.g.
// user: sami@example.com
// nodeKey: nodekey:abc...
// node: dns2-sami.tail3e209.ts.net
// plus a "via" line when routed. Parse defensively.
const fields = {};
for (const line of stdout.split('\n')) {
const m = line.match(/^\s*([a-zA-Z]+):\s*(.+)$/);
if (m) fields[m[1].toLowerCase()] = m[2].trim();
}
if (!fields.user && !fields.nodekey) return null;
return {
nodeKey: fields.nodekey || null,
userId: fields.userid ? Number(fields.userid) : null,
loginName: fields.user || null,
hostname: fields.node || null,
};
},
/**
* Split-DNS via the coordination API (capability-flagged: splitDnsApi).
* Sets the tailnet's global nameservers to the Technitium server for the
* given zone. Callers must check capabilities().splitDnsApi first.
*/
async configureDns({ zone, server } = {}) {
if (!zone || typeof zone !== 'string') {
throw new ProviderError('configureDns: zone is required', { code: 'bad_input' });
}
if (!server || typeof server !== 'string') {
throw new ProviderError('configureDns: server is required', { code: 'bad_input' });
}
return await coordCall('configureDns', async (c) => {
await c.request('POST', '/tailnet/-/dns/nameservers', {
body: { dns: [server], domains: [zone] },
});
return { configured: true, zone, server };
});
},
/** Late-bind the coord client (context wiring lands with routes/network.js). */
attachCoordClient(client) {
coord = client;
},
};
return provider;
}
module.exports = { createTailscaleProvider, toProviderError };
+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,
};