headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
import { createTransport } from 'nodemailer';
|
|
import { createRouter } from '../../create-router';
|
|
import { readServerSettings, writeServerSettings } from 'officerdb';
|
|
import { getTransport } from 'emailer';
|
|
|
|
type SmtpConfig = {
|
|
provider: 'resend' | 'smtp' | 'mailhog';
|
|
apiKey?: string;
|
|
host?: string;
|
|
port?: number;
|
|
username?: string;
|
|
password?: string;
|
|
secure?: boolean;
|
|
fromName: string;
|
|
fromEmail: string;
|
|
};
|
|
|
|
function maskSecret(value: string | undefined): string | undefined {
|
|
if (!value || value.length < 8) return value ? '****' : undefined;
|
|
return value.slice(0, 4) + '****' + value.slice(-4);
|
|
}
|
|
|
|
function serializeConfig(smtp: SmtpConfig) {
|
|
return {
|
|
provider: smtp.provider,
|
|
apiKey: maskSecret(smtp.apiKey),
|
|
host: smtp.host,
|
|
port: smtp.port,
|
|
username: smtp.username,
|
|
password: maskSecret(smtp.password),
|
|
secure: smtp.secure,
|
|
fromName: smtp.fromName,
|
|
fromEmail: smtp.fromEmail,
|
|
};
|
|
}
|
|
|
|
function buildTransportUrl(config: SmtpConfig): string {
|
|
if (config.provider === 'mailhog') {
|
|
return `smtp://${config.host ?? 'localhost'}:${config.port ?? 1025}`;
|
|
}
|
|
const auth = config.username
|
|
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@`
|
|
: '';
|
|
const protocol = config.secure ? 'smtps' : 'smtp';
|
|
return `${protocol}://${auth}${config.host}:${config.port ?? 587}`;
|
|
}
|
|
|
|
export const smtpRouter = createRouter();
|
|
|
|
smtpRouter.get('/', async (ctx) => {
|
|
const settings = await readServerSettings();
|
|
const smtp: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
|
|
if (smtp) return ctx.json(serializeConfig(smtp));
|
|
return ctx.json(null);
|
|
});
|
|
|
|
smtpRouter.put('/', async (ctx) => {
|
|
const body = await ctx.req.json<SmtpConfig>();
|
|
const settings = await readServerSettings();
|
|
|
|
const existing: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
|
|
if (existing) {
|
|
if (body.apiKey && body.apiKey.includes('****')) body.apiKey = existing.apiKey;
|
|
if (body.password && body.password.includes('****')) body.password = existing.password;
|
|
}
|
|
|
|
settings.smtp = body;
|
|
await writeServerSettings(settings);
|
|
return ctx.json({ success: true });
|
|
});
|
|
|
|
smtpRouter.post('/test-connection', async (ctx) => {
|
|
try {
|
|
const result = await getTransport();
|
|
|
|
if (result.type === 'resend') {
|
|
const res = await fetch('https://api.resend.com/domains', {
|
|
headers: { Authorization: `Bearer ${result.apiKey}` },
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.message ?? `Resend API error: ${res.status}`);
|
|
}
|
|
return ctx.json({ success: true });
|
|
}
|
|
|
|
await result.transport.verify();
|
|
return ctx.json({ success: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
return ctx.json({ error: message }, 500);
|
|
}
|
|
});
|
|
|
|
smtpRouter.post('/test', async (ctx) => {
|
|
const body = await ctx.req.json<SmtpConfig & { to: string }>();
|
|
if (!body.to) return ctx.json({ error: 'Recipient address required' }, 400);
|
|
|
|
// Resolve masked secrets from saved config
|
|
const settings = await readServerSettings();
|
|
const saved: SmtpConfig | undefined = settings.smtp as SmtpConfig | undefined;
|
|
if (saved) {
|
|
if (body.apiKey?.includes('****')) body.apiKey = saved.apiKey;
|
|
if (body.password?.includes('****')) body.password = saved.password;
|
|
}
|
|
|
|
const from = `${body.fromName} <${body.fromEmail}>`;
|
|
const testHtml =
|
|
'<h2>Officer Test Email</h2><p>If you received this, your email configuration is working correctly.</p>';
|
|
|
|
try {
|
|
if (body.provider === 'resend') {
|
|
const res = await fetch('https://api.resend.com/emails', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${body.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.message ?? `Resend API error: ${res.status}`);
|
|
}
|
|
return ctx.json({ success: true });
|
|
}
|
|
|
|
const url = buildTransportUrl(body);
|
|
const transport = createTransport(url);
|
|
await transport.sendMail({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml });
|
|
return ctx.json({ success: true });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
return ctx.json({ error: message }, 500);
|
|
}
|
|
});
|