import { Elysia, t } from "elysia"; import { and, desc, eq } from "drizzle-orm"; import { NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; import { farms } from "../db/schema"; import { farmProjection } from "../db/projections"; import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth"; import { PaginationQuery, paginate } from "./pagination"; import { 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", }, }, );