mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15: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:
@@ -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).
|
||||
- **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.
|
||||
|
||||
---
|
||||
@@ -500,7 +502,15 @@ type ActorInfo =
|
||||
properties: { userID: string; teamID: string; role: 'owner' | 'admin' | 'member' };
|
||||
}
|
||||
| { type: 'system'; properties: { teamID: string } }
|
||||
| { type: 'admin'; properties: {} };
|
||||
| {
|
||||
type: 'machine';
|
||||
properties: {
|
||||
machineID: string;
|
||||
ownerUserID: string | null;
|
||||
teamID: string | null;
|
||||
organisationID: string | null;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### API
|
||||
@@ -509,8 +519,8 @@ type ActorInfo =
|
||||
Actor.use(); // → ActorInfo (throws if no context set)
|
||||
Actor.with(value, fn); // Run fn in the given actor context
|
||||
Actor.assert(type); // Assert current actor type, returns narrowed type
|
||||
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'admin'
|
||||
Actor.userID; // → string (user/member only)
|
||||
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'machine'
|
||||
Actor.userID; // → string (user/member only; refuses a machine outright)
|
||||
Actor.linkedAccountID; // → string (user only)
|
||||
Actor.useTeam; // → string (member/system only — the teamID)
|
||||
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,
|
||||
"tag": "0014_machine_public_label",
|
||||
"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'),
|
||||
properties: z.object({
|
||||
machineID: z.string(),
|
||||
ownerUserID: z.string(),
|
||||
// Not optional: `machine.teamId` is notNull, so a host that authenticated
|
||||
// always has a team, and the branch that used to handle its absence was
|
||||
// handling a state that can no longer exist.
|
||||
teamID: z.string()
|
||||
// All three of these describe *who owns the hardware*, and a host is
|
||||
// owned one of two ways. A box somebody brought carries an owner and a
|
||||
// team; hardware an organisation owns outright carries neither and
|
||||
// carries an organisation instead. Exactly one of `teamID` and
|
||||
// `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' }
|
||||
};
|
||||
|
||||
export const Organisation = {
|
||||
id: Id('organisation'),
|
||||
name: 'Initech',
|
||||
slug: 'initech',
|
||||
domain: 'initech.example',
|
||||
domainVerified: true
|
||||
};
|
||||
|
||||
export const Team = {
|
||||
id: Id('team'),
|
||||
name: 'The A Team',
|
||||
slug: 'the-a-team',
|
||||
ownerId: Id('user'),
|
||||
organisationId: null,
|
||||
billingEmail: 'billing@example.com',
|
||||
plan: 'free',
|
||||
subscriptionStatus: 'active',
|
||||
|
||||
@@ -6,6 +6,7 @@ export namespace Identifier {
|
||||
export const prefixes = {
|
||||
user: 'usr',
|
||||
linkedAccount: 'lac',
|
||||
organisation: 'org',
|
||||
team: 'tem',
|
||||
teamMember: 'mem',
|
||||
verification: 'ver',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { OrganisationTable } from '../organisation/organisation.sql.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* A registered nessh host — the *box* that runs downloads and serves SSH, not
|
||||
* the laptop someone connects from. (`nessh-tui-redesign-guide.md` §7.2 uses
|
||||
* "machine" for the other end of that connection; this table is the host end.)
|
||||
* A registered host — the *box* that runs downloads and serves SSH, not the
|
||||
* laptop someone connects from. Note the word is used the other way round in
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* **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(
|
||||
'machine',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
ownerUserId: ulid('owner_user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
// 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. This was nullable, which cost a
|
||||
// `teamId ?? ownerUserId` branch at each call site instead. ref(d-0048)
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'restrict' }),
|
||||
/**
|
||||
* Who registered it, when a person did.
|
||||
*
|
||||
* Null for hardware an organisation owns, which is the whole point of
|
||||
* the column being nullable: a company's card is not anybody's personal
|
||||
* property, and parking it under whichever employee ran the command
|
||||
* made it one — where `cascade` below meant deleting that account
|
||||
* deleted the machine.
|
||||
*
|
||||
* `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(),
|
||||
// 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_endpoint_id_unique').on(t.endpointId),
|
||||
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({
|
||||
id: Identifier.ascending('machine'),
|
||||
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,
|
||||
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',
|
||||
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({
|
||||
description: 'Email address used for billing and invoices',
|
||||
example: Examples.Team.billingEmail
|
||||
@@ -171,6 +176,7 @@ export namespace Team {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
ownerId: input.ownerId,
|
||||
organisationId: input.organisationId,
|
||||
billingEmail: input.billingEmail,
|
||||
plan: input.plan,
|
||||
subscriptionStatus: input.subscriptionStatus,
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
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 { OrganisationTable } from '../organisation/organisation.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
export const TeamTable = pgTable('team', {
|
||||
...id,
|
||||
...timestamps,
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
ownerId: ulid('owner_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
billingEmail: text('billing_email'),
|
||||
plan: text('plan').notNull().default('free'),
|
||||
subscriptionStatus: text('subscription_status').notNull().default('active'),
|
||||
metadata: jsonb('metadata').$type<{}>()
|
||||
});
|
||||
export const TeamTable = pgTable(
|
||||
'team',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
ownerId: ulid('owner_id')
|
||||
.notNull()
|
||||
.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'),
|
||||
plan: text('plan').notNull().default('free'),
|
||||
subscriptionStatus: text('subscription_status').notNull().default('active'),
|
||||
metadata: jsonb('metadata').$type<{}>()
|
||||
},
|
||||
(t) => [index('team_organisation_idx').on(t.organisationId)]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user