mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +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:
@@ -4,6 +4,7 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
import { Organisation } from '@nestri/core/organisation/index';
|
||||
import { Team } from '@nestri/core/team/index';
|
||||
import { Member } from '@nestri/core/team/member';
|
||||
import { Hono } from 'hono';
|
||||
@@ -27,9 +28,9 @@ export namespace MachineApi {
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Machine'],
|
||||
summary: 'Register a nessh host',
|
||||
summary: 'Register a host',
|
||||
description:
|
||||
'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest.',
|
||||
'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest. Hardware belongs either to a team, which is the default and covers a host somebody brings, or to an organisation the caller belongs to, which is how hardware bought to serve other people is registered so that it is nobody\u2019s personal property.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -64,15 +65,28 @@ export namespace MachineApi {
|
||||
}),
|
||||
teamId: z.string().optional().meta({
|
||||
description:
|
||||
'Team to own this hardware. Defaults to the caller’s personal team, which always exists'
|
||||
'Team to own this hardware. Defaults to the caller\u2019s personal team, which always exists. Mutually exclusive with organisationId'
|
||||
}),
|
||||
organisationId: z.string().optional().meta({
|
||||
description:
|
||||
'Organisation to own this hardware outright, for a host that serves other people rather than its registrant. The caller must belong to it, and no team is recorded'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { label, teamId } = c.req.valid('json');
|
||||
const { label, teamId, organisationId } = c.req.valid('json');
|
||||
|
||||
// `notPublic` also admits admin, which has no user to own the box.
|
||||
// Registering is an act of ownership, so it needs a real one.
|
||||
if (teamId && organisationId) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
'Hardware belongs to a team or to an organisation, not to both',
|
||||
'organisationId'
|
||||
);
|
||||
}
|
||||
|
||||
// `notPublic` also admits a machine, which has no user to own a
|
||||
// box. Registering is an act of ownership, so it needs a real one.
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
@@ -82,11 +96,39 @@ export namespace MachineApi {
|
||||
);
|
||||
}
|
||||
|
||||
// A team has to be resolved rather than defaulted to null, because
|
||||
// `machine.teamId` is notNull. The order is: what the caller asked
|
||||
// for, then the team they are acting inside, then their personal
|
||||
// team — which `ensurePersonal` makes if this is an older user who
|
||||
// has none. ref(d-0048)
|
||||
// Naming an organisation registers fleet hardware: owned outright,
|
||||
// with no team and no person behind it, so that it survives the
|
||||
// account of whoever happened to run the command.
|
||||
if (organisationId) {
|
||||
if (!(await Organisation.isMember(Actor.userID, organisationId))) {
|
||||
// Membership is the verified address, so this refuses the
|
||||
// same way for an organisation that does not exist and one
|
||||
// the caller simply is not in — there is nothing to learn
|
||||
// from the difference.
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'You do not belong to that organisation'
|
||||
);
|
||||
}
|
||||
|
||||
const fleet = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: null,
|
||||
teamId: null,
|
||||
organisationId,
|
||||
label
|
||||
});
|
||||
return c.json({
|
||||
data: { machineId: fleet.id, slug: fleet.slug, secret: fleet.secret }
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise a team has to be resolved rather than left null, since
|
||||
// hardware with neither owner is hardware nothing can bill. The
|
||||
// order is: what the caller asked for, then the team they are
|
||||
// acting inside, then their personal team — which `ensurePersonal`
|
||||
// makes if this is an older user who has none. ref(d-0048)
|
||||
const owningTeam =
|
||||
teamId ??
|
||||
(actor.type === 'member'
|
||||
|
||||
89
apps/api/app/routes/organisation.ts
Normal file
89
apps/api/app/routes/organisation.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
import { Organisation } from '@nestri/core/organisation/index';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, notPublic, Result } from '../utils';
|
||||
|
||||
/**
|
||||
* The organisation a caller belongs to, and the hardware it owns.
|
||||
*
|
||||
* Read-only on purpose. Organisations are made by hand and their domains are
|
||||
* verified by hand, because the thing a verified domain grants is membership —
|
||||
* and a route that mints one would be a route that hands out membership of any
|
||||
* domain somebody types. When that changes, verification is what has to be
|
||||
* built first, not this file.
|
||||
*
|
||||
* There is no organisation to name in a path: membership is derived from the
|
||||
* caller's verified address, so there is exactly one answer and asking about
|
||||
* anybody else's is not a question this API takes.
|
||||
*/
|
||||
export namespace OrganisationApi {
|
||||
/** The caller's organisation, or a 404 that says what that means. */
|
||||
async function mine() {
|
||||
const organisation = await Organisation.forUser(Actor.userID);
|
||||
if (!organisation) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'Your address does not belong to a verified organisation domain'
|
||||
);
|
||||
}
|
||||
return organisation;
|
||||
}
|
||||
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Organisation'],
|
||||
summary: 'The organisation you belong to',
|
||||
description:
|
||||
'Derived from the domain of your verified email address. A personal address has no organisation, which is not an error in the product — it is the ordinary consumer account — but it is a 404 here because there is nothing to return.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Organisation.Info) } },
|
||||
description: 'Your organisation'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
async (c) => c.json({ data: await mine() })
|
||||
)
|
||||
.get(
|
||||
'/machines',
|
||||
describeRoute({
|
||||
tags: ['Organisation'],
|
||||
summary: 'The hardware your organisation owns',
|
||||
description:
|
||||
'Every host the organisation owns outright. These belong to no team and no person, which is what separates them from a host somebody brought — those appear under the team that owns them instead.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(Machine.Info).meta({
|
||||
description: 'The fleet',
|
||||
example: [Examples.Machine]
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'The fleet'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const organisation = await mine();
|
||||
return c.json({ data: await Machine.listByOrganisation(organisation.id) });
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user