add officer-invoiceshelf sidecar
owns the invoiceshelf contract: instance url, sanctum token and the company
header that scopes every request. the platform side is the usual thin
auth+forward proxy at /api/invoiceshelf and holds no credentials.
built against the live 2.4.2 instance rather than the 3.0.0-alpha.1 checkout
in _references — the route allow-list came from artisan route:list on the
running container. they differ: 2.4.2 has estimates/{id}/convert-to-invoice
but no invoices/{id}/convert-to-estimate.
three upstream quirks absorbed here:
- accept: application/json is mandatory, or an unauthenticated request 302s
to an html login instead of returning 401
- origin/referer must never be sent, or statefulapi() switches to session+csrf
and every request 419s. the proxy forwards neither.
- a wrong company header does not error, it silently returns another company's
data. the pinned company is explicit and logged.
document pdfs are repaired: 2.4.2 prefixes them with a literal serialised http
response (201 bytes) inside a body already typed application/pdf. we slice to
the %PDF- magic. the report routes don't have the bug.
resources are an allow-list. backups, disks, modules, update/*, installation/*,
mail config, settings writes and ownership transfer stay unreachable, and the
per-resource action list keeps `send` — which really emails the customer —
from being reachable by accident.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,17 @@ TRANSMISSION_USER=
|
|||||||
TRANSMISSION_PASS=
|
TRANSMISSION_PASS=
|
||||||
# TRANSMISSION_RPC_PATH=/transmission/rpc
|
# TRANSMISSION_RPC_PATH=/transmission/rpc
|
||||||
|
|
||||||
|
# InvoiceShelf (officer-invoiceshelf). The token is a Sanctum personal access token — mint one with
|
||||||
|
# POST /api/v1/auth/login {username: <email>, password, device_name} and copy the `token` field. It has
|
||||||
|
# full abilities and never expires, so treat it as a password.
|
||||||
|
# INVOICESHELF_COMPANY_ID pins which company every request is scoped to. Leave it unset on a
|
||||||
|
# single-company install and the sidecar resolves it once at boot and LOGS the choice — worth setting
|
||||||
|
# explicitly if you have more than one, because InvoiceShelf does not error on a wrong company header,
|
||||||
|
# it silently returns the other company's data.
|
||||||
|
INVOICESHELF_URL=https://invoice.example.com
|
||||||
|
INVOICESHELF_TOKEN="<sanctum api token, e.g. 1|xxxxxxxx>"
|
||||||
|
# INVOICESHELF_COMPANY_ID=1
|
||||||
|
|
||||||
# slskd (officer-slskd). The key is injected as X-API-Key on every forwarded request.
|
# slskd (officer-slskd). The key is injected as X-API-Key on every forwarded request.
|
||||||
SLSKD_URL=http://127.0.0.1:5030
|
SLSKD_URL=http://127.0.0.1:5030
|
||||||
SLSKD_API_KEY="<slskd api key>"
|
SLSKD_API_KEY="<slskd api key>"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Long-running and privileged work lives in **sidecars**: separate processes that
|
|||||||
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
||||||
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
|
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
|
||||||
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||||
`officer-slskd`, `officer-headscale`.
|
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`.
|
||||||
|
|
||||||
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
|
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
|
||||||
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
||||||
|
|||||||
@@ -82,5 +82,11 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/transmission/index.ts',
|
args: 'run src/servers/sidecar/transmission/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'officer-invoiceshelf',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { getInvoiceshelfServerUrl } from './sidecar-server';
|
||||||
|
|
||||||
|
// Thin reverse-proxy for /api/invoiceshelf/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||||
|
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||||
|
// subpath + query + body to the officer-invoiceshelf sidecar, which OWNS the InvoiceShelf contract and holds
|
||||||
|
// the API token.
|
||||||
|
//
|
||||||
|
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
|
||||||
|
// against an allow-list of resources — the administrative half of InvoiceShelf's API is unreachable by
|
||||||
|
// design. The full contract is documented at the top of src/servers/sidecar/invoiceshelf/index.ts. It is
|
||||||
|
// opaque from here: this file must never grow InvoiceShelf logic.
|
||||||
|
//
|
||||||
|
// Note which headers are forwarded, and which are NOT. Origin and Referer are deliberately dropped: if either
|
||||||
|
// reaches InvoiceShelf, its statefulApi() middleware switches from token auth to session+CSRF and every
|
||||||
|
// request 419s. The browser sets them on same-origin XHR, so passing them through would break the sidecar in
|
||||||
|
// a way that looks like an auth bug.
|
||||||
|
|
||||||
|
export const invoiceshelfRouter = createRouter();
|
||||||
|
|
||||||
|
const PREFIX = '/api/invoiceshelf';
|
||||||
|
|
||||||
|
invoiceshelfRouter.all('/*', async (ctx) => {
|
||||||
|
const baseUrl = getInvoiceshelfServerUrl();
|
||||||
|
if (!baseUrl) return ctx.text('invoiceshelf sidecar not available', 503);
|
||||||
|
|
||||||
|
const url = new URL(ctx.req.url);
|
||||||
|
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||||
|
const target = `${baseUrl}${subpath}${url.search}`;
|
||||||
|
|
||||||
|
const method = ctx.req.method;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const contentType = ctx.req.header('content-type');
|
||||||
|
if (contentType) headers['Content-Type'] = contentType;
|
||||||
|
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||||
|
// loopback only, so this header is trusted.
|
||||||
|
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||||
|
|
||||||
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||||
|
|
||||||
|
let upstream: Response;
|
||||||
|
try {
|
||||||
|
upstream = await fetch(target, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[invoiceshelf] proxy fetch failed', { target, error: String(err) });
|
||||||
|
return ctx.text('invoiceshelf sidecar unreachable', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
|
|
||||||
|
// The officer-invoiceshelf sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||||
|
// connect. We remember it so `/api/invoiceshelf/*` always forwards to the current sidecar. The platform holds
|
||||||
|
// NO knowledge of InvoiceShelf itself — not its URL, not its API token, and not which company it acts as.
|
||||||
|
|
||||||
|
let serverPort: number | null = null;
|
||||||
|
|
||||||
|
sidecar.on('invoiceshelf:server', (msg) => {
|
||||||
|
const port = (msg as { port?: number }).port;
|
||||||
|
if (typeof port !== 'number') return;
|
||||||
|
serverPort = port;
|
||||||
|
console.log(`[invoiceshelf] sidecar registered on port ${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||||
|
export function getInvoiceshelfServerUrl(): string | null {
|
||||||
|
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { vaultRouter } from './api/vault/router';
|
|||||||
import { slskdRouter } from './api/slskd/router';
|
import { slskdRouter } from './api/slskd/router';
|
||||||
import { headscaleRouter } from './api/headscale/router';
|
import { headscaleRouter } from './api/headscale/router';
|
||||||
import { transmissionRouter } from './api/transmission/router';
|
import { transmissionRouter } from './api/transmission/router';
|
||||||
|
import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||||
import { vpnRouter } from './api/vpn/router';
|
import { vpnRouter } from './api/vpn/router';
|
||||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||||
import { activityRouter } from './api/activity/router';
|
import { activityRouter } from './api/activity/router';
|
||||||
@@ -32,6 +33,7 @@ import './api/vault/sidecar-server'; // side-effect: capture the officer-vault r
|
|||||||
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
||||||
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
||||||
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
|
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
|
||||||
|
import './api/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port
|
||||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||||
import { dockRouter } from './api/dock/dock';
|
import { dockRouter } from './api/dock/dock';
|
||||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||||
@@ -116,6 +118,7 @@ protectedRouter.route('/music', musicRouter);
|
|||||||
protectedRouter.route('/slskd', slskdRouter);
|
protectedRouter.route('/slskd', slskdRouter);
|
||||||
protectedRouter.route('/headscale', headscaleRouter);
|
protectedRouter.route('/headscale', headscaleRouter);
|
||||||
protectedRouter.route('/transmission', transmissionRouter);
|
protectedRouter.route('/transmission', transmissionRouter);
|
||||||
|
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
||||||
protectedRouter.route('/vpn', vpnRouter);
|
protectedRouter.route('/vpn', vpnRouter);
|
||||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||||
protectedRouter.route('/activity', activityRouter);
|
protectedRouter.route('/activity', activityRouter);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
|
import { createSidecarConnector } from '../connect';
|
||||||
|
import { handleOfficerRoute } from './routes';
|
||||||
|
import { callUpstream, getConfig, resolveCompanyId } from './upstream';
|
||||||
|
|
||||||
|
// The officer-invoiceshelf sidecar. Owns the whole InvoiceShelf contract for Officer: the instance URL, the
|
||||||
|
// Sanctum API token, and the `company` header that scopes every request. The platform API is a thin
|
||||||
|
// auth-gated forwarder (src/servers/api/invoiceshelf/router.ts) holding no InvoiceShelf credentials.
|
||||||
|
//
|
||||||
|
// Built and verified against the LIVE instance, which runs 2.4.2 — NOT against the 3.0.0-alpha.1 checkout in
|
||||||
|
// _references/InvoiceShelf. The two differ in ways that matter (2.4.2 has no invoices/{id}/convert-to-estimate
|
||||||
|
// and no installation/is-installed). The route allow-list in routes.ts came from `artisan route:list` on the
|
||||||
|
// running container.
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP CONTRACT — the platform strips its /api/invoiceshelf mount prefix before forwarding.
|
||||||
|
//
|
||||||
|
// GET /_health ours. Confirms the token is live and reports the pinned company.
|
||||||
|
// GET /_officer/summary me + current company + dashboard totals, one call
|
||||||
|
// GET /_officer/lookups customers/items/units/tax-types/categories/payment-methods/currencies
|
||||||
|
// GET /_officer/bootstrap the upstream's own bootstrap blob
|
||||||
|
// GET /_officer/next-number?key=invoice the number the next document would take
|
||||||
|
// GET /_officer/search?search= cross-entity search
|
||||||
|
// GET /_officer/reports/:kind sales-customers|sales-items|tax-summary|profit-loss|expenses → PDF
|
||||||
|
//
|
||||||
|
// GET|POST /_officer/:resource list / create
|
||||||
|
// GET|PUT|DELETE /_officer/:resource/:id read / update / delete
|
||||||
|
// POST /_officer/:resource/delete bulk delete {ids:[…]}
|
||||||
|
// GET /_officer/:resource/templates invoices|estimates only
|
||||||
|
// GET /_officer/:resource/:id/pdf invoices|estimates|payments — magic-byte repaired
|
||||||
|
// GET /_officer/:resource/:id/preview rendered email HTML, sends nothing.
|
||||||
|
// Requires ?subject=&body=&from=&to= — the controller
|
||||||
|
// validates them and 422s otherwise.
|
||||||
|
// GET /_officer/customers/:id/stats
|
||||||
|
// POST /_officer/:resource/:id/:action status|clone|send|convert-to-invoice|duplicate
|
||||||
|
// anything else 404
|
||||||
|
//
|
||||||
|
// `:resource` is an allow-list (routes.ts). The administrative half of the API — backups, disks, modules,
|
||||||
|
// update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||||
|
|
||||||
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
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 server = Bun.serve({
|
||||||
|
port,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
// Expense receipts and company logos are uploaded as multipart bodies through this proxy.
|
||||||
|
maxRequestBodySize: 64 * 1024 * 1024,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const cfg = getConfig();
|
||||||
|
|
||||||
|
if (url.pathname === '/_health') {
|
||||||
|
if (!cfg) return Response.json({ ok: false, error: 'INVOICESHELF_URL/TOKEN not configured' }, { status: 503 });
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
const [versionRes, company] = await Promise.all([
|
||||||
|
callUpstream(cfg, { path: '/api/v1/app/version', withCompany: false }),
|
||||||
|
resolveCompanyId(cfg),
|
||||||
|
]);
|
||||||
|
if (!versionRes.ok) {
|
||||||
|
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
const version = (await versionRes.json()) as { version?: string };
|
||||||
|
return Response.json({ ok: true, version: version.version ?? null, company, ms: Date.now() - started });
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/_officer/')) {
|
||||||
|
if (!cfg) return Response.json({ error: 'invoiceshelf not configured' }, { status: 503 });
|
||||||
|
try {
|
||||||
|
const res = await handleOfficerRoute(cfg, req, url);
|
||||||
|
if (res) return res;
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err);
|
||||||
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[invoiceshelf] listening on 127.0.0.1:${port} -> ${getConfig()?.base ?? '(INVOICESHELF_URL unset)'}`);
|
||||||
|
|
||||||
|
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: 'invoiceshelf',
|
||||||
|
capabilities: ['invoiceshelf'],
|
||||||
|
onCommand(cmd, reply) {
|
||||||
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
|
},
|
||||||
|
onConnected() {
|
||||||
|
connection.send({ type: 'invoiceshelf:server', port });
|
||||||
|
console.log(`[invoiceshelf] reported server port ${port} to API`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`[invoiceshelf] ${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'));
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import type { UpstreamConfig } from './upstream';
|
||||||
|
import { callUpstream, resolveCompanyHash } from './upstream';
|
||||||
|
|
||||||
|
// The Officer-owned route surface. Every path here is deliberate: this is an allow-list, not a passthrough.
|
||||||
|
//
|
||||||
|
// InvoiceShelf's API is already REST, so most of these are shaped proxies rather than translations — the
|
||||||
|
// value the sidecar adds is (a) holding the credential, (b) pinning the company, (c) repairing the PDF
|
||||||
|
// responses, and (d) refusing to expose the administrative half of the API at all.
|
||||||
|
//
|
||||||
|
// DELIBERATELY NOT EXPOSED: backups, disks, modules, update/*, installation/*, mail config and test-send,
|
||||||
|
// settings writes, and transfer/ownership. Those either mutate the deployment or can lock the owner out of
|
||||||
|
// it, and none of them belong behind a dashboard proxy. Adding one should be a decision, not an accident.
|
||||||
|
|
||||||
|
type Capabilities = { write: boolean; bulkDelete: boolean };
|
||||||
|
|
||||||
|
const RESOURCES: Record<string, Capabilities> = {
|
||||||
|
invoices: { write: true, bulkDelete: true },
|
||||||
|
estimates: { write: true, bulkDelete: true },
|
||||||
|
'recurring-invoices': { write: true, bulkDelete: true },
|
||||||
|
payments: { write: true, bulkDelete: true },
|
||||||
|
expenses: { write: true, bulkDelete: true },
|
||||||
|
customers: { write: true, bulkDelete: true },
|
||||||
|
items: { write: true, bulkDelete: true },
|
||||||
|
units: { write: true, bulkDelete: false },
|
||||||
|
categories: { write: true, bulkDelete: false },
|
||||||
|
'tax-types': { write: true, bulkDelete: false },
|
||||||
|
'payment-methods': { write: true, bulkDelete: false },
|
||||||
|
notes: { write: true, bulkDelete: false },
|
||||||
|
'custom-fields': { write: true, bulkDelete: false },
|
||||||
|
currencies: { write: false, bulkDelete: false },
|
||||||
|
countries: { write: false, bulkDelete: false },
|
||||||
|
users: { write: false, bulkDelete: false },
|
||||||
|
roles: { write: false, bulkDelete: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-REST sub-actions, per resource. Verified against `artisan route:list` on the LIVE 2.4.2 instance
|
||||||
|
// rather than the 3.0.0-alpha checkout in _references — they differ. Notably 2.4.2 has
|
||||||
|
// estimates/{id}/convert-to-invoice but NO invoices/{id}/convert-to-estimate, which 3.0 added.
|
||||||
|
const ACTIONS: Record<string, readonly string[]> = {
|
||||||
|
invoices: ['status', 'clone', 'send'],
|
||||||
|
estimates: ['status', 'clone', 'send', 'convert-to-invoice'],
|
||||||
|
payments: ['send'],
|
||||||
|
expenses: ['duplicate'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resources whose PDF lives on a web route keyed by `unique_hash`.
|
||||||
|
const PDF_RESOURCES = new Set(['invoices', 'estimates', 'payments']);
|
||||||
|
|
||||||
|
const REPORTS: Record<string, string> = {
|
||||||
|
'sales-customers': 'reports/sales/customers',
|
||||||
|
'sales-items': 'reports/sales/items',
|
||||||
|
'tax-summary': 'reports/tax-summary',
|
||||||
|
'profit-loss': 'reports/profit-loss',
|
||||||
|
expenses: 'reports/expenses',
|
||||||
|
};
|
||||||
|
|
||||||
|
const notFound = () => Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
const badRequest = (error: string) => Response.json({ error }, { status: 400 });
|
||||||
|
|
||||||
|
/** Pass an upstream JSON response through untouched — including Laravel's 422 `errors{}` envelope. */
|
||||||
|
async function passthrough(res: Response): Promise<Response> {
|
||||||
|
const body = await res.arrayBuffer();
|
||||||
|
const headers = new Headers();
|
||||||
|
const type = res.headers.get('content-type');
|
||||||
|
if (type) headers.set('Content-Type', type);
|
||||||
|
return new Response(body, { status: res.status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InvoiceShelf 2.4.2 prefixes the document PDFs with a literal serialised HTTP response — the body of
|
||||||
|
* `/invoices/pdf/{hash}` starts `HTTP/1.0 200 OK\r\nCache-Control: …\r\n\r\n%PDF-1.7`, 201 bytes of it,
|
||||||
|
* inside a response whose own Content-Type is already application/pdf. Every PDF reader rejects that.
|
||||||
|
*
|
||||||
|
* So: find the `%PDF-` magic and slice from there. Verified on this instance — the document routes carry the
|
||||||
|
* prefix, the report routes do not, and slicing is a no-op when the magic is already at offset 0.
|
||||||
|
*/
|
||||||
|
function repairPdf(bytes: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> | null {
|
||||||
|
const magic = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
|
||||||
|
const limit = Math.min(bytes.length - magic.length, 4096);
|
||||||
|
for (let i = 0; i <= limit; i++) {
|
||||||
|
let hit = true;
|
||||||
|
for (let j = 0; j < magic.length; j++) {
|
||||||
|
if (bytes[i + j] !== magic[j]) {
|
||||||
|
hit = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hit) return i === 0 ? bytes : bytes.subarray(i);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PdfParams = { cfg: UpstreamConfig; path: string; query: string; filename: string };
|
||||||
|
|
||||||
|
async function servePdf({ cfg, path, query, filename }: PdfParams): Promise<Response> {
|
||||||
|
const res = await callUpstream(cfg, { path, query, accept: 'application/pdf' });
|
||||||
|
if (!res.ok) return passthrough(res);
|
||||||
|
|
||||||
|
const repaired = repairPdf(new Uint8Array(await res.arrayBuffer()));
|
||||||
|
if (!repaired) return Response.json({ error: 'upstream did not return a PDF' }, { status: 502 });
|
||||||
|
|
||||||
|
return new Response(new Blob([repaired]), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `inline; filename="${filename}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a document's `unique_hash`, which its PDF web route is keyed by. */
|
||||||
|
async function fetchUniqueHash(cfg: UpstreamConfig, resource: string, id: string): Promise<string | null> {
|
||||||
|
const res = await callUpstream(cfg, { path: `/api/v1/${resource}/${id}` });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const payload = (await res.json()) as { data?: { unique_hash?: string } };
|
||||||
|
return payload.data?.unique_hash ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(req: Request): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await req.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bootstrap = {
|
||||||
|
current_user?: unknown;
|
||||||
|
current_company?: unknown;
|
||||||
|
current_company_currency?: unknown;
|
||||||
|
current_company_settings?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a dashboard needs on first paint, in one round trip: who we are, which company we are pinned to,
|
||||||
|
* how to format its money, and the totals.
|
||||||
|
*
|
||||||
|
* The currency matters more than it looks. Every amount in this API is an integer in minor units, and
|
||||||
|
* InvoiceShelf's own frontend just divides by 100 — but `current_company_currency` carries the real
|
||||||
|
* precision, separators and `swap_currency_symbol` (this instance is EUR: "1.234,56 €"). A client that
|
||||||
|
* formats from these fields is right for currencies the upstream's own UI gets wrong.
|
||||||
|
*/
|
||||||
|
async function summary(cfg: UpstreamConfig): Promise<Response> {
|
||||||
|
const [bootRes, dashRes] = await Promise.all([
|
||||||
|
callUpstream(cfg, { path: '/api/v1/bootstrap' }),
|
||||||
|
callUpstream(cfg, { path: '/api/v1/dashboard' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!dashRes.ok) return passthrough(dashRes);
|
||||||
|
|
||||||
|
const boot = bootRes.ok ? ((await bootRes.json()) as Bootstrap) : null;
|
||||||
|
const dashboard = await dashRes.json();
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
me: boot?.current_user ?? null,
|
||||||
|
company: boot?.current_company ?? null,
|
||||||
|
currency: boot?.current_company_currency ?? null,
|
||||||
|
settings: boot?.current_company_settings ?? null,
|
||||||
|
dashboard,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reference-data a form needs, fetched in parallel. All of these are small and change rarely, so one
|
||||||
|
* round trip beats seven — and `limit=all` keeps customers and items out of pagination.
|
||||||
|
*/
|
||||||
|
async function lookups(cfg: UpstreamConfig): Promise<Response> {
|
||||||
|
const wanted = [
|
||||||
|
['customers', '/api/v1/customers?limit=all'],
|
||||||
|
['items', '/api/v1/items?limit=all'],
|
||||||
|
['units', '/api/v1/units?limit=all'],
|
||||||
|
['taxTypes', '/api/v1/tax-types?limit=all'],
|
||||||
|
['categories', '/api/v1/categories?limit=all'],
|
||||||
|
['paymentMethods', '/api/v1/payment-methods?limit=all'],
|
||||||
|
['currencies', '/api/v1/currencies'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
wanted.map(async ([key, path]) => {
|
||||||
|
const res = await callUpstream(cfg, { path });
|
||||||
|
if (!res.ok) return [key, []] as const;
|
||||||
|
const payload = (await res.json()) as { data?: unknown };
|
||||||
|
return [key, payload.data ?? []] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response.json(Object.fromEntries(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const segments = url.pathname
|
||||||
|
.replace(/^\/_officer\/?/, '')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean);
|
||||||
|
const query = url.search;
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
const head = segments[0];
|
||||||
|
if (!head) return notFound();
|
||||||
|
const second = segments[1];
|
||||||
|
const third = segments[2];
|
||||||
|
|
||||||
|
// ── Officer-composed reads ────────────────────────────────────────────────────────────────────
|
||||||
|
if (head === 'summary' && method === 'GET') return summary(cfg);
|
||||||
|
if (head === 'lookups' && method === 'GET') return lookups(cfg);
|
||||||
|
|
||||||
|
if (head === 'bootstrap' && method === 'GET') {
|
||||||
|
return passthrough(await callUpstream(cfg, { path: '/api/v1/bootstrap', query }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `?key=invoice|estimate|payment` — the number the next document would get.
|
||||||
|
if (head === 'next-number' && method === 'GET') {
|
||||||
|
return passthrough(await callUpstream(cfg, { path: '/api/v1/next-number', query }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'search' && method === 'GET') {
|
||||||
|
return passthrough(await callUpstream(cfg, { path: '/api/v1/search', query }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reports: PDFs on web routes keyed by the company hash ─────────────────────────────────────
|
||||||
|
if (head === 'reports') {
|
||||||
|
if (method !== 'GET' || !second) return notFound();
|
||||||
|
const route = REPORTS[second];
|
||||||
|
if (!route) return notFound();
|
||||||
|
const hash = await resolveCompanyHash(cfg);
|
||||||
|
return servePdf({ cfg, path: `/${route}/${hash}`, query, filename: `${second}.pdf` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Allow-listed resources ────────────────────────────────────────────────────────────────────
|
||||||
|
const caps = RESOURCES[head];
|
||||||
|
if (!caps) return notFound();
|
||||||
|
|
||||||
|
const base = `/api/v1/${head}`;
|
||||||
|
const contentType = req.headers.get('content-type') ?? 'application/json';
|
||||||
|
|
||||||
|
// Collection: list and create.
|
||||||
|
if (!second) {
|
||||||
|
if (method === 'GET') return passthrough(await callUpstream(cfg, { path: base, query }));
|
||||||
|
if (method === 'POST') {
|
||||||
|
if (!caps.write) return badRequest(`${head} is read-only`);
|
||||||
|
return passthrough(
|
||||||
|
await callUpstream(cfg, { path: base, method: 'POST', body: await req.arrayBuffer(), contentType }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk delete — InvoiceShelf models it as POST /{resource}/delete with {"ids":[…]}, not DELETE.
|
||||||
|
if (second === 'delete' && method === 'POST') {
|
||||||
|
if (!caps.bulkDelete) return badRequest(`${head} has no bulk delete`);
|
||||||
|
const body = await readJson(req);
|
||||||
|
const ids = (body as { ids?: unknown })?.ids;
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) return badRequest('ids must be a non-empty array');
|
||||||
|
return passthrough(
|
||||||
|
await callUpstream(cfg, {
|
||||||
|
path: `${base}/delete`,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
contentType: 'application/json',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The template galleries, which sit alongside the collection rather than under an id.
|
||||||
|
if (second === 'templates' && method === 'GET' && (head === 'invoices' || head === 'estimates')) {
|
||||||
|
return passthrough(await callUpstream(cfg, { path: `${base}/templates` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = second;
|
||||||
|
|
||||||
|
// Single resource.
|
||||||
|
if (!third) {
|
||||||
|
if (method === 'GET') return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, query }));
|
||||||
|
if (method === 'PUT' || method === 'PATCH') {
|
||||||
|
if (!caps.write) return badRequest(`${head} is read-only`);
|
||||||
|
return passthrough(
|
||||||
|
await callUpstream(cfg, { path: `${base}/${id}`, method: 'PUT', body: await req.arrayBuffer(), contentType }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
if (!caps.write) return badRequest(`${head} is read-only`);
|
||||||
|
return passthrough(await callUpstream(cfg, { path: `${base}/${id}`, method: 'DELETE' }));
|
||||||
|
}
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-resources ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
if (third === 'pdf' && method === 'GET') {
|
||||||
|
if (!PDF_RESOURCES.has(head)) return notFound();
|
||||||
|
const hash = await fetchUniqueHash(cfg, head, id);
|
||||||
|
if (!hash) return Response.json({ error: `${head}/${id} not found` }, { status: 404 });
|
||||||
|
// Built from OUR base rather than the upstream's `*_pdf_url`, which is rendered from the instance's
|
||||||
|
// APP_URL and can point somewhere this process cannot reach.
|
||||||
|
return servePdf({ cfg, path: `/${head}/pdf/${hash}`, query, filename: `${head.slice(0, -1)}-${id}.pdf` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rendered email body, as HTML, without sending anything.
|
||||||
|
if (third === 'preview' && method === 'GET') {
|
||||||
|
if (!ACTIONS[head]?.includes('send')) return notFound();
|
||||||
|
return passthrough(await callUpstream(cfg, { path: `${base}/${id}/send/preview`, query }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'customers' && third === 'stats' && method === 'GET') {
|
||||||
|
return passthrough(await callUpstream(cfg, { path: `${base}/${id}/stats`, query }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named actions. `send` really does email the customer, which is why actions are an explicit allow-list
|
||||||
|
// per resource rather than a wildcard under the id.
|
||||||
|
if (method === 'POST' && ACTIONS[head]?.includes(third)) {
|
||||||
|
return passthrough(
|
||||||
|
await callUpstream(cfg, {
|
||||||
|
path: `${base}/${id}/${third}`,
|
||||||
|
method: 'POST',
|
||||||
|
body: await req.arrayBuffer(),
|
||||||
|
contentType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// InvoiceShelf upstream config + the one function that talks to it.
|
||||||
|
//
|
||||||
|
// All knowledge of the InvoiceShelf instance — its URL, its API token and which company the token acts on
|
||||||
|
// behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin
|
||||||
|
// auth+forward proxy and holds NO InvoiceShelf credentials.
|
||||||
|
//
|
||||||
|
// Three things about InvoiceShelf's API are load-bearing and easy to get wrong:
|
||||||
|
//
|
||||||
|
// 1. `Accept: application/json` is MANDATORY. Without it Laravel's Authenticate middleware answers an
|
||||||
|
// unauthenticated request with a 302 to an HTML login page instead of a 401, so every error turns into
|
||||||
|
// an unparseable redirect.
|
||||||
|
// 2. NEVER send Origin or Referer. InvoiceShelf's `statefulApi()` middleware treats a request carrying
|
||||||
|
// either as a first-party browser call and switches from token auth to session+CSRF, which then fails
|
||||||
|
// 419. Bun's fetch adds neither on its own; the platform proxy in api/invoiceshelf/router.ts
|
||||||
|
// deliberately forwards neither. Don't add them.
|
||||||
|
// 3. The `company` header (lowercase, a bare numeric id) scopes almost every route — and a wrong or
|
||||||
|
// missing value does NOT error. It silently falls back to another company's data. That is the reason
|
||||||
|
// resolveCompanyId() pins one explicitly and logs it, rather than relying on the fallback.
|
||||||
|
|
||||||
|
const { INVOICESHELF_URL, INVOICESHELF_TOKEN, INVOICESHELF_COMPANY_ID } = process.env;
|
||||||
|
|
||||||
|
export type UpstreamConfig = { base: string; token: string };
|
||||||
|
|
||||||
|
let warnedUnset = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
|
||||||
|
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
|
||||||
|
*/
|
||||||
|
export function getConfig(): UpstreamConfig | null {
|
||||||
|
const base = INVOICESHELF_URL?.trim().replace(/\/+$/, '');
|
||||||
|
const token = INVOICESHELF_TOKEN?.trim();
|
||||||
|
if (!base || !token) {
|
||||||
|
if (!warnedUnset) {
|
||||||
|
const missing = [!base && 'INVOICESHELF_URL', !token && 'INVOICESHELF_TOKEN'].filter(Boolean).join(' and ');
|
||||||
|
console.warn(`[invoiceshelf] ${missing} unset — the sidecar will respond 503 until set`);
|
||||||
|
warnedUnset = true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { base, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
let companyPromise: Promise<number> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company id every scoped request is pinned to.
|
||||||
|
*
|
||||||
|
* `INVOICESHELF_COMPANY_ID` wins when set. Otherwise we ask the instance once and take the first company the
|
||||||
|
* token can see — which is right for a single-company install and, crucially, is LOGGED. The silent-fallback
|
||||||
|
* behaviour is the sharpest edge in this API; making the choice explicit and visible is the whole point.
|
||||||
|
*
|
||||||
|
* The resolved promise is cached, so concurrent first requests share one lookup. A failure clears the cache
|
||||||
|
* so the next request retries instead of latching a transient network error forever.
|
||||||
|
*/
|
||||||
|
export async function resolveCompanyId(cfg: UpstreamConfig): Promise<number> {
|
||||||
|
const pinned = Number(INVOICESHELF_COMPANY_ID?.trim());
|
||||||
|
if (Number.isInteger(pinned) && pinned > 0) return pinned;
|
||||||
|
|
||||||
|
companyPromise ??= (async () => {
|
||||||
|
const res = await fetch(`${cfg.base}/api/v1/companies`, {
|
||||||
|
headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`companies lookup failed with ${res.status}`);
|
||||||
|
const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> };
|
||||||
|
const first = payload.data?.[0];
|
||||||
|
if (typeof first?.id !== 'number') throw new Error('no company visible to this token');
|
||||||
|
console.log(
|
||||||
|
`[invoiceshelf] pinned to company ${first.id} (${first.name ?? 'unnamed'}) — set INVOICESHELF_COMPANY_ID to override`,
|
||||||
|
);
|
||||||
|
return first.id;
|
||||||
|
})().catch((err) => {
|
||||||
|
companyPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
return companyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pinned company's `unique_hash`. The report PDFs live on web routes keyed by it
|
||||||
|
* (`/reports/sales/customers/{hash}`), not by company id, so it has to be looked up and is worth caching.
|
||||||
|
*/
|
||||||
|
export async function resolveCompanyHash(cfg: UpstreamConfig): Promise<string> {
|
||||||
|
hashPromise ??= (async () => {
|
||||||
|
const res = await callUpstream(cfg, { path: '/api/v1/current-company' });
|
||||||
|
if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`);
|
||||||
|
const payload = (await res.json()) as { data?: { unique_hash?: string } };
|
||||||
|
const hash = payload.data?.unique_hash;
|
||||||
|
if (!hash) throw new Error('current-company returned no unique_hash');
|
||||||
|
return hash;
|
||||||
|
})().catch((err) => {
|
||||||
|
hashPromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hashPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallOptions = {
|
||||||
|
/** Absolute path on the InvoiceShelf host, e.g. `/api/v1/invoices`. */
|
||||||
|
path: string;
|
||||||
|
method?: string;
|
||||||
|
/** Raw search string including the leading `?`, or empty. */
|
||||||
|
query?: string;
|
||||||
|
body?: BodyInit | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
/** Defaults to `application/json`; PDF routes ask for the bytes instead. */
|
||||||
|
accept?: string;
|
||||||
|
/** Set false for the handful of routes scoped to the user rather than a company. */
|
||||||
|
withCompany?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */
|
||||||
|
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${cfg.token}`,
|
||||||
|
Accept: opts.accept ?? 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.withCompany !== false) headers.company = String(await resolveCompanyId(cfg));
|
||||||
|
if (opts.contentType) headers['Content-Type'] = opts.contentType;
|
||||||
|
|
||||||
|
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body ?? undefined,
|
||||||
|
// A 302 here means the token was rejected and Laravel is redirecting to the HTML login. Following it
|
||||||
|
// would turn a clean 401 into a 200 full of HTML, so we surface the redirect instead.
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 419 || (res.status >= 300 && res.status < 400)) {
|
||||||
|
console.warn(`[invoiceshelf] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (token rejected or stateful)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@ export type SidecarEvent =
|
|||||||
| { type: 'headscale:server'; port: number }
|
| { type: 'headscale:server'; port: number }
|
||||||
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
|
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
| { type: 'transmission:server'; port: number }
|
| { type: 'transmission:server'; port: number }
|
||||||
|
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
|
| { type: 'invoiceshelf:server'; port: number }
|
||||||
// Generic
|
// Generic
|
||||||
| { type: 'error'; id?: string; error: string };
|
| { type: 'error'; id?: string; error: string };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user