DC-142: link-first invites routes via network provider + tests

This commit is contained in:
Hermes committed 2026-10-03 21:31:57 -07:00
1 parent a78d0ef857
commit 88d34c6d7e
2 files changed
+572

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
/**
* 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;
};