Two things reported from a production instance. The URL step was refused with "an access token is required the first time" — a check that predates splitting the screen into two steps, and which made the first step impossible to complete. Naming the instance is now its own step; a member still needs a token, since setting one is all they do. And the fields were a div, so pressing Enter did nothing and the button was the only way to submit. That is a keyboard trap wearing a layout. Now a <form> with onSubmit, with the other two buttons marked type="button" so they do not submit it. 19 panel files across the platform have the same problem. Recorded in the platform's TODO.md rather than swept blindly — a search box has no submit, and a multi-field editor wants the useForm hook. Also dropped the hint under the URL field, which repeated the sentence directly above it.
460 lines
23 KiB
TypeScript
460 lines
23 KiB
TypeScript
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
|
import { rewriteHtml, rewriteLocation, rewriteSetCookie } from './rewrite';
|
|
import {
|
|
getServiceConnection,
|
|
saveServiceConnection,
|
|
deleteServiceConnection,
|
|
recordServiceProbe,
|
|
getServiceInstanceUrl,
|
|
getOwnerUser,
|
|
} from 'officerdb';
|
|
import {
|
|
callGitea,
|
|
getGiteaConfig,
|
|
invalidateGiteaConfig,
|
|
invalidateAllGiteaConfigs,
|
|
normalizeBase,
|
|
probe,
|
|
reachable,
|
|
UnreadableCredentials,
|
|
} from './upstream';
|
|
import { readTeaLogin, readAnyTeaLogin, writeTeaLogin, osUserFor, testTeaLogin } from './tea-config';
|
|
import { API_URL } from '@@/officer-url.mjs';
|
|
|
|
// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
|
|
// token. The platform side is a thin auth-gated forwarder holding no Gitea credentials.
|
|
//
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
// HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding.
|
|
//
|
|
// GET /_health is the instance up, and does the stored token work
|
|
// GET /_config the connection MINUS the token
|
|
// PUT/POST /_config { url, token } — validated live before it is stored
|
|
// DELETE /_config forget the connection
|
|
// ANY /_api/* forwarded to the instance's own /api/*, token attached
|
|
//
|
|
// That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so
|
|
// the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than
|
|
// growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a
|
|
// save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`.
|
|
// Gitea genuinely is one-of: nobody runs two.
|
|
//
|
|
// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same
|
|
// reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor
|
|
// versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to
|
|
// speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the
|
|
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Everything under /api/v1 the UI legitimately needs.
|
|
//
|
|
// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site
|
|
// admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint
|
|
// tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted
|
|
// into a connection form should not silently become a remote root shell for the instance. If an admin
|
|
// screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix.
|
|
const ALLOWED = [
|
|
/^\/api\/v1\/version(\/|$|\?)/,
|
|
/^\/api\/v1\/user(\/|$|\?)/,
|
|
// Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES
|
|
// access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there
|
|
// precisely to stop a token escalating its own scopes, and answers `401 auth required` to the
|
|
// `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is
|
|
// GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check.
|
|
/^\/api\/v1\/users(\/|$|\?)/,
|
|
/^\/api\/v1\/orgs(\/|$|\?)/,
|
|
/^\/api\/v1\/org(\/|$|\?)/,
|
|
/^\/api\/v1\/teams(\/|$|\?)/,
|
|
/^\/api\/v1\/repos(\/|$|\?)/,
|
|
/^\/api\/v1\/repositories(\/|$|\?)/,
|
|
/^\/api\/v1\/notifications(\/|$|\?)/,
|
|
/^\/api\/v1\/issues(\/|$|\?)/,
|
|
/^\/api\/v1\/packages(\/|$|\?)/,
|
|
// Instance metadata the UI reads to match the instance's own behaviour rather than guess it —
|
|
// default branch name, whether the markdown renderer allows raw HTML, which features are enabled.
|
|
/^\/api\/v1\/settings(\/|$|\?)/,
|
|
/^\/api\/v1\/topics(\/|$|\?)/,
|
|
/^\/api\/v1\/licenses(\/|$|\?)/,
|
|
/^\/api\/v1\/gitignore(\/|$|\?)/,
|
|
/^\/api\/v1\/label(\/|$|\?)/,
|
|
// Rendering. Gitea's own web UI renders a README through these, and matching it matters: the
|
|
// instance resolves relative links, #issue references and @mentions against the repo context, which a
|
|
// client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown.
|
|
/^\/api\/v1\/markdown(\/|$|\?)/,
|
|
/^\/api\/v1\/markup(\/|$|\?)/,
|
|
];
|
|
|
|
function getFreePort(): number {
|
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
|
const p = probeServer.port;
|
|
probeServer.stop(true);
|
|
if (p == null) throw new Error('failed to acquire a free port');
|
|
return p;
|
|
}
|
|
|
|
const port = getFreePort();
|
|
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
|
|
|
/** Whoever owns the platform configures the instance; everyone else supplies only their own token. */
|
|
async function isOwner(userId: number): Promise<boolean> {
|
|
const owner = await getOwnerUser();
|
|
return !!owner && owner.id === userId;
|
|
}
|
|
|
|
// A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs
|
|
// no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have
|
|
// not connected yet" from "there is nothing here to connect to", which are different screens.
|
|
//
|
|
// `instanceUrl` is the resolved base, returned to members as well as the owner. Not setting it is the
|
|
// invariant (a member naming the URL would be a per-user SSRF hop); not SEEING it never was, and could
|
|
// not be: every avatar_url and html_url the instance hands back is on that origin and gets rendered.
|
|
// The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees
|
|
// with its public host, so links have to be rebased client-side — see retargetUrls.
|
|
async function connectionState(userId: number): Promise<Response> {
|
|
// Asked first, and only for its failure: a row whose token cannot be decrypted must NOT be reported as
|
|
// configured. It would send the panel to the frame, which then throws on every request — which is
|
|
// exactly what happened, and it reads as the sidecar being broken rather than as a token needing
|
|
// re-entering.
|
|
try {
|
|
await getGiteaConfig(userId);
|
|
} catch (err) {
|
|
if (err instanceof UnreadableCredentials) {
|
|
return Response.json({
|
|
configured: false,
|
|
unreadable: true,
|
|
isOwner: await isOwner(userId),
|
|
error: err.message,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
const connection = await getServiceConnection(userId, 'gitea');
|
|
const instanceUrl = await getServiceInstanceUrl('gitea');
|
|
|
|
// The token's home is `tea`'s own config, not the database — see tea-config.ts. So "configured" for a
|
|
// MEMBER means tea has a login for this instance; the row only carries the URL, which is the owner's.
|
|
//
|
|
// Read rather than asked for: on a machine where the owner and their members already ran `tea login`
|
|
// by hand, the settings page should say "connected" instead of demanding something they have.
|
|
// With an instance known, ask about THAT one. Without, offer whatever tea already has — a machine
|
|
// where somebody ran `tea login` by hand knows the URL and holds a working token, and asking them to
|
|
// retype it is asking for something the box can already see.
|
|
const tea = instanceUrl ? await readTeaLogin(userId, instanceUrl) : await readAnyTeaLogin(userId);
|
|
const osUser = await osUserFor(userId);
|
|
|
|
return Response.json({
|
|
configured: !!instanceUrl && !!tea?.hasToken,
|
|
connection,
|
|
instanceUrl,
|
|
instanceConfigured: !!instanceUrl,
|
|
isOwner: await isOwner(userId),
|
|
// What tea already knows, so the page can say who you are without a round trip to Gitea.
|
|
// `url` included so the form can offer it when the platform has no instance yet.
|
|
tea: tea ? { name: tea.name, url: tea.url, user: tea.user, hasToken: tea.hasToken } : null,
|
|
// Null means never provisioned an OS account, so there is nowhere to write a config. The page says
|
|
// so rather than failing on save.
|
|
canStoreToken: !!osUser || (await isOwner(userId)),
|
|
});
|
|
}
|
|
|
|
async function handleConfig(req: Request, userId: number): Promise<Response> {
|
|
if (req.method === 'GET') return connectionState(userId);
|
|
|
|
if (req.method === 'DELETE') {
|
|
const owner = await isOwner(userId);
|
|
await deleteServiceConnection(userId, 'gitea');
|
|
// The owner disconnecting removes the instance every member's row inherits, so every cached config
|
|
// is now wrong — they must all resolve to null rather than keep talking to a server the platform no
|
|
// longer claims. A member disconnecting only affects themselves.
|
|
if (owner) invalidateAllGiteaConfigs();
|
|
else invalidateGiteaConfig(userId);
|
|
return connectionState(userId);
|
|
}
|
|
|
|
if (req.method === 'PUT' || req.method === 'POST') {
|
|
const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {};
|
|
// A blank token on a save means "keep the stored one", so the URL can be corrected without the
|
|
// token being re-pasted. There is no way to read it back out to re-send it.
|
|
const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null;
|
|
const owner = await isOwner(userId);
|
|
|
|
// The instance is the owner's; the token is each person's own. Which half of the form you get is
|
|
// decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a
|
|
// rule: a member who could name the URL would have a per-user SSRF hop behind a settings form.
|
|
let base: string;
|
|
if (owner) {
|
|
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
|
|
if (!url) return bad('url is required');
|
|
if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://');
|
|
base = url;
|
|
} else {
|
|
// Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere
|
|
// and leave them debugging a screen that is quietly talking to a different server.
|
|
if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403);
|
|
const instance = await getServiceInstanceUrl('gitea');
|
|
if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409);
|
|
base = instance;
|
|
}
|
|
|
|
const existing = await getServiceConnection(userId, 'gitea');
|
|
// NOT required on the first save any more. Naming the instance is its own step — the owner says
|
|
// where Gitea is, and the token is asked for (or found in tea's config) afterwards. This check
|
|
// predates that split and refused the URL step outright.
|
|
//
|
|
// A member is different: they set nothing but a token, so an empty save from them means nothing.
|
|
if (!owner && !token && !(await readTeaLogin(userId, base))?.hasToken) {
|
|
return bad('an access token is required');
|
|
}
|
|
|
|
// Probe with the credentials as they WILL be, so a save either stores something that works or
|
|
// fails with the reason. Storing first and discovering later makes every downstream screen fail
|
|
// mysteriously instead. For a member this also proves their token is valid against the shared
|
|
// instance, which is the only thing that can be wrong on their side.
|
|
// Setting the INSTANCE is its own step, and it must not demand a token: the owner names where Gitea
|
|
// is first, and only then does the page ask for — or discover — a token for it. Probing with one
|
|
// when there is none would refuse a perfectly good URL for the wrong reason.
|
|
const current = token ? null : await getGiteaConfig(userId);
|
|
const candidate = token ?? current?.token ?? null;
|
|
const result = candidate
|
|
? await probe({ id: existing?.id ?? 0, base, token: candidate })
|
|
: await reachable(base);
|
|
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
|
|
|
|
// THE TOKEN GOES TO `tea`, NOT THE DATABASE.
|
|
//
|
|
// It used to be stored encrypted in `service_connections`, and nothing needs it there any more: the
|
|
// UI is an iframe carrying the browser's own Gitea session, so the only consumer left was this
|
|
// probe. `tea` keeps its own copy regardless — it is what the agent uses, and what this plugin's
|
|
// affordance tells the agent to use — so storing a second one is a drift waiting to happen: rotate
|
|
// with `tea login` and the database quietly keeps the dead one.
|
|
//
|
|
// Written as the account, so a member's token lands in a home only they can read.
|
|
if (token) {
|
|
const written = await writeTeaLogin(userId, base, token);
|
|
if (!written.ok) return bad(`could not store the token for tea: ${written.error}`);
|
|
}
|
|
|
|
const connection = await saveServiceConnection({
|
|
userId,
|
|
service: 'gitea',
|
|
// Null for anyone but the owner: their row is a credential, and inherits the instance on read.
|
|
url: owner ? base : null,
|
|
// Deliberately NOT stored. `secret: null` clears anything an earlier version of this wrote, so a
|
|
// machine upgraded into this design stops carrying a copy it no longer reads.
|
|
secret: null,
|
|
version: result.version,
|
|
});
|
|
// An owner's save may have moved the instance, and every member's cached base was resolved from it.
|
|
// Clearing only the caller would leave them pointed at the previous host until this process restarts.
|
|
if (owner) invalidateAllGiteaConfigs();
|
|
else invalidateGiteaConfig(userId);
|
|
return Response.json({ connection, user: result.user, version: result.version });
|
|
}
|
|
|
|
return bad('method not allowed', 405);
|
|
}
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
// Release attachments and file uploads through the contents API.
|
|
maxRequestBodySize: 64 * 1024 * 1024,
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
|
|
const officerUser = req.headers.get('X-Officer-User');
|
|
const userId = Number(officerUser);
|
|
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
|
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
|
}
|
|
|
|
try {
|
|
if (url.pathname === '/_config') return await handleConfig(req, userId);
|
|
|
|
const config = await getGiteaConfig(userId);
|
|
|
|
// Prove the token works, by asking `tea` to use it as this user. Deliberately not a call the
|
|
// platform makes with a token it read: the point is to test what the AGENT will get.
|
|
if (url.pathname === '/_test') {
|
|
return Response.json(await testTeaLogin(userId));
|
|
}
|
|
|
|
if (url.pathname === '/_health') {
|
|
if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
|
const started = Date.now();
|
|
const result = await probe(config);
|
|
const ms = Date.now() - started;
|
|
if (!result.ok) {
|
|
return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
|
|
}
|
|
await recordServiceProbe(userId, 'gitea', result.version);
|
|
return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms });
|
|
}
|
|
|
|
// ── The web UI, proxied for the iframe ──
|
|
//
|
|
// Everything above speaks Gitea's JSON API with the OWNER'S stored token. This does the opposite:
|
|
// it forwards the browser's own request, untouched, and injects nothing.
|
|
//
|
|
// That is deliberate. Gitea's web interface authenticates with its own session cookie, not with an
|
|
// API token, and the person looking at the iframe signs in to Gitea as themselves. Injecting the
|
|
// stored token here would silently show every user the OWNER'S view of every repository — the one
|
|
// failure this whole plugin must not have.
|
|
//
|
|
// Same-origin is what makes it work at all: the browser is on the platform's origin, so Gitea's
|
|
// cookie is first-party and survives. Pointed at gitea.officer.dev directly, an iframe gets a login
|
|
// page whose cookie the browser discards.
|
|
if (url.pathname.startsWith('/_web')) {
|
|
if (!config) return new Response('gitea not connected', { status: 503 });
|
|
const subpath = url.pathname.slice('/_web'.length) || '/';
|
|
|
|
// Hop-by-hop headers and the platform's own auth are dropped; everything else — cookies above
|
|
// all — goes through verbatim.
|
|
const headers = new Headers(req.headers);
|
|
for (const h of ['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'authorization']) {
|
|
headers.delete(h);
|
|
}
|
|
headers.delete('x-officer-user');
|
|
|
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
let upstream: Response;
|
|
try {
|
|
upstream = await fetch(`${config.base}${subpath}${url.search}`, {
|
|
method: req.method,
|
|
headers,
|
|
body: hasBody ? await req.arrayBuffer() : undefined,
|
|
// MANUAL: a 302 to /user/login is meaningful to the iframe and must reach it as a redirect,
|
|
// not be followed here into a page the browser never asked for.
|
|
redirect: 'manual',
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
} catch {
|
|
return new Response('gitea unreachable', { status: 502 });
|
|
}
|
|
|
|
const out = new Headers(upstream.headers);
|
|
// Gitea refuses to be framed by default, and the whole point here is to frame it. Safe because
|
|
// the frame is the platform's own origin, which the browser has already authenticated.
|
|
out.delete('x-frame-options');
|
|
out.delete('content-security-policy');
|
|
for (const h of ['connection', 'keep-alive', 'transfer-encoding']) out.delete(h);
|
|
// CONTENT-ENCODING ALWAYS, not just when rewriting.
|
|
//
|
|
// Bun's `fetch` sets its own Accept-Encoding and decompresses the response transparently, so
|
|
// `upstream.body` is plain while `upstream.headers` still says gzip. Passing both on hands the
|
|
// next hop a body that does not match its own header, and it dies with ZlibError.
|
|
//
|
|
// This was fixed for the HTML branch below and missed here, so the PAGE rendered while every
|
|
// stylesheet, script and image beside it answered 502 — which looks like a broken asset pipeline
|
|
// rather than one header.
|
|
out.delete('content-encoding');
|
|
out.delete('content-length');
|
|
|
|
// Where the browser thinks this is. Passed by the plugin's router rather than hardcoded — the
|
|
// sidecar has no business knowing how the platform routes to it, and `mountPrefix` is the only
|
|
// thing entitled to decide.
|
|
const base = req.headers.get('X-Officer-Frame-Base');
|
|
const origins = [config.base];
|
|
|
|
if (base) {
|
|
const location = out.get('location');
|
|
if (location) out.set('location', rewriteLocation(location, base, origins));
|
|
|
|
// Every Set-Cookie, not just the first: Gitea sets a session and a CSRF cookie together, and
|
|
// `Headers.get` would concatenate them into one broken value.
|
|
const cookies = (out as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? [];
|
|
if (cookies.length) {
|
|
out.delete('set-cookie');
|
|
for (const c of cookies) out.append('set-cookie', rewriteSetCookie(c, base));
|
|
}
|
|
|
|
// HTML only. Rewriting JavaScript hits strings that are not URLs and corrupts the file —
|
|
// `appSubUrl`, patched below by the same pass, is what makes Gitea's own scripts correct.
|
|
if ((out.get('content-type') ?? '').includes('text/html')) {
|
|
const body = rewriteHtml(await upstream.text(), base, origins);
|
|
// `content-encoding` and `content-length` are already gone, dropped for every response
|
|
// above — the body changing length here is one more reason they have to be.
|
|
return new Response(body, { status: upstream.status, headers: out });
|
|
}
|
|
}
|
|
|
|
return new Response(upstream.body, { status: upstream.status, headers: out });
|
|
}
|
|
|
|
if (url.pathname.startsWith('/_api/')) {
|
|
if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 });
|
|
const subpath = url.pathname.slice('/_api'.length) + url.search;
|
|
if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403);
|
|
|
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
const upstream = await callGitea(config, subpath, {
|
|
method: req.method,
|
|
headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {},
|
|
body: hasBody ? await req.arrayBuffer() : undefined,
|
|
});
|
|
return new Response(upstream.body, {
|
|
status: upstream.status,
|
|
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Path only. A repository body is the owner's private code.
|
|
console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err));
|
|
// Named, because the remedy is specific and the generic message sends people to the logs. This one
|
|
// is not a fault: a token was encrypted under a key that no longer exists.
|
|
if (err instanceof UnreadableCredentials) {
|
|
return Response.json({ error: err.message, unreadable: true }, { status: 409 });
|
|
}
|
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
}
|
|
|
|
return Response.json({ error: 'not found' }, { status: 404 });
|
|
},
|
|
});
|
|
|
|
console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`);
|
|
|
|
type ReplyFn = (msg: SidecarEvent) => void;
|
|
|
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|
switch (cmd.type) {
|
|
case 'ping':
|
|
reply({ type: 'pong', id: cmd.id });
|
|
break;
|
|
default:
|
|
reply({
|
|
type: 'error',
|
|
id: (cmd as SidecarCommand).id,
|
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const connection = createSidecarConnector({
|
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
name: 'gitea',
|
|
handles: ['gitea'],
|
|
onCommand(cmd, reply) {
|
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
|
},
|
|
onConnected() {
|
|
connection.send({ type: 'gitea:server', port });
|
|
console.log(`[gitea] reported server port ${port} to API`);
|
|
},
|
|
});
|
|
|
|
function shutdown(signal: string) {
|
|
console.log(`[gitea] ${signal} received, shutting down...`);
|
|
try {
|
|
server.stop(true);
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
connection.destroy();
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|