DC-142: link-first invites routes via network provider + tests
This commit is contained in:
2 files changed
+572
No files matched your search
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Route tests for network-invites routes (DC-142).
|
||||||
|
* Coverage:
|
||||||
|
* - GET / lists invites via provider; empty list when provider lacks
|
||||||
|
* listInvites (capability degradation)
|
||||||
|
* - POST / without sendEmail → deliveredVia 'manual' (default NO email)
|
||||||
|
* - POST / with sendEmail:true + emailSent → deliveredVia 'email'
|
||||||
|
* - POST / provider email failure (emailSent:false or email_send_failed
|
||||||
|
* with link on the error) → deliveredVia 'failed', link still present
|
||||||
|
* - DELETE /:id → 204; not_found maps to 404; bad_input maps to 400
|
||||||
|
* - 503 + code provider_not_active when no provider is connected
|
||||||
|
* - no raw token / 'uinv/' ever reaches the log mock (redaction)
|
||||||
|
* Hermetic — supertest against an in-process express app, mocked provider,
|
||||||
|
* no network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* eslint-disable require-await -- provider mocks resolve settled values on
|
||||||
|
purpose; the route under test does the awaiting. */
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const networkInvitesRoutes = require('../routes/network-invites');
|
||||||
|
const { ProviderError, ProviderNotActiveError } = require('../src/network/errors');
|
||||||
|
|
||||||
|
const INVITE_URL = 'https://login.tailscale.com/uinv/abc123secretcode';
|
||||||
|
|
||||||
|
function asyncHandler(fn) {
|
||||||
|
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capturing logger — every call is recorded so tests can grep for leaks. */
|
||||||
|
function captureLog() {
|
||||||
|
const calls = [];
|
||||||
|
const mk = (level) => (...args) => calls.push({ level, args });
|
||||||
|
return { info: mk('info'), warn: mk('warn'), error: mk('error'), calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProvider(overrides = {}) {
|
||||||
|
const provider = {
|
||||||
|
id: () => 'mock',
|
||||||
|
listInvites: async () => [{ id: 'inv-1', inviteUrl: INVITE_URL }],
|
||||||
|
createInvite: async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-2' }),
|
||||||
|
revokeInvite: async () => ({ revoked: true }),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createApp({ provider, registry } = {}) {
|
||||||
|
const log = captureLog();
|
||||||
|
const networkRegistry = registry || {
|
||||||
|
getActiveProvider: provider
|
||||||
|
? () => provider
|
||||||
|
: () => { throw new ProviderNotActiveError(); },
|
||||||
|
};
|
||||||
|
const router = networkInvitesRoutes({ networkRegistry, asyncHandler, log });
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/network/invites', router);
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
res.status(status).json({ success: false, error: err.message, code: err.code || null });
|
||||||
|
});
|
||||||
|
return { app, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('network-invites routes: contract', () => {
|
||||||
|
test('GET / lists active invites via provider', async () => {
|
||||||
|
const { app } = createApp({ provider: makeProvider() });
|
||||||
|
const res = await request(app).get('/network/invites');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.count).toBe(1);
|
||||||
|
expect(res.body.data[0].id).toBe('inv-1');
|
||||||
|
expect(res.body.supported).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET / returns empty list when provider lacks listInvites', async () => {
|
||||||
|
const provider = makeProvider();
|
||||||
|
delete provider.listInvites;
|
||||||
|
const { app } = createApp({ provider });
|
||||||
|
const res = await request(app).get('/network/invites');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.count).toBe(0);
|
||||||
|
expect(res.body.data).toEqual([]);
|
||||||
|
expect(res.body.supported).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST / without sendEmail creates link-only invite (deliveredVia manual)', async () => {
|
||||||
|
const createInvite = jest.fn(async ({ email }) => {
|
||||||
|
expect(email).toBeUndefined(); // link-first: no email key at all
|
||||||
|
return { inviteUrl: INVITE_URL, id: 'inv-2' };
|
||||||
|
});
|
||||||
|
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||||
|
expect(res.body.deliveredVia).toBe('manual');
|
||||||
|
expect(createInvite).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createInvite).toHaveBeenCalledWith({ role: 'member' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST / response always includes shareText — a human sentence containing the link', async () => {
|
||||||
|
const { app } = createApp({ provider: makeProvider() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(typeof res.body.shareText).toBe('string');
|
||||||
|
expect(res.body.shareText).toContain('Sami invited you to join the family network');
|
||||||
|
expect(res.body.shareText).toContain(INVITE_URL);
|
||||||
|
// A sentence, not a JSON blob:
|
||||||
|
expect(res.body.shareText.startsWith('{')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST / rejects invalid bodies (400)', async () => {
|
||||||
|
const { app } = createApp({ provider: makeProvider() });
|
||||||
|
const noName = await request(app).post('/network/invites').send({ role: 'member' });
|
||||||
|
expect(noName.status).toBe(400);
|
||||||
|
expect(noName.body.error).toMatch(/displayName/);
|
||||||
|
|
||||||
|
const badRole = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'superadmin' });
|
||||||
|
expect(badRole.status).toBe(400);
|
||||||
|
expect(badRole.body.error).toMatch(/role/);
|
||||||
|
|
||||||
|
const badEmail = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member', email: 'not-an-email' });
|
||||||
|
expect(badEmail.status).toBe(400);
|
||||||
|
expect(badEmail.body.error).toMatch(/email/);
|
||||||
|
|
||||||
|
const sendNoAddr = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member', sendEmail: true });
|
||||||
|
expect(sendNoAddr.status).toBe(400);
|
||||||
|
expect(sendNoAddr.body.error).toMatch(/sendEmail/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail is strict opt-in: truthy non-true values do not trigger email', async () => {
|
||||||
|
const createInvite = jest.fn(async ({ email }) => {
|
||||||
|
if (email) return { inviteUrl: INVITE_URL, id: 'inv-e', emailSent: true };
|
||||||
|
return { inviteUrl: INVITE_URL, id: 'inv-m' };
|
||||||
|
});
|
||||||
|
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member', email: 'x@y.io', sendEmail: 'yes' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.deliveredVia).toBe('manual');
|
||||||
|
expect(createInvite).toHaveBeenCalledWith({ role: 'member' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('network-invites routes: email leg', () => {
|
||||||
|
test('POST / with sendEmail:true and provider-confirmed send → deliveredVia email', async () => {
|
||||||
|
const createInvite = jest.fn(async () => ({
|
||||||
|
inviteUrl: INVITE_URL,
|
||||||
|
id: 'inv-3',
|
||||||
|
emailSent: true,
|
||||||
|
}));
|
||||||
|
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'admin', email: 'friend@example.com', sendEmail: true });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.deliveredVia).toBe('email');
|
||||||
|
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||||
|
expect(createInvite).toHaveBeenCalledWith({ role: 'admin', email: 'friend@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provider does not confirm send (emailSent false) → deliveredVia failed, link present', async () => {
|
||||||
|
const createInvite = jest.fn(async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-4' }));
|
||||||
|
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true });
|
||||||
|
expect(res.status).toBe(201); // not a total failure — link still returned
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||||
|
expect(res.body.shareText).toContain(INVITE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provider throws email_send_failed with link on the error → deliveredVia failed, link present', async () => {
|
||||||
|
const err = new ProviderError('provider email send failed', {
|
||||||
|
code: 'email_send_failed',
|
||||||
|
});
|
||||||
|
err.inviteUrl = INVITE_URL; // provider minted the invite, email leg failed
|
||||||
|
const createInvite = jest.fn(async () => { throw err; });
|
||||||
|
const { app } = createApp({ provider: makeProvider({ createInvite }) });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
expect(res.body.inviteUrl).toBe(INVITE_URL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('network-invites routes: revoke', () => {
|
||||||
|
test('DELETE /:id returns 204 on success', async () => {
|
||||||
|
const revokeInvite = jest.fn(async (id) => {
|
||||||
|
expect(id).toBe('inv-9');
|
||||||
|
return { revoked: true };
|
||||||
|
});
|
||||||
|
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||||
|
const res = await request(app).delete('/network/invites/inv-9');
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(revokeInvite).toHaveBeenCalledWith('inv-9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE maps provider not_found → 404', async () => {
|
||||||
|
const revokeInvite = async () => {
|
||||||
|
throw new ProviderError('invite not found', { code: 'not_found' });
|
||||||
|
};
|
||||||
|
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||||
|
const res = await request(app).delete('/network/invites/ghost');
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE maps provider bad_input → 400', async () => {
|
||||||
|
const revokeInvite = async () => {
|
||||||
|
throw new ProviderError('revokeInvite: id is required', { code: 'bad_input' });
|
||||||
|
};
|
||||||
|
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||||
|
const res = await request(app).delete('/network/invites/x');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE maps upstream 5xx ProviderError → 502', async () => {
|
||||||
|
const revokeInvite = async () => {
|
||||||
|
throw new ProviderError('upstream unavailable', { code: 'server_error' });
|
||||||
|
};
|
||||||
|
const { app } = createApp({ provider: makeProvider({ revokeInvite }) });
|
||||||
|
const res = await request(app).delete('/network/invites/x');
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('network-invites routes: provider inactive', () => {
|
||||||
|
[['GET', '/network/invites', null],
|
||||||
|
['POST', '/network/invites', { displayName: 'Sami', role: 'member' }],
|
||||||
|
['DELETE', '/network/invites/inv-1', null]].forEach(([method, url, body]) => {
|
||||||
|
test(`${method} returns 503 provider_not_active when no provider connected`, async () => {
|
||||||
|
const { app } = createApp({}); // registry throws ProviderNotActiveError
|
||||||
|
const req = request(app)[method.toLowerCase()](url);
|
||||||
|
if (body) req.send(body);
|
||||||
|
const res = await req;
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
expect(res.body.code).toBe('provider_not_active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing registry entirely also yields 503', async () => {
|
||||||
|
const { app } = createApp({ registry: {} }); // no getActiveProvider fn
|
||||||
|
const res = await request(app).get('/network/invites');
|
||||||
|
expect(res.status).toBe(503);
|
||||||
|
expect(res.body.code).toBe('provider_not_active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('network-invites routes: no raw tokens in logs', () => {
|
||||||
|
function assertNoLeak(log) {
|
||||||
|
expect(log.calls.length).toBeGreaterThan(0); // logging happened…
|
||||||
|
for (const { args } of log.calls) {
|
||||||
|
for (const arg of args) {
|
||||||
|
if (typeof arg !== 'string') continue;
|
||||||
|
expect(arg).not.toContain('uinv/'); // …but never the raw link
|
||||||
|
expect(arg).not.toContain('abc123secretcode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('create (manual) never logs the raw invite URL', async () => {
|
||||||
|
const { app, log } = createApp({ provider: makeProvider() });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
assertNoLeak(log);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create (email failed) never logs the raw invite URL', async () => {
|
||||||
|
const createInvite = async () => await Promise.resolve({ inviteUrl: INVITE_URL, id: 'inv-f' });
|
||||||
|
const { app, log } = createApp({ provider: makeProvider({ createInvite }) });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/network/invites')
|
||||||
|
.send({ displayName: 'Sami', role: 'member', email: 'f@example.com', sendEmail: true });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
assertNoLeak(log); // warn fired — but with redacted fields only
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redaction helper replaces link-bearing field values with [REDACTED]', async () => {
|
||||||
|
const { app, log } = createApp({ provider: makeProvider() });
|
||||||
|
await request(app).post('/network/invites').send({ displayName: 'Sami', role: 'member' });
|
||||||
|
const createCall = log.calls.find((c) => c.args[1] === 'invite created');
|
||||||
|
expect(createCall).toBeTruthy();
|
||||||
|
const fields = createCall.args[2];
|
||||||
|
expect(fields.inviteUrl).toBeUndefined();
|
||||||
|
expect(JSON.stringify(fields)).not.toContain('uinv/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
Reference in new issue
Block a user