mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-24 03:28:18 +03:00
feat(core,api): an organisation owns hardware, and a domain says who belongs
Two kinds of machine were modelled as one. A host somebody brings is theirs, reached through a team, and should die with their account. A host bought to serve other people's workloads is none of those things — and there was nowhere to put it, so it had to be registered under an employee's personal team, where it was that person's property and their account going away took it with them. Ownership becomes an either/or. A machine names a team or an organisation, exactly one, enforced by a check constraint rather than by convention: both null is a host nothing can bill, and both set is two answers to "whose is this?" where whichever join a query happens to take decides who pays. Hardware an organisation owns has no team and no person at all, which is the point. The organisation is deliberately not a billing subject and has no plan columns. It says who owns the metal; a team pays for what it uses either way. Membership is derived from a verified email domain rather than stored. An address is already the root identity, so a second record of who belongs where is a second answer that can disagree with the first — and deriving it means signing in with a personal address still gets an ordinary personal account, which is what lets one person hold a company account and use the consumer product. Nothing is granted on an unverified domain or an unverified address: either one is a string somebody typed. Entitlement on fleet hardware refuses everyone for now, with a reason that says so. What grants a run on metered hardware is a plan, and there is nothing to ask yet, so it fails closed rather than giving the expensive case away. The branch is written out so the plan check has one obvious place to land. Routes are read-only, and nothing seeds an organisation. Creating one grants membership to everyone who can receive mail at a domain, so it is an operator action against the database — a migration that inserted one would insert it into every deployment, including ones we have nothing to do with. See docs/deploy.md.
This commit is contained in:
@@ -43,15 +43,21 @@ export namespace Machine {
|
||||
description: 'Unique identifier for the machine',
|
||||
example: Examples.Machine.id
|
||||
}),
|
||||
ownerUserId: z.string().meta({
|
||||
description: 'The user who registered this machine',
|
||||
ownerUserId: z.string().nullable().meta({
|
||||
description:
|
||||
'The user who registered this machine, or null for hardware an organisation owns outright — a company card is nobody\u2019s personal property',
|
||||
example: Examples.Machine.ownerUserId
|
||||
}),
|
||||
teamId: z.string().meta({
|
||||
teamId: z.string().nullable().meta({
|
||||
description:
|
||||
'The team that owns this hardware. Always set — every user has a personal team',
|
||||
'The team that owns this hardware, for a host somebody brought. Null exactly when organisationId is set',
|
||||
example: Examples.Machine.teamId
|
||||
}),
|
||||
organisationId: z.string().nullable().meta({
|
||||
description:
|
||||
'The organisation that owns this hardware outright, for a host serving workloads rather than its owner\u2019s. Null exactly when teamId is set',
|
||||
example: null
|
||||
}),
|
||||
label: z.string().meta({
|
||||
description: 'Human-readable name for the box',
|
||||
example: Examples.Machine.label
|
||||
@@ -114,7 +120,11 @@ export namespace Machine {
|
||||
* than looking it up.
|
||||
*/
|
||||
export const register = fn(
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true }),
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true })
|
||||
.extend({ organisationId: Info.shape.organisationId.optional() })
|
||||
.refine((v) => (v.teamId === null) !== ((v.organisationId ?? null) === null), {
|
||||
message: 'A machine belongs to a team or to an organisation, and not to both'
|
||||
}),
|
||||
async (input) => {
|
||||
const secret = generateSecret();
|
||||
const secretHash = await hashSecret(secret);
|
||||
@@ -132,6 +142,7 @@ export namespace Machine {
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
organisationId: input.organisationId ?? null,
|
||||
label: input.label,
|
||||
slug,
|
||||
secretHash,
|
||||
@@ -236,7 +247,7 @@ export namespace Machine {
|
||||
* is left to re-registration until renting makes it worth building.
|
||||
*/
|
||||
export const setTeam = fn(
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true }),
|
||||
Info.pick({ id: true }).extend({ ownerUserId: z.string(), teamId: z.string() }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
@@ -367,8 +378,11 @@ export namespace Machine {
|
||||
/** Why a user may — or may not — use a box. */
|
||||
export const Entitlement = z.object({
|
||||
entitled: z.boolean(),
|
||||
/** `owner`, `team`, or `none`. Present so a refusal can explain itself. */
|
||||
reason: z.enum(['owner', 'team', 'none'])
|
||||
/**
|
||||
* `owner`, `team`, `fleet`, or `none`. Present so a refusal can explain
|
||||
* itself rather than being an unexplained no.
|
||||
*/
|
||||
reason: z.enum(['owner', 'team', 'fleet', 'none'])
|
||||
});
|
||||
|
||||
export type Entitlement = z.infer<typeof Entitlement>;
|
||||
@@ -376,14 +390,20 @@ export namespace Machine {
|
||||
/**
|
||||
* Whether a user may use a box.
|
||||
*
|
||||
* The whole access model in one function: a solo box (`teamId` null) is the
|
||||
* owner's alone, and a team-scoped box is open to that team. Multi-user
|
||||
* access is the paid tier, so this is the line the paywall sits on — worth
|
||||
* having exactly one implementation of.
|
||||
* The whole access model in one function: a box someone brought is open to
|
||||
* its owner and to the team it was registered under, and hardware an
|
||||
* organisation owns outright is open to whoever has paid for a run on it.
|
||||
*
|
||||
* Membership is read live rather than cached in the machine row, so
|
||||
* removing someone from a team takes their box access with it and nobody
|
||||
* has to remember to revoke anything.
|
||||
*
|
||||
* **Fleet hardware refuses everyone for now, and that is deliberate.** What
|
||||
* grants it is a plan, and nothing here can yet ask whether a user has one
|
||||
* — so the honest answer is no rather than a yes that would hand out metered
|
||||
* hardware for free. Failing closed on the expensive case is the cheap
|
||||
* mistake to make; the branch is written out so there is one obvious place
|
||||
* for the plan check to land. todo(d-0051)
|
||||
*/
|
||||
export const entitlement = fn(
|
||||
z.object({ machineId: z.string(), userId: z.string() }),
|
||||
@@ -392,7 +412,12 @@ export namespace Machine {
|
||||
if (!machine) {
|
||||
return { entitled: false, reason: 'none' };
|
||||
}
|
||||
if (machine.ownerUserId === input.userId) {
|
||||
if (machine.organisationId) {
|
||||
// Fleet hardware. Not the owner's and not a team's, so neither
|
||||
// test below means anything here.
|
||||
return { entitled: false, reason: 'fleet' };
|
||||
}
|
||||
if (machine.ownerUserId && machine.ownerUserId === input.userId) {
|
||||
return { entitled: true, reason: 'owner' };
|
||||
}
|
||||
if (!machine.teamId) {
|
||||
@@ -407,7 +432,21 @@ export namespace Machine {
|
||||
}
|
||||
);
|
||||
|
||||
export const listByOwner = fn(Info.shape.ownerUserId, async (ownerUserId) => {
|
||||
/** Every host an organisation owns outright — its fleet. */
|
||||
export const listByOrganisation = fn(z.string(), async (organisationId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(MachineTable)
|
||||
.where(
|
||||
and(eq(MachineTable.organisationId, organisationId), isNull(MachineTable.timeDeleted))
|
||||
)
|
||||
.orderBy(MachineTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByOwner = fn(z.string(), async (ownerUserId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
@@ -432,6 +471,7 @@ export namespace Machine {
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
organisationId: input.organisationId,
|
||||
label: input.label,
|
||||
slug: input.slug,
|
||||
lastSeen: input.lastSeen?.toISOString() ?? null,
|
||||
|
||||
Reference in New Issue
Block a user