/** * Network invite routes — DC-142 (link-first invites via network provider). * * GET /api/v1/network/invites → list active invites via provider * POST /api/v1/network/invites → create an invite (link-first) * DELETE /api/v1/network/invites/:id → revoke an invite (204) * * Link-first contract (DC-085 pattern, specs/PRIVATE-NETWORK-SPEC.md §5): * - The inviteUrl IS the deliverable. Email is an opt-in checkbox * (`sendEmail: true`, strict === true — default NO email). * - POST response ALWAYS includes { inviteUrl, shareText, deliveredVia }: * shareText — pre-formed human sentence (never a JSON blob), built * server-side so every client picks up the wording * deliveredVia — 'manual' (no email requested) | 'email' (provider * confirmed the send) | 'failed' (email was requested but * the send failed — the link is STILL returned so the * operator can share it manually) * - NEVER log raw inviteUrl/token values. Every log call goes through * redact() which replaces link-bearing fields with '[REDACTED]'. The DC-048 * mistake (echoing invite links to the server console) must not return. * * Provider resolution goes exclusively through deps.networkRegistry * (src/network registry). When no provider is connected the registry throws * ProviderNotActiveError; this module maps it (and a missing registry) to a * 503 with code 'provider_not_active' — the setup wizard (DC-152) is the fix. * * Hermetic by construction: the factory takes an injectable registry, logger * and asyncHandler; tests pass a mocked provider, no network. */ 'use strict'; const { ValidationError, NotFoundError } = require('../src/utilities/errors'); const { ok, created, noContent } = require('../src/utils/responses'); const { ProviderError, ProviderNotActiveError } = require('../src/network/errors'); /** Tailscale user-invite roles (provider contract, DC-140). */ const VALID_ROLES = new Set(['member', 'admin']); const REDACTED = '[REDACTED]'; /** Fields whose values embed the raw invite link — never allowed into logs. */ const REDACT_KEYS = new Set(['inviteUrl', 'acceptUrl', 'url', 'token', 'shareText']); /** * Shallow-copy a log-fields object, replacing link/token-bearing values. * The admin already has the link in the API response; the console does not * need a copy (DC-085 rule 2). */ function redact(fields) { const out = {}; for (const [key, value] of Object.entries(fields || {})) { out[key] = REDACT_KEYS.has(key) ? REDACTED : value; } return out; } /** 503 — no network provider connected (mapped from ProviderNotActiveError). */ class ProviderInactiveError extends Error { constructor(message) { super(message || 'No active network provider configured'); this.name = 'ProviderInactiveError'; this.statusCode = 503; this.code = 'provider_not_active'; this.isOperational = true; } } /** Validate the provider's createInvite result shape. */ function assertInviteResult(invite) { if (!invite || typeof invite.inviteUrl !== 'string' || !invite.inviteUrl) { throw new Error('provider createInvite() must return { inviteUrl }'); } } /** Pull the still-valid link off a failed email send, or null. */ function linkFromEmailFailure(err) { if (!err || err.code !== 'email_send_failed') return null; const details = err.details; const url = err.inviteUrl || (details && details.inviteUrl); return url ? { url, id: (details && details.id) || null } : null; } module.exports = function networkInvitesRoutesFactory({ networkRegistry, asyncHandler, log = { info() {}, warn() {}, error() {} }, } = {}) { const router = require('express').Router(); const safeAsync = typeof asyncHandler === 'function' ? asyncHandler : (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); /** Resolve the active provider or throw the 503-mapped inactive error. */ function requireProvider() { if (!networkRegistry || typeof networkRegistry.getActiveProvider !== 'function') { throw new ProviderInactiveError(); } try { return networkRegistry.getActiveProvider(); } catch (err) { if (err instanceof ProviderNotActiveError || (err && err.code === 'provider_not_active')) { throw new ProviderInactiveError(err.message); } throw err; } } /** Map ProviderError codes onto HTTP statuses for the error middleware. */ function mapProviderError(err) { if (err instanceof ProviderInactiveError) return err; if (err && (err instanceof ProviderError || typeof err.code === 'string')) { if (err.code === 'not_found') return new NotFoundError(err.message || 'invite not found'); if (err.code === 'bad_input') return new ValidationError(err.message || 'invalid input', 'invite'); if (err.code === 'provider_not_active') return new ProviderInactiveError(err.message); const upstream = new Error(err.message || 'network provider error'); upstream.statusCode = 502; upstream.code = err.code || 'provider_error'; upstream.isOperational = true; return upstream; } return err; } /** * Email leg of the link-first flow (only entered when sendEmail === true). * Never a total failure when the link exists: a failed send downgrades to * deliveredVia 'failed' with the link still in the response (DC-085 rule 1). */ async function createInviteWithEmail(provider, { role, email }) { let invite; let deliveredVia; try { invite = await provider.createInvite({ role, email }); assertInviteResult(invite); // Providers confirm the email leg with emailSent: true on the result. deliveredVia = invite.emailSent === true ? 'email' : 'failed'; if (deliveredVia === 'failed') { log.warn && log.warn( 'network-invites', 'provider did not confirm email delivery — link returned for manual sharing', redact({ inviteId: invite.id || null, email, role }) ); } return { invite, deliveredVia }; } catch (err) { const salvaged = linkFromEmailFailure(err); if (salvaged) { log.warn && log.warn( 'network-invites', 'provider email send failed — link returned for manual sharing', redact({ inviteId: salvaged.id, email, role }) ); return { invite: { inviteUrl: salvaged.url, id: salvaged.id }, deliveredVia: 'failed' }; } throw mapProviderError(err); } } // ─── GET / — list active invites ──────────────────────────────────────── router.get('/', safeAsync(async (_req, res) => { const provider = requireProvider(); // Capability degradation (spec §3): providers without listInvites (the // DC-140 tailscale provider does not expose it yet) get an honest empty // list instead of a 500. if (typeof provider.listInvites !== 'function') { log.info && log.info('network-invites', 'provider cannot list invites — returning empty list'); ok(res, { data: [], count: 0, supported: false }); return; } const invites = await provider.listInvites(); if (!Array.isArray(invites)) { throw new ValidationError('provider listInvites() must return an array', 'provider'); } ok(res, { data: invites, count: invites.length, supported: true }); })); // ─── POST / — create an invite (link-first, email opt-in) ─────────────── router.post('/', safeAsync(async (req, res) => { const body = req.body || {}; const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''; if (!displayName || displayName.length > 80) { throw new ValidationError('displayName is required (1-80 characters)', 'displayName'); } const role = body.role; if (!VALID_ROLES.has(role)) { throw new ValidationError('role must be one of: ' + [...VALID_ROLES].join(', '), 'role'); } // Strict opt-in: only sendEmail === true counts (never 'yes'/1/'on'). const sendEmail = body.sendEmail === true; let email; if (body.email !== undefined && body.email !== null && body.email !== '') { if (typeof body.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { throw new ValidationError('email must be a valid address when provided', 'email'); } email = body.email; } if (sendEmail && !email) { throw new ValidationError('sendEmail: true requires an email address', 'sendEmail'); } const provider = requireProvider(); let invite; let deliveredVia; if (sendEmail) { ({ invite, deliveredVia } = await createInviteWithEmail(provider, { role, email })); } else { // Default path: no email key at all — the provider mints the link // WITHOUT sending anything (Tailscale user-invites: email optional). try { invite = await provider.createInvite({ role }); } catch (err) { throw mapProviderError(err); } assertInviteResult(invite); deliveredVia = 'manual'; } // Pre-formed human sentence (never a JSON blob). Server-side canonical // wording so every client picks it up on refresh (DC-085 rule 3). const shareText = displayName + ' invited you to join the family network — open this link on your device: ' + invite.inviteUrl; log.info && log.info( 'network-invites', 'invite created', redact({ inviteId: invite.id || null, role, deliveredVia, email }) ); created(res, { inviteUrl: invite.inviteUrl, shareText, deliveredVia, id: invite.id || null, role, displayName, }); })); // ─── DELETE /:id — revoke an invite ───────────────────────────────────── router.delete('/:id', safeAsync(async (req, res) => { const id = req.params && req.params.id; if (!id) throw new ValidationError('invite id is required', 'id'); const provider = requireProvider(); try { await provider.revokeInvite(id); } catch (err) { throw mapProviderError(err); } // inviteId is provider metadata, not the token — safe to log. log.info && log.info('network-invites', 'invite revoked', { inviteId: id }); noContent(res); })); return router; };