Add organisation invitations and one-time-code gateway enrolment

Invitations: token shown once, only its hash stored, 7-day expiry, the
accepting account's email must match. Enrolment codes: 15 minutes,
single use, hashed; POST /auth/enrol returns the farm's existing gateway
token without rotating it and is rate-limited per IP. No email is sent;
the inviter shares the link. Migration 0007, unit tests and
scripts/e2e-onboarding.sh.
This commit is contained in:
2026-10-30 08:13:00 +00:00
parent b431ee4030
commit fc8cf1d509
13 changed files with 1692 additions and 12 deletions
+1
View File
@@ -4,3 +4,4 @@ export * from "./printers";
export * from "./print_jobs";
export * from "./embeddings";
export * from "./alerts";
export * from "./onboarding";
+51
View File
@@ -0,0 +1,51 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { orgRoleEnum, organisations } from "./orgs";
import { farms } from "./farms";
/**
* An invitation to join an organisation, addressed to an email.
*
* Only a hash of the token is stored: the token is the whole credential, and
* the table is the kind of thing that ends up in a backup or a screenshot.
* It is shown to the inviter once, when the invitation is made.
*/
export const orgInvitations = pgTable(
"org_invitations",
{
id: uuid("id").primaryKey().defaultRandom(),
orgId: uuid("org_id")
.notNull()
.references(() => organisations.id, { onDelete: "cascade" }),
/** Lower-cased: addresses are compared case-insensitively. */
email: text("email").notNull(),
role: orgRoleEnum("role").notNull(),
tokenHash: text("token_hash").notNull().unique(),
invitedBy: uuid("invited_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
acceptedBy: uuid("accepted_by"),
},
(table) => ({ orgIdx: index("org_invitations_org_idx").on(table.orgId) }),
);
/**
* A short-lived, single-use code that lets a gateway fetch its farm's
* credentials without anyone copying a token around. Hashed like an
* invitation token, and for the same reason.
*/
export const enrolmentCodes = pgTable(
"enrolment_codes",
{
id: uuid("id").primaryKey().defaultRandom(),
farmId: uuid("farm_id")
.notNull()
.references(() => farms.id, { onDelete: "cascade" }),
codeHash: text("code_hash").notNull().unique(),
createdBy: uuid("created_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
usedAt: timestamp("used_at", { withTimezone: true }),
},
(table) => ({ farmIdx: index("enrolment_codes_farm_idx").on(table.farmId) }),
);
+6
View File
@@ -47,6 +47,12 @@ export const orgMembers = pgTable(
.references(() => organisations.id, { onDelete: "cascade" }),
userId: uuid("user_id").notNull(),
role: orgRoleEnum("role").notNull().default("viewer"),
/**
* Shown in member lists, which would otherwise be bare ids. Taken from
* the token's `email` claim when someone creates an organisation or
* accepts an invitation; null for a member added by id.
*/
email: text("email"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},