import { Elysia, t } from "elysia"; import { and, desc, eq } from "drizzle-orm"; import { NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; import { farms, printers } from "../db/schema"; import { farmProjection } from "../db/projections"; import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; import { PaginationQuery, paginate } from "./pagination"; import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters } from "./ws/edge"; const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); export const farmRoutes = new Elysia({ prefix: "/farms" }) .use(requireUser) /** * Farms in the caller's organisations. The scoping is in the `where`, not * applied to the result afterwards: a filter that runs after the query is * one `return` away from being skipped, and it also makes the database read * rows it is not allowed to show. * * A projection rather than `select *`, because the table holds * `edgeProxyToken`, which trades for an edge JWT - see src/db/projections.ts. */ .get( "/", async ({ user, query }) => { const scope = query.orgId ? and(visibleFarms(user.id), eq(farms.orgId, query.orgId)) : visibleFarms(user.id); return db.query.farms.findMany({ columns: farmProjection.columns, where: scope, orderBy: [desc(farms.createdAt)], ...paginate(query), }); }, { query: t.Object({ orgId: t.Optional(t.String({ format: "uuid" })), ...PaginationQuery, }), detail: { tags: ["farms"], summary: "Farms in the caller's organisations" }, }, ) .get( "/:id", async ({ user, params }) => { const { role } = await requireFarm(user.id, params.id, "viewer"); const farm = await db.query.farms.findFirst({ columns: farmProjection.columns, where: eq(farms.id, params.id), }); if (!farm) throw new NotFoundError(`Farm ${params.id} not found`); return { ...farm, role }; }, { params: IdParams, detail: { tags: ["farms"] } }, ) .post( "/", async ({ user, body, set }) => { // `orgId` comes from the body and is then authorized, which is the // right way round: the caller says where the farm should go, and the // check is whether they own that organisation. What must never come // from the body is *who the caller is* - that is `user.id`, from a // verified token, and it is why there is no `ownerId` field here any // more. await requireOrg(user.id, body.orgId, "owner", "creating a farm"); // The generated token is deliberately not returned. A farm is created // far more often than a gateway is provisioned, and a credential that // comes back from a routine create ends up in logs, browser history and // screenshots. Fetch it when a gateway actually needs it, below. const [farm] = await db .insert(farms) .values({ name: body.name, orgId: body.orgId, edgeProxyToken: crypto.randomUUID(), location: body.location, }) .returning(farmProjection.returning); set.status = 201; return farm; }, { body: t.Object({ name: t.String({ minLength: 1, maxLength: 200 }), orgId: t.String({ format: "uuid" }), location: t.Optional(t.String({ maxLength: 500 })), }), detail: { tags: ["farms"] }, }, ) .patch( "/:id", async ({ user, params, body }) => { await requireFarm(user.id, params.id, "owner", "editing a farm"); // `orgId` is not patchable here on purpose: moving a farm between // organisations changes who can see its print history and would need // the `owner` role in *both*, so it wants its own endpoint rather than // a field on a general update. const [farm] = await db .update(farms) .set({ ...body, updatedAt: new Date() }) .where(eq(farms.id, params.id)) .returning(farmProjection.returning); if (!farm) throw new NotFoundError(`Farm ${params.id} not found`); return farm; }, { params: IdParams, body: t.Object({ name: t.Optional(t.String({ minLength: 1, maxLength: 200 })), location: t.Optional(t.String({ maxLength: 500 })), settings: t.Optional(t.Record(t.String(), t.Unknown())), }), detail: { tags: ["farms"] }, }, ) .delete( "/:id", async ({ user, params }) => { await requireFarm(user.id, params.id, "owner", "deleting a farm"); // Cascades to the farm's printers and print jobs, per the schema's // foreign keys. Worth knowing before calling it. await db.delete(farms).where(eq(farms.id, params.id)); return { deleted: params.id }; }, { params: IdParams, detail: { tags: ["farms"] } }, ) /** * Issues a *new* `edgeProxyToken` for this farm and returns it once. * * The only route that ever returns that credential, and it rotates on every * call rather than revealing the stored one: a reveal endpoint returns the * same secret to everyone who asks and leaves no trace of having been * called, while a rotate endpoint makes the old value useless, so a * gateway's token leaking has a bounded blast radius and reprovisioning is * the fix. * * The cost is that rotating locks out the gateway currently using the old * token until it is reconfigured, which is the right trade for a credential * that drives physical machines - but it does mean this is not a "show me * the token" button. */ .post( "/:id/edge-proxy-token", async ({ user, params }) => { await requireFarm(user.id, params.id, "owner", "rotating a farm's gateway credential"); const token = crypto.randomUUID(); const [farm] = await db .update(farms) .set({ edgeProxyToken: token, updatedAt: new Date() }) .where(eq(farms.id, params.id)) .returning({ id: farms.id }); if (!farm) throw new NotFoundError(`Farm ${params.id} not found`); return { farmId: farm.id, edgeProxyToken: token, note: "Any gateway still using the previous token must be reconfigured.", }; }, { params: IdParams, detail: { tags: ["farms"], summary: "Rotate and return this farm's edge-proxy token (owner only)", }, }, ) /** * Printers this farm's gateway can see that no printer row claims. * * This exists because of the worst first-run experience in the platform. * A gateway reports its fleet by the ids in its own `printers.toml`, and * `persistTelemetry` refuses to create rows for ids it does not recognise - * correctly, since a typo there would otherwise become a permanent phantom * printer that nobody can explain. But the refusal used to be silent, so a * new owner saw a connected gateway, an empty fleet, and no way to find out * why. The answer was in the server's stderr. * * Now it is here. Claim one by creating it with `POST /printers`, passing * the `gatewayPrinterId` from this list; the next telemetry batch fills in * everything else and the entry disappears from here. * * `viewer`, not `owner`: this reports what hardware exists, which is the * same class of fact as the printer list itself. Creating the row still * needs `owner`. * * `gatewayOnline` is in the response because an empty list is ambiguous on * its own - it means either "everything is claimed" or "no gateway has ever * connected", and those need completely different things done about them. */ .get( "/:id/unclaimed-printers", async ({ user, params }) => { await requireFarm(user.id, params.id, "viewer"); return { farmId: params.id, gatewayOnline: isGatewayOnline(params.id), unclaimed: listUnclaimedPrinters(params.id), }; }, { params: IdParams, detail: { tags: ["farms"], summary: "Printers the gateway reports that no printer row claims yet", }, }, ) /** * Printers the gateway *found* on its own network and USB ports, which is * a different thing from the list above. * * An unclaimed printer is in the gateway's fleet file already: its address * and credential were written down by hand, and only the printer row is * missing. A discovered one was merely seen. The point of it is that * adding a printer should not start with someone walking to a machine to * read its IP address off the screen. * * What a candidate cannot carry is a credential, and no amount of probing * will change that: a Bambu access code lives on the printer's display and * a PrusaLink API key in its web UI. So this shortens the job rather than * removing it - the operator still types one secret, instead of a secret * and an address and a serial number. * * `null` for `discovery` is the normal answer, and means the gateway has * never reported a scan - almost always because discovery is off, which is * its default. That is not the same as a report with an empty `printers`, * which means the gateway looked and found nothing. * * `viewer`, like the list above and for the same reason: this reports what * hardware exists. Creating the row still needs `owner`. */ .get( "/:id/discovered-printers", async ({ user, params }) => { await requireFarm(user.id, params.id, "viewer"); const discovery = getDiscoveredPrinters(params.id); const gatewayOnline = isGatewayOnline(params.id); if (!discovery) { return { farmId: params.id, gatewayOnline, discovery: null }; } // Cross-check against the rows this farm already has, so the console // can grey out a candidate instead of offering a duplicate. The // gateway filters its own fleet file before reporting, but it knows // nothing about printer rows registered in the cloud and never // claimed, which is exactly the case that produces a confusing // "add" button for a printer the operator already added. const existing = await db .select({ id: printers.id, serialNumber: printers.serialNumber, lanAddress: printers.lanAddress, }) .from(printers) .where(eq(printers.farmId, params.id)); const bySerial = new Map(); const byAddress = new Map(); for (const row of existing) { if (row.serialNumber) bySerial.set(row.serialNumber.toLowerCase(), row.id); if (row.lanAddress) byAddress.set(row.lanAddress.toLowerCase(), row.id); } return { farmId: params.id, gatewayOnline, discovery: { scannedAt: discovery.scannedAt, reportedAt: discovery.reportedAt, gatewayId: discovery.gatewayId, printers: discovery.printers.map((candidate) => { const bySerialMatch = candidate.serial ? bySerial.get(candidate.serial.toLowerCase()) : undefined; const byAddressMatch = byAddress.get(candidate.address.toLowerCase()); return { ...candidate, /** * The printer row this candidate looks like, or null. * * `matchedOn` says how sure that is, and the two are not * equally sure. A serial number identifies a machine. An * address identifies a DHCP lease, so an address match can * be the printer that held that address last month - which * is why the basis is reported instead of just a boolean, * and why the console should warn on `address` rather than * refuse to add. */ existingPrinterId: bySerialMatch ?? byAddressMatch ?? null, matchedOn: bySerialMatch ? "serial" : byAddressMatch ? "address" : null, }; }), }, }; }, { params: IdParams, detail: { tags: ["farms"], summary: "Printers the gateway found on its network that are not in its fleet", }, }, );