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:
@@ -15,6 +15,7 @@ import { GameApi } from './routes/game.js';
|
|||||||
import { IndexApi } from './routes/index.js';
|
import { IndexApi } from './routes/index.js';
|
||||||
import { LibraryApi } from './routes/library.js';
|
import { LibraryApi } from './routes/library.js';
|
||||||
import { MachineApi } from './routes/machine.js';
|
import { MachineApi } from './routes/machine.js';
|
||||||
|
import { OrganisationApi } from './routes/organisation.js';
|
||||||
import { SessionApi } from './routes/session.js';
|
import { SessionApi } from './routes/session.js';
|
||||||
import { SteamApi } from './routes/steam.js';
|
import { SteamApi } from './routes/steam.js';
|
||||||
import { UserApi } from './routes/user.js';
|
import { UserApi } from './routes/user.js';
|
||||||
@@ -42,6 +43,7 @@ const routes = app
|
|||||||
.route('/steam', SteamApi.route)
|
.route('/steam', SteamApi.route)
|
||||||
.route('/library', LibraryApi.route)
|
.route('/library', LibraryApi.route)
|
||||||
.route('/games', GameApi.route)
|
.route('/games', GameApi.route)
|
||||||
|
.route('/organisation', OrganisationApi.route)
|
||||||
.route('/machine', MachineApi.route)
|
.route('/machine', MachineApi.route)
|
||||||
.route('/machine', SessionApi.machineRoute)
|
.route('/machine', SessionApi.machineRoute)
|
||||||
.route('/machine', EnrolmentApi.route)
|
.route('/machine', EnrolmentApi.route)
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
|||||||
properties: {
|
properties: {
|
||||||
machineID: machine.id,
|
machineID: machine.id,
|
||||||
ownerUserID: machine.ownerUserId,
|
ownerUserID: machine.ownerUserId,
|
||||||
teamID: machine.teamId
|
teamID: machine.teamId,
|
||||||
|
organisationID: machine.organisationId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
next
|
next
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
|||||||
import { Examples } from '@nestri/core/examples';
|
import { Examples } from '@nestri/core/examples';
|
||||||
import { Identifier } from '@nestri/core/id';
|
import { Identifier } from '@nestri/core/id';
|
||||||
import { Machine } from '@nestri/core/machine/index';
|
import { Machine } from '@nestri/core/machine/index';
|
||||||
|
import { Organisation } from '@nestri/core/organisation/index';
|
||||||
import { Team } from '@nestri/core/team/index';
|
import { Team } from '@nestri/core/team/index';
|
||||||
import { Member } from '@nestri/core/team/member';
|
import { Member } from '@nestri/core/team/member';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
@@ -27,9 +28,9 @@ export namespace MachineApi {
|
|||||||
notPublic,
|
notPublic,
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Machine'],
|
tags: ['Machine'],
|
||||||
summary: 'Register a nessh host',
|
summary: 'Register a host',
|
||||||
description:
|
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: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
content: {
|
content: {
|
||||||
@@ -64,15 +65,28 @@ export namespace MachineApi {
|
|||||||
}),
|
}),
|
||||||
teamId: z.string().optional().meta({
|
teamId: z.string().optional().meta({
|
||||||
description:
|
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) => {
|
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.
|
if (teamId && organisationId) {
|
||||||
// Registering is an act of ownership, so it needs a real one.
|
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();
|
const actor = Actor.use();
|
||||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||||
throw new VisibleError(
|
throw new VisibleError(
|
||||||
@@ -82,11 +96,39 @@ export namespace MachineApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A team has to be resolved rather than defaulted to null, because
|
// Naming an organisation registers fleet hardware: owned outright,
|
||||||
// `machine.teamId` is notNull. The order is: what the caller asked
|
// with no team and no person behind it, so that it survives the
|
||||||
// for, then the team they are acting inside, then their personal
|
// account of whoever happened to run the command.
|
||||||
// team — which `ensurePersonal` makes if this is an older user who
|
if (organisationId) {
|
||||||
// has none. ref(d-0048)
|
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 =
|
const owningTeam =
|
||||||
teamId ??
|
teamId ??
|
||||||
(actor.type === 'member'
|
(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) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,45 @@ binding names a script that has to exist. The custom domains in the config are
|
|||||||
what create the DNS records — there is no separate step, and no separate tool
|
what create the DNS records — there is no separate step, and no separate tool
|
||||||
holding the other half of that fact.
|
holding the other half of that fact.
|
||||||
|
|
||||||
|
## Organisations, and why none are created for you
|
||||||
|
|
||||||
|
An organisation owns hardware outright — a host that serves other people's
|
||||||
|
workloads rather than its registrant's — and gathers the teams whose members
|
||||||
|
sign in with its email domain. Membership is derived from that domain, so the
|
||||||
|
`domain_verified` flag is the whole of the access decision: an address on a
|
||||||
|
verified domain *is* membership, and nothing grants anything on an unverified
|
||||||
|
one.
|
||||||
|
|
||||||
|
**Nothing seeds one, deliberately, and it must stay that way.** A migration
|
||||||
|
that inserted a row here would insert it into every deployment, including
|
||||||
|
somebody else's — handing every account on that domain membership of a
|
||||||
|
deployment its owners have nothing to do with. Seeding business data is what
|
||||||
|
makes a schema migration a back door.
|
||||||
|
|
||||||
|
So it is an operator action, run once against the database, by whoever is
|
||||||
|
allowed to decide that a domain is really theirs:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO organisation (id, name, slug, domain, domain_verified)
|
||||||
|
VALUES (
|
||||||
|
'org_' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 26),
|
||||||
|
'Example',
|
||||||
|
'example',
|
||||||
|
'example.com',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things to get right, because nothing checks them for you. The domain is
|
||||||
|
lower-cased and has no `@` — it is compared literally against the domain half
|
||||||
|
of an address. And `domain_verified` should be `true` only for a domain you
|
||||||
|
control: everyone who can receive mail at it becomes a member the next time
|
||||||
|
they sign in, with no further step.
|
||||||
|
|
||||||
|
Hardware is then registered to it by a member, with `organisationId` instead of
|
||||||
|
a team on `POST /machine/register`. Such a host has no owner and no team, which
|
||||||
|
is the point — it outlives the account of whoever ran the command.
|
||||||
|
|
||||||
## Containers
|
## Containers
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -446,7 +446,9 @@ Game ───1:N─── Download ← per-host game depot downloads
|
|||||||
|
|
||||||
- **User**: Person record. Email is nullable (gaming accounts don't provide one).
|
- **User**: Person record. Email is nullable (gaming accounts don't provide one).
|
||||||
- **LinkedAccount**: A gaming/OAuth identity. `(provider, providerAccountId)` is unique.
|
- **LinkedAccount**: A gaming/OAuth identity. `(provider, providerAccountId)` is unique.
|
||||||
- **Team**: Organization for billing/collaboration. First team is auto-created as "personal" team.
|
- **Team**: The billing subject, and how people collaborate. The first one is auto-created as a "personal" team.
|
||||||
|
- **Organisation**: A company. Owns hardware outright — a host serving other people's workloads, belonging to no team and no person — and gathers teams under a verified email domain. Membership is derived from that domain rather than stored. **Not a billing subject**: a team pays for what it uses whether it sits under an organisation or not.
|
||||||
|
- **Machine**: A registered host. Owned by a team (a host somebody brought) or by an organisation (fleet hardware), never both and never neither — a check constraint, not a convention.
|
||||||
- **TeamMember**: Joins User → Team with a role. `(teamId, userId)` is unique.
|
- **TeamMember**: Joins User → Team with a role. `(teamId, userId)` is unique.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -500,7 +502,15 @@ type ActorInfo =
|
|||||||
properties: { userID: string; teamID: string; role: 'owner' | 'admin' | 'member' };
|
properties: { userID: string; teamID: string; role: 'owner' | 'admin' | 'member' };
|
||||||
}
|
}
|
||||||
| { type: 'system'; properties: { teamID: string } }
|
| { type: 'system'; properties: { teamID: string } }
|
||||||
| { type: 'admin'; properties: {} };
|
| {
|
||||||
|
type: 'machine';
|
||||||
|
properties: {
|
||||||
|
machineID: string;
|
||||||
|
ownerUserID: string | null;
|
||||||
|
teamID: string | null;
|
||||||
|
organisationID: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### API
|
### API
|
||||||
@@ -509,8 +519,8 @@ type ActorInfo =
|
|||||||
Actor.use(); // → ActorInfo (throws if no context set)
|
Actor.use(); // → ActorInfo (throws if no context set)
|
||||||
Actor.with(value, fn); // Run fn in the given actor context
|
Actor.with(value, fn); // Run fn in the given actor context
|
||||||
Actor.assert(type); // Assert current actor type, returns narrowed type
|
Actor.assert(type); // Assert current actor type, returns narrowed type
|
||||||
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'admin'
|
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'machine'
|
||||||
Actor.userID; // → string (user/member only)
|
Actor.userID; // → string (user/member only; refuses a machine outright)
|
||||||
Actor.linkedAccountID; // → string (user only)
|
Actor.linkedAccountID; // → string (user only)
|
||||||
Actor.useTeam; // → string (member/system only — the teamID)
|
Actor.useTeam; // → string (member/system only — the teamID)
|
||||||
Actor.role; // → 'owner' | 'admin' | 'member' (member only)
|
Actor.role; // → 'owner' | 'admin' | 'member' (member only)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Hardware an organisation owns, rather than a person.
|
||||||
|
--
|
||||||
|
-- Two kinds of machine were being 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 until
|
||||||
|
-- now it had to be registered under some employee's personal team -- so the
|
||||||
|
-- company's card was that employee's personal property, and their account
|
||||||
|
-- going away took it. ref(d-0048)
|
||||||
|
--
|
||||||
|
-- So ownership becomes an either/or. `team_id` for a host somebody brought,
|
||||||
|
-- `organisation_id` for one a company owns outright, exactly one of them set,
|
||||||
|
-- and a check constraint rather than a convention -- because 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 would decide who pays.
|
||||||
|
--
|
||||||
|
-- `owner_user_id` becomes nullable so that fleet hardware can have no person
|
||||||
|
-- behind it at all. Its ON DELETE CASCADE is deliberately left alone: it now
|
||||||
|
-- only ever fires for a host somebody brought, where a box dying with its
|
||||||
|
-- owner's account is what that owner expects, and it cannot reach fleet
|
||||||
|
-- hardware because the column it follows is null there.
|
||||||
|
--
|
||||||
|
-- Note the check is safe to apply in one step. Every existing row has a team
|
||||||
|
-- and no organisation, so all of them already satisfy it -- which is only true
|
||||||
|
-- because `team_id` was NOT NULL before this migration relaxed it.
|
||||||
|
--
|
||||||
|
-- `organisation.domain` is what makes someone a member, and membership is
|
||||||
|
-- derived from it 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. `domain_verified` defaults false because an unverified claim
|
||||||
|
-- is a string somebody typed, and nothing may be granted on one.
|
||||||
|
--
|
||||||
|
-- The organisation deliberately has no plan or subscription columns. It says
|
||||||
|
-- who owns the metal, not who owes money; a team pays for what it uses whether
|
||||||
|
-- it sits under an organisation or not.
|
||||||
|
|
||||||
|
CREATE TABLE "organisation" (
|
||||||
|
"id" char(30) PRIMARY KEY NOT NULL,
|
||||||
|
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"time_deleted" timestamp with time zone,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"domain" text NOT NULL,
|
||||||
|
"domain_verified" boolean DEFAULT false NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "machine" ALTER COLUMN "owner_user_id" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "machine" ALTER COLUMN "team_id" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "machine" ADD COLUMN "organisation_id" char(30);--> statement-breakpoint
|
||||||
|
ALTER TABLE "team" ADD COLUMN "organisation_id" char(30);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "organisation_slug_unique" ON "organisation" USING btree ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "organisation_domain_unique" ON "organisation" USING btree ("domain");--> statement-breakpoint
|
||||||
|
ALTER TABLE "machine" ADD CONSTRAINT "machine_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "team" ADD CONSTRAINT "team_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "machine_organisation_idx" ON "machine" USING btree ("organisation_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "team_organisation_idx" ON "team" USING btree ("organisation_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "machine" ADD CONSTRAINT "machine_one_owner" CHECK (("machine"."team_id" is null) != ("machine"."organisation_id" is null));
|
||||||
2989
packages/core/migrations/meta/0015_snapshot.json
Normal file
2989
packages/core/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1789680491539,
|
"when": 1789680491539,
|
||||||
"tag": "0014_machine_public_label",
|
"tag": "0014_machine_public_label",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789762221718,
|
||||||
|
"tag": "0015_organisation_owns_fleet_hardware",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,16 @@ const Machine = z.object({
|
|||||||
type: z.literal('machine'),
|
type: z.literal('machine'),
|
||||||
properties: z.object({
|
properties: z.object({
|
||||||
machineID: z.string(),
|
machineID: z.string(),
|
||||||
ownerUserID: z.string(),
|
// All three of these describe *who owns the hardware*, and a host is
|
||||||
// Not optional: `machine.teamId` is notNull, so a host that authenticated
|
// owned one of two ways. A box somebody brought carries an owner and a
|
||||||
// always has a team, and the branch that used to handle its absence was
|
// team; hardware an organisation owns outright carries neither and
|
||||||
// handling a state that can no longer exist.
|
// carries an organisation instead. Exactly one of `teamID` and
|
||||||
teamID: z.string()
|
// `organisationID` is ever set, which the database enforces rather than
|
||||||
|
// this schema — so read the one you mean and do not infer it from the
|
||||||
|
// other being absent.
|
||||||
|
ownerUserID: z.string().nullable(),
|
||||||
|
teamID: z.string().nullable(),
|
||||||
|
organisationID: z.string().nullable()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,20 @@ export namespace Examples {
|
|||||||
profile: { personaname: 'John Doe', avatarfull: 'https://avatars.steamstatic.com/xxxx.jpg' }
|
profile: { personaname: 'John Doe', avatarfull: 'https://avatars.steamstatic.com/xxxx.jpg' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Organisation = {
|
||||||
|
id: Id('organisation'),
|
||||||
|
name: 'Initech',
|
||||||
|
slug: 'initech',
|
||||||
|
domain: 'initech.example',
|
||||||
|
domainVerified: true
|
||||||
|
};
|
||||||
|
|
||||||
export const Team = {
|
export const Team = {
|
||||||
id: Id('team'),
|
id: Id('team'),
|
||||||
name: 'The A Team',
|
name: 'The A Team',
|
||||||
slug: 'the-a-team',
|
slug: 'the-a-team',
|
||||||
ownerId: Id('user'),
|
ownerId: Id('user'),
|
||||||
|
organisationId: null,
|
||||||
billingEmail: 'billing@example.com',
|
billingEmail: 'billing@example.com',
|
||||||
plan: 'free',
|
plan: 'free',
|
||||||
subscriptionStatus: 'active',
|
subscriptionStatus: 'active',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export namespace Identifier {
|
|||||||
export const prefixes = {
|
export const prefixes = {
|
||||||
user: 'usr',
|
user: 'usr',
|
||||||
linkedAccount: 'lac',
|
linkedAccount: 'lac',
|
||||||
|
organisation: 'org',
|
||||||
team: 'tem',
|
team: 'tem',
|
||||||
teamMember: 'mem',
|
teamMember: 'mem',
|
||||||
verification: 'ver',
|
verification: 'ver',
|
||||||
|
|||||||
@@ -43,15 +43,21 @@ export namespace Machine {
|
|||||||
description: 'Unique identifier for the machine',
|
description: 'Unique identifier for the machine',
|
||||||
example: Examples.Machine.id
|
example: Examples.Machine.id
|
||||||
}),
|
}),
|
||||||
ownerUserId: z.string().meta({
|
ownerUserId: z.string().nullable().meta({
|
||||||
description: 'The user who registered this machine',
|
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
|
example: Examples.Machine.ownerUserId
|
||||||
}),
|
}),
|
||||||
teamId: z.string().meta({
|
teamId: z.string().nullable().meta({
|
||||||
description:
|
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
|
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({
|
label: z.string().meta({
|
||||||
description: 'Human-readable name for the box',
|
description: 'Human-readable name for the box',
|
||||||
example: Examples.Machine.label
|
example: Examples.Machine.label
|
||||||
@@ -114,7 +120,11 @@ export namespace Machine {
|
|||||||
* than looking it up.
|
* than looking it up.
|
||||||
*/
|
*/
|
||||||
export const register = fn(
|
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) => {
|
async (input) => {
|
||||||
const secret = generateSecret();
|
const secret = generateSecret();
|
||||||
const secretHash = await hashSecret(secret);
|
const secretHash = await hashSecret(secret);
|
||||||
@@ -132,6 +142,7 @@ export namespace Machine {
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
ownerUserId: input.ownerUserId,
|
ownerUserId: input.ownerUserId,
|
||||||
teamId: input.teamId,
|
teamId: input.teamId,
|
||||||
|
organisationId: input.organisationId ?? null,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
slug,
|
slug,
|
||||||
secretHash,
|
secretHash,
|
||||||
@@ -236,7 +247,7 @@ export namespace Machine {
|
|||||||
* is left to re-registration until renting makes it worth building.
|
* is left to re-registration until renting makes it worth building.
|
||||||
*/
|
*/
|
||||||
export const setTeam = fn(
|
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) => {
|
async (input) => {
|
||||||
return Database.use(async (tx) => {
|
return Database.use(async (tx) => {
|
||||||
return tx
|
return tx
|
||||||
@@ -367,8 +378,11 @@ export namespace Machine {
|
|||||||
/** Why a user may — or may not — use a box. */
|
/** Why a user may — or may not — use a box. */
|
||||||
export const Entitlement = z.object({
|
export const Entitlement = z.object({
|
||||||
entitled: z.boolean(),
|
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>;
|
export type Entitlement = z.infer<typeof Entitlement>;
|
||||||
@@ -376,14 +390,20 @@ export namespace Machine {
|
|||||||
/**
|
/**
|
||||||
* Whether a user may use a box.
|
* Whether a user may use a box.
|
||||||
*
|
*
|
||||||
* The whole access model in one function: a solo box (`teamId` null) is the
|
* The whole access model in one function: a box someone brought is open to
|
||||||
* owner's alone, and a team-scoped box is open to that team. Multi-user
|
* its owner and to the team it was registered under, and hardware an
|
||||||
* access is the paid tier, so this is the line the paywall sits on — worth
|
* organisation owns outright is open to whoever has paid for a run on it.
|
||||||
* having exactly one implementation of.
|
|
||||||
*
|
*
|
||||||
* Membership is read live rather than cached in the machine row, so
|
* 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
|
* removing someone from a team takes their box access with it and nobody
|
||||||
* has to remember to revoke anything.
|
* 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(
|
export const entitlement = fn(
|
||||||
z.object({ machineId: z.string(), userId: z.string() }),
|
z.object({ machineId: z.string(), userId: z.string() }),
|
||||||
@@ -392,7 +412,12 @@ export namespace Machine {
|
|||||||
if (!machine) {
|
if (!machine) {
|
||||||
return { entitled: false, reason: 'none' };
|
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' };
|
return { entitled: true, reason: 'owner' };
|
||||||
}
|
}
|
||||||
if (!machine.teamId) {
|
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 Database.use(async (tx) => {
|
||||||
return tx
|
return tx
|
||||||
.select()
|
.select()
|
||||||
@@ -432,6 +471,7 @@ export namespace Machine {
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
ownerUserId: input.ownerUserId,
|
ownerUserId: input.ownerUserId,
|
||||||
teamId: input.teamId,
|
teamId: input.teamId,
|
||||||
|
organisationId: input.organisationId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
lastSeen: input.lastSeen?.toISOString() ?? null,
|
lastSeen: input.lastSeen?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -1,33 +1,67 @@
|
|||||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { check, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||||
|
import { OrganisationTable } from '../organisation/organisation.sql.js';
|
||||||
import { TeamTable } from '../team/team.sql.js';
|
import { TeamTable } from '../team/team.sql.js';
|
||||||
import { UserTable } from '../user/user.sql.js';
|
import { UserTable } from '../user/user.sql.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A registered nessh host — the *box* that runs downloads and serves SSH, not
|
* A registered host — the *box* that runs downloads and serves SSH, not the
|
||||||
* the laptop someone connects from. (`nessh-tui-redesign-guide.md` §7.2 uses
|
* laptop someone connects from. Note the word is used the other way round in
|
||||||
* "machine" for the other end of that connection; this table is the host end.)
|
* some client-facing writing, where "machine" is the end a person sits at;
|
||||||
|
* this table is the host end.
|
||||||
*
|
*
|
||||||
* A box does not assert who it is. It registers once against an owner's token
|
* A box does not assert who it is. It registers once against an owner's token
|
||||||
* and is handed an id and a secret, so ids are unique because the API assigns
|
* and is handed an id and a secret, so ids are unique because the API assigns
|
||||||
* them rather than because a self-reported string happened not to collide.
|
* them rather than because a self-reported string happened not to collide.
|
||||||
|
*
|
||||||
|
* **Hardware is owned one of two ways, and never both.** Someone brings their
|
||||||
|
* own and reaches it through a team; or an organisation owns it outright, to
|
||||||
|
* serve workloads for people who have no hardware of their own. The check
|
||||||
|
* constraint below is what keeps that an either/or rather than a convention.
|
||||||
*/
|
*/
|
||||||
export const MachineTable = pgTable(
|
export const MachineTable = pgTable(
|
||||||
'machine',
|
'machine',
|
||||||
{
|
{
|
||||||
...id,
|
...id,
|
||||||
...timestamps,
|
...timestamps,
|
||||||
ownerUserId: ulid('owner_user_id')
|
/**
|
||||||
.notNull()
|
* Who registered it, when a person did.
|
||||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
*
|
||||||
// Every user gets a personal team at signup, so there is always one to
|
* Null for hardware an organisation owns, which is the whole point of
|
||||||
// point at and the single-operator case is a team of one rather than a
|
* the column being nullable: a company's card is not anybody's personal
|
||||||
// special case in every query. This was nullable, which cost a
|
* property, and parking it under whichever employee ran the command
|
||||||
// `teamId ?? ownerUserId` branch at each call site instead. ref(d-0048)
|
* made it one — where `cascade` below meant deleting that account
|
||||||
teamId: ulid('team_id')
|
* deleted the machine.
|
||||||
.notNull()
|
*
|
||||||
.references(() => TeamTable.id, { onDelete: 'restrict' }),
|
* `cascade` stays, and is right once null is available. It only ever
|
||||||
|
* fires for a host somebody brought, and a box dying with the account
|
||||||
|
* that owns it is the behaviour that account expects. Fleet hardware is
|
||||||
|
* never reached by it, because the column it would follow is null.
|
||||||
|
*/
|
||||||
|
ownerUserId: ulid('owner_user_id').references(() => UserTable.id, {
|
||||||
|
onDelete: 'cascade'
|
||||||
|
}),
|
||||||
|
// A team's own hardware, brought by one of its members. Every user gets
|
||||||
|
// a personal team at signup, so there is always one to point at and the
|
||||||
|
// single-operator case is a team of one rather than a special case in
|
||||||
|
// every query. ref(d-0048)
|
||||||
|
//
|
||||||
|
// Null exactly when `organisationId` is set; see the check below.
|
||||||
|
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'restrict' }),
|
||||||
|
/**
|
||||||
|
* The organisation that owns this host outright.
|
||||||
|
*
|
||||||
|
* Set for fleet hardware and null for everything else. Deliberately not
|
||||||
|
* reached through a team: a team's machines belong to that team, and
|
||||||
|
* putting the fleet in a team would make every query that asks "whose
|
||||||
|
* hardware is this?" answer with a team that does not pay for it and
|
||||||
|
* cannot be billed for it.
|
||||||
|
*/
|
||||||
|
organisationId: ulid('organisation_id').references(() => OrganisationTable.id, {
|
||||||
|
onDelete: 'restrict'
|
||||||
|
}),
|
||||||
label: text('label').notNull(),
|
label: text('label').notNull(),
|
||||||
// The name this host is reached at: `amber-otter-4821.nestri.link`.
|
// The name this host is reached at: `amber-otter-4821.nestri.link`.
|
||||||
//
|
//
|
||||||
@@ -72,6 +106,12 @@ export const MachineTable = pgTable(
|
|||||||
uniqueIndex('machine_slug_unique').on(t.slug),
|
uniqueIndex('machine_slug_unique').on(t.slug),
|
||||||
uniqueIndex('machine_endpoint_id_unique').on(t.endpointId),
|
uniqueIndex('machine_endpoint_id_unique').on(t.endpointId),
|
||||||
index('machine_owner_idx').on(t.ownerUserId),
|
index('machine_owner_idx').on(t.ownerUserId),
|
||||||
index('machine_team_idx').on(t.teamId)
|
index('machine_team_idx').on(t.teamId),
|
||||||
|
index('machine_organisation_idx').on(t.organisationId),
|
||||||
|
// Exactly one owner, enforced here rather than in the code that writes
|
||||||
|
// rows. Both null is a host nobody owns and nothing can bill; both set
|
||||||
|
// is two answers to one question, and whichever one a given query
|
||||||
|
// happens to join through would decide who pays.
|
||||||
|
check('machine_one_owner', sql`(${t.teamId} is null) != (${t.organisationId} is null)`)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ describe('Machine registration', () => {
|
|||||||
Machine.register({
|
Machine.register({
|
||||||
id: Identifier.ascending('machine'),
|
id: Identifier.ascending('machine'),
|
||||||
ownerUserId: owner.userId,
|
ownerUserId: owner.userId,
|
||||||
// @ts-expect-error — the point of the test is that this is refused
|
// Null is a real value now — it is how fleet hardware says it has
|
||||||
|
// no team — so this is refused for naming neither owner.
|
||||||
teamId: null,
|
teamId: null,
|
||||||
label: 'teamless'
|
label: 'teamless'
|
||||||
})
|
})
|
||||||
|
|||||||
193
packages/core/src/organisation/index.ts
Normal file
193
packages/core/src/organisation/index.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
import { Database } from '../db/index.js';
|
||||||
|
import { Examples } from '../examples.js';
|
||||||
|
import { fn } from '../fn.js';
|
||||||
|
import { UserTable } from '../user/user.sql.js';
|
||||||
|
import { OrganisationTable } from './organisation.sql.js';
|
||||||
|
|
||||||
|
export namespace Organisation {
|
||||||
|
export const Info = z
|
||||||
|
.object({
|
||||||
|
id: z.string().meta({
|
||||||
|
description: 'Unique identifier for the organisation',
|
||||||
|
example: Examples.Organisation.id
|
||||||
|
}),
|
||||||
|
name: z.string().meta({
|
||||||
|
description: 'Display name of the organisation',
|
||||||
|
example: Examples.Organisation.name
|
||||||
|
}),
|
||||||
|
slug: z.string().meta({
|
||||||
|
description: 'URL-friendly unique slug for the organisation',
|
||||||
|
example: Examples.Organisation.slug
|
||||||
|
}),
|
||||||
|
domain: z.string().meta({
|
||||||
|
description:
|
||||||
|
'The email domain whose verified addresses belong to this organisation, without an @',
|
||||||
|
example: Examples.Organisation.domain
|
||||||
|
}),
|
||||||
|
domainVerified: z.boolean().meta({
|
||||||
|
description:
|
||||||
|
'Whether the domain has been shown to belong to them. Nothing is granted on an unverified claim',
|
||||||
|
example: Examples.Organisation.domainVerified
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: 'Organisation',
|
||||||
|
description:
|
||||||
|
'A company. It owns hardware outright, rather than through a team, and gathers the teams whose members sign in with its domain.',
|
||||||
|
example: Examples.Organisation
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Info = z.infer<typeof Info>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The domain part of an address, lower-cased.
|
||||||
|
*
|
||||||
|
* Returns null for anything that is not one address with one `@`, because
|
||||||
|
* every caller here is about to use the answer to decide membership and a
|
||||||
|
* best guess at a malformed address is the wrong kind of helpful.
|
||||||
|
*/
|
||||||
|
export function domainOf(email: string | null | undefined): string | null {
|
||||||
|
if (!email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parts = email.trim().toLowerCase().split('@');
|
||||||
|
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parts[1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const create = fn(
|
||||||
|
Info.pick({ id: true, name: true, slug: true, domain: true }).extend({
|
||||||
|
domainVerified: Info.shape.domainVerified.optional()
|
||||||
|
}),
|
||||||
|
async (input) => {
|
||||||
|
await Database.use(async (tx) => {
|
||||||
|
await tx.insert(OrganisationTable).values({
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
slug: input.slug,
|
||||||
|
domain: input.domain.trim().toLowerCase(),
|
||||||
|
domainVerified: input.domainVerified ?? false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return input.id;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fromID = fn(Info.shape.id, async (id) => {
|
||||||
|
return Database.use(async (tx) => {
|
||||||
|
return tx
|
||||||
|
.select()
|
||||||
|
.from(OrganisationTable)
|
||||||
|
.where(and(eq(OrganisationTable.id, id), isNull(OrganisationTable.timeDeleted)))
|
||||||
|
.then((rows) => {
|
||||||
|
const row = rows.at(0);
|
||||||
|
return row ? serialize(row) : null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fromSlug = fn(Info.shape.slug, async (slug) => {
|
||||||
|
return Database.use(async (tx) => {
|
||||||
|
return tx
|
||||||
|
.select()
|
||||||
|
.from(OrganisationTable)
|
||||||
|
.where(and(eq(OrganisationTable.slug, slug), isNull(OrganisationTable.timeDeleted)))
|
||||||
|
.then((rows) => {
|
||||||
|
const row = rows.at(0);
|
||||||
|
return row ? serialize(row) : null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The organisation a domain belongs to, if one has proved it does.
|
||||||
|
*
|
||||||
|
* Only ever answers with a *verified* domain. An unverified row is a claim
|
||||||
|
* anybody could have typed, and answering with it would let whoever typed
|
||||||
|
* `gmail.com` reach every account on it.
|
||||||
|
*/
|
||||||
|
export const fromVerifiedDomain = fn(Info.shape.domain, async (domain) => {
|
||||||
|
const normalized = domain.trim().toLowerCase();
|
||||||
|
return Database.use(async (tx) => {
|
||||||
|
return tx
|
||||||
|
.select()
|
||||||
|
.from(OrganisationTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(OrganisationTable.domain, normalized),
|
||||||
|
eq(OrganisationTable.domainVerified, true),
|
||||||
|
isNull(OrganisationTable.timeDeleted)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => {
|
||||||
|
const row = rows.at(0);
|
||||||
|
return row ? serialize(row) : null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which organisation a user belongs to, derived rather than stored.
|
||||||
|
*
|
||||||
|
* Membership is their verified address's domain matching a verified
|
||||||
|
* organisation domain, and there is no membership table on purpose: 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.
|
||||||
|
*
|
||||||
|
* Two consequences worth stating, because both are features here. Signing
|
||||||
|
* in with a personal address gets an ordinary personal account, which is
|
||||||
|
* what lets the same person hold a company account and use the consumer
|
||||||
|
* product. And a user belongs to at most one organisation — when someone
|
||||||
|
* needs to be in two, this is where a membership table goes, and until then
|
||||||
|
* it would be a table with one row per user saying what the address says.
|
||||||
|
*
|
||||||
|
* An unverified address is not membership. It is a string somebody typed.
|
||||||
|
*/
|
||||||
|
export const forUser = fn(z.string(), async (userId) => {
|
||||||
|
const user = await Database.use(async (tx) => {
|
||||||
|
return tx
|
||||||
|
.select({ email: UserTable.email, emailVerified: UserTable.emailVerified })
|
||||||
|
.from(UserTable)
|
||||||
|
.where(and(eq(UserTable.id, userId), isNull(UserTable.timeDeleted)))
|
||||||
|
.then((rows) => rows.at(0) ?? null);
|
||||||
|
});
|
||||||
|
if (!user?.emailVerified) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const domain = domainOf(user.email);
|
||||||
|
if (!domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return fromVerifiedDomain(domain);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Whether this user may act for this organisation. */
|
||||||
|
export async function isMember(userId: string, organisationId: string): Promise<boolean> {
|
||||||
|
const organisation = await forUser(userId);
|
||||||
|
return organisation?.id === organisationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const remove = fn(Info.shape.id, async (id) => {
|
||||||
|
await Database.use(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(OrganisationTable)
|
||||||
|
.set({ timeDeleted: sql`now()` })
|
||||||
|
.where(eq(OrganisationTable.id, id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export function serialize(input: typeof OrganisationTable.$inferSelect): Info {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
name: input.name,
|
||||||
|
slug: input.slug,
|
||||||
|
domain: input.domain,
|
||||||
|
domainVerified: input.domainVerified
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
60
packages/core/src/organisation/organisation.sql.ts
Normal file
60
packages/core/src/organisation/organisation.sql.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { boolean, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
import { id, timestamps } from '../db/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A company, and the owner of hardware that belongs to nobody in particular.
|
||||||
|
*
|
||||||
|
* It exists because two kinds of machine were being modelled as one. A host
|
||||||
|
* somebody brings is theirs, reached through a team, and dies with their
|
||||||
|
* account — which is right. A host bought to serve other people's workloads is
|
||||||
|
* none of those things, and until now it had to be registered under some
|
||||||
|
* employee's personal team, where a deleted user row would take it with it.
|
||||||
|
*
|
||||||
|
* So an organisation owns fleet hardware *directly* rather than through a team
|
||||||
|
* inside it. Those are different relationships and collapsing them was the
|
||||||
|
* bug: a team's hardware is the team's, and the fleet is the company's.
|
||||||
|
*
|
||||||
|
* **Not a billing subject.** A team pays for what it uses whether it belongs
|
||||||
|
* to an organisation or not, so there are deliberately no plan or subscription
|
||||||
|
* columns here — this row says who owns the metal, not who owes money.
|
||||||
|
*/
|
||||||
|
export const OrganisationTable = pgTable(
|
||||||
|
'organisation',
|
||||||
|
{
|
||||||
|
...id,
|
||||||
|
...timestamps,
|
||||||
|
name: text('name').notNull(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
/**
|
||||||
|
* The email domain that makes someone a member, without an `@`.
|
||||||
|
*
|
||||||
|
* Membership is derived from this rather than stored: an address is
|
||||||
|
* already the root identity, and one verified domain answers "who
|
||||||
|
* belongs here?" without a table that can disagree with it. Someone
|
||||||
|
* signing in with a personal address gets their ordinary personal
|
||||||
|
* account, which is what makes it safe to dogfood the company account
|
||||||
|
* and the consumer product from the same machine.
|
||||||
|
*
|
||||||
|
* Lower-cased and unique, for the same reason a user's address is: two
|
||||||
|
* organisations claiming one domain would make membership ambiguous in
|
||||||
|
* exactly the case that matters. Nothing here enforces the case, so
|
||||||
|
* anything writing it has to normalize first.
|
||||||
|
*/
|
||||||
|
domain: text('domain').notNull(),
|
||||||
|
/**
|
||||||
|
* Whether the domain has been shown to belong to them.
|
||||||
|
*
|
||||||
|
* Separate from the domain itself because an unverified claim is a real
|
||||||
|
* state and must never grant anything: anyone can type `gmail.com`, and
|
||||||
|
* membership derived from an unchecked claim would hand them every
|
||||||
|
* account on it. Nothing verifies domains yet, so this is set by hand
|
||||||
|
* and read by everything that grants.
|
||||||
|
*/
|
||||||
|
domainVerified: boolean('domain_verified').notNull().default(false)
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('organisation_slug_unique').on(t.slug),
|
||||||
|
uniqueIndex('organisation_domain_unique').on(t.domain)
|
||||||
|
]
|
||||||
|
);
|
||||||
213
packages/core/src/organisation/organisation.test.ts
Normal file
213
packages/core/src/organisation/organisation.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { afterAll, describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { Actor } from '../actor.js';
|
||||||
|
import { testDb } from '../db/test.js';
|
||||||
|
import { Identifier } from '../id.js';
|
||||||
|
import { Machine } from '../machine/index.js';
|
||||||
|
import { User } from '../user/index.js';
|
||||||
|
import { Organisation } from './index.js';
|
||||||
|
|
||||||
|
const sql = testDb();
|
||||||
|
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
const createdOrgIds: string[] = [];
|
||||||
|
|
||||||
|
/** A user with an address, verified or not, and nothing else attached. */
|
||||||
|
async function newUser(label: string, email: string | null, emailVerified: boolean) {
|
||||||
|
const userId = Identifier.ascending('user');
|
||||||
|
await User.create({ id: userId, name: label, email, emailVerified, image: null });
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newOrg(label: string, domain: string, domainVerified: boolean) {
|
||||||
|
const id = Identifier.ascending('organisation');
|
||||||
|
await Organisation.create({
|
||||||
|
id,
|
||||||
|
name: label,
|
||||||
|
slug: `${label}-${id.slice(-6)}`,
|
||||||
|
domain,
|
||||||
|
domainVerified
|
||||||
|
});
|
||||||
|
createdOrgIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Machines reference organisations with `restrict`, so the fleet has to go
|
||||||
|
// before the organisation that owns it.
|
||||||
|
if (createdOrgIds.length > 0) {
|
||||||
|
await sql`delete from "machine" where organisation_id in ${sql(createdOrgIds)}`;
|
||||||
|
}
|
||||||
|
if (createdUserIds.length > 0) {
|
||||||
|
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||||
|
createdUserIds.length = 0;
|
||||||
|
}
|
||||||
|
if (createdOrgIds.length > 0) {
|
||||||
|
await sql`delete from "organisation" where id in ${sql(createdOrgIds)}`;
|
||||||
|
createdOrgIds.length = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Organisation membership', () => {
|
||||||
|
test('a verified address on a verified domain is membership', async () => {
|
||||||
|
const orgId = await newOrg('org-member', 'member.example', true);
|
||||||
|
const userId = await newUser('member', 'someone@member.example', true);
|
||||||
|
|
||||||
|
const found = await Organisation.forUser(userId);
|
||||||
|
expect(found?.id).toBe(orgId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unverified domain grants nothing', async () => {
|
||||||
|
// Anyone can type a domain into a row. Until it is shown to be theirs,
|
||||||
|
// honouring it would hand them every account on it.
|
||||||
|
await newOrg('org-unverified', 'unverified.example', false);
|
||||||
|
const userId = await newUser('unverified', 'someone@unverified.example', true);
|
||||||
|
|
||||||
|
expect(await Organisation.forUser(userId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unverified address is not membership either', async () => {
|
||||||
|
// The address is a string somebody typed until the code has been
|
||||||
|
// entered, and the domain half is no more trustworthy than the rest.
|
||||||
|
await newOrg('org-bothends', 'bothends.example', true);
|
||||||
|
const userId = await newUser('bothends', 'someone@bothends.example', false);
|
||||||
|
|
||||||
|
expect(await Organisation.forUser(userId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a personal address belongs to no organisation, and that is not an error', async () => {
|
||||||
|
await newOrg('org-personal', 'personal-co.example', true);
|
||||||
|
const userId = await newUser('personal', 'someone@gmail.example', true);
|
||||||
|
|
||||||
|
expect(await Organisation.forUser(userId)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the domain match ignores case, because addresses do', async () => {
|
||||||
|
const orgId = await newOrg('org-case', 'case.example', true);
|
||||||
|
const userId = await newUser('case', 'Someone@CASE.example', true);
|
||||||
|
|
||||||
|
expect((await Organisation.forUser(userId))?.id).toBe(orgId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('domainOf', () => {
|
||||||
|
test('takes the domain half, lower-cased', () => {
|
||||||
|
expect(Organisation.domainOf('Someone@Example.COM')).toBe('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses anything that is not one address', () => {
|
||||||
|
// Every caller uses the answer to decide membership, so a best guess at
|
||||||
|
// a malformed address is the wrong kind of helpful.
|
||||||
|
for (const bad of [
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
'no-at-sign',
|
||||||
|
'two@at@signs',
|
||||||
|
'@nolocal',
|
||||||
|
'nodomain@'
|
||||||
|
]) {
|
||||||
|
expect(Organisation.domainOf(bad)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Fleet hardware', () => {
|
||||||
|
test('a host an organisation owns has no owner and no team', async () => {
|
||||||
|
const orgId = await newOrg('org-fleet', 'fleet.example', true);
|
||||||
|
const registered = await Machine.register({
|
||||||
|
id: Identifier.ascending('machine'),
|
||||||
|
ownerUserId: null,
|
||||||
|
teamId: null,
|
||||||
|
organisationId: orgId,
|
||||||
|
label: 'fleet-card'
|
||||||
|
});
|
||||||
|
|
||||||
|
const machine = await Machine.fromID(registered.id);
|
||||||
|
expect(machine?.organisationId).toBe(orgId);
|
||||||
|
expect(machine?.ownerUserId).toBeNull();
|
||||||
|
expect(machine?.teamId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hardware belongs to a team or an organisation, never both and never neither', async () => {
|
||||||
|
const orgId = await newOrg('org-either', 'either.example', true);
|
||||||
|
|
||||||
|
// `fn()` parses synchronously, so a bad argument never becomes a
|
||||||
|
// rejected promise.
|
||||||
|
expect(() =>
|
||||||
|
Machine.register({
|
||||||
|
id: Identifier.ascending('machine'),
|
||||||
|
ownerUserId: null,
|
||||||
|
teamId: null,
|
||||||
|
label: 'ownerless'
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
Machine.register({
|
||||||
|
id: Identifier.ascending('machine'),
|
||||||
|
ownerUserId: null,
|
||||||
|
teamId: 'tem_whatever',
|
||||||
|
organisationId: orgId,
|
||||||
|
label: 'doubly-owned'
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the fleet lists separately from anybody’s own hardware', async () => {
|
||||||
|
const orgId = await newOrg('org-list', 'list.example', true);
|
||||||
|
const registered = await Machine.register({
|
||||||
|
id: Identifier.ascending('machine'),
|
||||||
|
ownerUserId: null,
|
||||||
|
teamId: null,
|
||||||
|
organisationId: orgId,
|
||||||
|
label: 'listed-card'
|
||||||
|
});
|
||||||
|
|
||||||
|
const fleet = await Machine.listByOrganisation(orgId);
|
||||||
|
expect(fleet.map((m) => m.id)).toContain(registered.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fleet hardware survives the account that registered it', async () => {
|
||||||
|
// The whole reason the column is nullable. It used to cascade, so
|
||||||
|
// deleting whoever ran the command deleted the machine.
|
||||||
|
const orgId = await newOrg('org-survives', 'survives.example', true);
|
||||||
|
const userId = await newUser('survives', 'admin@survives.example', true);
|
||||||
|
const registered = await Actor.with(
|
||||||
|
{ type: 'user', properties: { userID: userId, linkedAccountID: '' } },
|
||||||
|
async () =>
|
||||||
|
Machine.register({
|
||||||
|
id: Identifier.ascending('machine'),
|
||||||
|
ownerUserId: null,
|
||||||
|
teamId: null,
|
||||||
|
organisationId: orgId,
|
||||||
|
label: 'outlives-me'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await sql`delete from "user" where id = ${userId}`;
|
||||||
|
createdUserIds.splice(createdUserIds.indexOf(userId), 1);
|
||||||
|
|
||||||
|
expect((await Machine.fromID(registered.id))?.organisationId).toBe(orgId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Entitlement on fleet hardware', () => {
|
||||||
|
test('nobody is entitled yet, and the reason says why', async () => {
|
||||||
|
// What grants a run on metered hardware is a plan, and there is nothing
|
||||||
|
// to ask yet — so this fails closed rather than giving it away.
|
||||||
|
const orgId = await newOrg('org-entitle', 'entitle.example', true);
|
||||||
|
const userId = await newUser('entitle', 'someone@entitle.example', true);
|
||||||
|
const registered = await Machine.register({
|
||||||
|
id: Identifier.ascending('machine'),
|
||||||
|
ownerUserId: null,
|
||||||
|
teamId: null,
|
||||||
|
organisationId: orgId,
|
||||||
|
label: 'metered'
|
||||||
|
});
|
||||||
|
|
||||||
|
const answer = await Machine.entitlement({ machineId: registered.id, userId });
|
||||||
|
expect(answer).toEqual({ entitled: false, reason: 'fleet' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,11 @@ export namespace Team {
|
|||||||
description: 'The user who owns/created this team',
|
description: 'The user who owns/created this team',
|
||||||
example: Examples.Team.ownerId
|
example: Examples.Team.ownerId
|
||||||
}),
|
}),
|
||||||
|
organisationId: z.string().nullable().optional().meta({
|
||||||
|
description:
|
||||||
|
'The organisation this team belongs to, or null for a personal team. It groups teams under a company; it does not move billing, which stays on the team',
|
||||||
|
example: Examples.Team.organisationId
|
||||||
|
}),
|
||||||
billingEmail: z.email().nullable().optional().meta({
|
billingEmail: z.email().nullable().optional().meta({
|
||||||
description: 'Email address used for billing and invoices',
|
description: 'Email address used for billing and invoices',
|
||||||
example: Examples.Team.billingEmail
|
example: Examples.Team.billingEmail
|
||||||
@@ -171,6 +176,7 @@ export namespace Team {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
ownerId: input.ownerId,
|
ownerId: input.ownerId,
|
||||||
|
organisationId: input.organisationId,
|
||||||
billingEmail: input.billingEmail,
|
billingEmail: input.billingEmail,
|
||||||
plan: input.plan,
|
plan: input.plan,
|
||||||
subscriptionStatus: input.subscriptionStatus,
|
subscriptionStatus: input.subscriptionStatus,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { jsonb, pgTable, text } from 'drizzle-orm/pg-core';
|
import { index, jsonb, pgTable, text } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
import { id, timestamps, ulid } from '../db/types.js';
|
import { id, timestamps, ulid } from '../db/types.js';
|
||||||
|
import { OrganisationTable } from '../organisation/organisation.sql.js';
|
||||||
import { UserTable } from '../user/user.sql.js';
|
import { UserTable } from '../user/user.sql.js';
|
||||||
|
|
||||||
export const TeamTable = pgTable('team', {
|
export const TeamTable = pgTable(
|
||||||
|
'team',
|
||||||
|
{
|
||||||
...id,
|
...id,
|
||||||
...timestamps,
|
...timestamps,
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
@@ -11,8 +14,25 @@ export const TeamTable = pgTable('team', {
|
|||||||
ownerId: ulid('owner_id')
|
ownerId: ulid('owner_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||||
|
/**
|
||||||
|
* The organisation this team belongs to, if it belongs to one.
|
||||||
|
*
|
||||||
|
* Null for every personal team, which is most of them, and that is the
|
||||||
|
* ordinary case rather than a missing value. It groups teams under a
|
||||||
|
* company and decides which of them a verified domain reaches; it does not
|
||||||
|
* move billing, which stays on the team either way.
|
||||||
|
*
|
||||||
|
* `restrict`, so an organisation with teams cannot be deleted out from
|
||||||
|
* under them — where those teams should go is a decision, and there is no
|
||||||
|
* UI for it, so the database refuses rather than guessing.
|
||||||
|
*/
|
||||||
|
organisationId: ulid('organisation_id').references(() => OrganisationTable.id, {
|
||||||
|
onDelete: 'restrict'
|
||||||
|
}),
|
||||||
billingEmail: text('billing_email'),
|
billingEmail: text('billing_email'),
|
||||||
plan: text('plan').notNull().default('free'),
|
plan: text('plan').notNull().default('free'),
|
||||||
subscriptionStatus: text('subscription_status').notNull().default('active'),
|
subscriptionStatus: text('subscription_status').notNull().default('active'),
|
||||||
metadata: jsonb('metadata').$type<{}>()
|
metadata: jsonb('metadata').$type<{}>()
|
||||||
});
|
},
|
||||||
|
(t) => [index('team_organisation_idx').on(t.organisationId)]
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user