From 7ef3914730e331c77f5a825c12a783013914e3f5 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 21:16:20 -0700 Subject: [PATCH] DC-140: network provider abstraction (src/network/) + tailscale provider + registry + tests --- .../network-provider-tailscale.test.js | 444 ++++++++++++++++++ .../__tests__/network-registry.test.js | 166 +++++++ dashcaddy-api/src/network/errors.js | 59 +++ dashcaddy-api/src/network/index.js | 24 + dashcaddy-api/src/network/provider.js | 75 +++ .../src/network/providers/tailscale.js | 389 +++++++++++++++ dashcaddy-api/src/network/registry.js | 151 ++++++ 7 files changed, 1308 insertions(+) create mode 100644 dashcaddy-api/__tests__/network-provider-tailscale.test.js create mode 100644 dashcaddy-api/__tests__/network-registry.test.js create mode 100644 dashcaddy-api/src/network/errors.js create mode 100644 dashcaddy-api/src/network/index.js create mode 100644 dashcaddy-api/src/network/provider.js create mode 100644 dashcaddy-api/src/network/providers/tailscale.js create mode 100644 dashcaddy-api/src/network/registry.js diff --git a/dashcaddy-api/__tests__/network-provider-tailscale.test.js b/dashcaddy-api/__tests__/network-provider-tailscale.test.js new file mode 100644 index 0000000..c390836 --- /dev/null +++ b/dashcaddy-api/__tests__/network-provider-tailscale.test.js @@ -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([]); + }); +}); diff --git a/dashcaddy-api/__tests__/network-registry.test.js b/dashcaddy-api/__tests__/network-registry.test.js new file mode 100644 index 0000000..d128200 --- /dev/null +++ b/dashcaddy-api/__tests__/network-registry.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/src/network/errors.js b/dashcaddy-api/src/network/errors.js new file mode 100644 index 0000000..5cc7650 --- /dev/null +++ b/dashcaddy-api/src/network/errors.js @@ -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 }; diff --git a/dashcaddy-api/src/network/index.js b/dashcaddy-api/src/network/index.js new file mode 100644 index 0000000..b697922 --- /dev/null +++ b/dashcaddy-api/src/network/index.js @@ -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, +}; diff --git a/dashcaddy-api/src/network/provider.js b/dashcaddy-api/src/network/provider.js new file mode 100644 index 0000000..22be3bb --- /dev/null +++ b/dashcaddy-api/src/network/provider.js @@ -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', + ], +}; diff --git a/dashcaddy-api/src/network/providers/tailscale.js b/dashcaddy-api/src/network/providers/tailscale.js new file mode 100644 index 0000000..ab11239 --- /dev/null +++ b/dashcaddy-api/src/network/providers/tailscale.js @@ -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 }; diff --git a/dashcaddy-api/src/network/registry.js b/dashcaddy-api/src/network/registry.js new file mode 100644 index 0000000..97877ac --- /dev/null +++ b/dashcaddy-api/src/network/registry.js @@ -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, +};