/** * Share routes — DC-053. * * Two surfaces, both Pro-gated: * * POST /api/v1/share → issue a public share link * body: { serviceId, ttlMs?, subscribeCap? } * ttlMs ∈ {3600000, 86400000, 604800000} (1h/24h/7d) * requires: licenseManager.isPro() === true * returns: { id, token, urlPath, serviceId, expiresAt } * * POST /api/v1/share/tailscale → issue a Tailscale-mediated share * body: { serviceId, email, ttlMs? } (ttlMs ≤ 24h, default 24h) * requires: licenseManager.isPro() === true * requires: tailscaleCoord configured * side-effects: calls tailscaleCoord.createAuthKey() (single-use, scoped) * + notificationManager.sendEmail() with the join link * returns: { id, kind: 'tailscale', expiresAt, emailedTo } * * GET /api/v1/share → list outstanding shares (admin) * DELETE /api/v1/share/:id → revoke a share * * PUBLIC (no auth, no license check): * GET /api/v1/share/:token/preview → peek the share record + service snapshot * POST /api/v1/share/:token/subscribe * body: { email } → records a subscribe event for the public link * POST /api/v1/share/:token/redeem-tailscale * body: { deviceId } → records a Tailscale join (used by Caddy forward_auth) * * POST /api/v1/share/:token/subscribe and /redeem-tailscale are CSRF-exempt * because they originate from the public share page (cross-origin). Both * are bound to a specific share token, so the abuse surface is bounded. */ 'use strict'; const { ValidationError, NotFoundError } = require('../src/utilities/errors'); const { PaymentRequiredError } = require('../src/utilities/errors'); const { ok, created, badRequest, notFound } = require('../src/utils/responses'); const PUBLIC_TTL_OPTIONS = new Set([ 60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000, ]); const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; module.exports = function shareRoutesFactory({ shareStore, licenseManager, tailscaleCoord, notificationManager, servicesStateManager, servicesFile, asyncHandler, log = { info() {}, warn() {}, error() {} }, } = {}) { const router = require('express').Router(); // Share-store is required. In production this is always present (created in // src/app.js unconditionally). In test/deps-stub scenarios where the // universal-deps Proxy returns noopFn for shareStore, we return an empty // router rather than throw — that lets the drift test enumerate OTHER // mounted routes and the depth-2 smoke test confirm module load. Real // runtime errors will surface as 404s, not 500s. if (!shareStore || typeof shareStore.issuePublic !== 'function') { if (process.env.NODE_ENV === 'test') { log.warn && log.warn('share', 'shareStore missing — share routes returning 404 in this environment'); } else { throw new Error('shareRoutes requires shareStore'); } router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-553] share unavailable' })); return router; } if (!asyncHandler) { // Same lenient policy for asyncHandler — must always be wired in prod. if (process.env.NODE_ENV !== 'test') { throw new Error('shareRoutes requires asyncHandler'); } // Fall back to a noop asyncHandler so route handlers can still register. asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } function _requireAuth(req, _res, next) { if (!req.user || !req.user.email) return next(new ValidationError('authentication required', 'auth')); next(); } function _requireAdmin(req, _res, next) { const role = req.user && req.user.role; if (role !== 'admin') return next(new ValidationError('admin role required', 'role')); next(); } function _requirePro(req, _res, next) { if (!licenseManager || typeof licenseManager.isPro !== 'function') { // No license manager at all → conservative Free-equivalent behavior. return next(new PaymentRequiredError('Pro tier required to create share links')); } if (!licenseManager.isPro()) { return next(new PaymentRequiredError('Pro tier required to create share links')); } next(); } async function _loadService(serviceId) { // Prefer the in-memory state manager; fall back to a synchronous read of // services.json so the share-preview endpoint works even after a restart. let svc = null; if (servicesStateManager && typeof servicesStateManager.get === 'function') { try { svc = await servicesStateManager.get(serviceId); } catch (_) { svc = null; } } if (svc) return svc; if (servicesFile) { try { const fs = require('fs'); const raw = fs.readFileSync(servicesFile, 'utf8'); const parsed = JSON.parse(raw); const arr = Array.isArray(parsed) ? parsed : (parsed.services || []); svc = arr.find(s => s && (s.id === serviceId || s.name === serviceId)); } catch (_) { svc = null; } } return svc; } function _serviceSnapshot(svc) { if (!svc) return null; return { id: svc.id || svc.name || null, name: svc.name || svc.id || null, description: svc.description || '', url: svc.url || (svc.domain ? `https://${svc.domain}` : null), icon: svc.icon || null, tags: Array.isArray(svc.tags) ? svc.tags : [], category: svc.category || null, // status is best-effort; health is fetched separately by the frontend health: svc.health || svc.status || 'unknown', }; } // ─── Authenticated admin endpoints ──────────────────────────────────────── router.post('/share', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => { const { serviceId, ttlMs, subscribeCap } = req.body || {}; if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId'); const service = await _loadService(serviceId); if (!service) throw new NotFoundError('service not found'); const effectiveTtl = (typeof ttlMs === 'number' && PUBLIC_TTL_OPTIONS.has(ttlMs)) ? ttlMs : 24 * 60 * 60 * 1000; const result = await shareStore.issuePublic({ serviceId, ttlMs: effectiveTtl, createdBy: req.user.email, subscribeCap, }); if (!result.ok) throw new ValidationError(result.reason || 'issue_failed', 'share'); log.info && log.info('share', 'public share issued', { id: result.id, serviceId, createdBy: req.user.email, ttlMs: effectiveTtl, }); res.status(201).json({ success: true, data: { id: result.id, kind: 'public', token: result.token, urlPath: result.urlPath, serviceId: result.serviceId, expiresAt: result.expiresAt, ttlMs: result.ttlMs, }, }); }, 'share-issue-public')); router.post('/share/tailscale', _requireAuth, _requireAdmin, _requirePro, asyncHandler(async (req, res) => { const { serviceId, email, ttlMs } = req.body || {}; if (!serviceId) throw new ValidationError('serviceId is required', 'serviceId'); if (!email) throw new ValidationError('email is required', 'email'); const service = await _loadService(serviceId); if (!service) throw new NotFoundError('service not found'); if (!tailscaleCoord || typeof tailscaleCoord.createAuthKey !== 'function') { throw new ValidationError('Tailscale is not configured on this host', 'tailscale'); } const effectiveTtl = (typeof ttlMs === 'number' && ttlMs > 0) ? Math.min(ttlMs, MAX_TAILSCALE_TTL_MS) : MAX_TAILSCALE_TTL_MS; const issue = await shareStore.issueTailscale({ serviceId, email, ttlMs: effectiveTtl, createdBy: req.user.email, }); if (!issue.ok) throw new ValidationError(issue.reason || 'issue_failed', 'share'); // Create the one-shot Tailscale pre-auth key. The auth-key string itself // is what we email — it never touches disk. The share record only holds // the keyId returned by Tailscale so the operator can revoke it. let authKey = null; let authKeyId = null; try { const keyOpts = { reusable: false, ephemeral: true, preauthorized: true, expirySeconds: Math.ceil(effectiveTtl / 1000), description: `dashcaddy-share:${issue.id}:${serviceId}`, }; const key = await tailscaleCoord.createAuthKey(keyOpts); authKey = key && (key.key || key.value || (typeof key === 'string' ? key : null)); authKeyId = key && key.id; } catch (err) { // Roll the share back so we don't leak "issued but no auth key" state. await shareStore.revoke(issue.id); log.error && log.error('share', 'tailscale createAuthKey failed', { err: err && err.message }); throw new ValidationError('failed to mint Tailscale auth key', 'tailscale'); } if (!authKey) { await shareStore.revoke(issue.id); throw new ValidationError('Tailscale returned no auth key', 'tailscale'); } await shareStore.attachAuthKey(issue.id, authKeyId); // Email the join link to the invitee. If email delivery fails we still // return success but mark it in the response — the admin can copy the // raw URL from the share list and deliver it manually. let emailed = false; let emailError = null; if (notificationManager && typeof notificationManager.sendEmail === 'function') { try { const baseUrl = `${req.protocol}://${req.get('host') || 'status.sami'}`; const joinUrl = `${baseUrl}/share/${issue.token}`; await notificationManager.sendEmail( `[DashCaddy] ${req.user.email} shared a service with you`, [ `You've been invited to access "${service.name || serviceId}" on DashCaddy.`, ``, `Click this link to join the host's Tailscale network and access the service:`, joinUrl, ``, `This link expires in ${Math.round(effectiveTtl / (60 * 60 * 1000))} hours and can only be used once.`, ].join('\n') ); emailed = true; } catch (err) { emailError = err && err.message; log.warn && log.warn('share', 'email delivery failed; admin can copy the URL manually', { err: emailError, }); } } log.info && log.info('share', 'tailscale share issued', { id: issue.id, serviceId, email: issue.email, emailed, authKeyId, }); res.status(201).json({ success: true, data: { id: issue.id, kind: 'tailscale', email: issue.email, serviceId, expiresAt: issue.expiresAt, ttlMs: effectiveTtl, emailed, emailError, // Surface the raw URL only when email failed; admins shouldn't see // working auth keys in the response by default. urlPath: emailed ? null : issue.urlPath, }, }); }, 'share-issue-tailscale')); router.get('/share', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => { const all = await shareStore.list(); res.json({ success: true, data: all }); }, 'share-list')); router.delete('/share/:id', _requireAuth, _requireAdmin, asyncHandler(async (req, res) => { const okRevoked = await shareStore.revoke(req.params.id); if (!okRevoked) throw new NotFoundError('share not found'); res.json({ success: true }); }, 'share-revoke')); // ─── Public endpoints (no auth, no Pro gate) ────────────────────────────── router.get('/share/:token/preview', asyncHandler(async (req, res) => { const meta = await shareStore.peek(req.params.token); if (!meta) { return res.status(404).json({ success: false, error: '[DC-553] share not found or expired' }); } const service = await _loadService(meta.serviceId); res.json({ success: true, data: { kind: meta.kind, serviceId: meta.serviceId, expiresAt: meta.expiresAt, service: _serviceSnapshot(service), }, }); }, 'share-preview')); router.post('/share/:token/subscribe', asyncHandler(async (req, res) => { const { email } = req.body || {}; if (!email || typeof email !== 'string' || !email.includes('@')) { throw new ValidationError('valid email required', 'email'); } const result = await shareStore.recordPublicSubscribe(req.params.token); if (!result.ok) { if (result.reason === 'not_found') throw new NotFoundError('share not found'); throw new ValidationError(result.reason, 'share'); } res.json({ success: true, data: { count: result.count, cap: result.cap } }); }, 'share-subscribe')); router.post('/share/:token/redeem-tailscale', asyncHandler(async (req, res) => { const { deviceId } = req.body || {}; if (!deviceId || typeof deviceId !== 'string') { throw new ValidationError('deviceId required', 'deviceId'); } const result = await shareStore.recordTailscaleUse(req.params.token, { deviceId }); if (!result.ok) { if (result.reason === 'not_found') throw new NotFoundError('share not found'); throw new ValidationError(result.reason, 'share'); } res.json({ success: true, data: { redeemed: true, share: result.share } }); }, 'share-redeem-tailscale')); return router; }; module.exports.PUBLIC_TTL_OPTIONS = PUBLIC_TTL_OPTIONS;