diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index 6c12cd59..dd79d552 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -15,6 +15,7 @@ import { GameApi } from './routes/game.js'; import { IndexApi } from './routes/index.js'; import { LibraryApi } from './routes/library.js'; import { MachineApi } from './routes/machine.js'; +import { OrganisationApi } from './routes/organisation.js'; import { SessionApi } from './routes/session.js'; import { SteamApi } from './routes/steam.js'; import { UserApi } from './routes/user.js'; @@ -42,6 +43,7 @@ const routes = app .route('/steam', SteamApi.route) .route('/library', LibraryApi.route) .route('/games', GameApi.route) + .route('/organisation', OrganisationApi.route) .route('/machine', MachineApi.route) .route('/machine', SessionApi.machineRoute) .route('/machine', EnrolmentApi.route) diff --git a/apps/api/app/middleware/auth.ts b/apps/api/app/middleware/auth.ts index 51097076..21e0244a 100644 --- a/apps/api/app/middleware/auth.ts +++ b/apps/api/app/middleware/auth.ts @@ -100,7 +100,8 @@ export const auth: MiddlewareHandler = async (c, next) => { properties: { machineID: machine.id, ownerUserID: machine.ownerUserId, - teamID: machine.teamId + teamID: machine.teamId, + organisationID: machine.organisationId } }, next diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index 017584fa..1960337e 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -4,6 +4,7 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error'; import { Examples } from '@nestri/core/examples'; import { Identifier } from '@nestri/core/id'; import { Machine } from '@nestri/core/machine/index'; +import { Organisation } from '@nestri/core/organisation/index'; import { Team } from '@nestri/core/team/index'; import { Member } from '@nestri/core/team/member'; import { Hono } from 'hono'; @@ -27,9 +28,9 @@ export namespace MachineApi { notPublic, describeRoute({ tags: ['Machine'], - summary: 'Register a nessh host', + summary: 'Register a host', description: - 'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest.', + 'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest. Hardware belongs either to a team, which is the default and covers a host somebody brings, or to an organisation the caller belongs to, which is how hardware bought to serve other people is registered so that it is nobody\u2019s personal property.', responses: { 200: { content: { @@ -64,15 +65,28 @@ export namespace MachineApi { }), teamId: z.string().optional().meta({ description: - 'Team to own this hardware. Defaults to the caller’s personal team, which always exists' + 'Team to own this hardware. Defaults to the caller\u2019s personal team, which always exists. Mutually exclusive with organisationId' + }), + organisationId: z.string().optional().meta({ + description: + 'Organisation to own this hardware outright, for a host that serves other people rather than its registrant. The caller must belong to it, and no team is recorded' }) }) ), async (c) => { - const { label, teamId } = c.req.valid('json'); + const { label, teamId, organisationId } = c.req.valid('json'); - // `notPublic` also admits admin, which has no user to own the box. - // Registering is an act of ownership, so it needs a real one. + if (teamId && organisationId) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.INVALID_PARAMETER, + 'Hardware belongs to a team or to an organisation, not to both', + 'organisationId' + ); + } + + // `notPublic` also admits a machine, which has no user to own a + // box. Registering is an act of ownership, so it needs a real one. const actor = Actor.use(); if (actor.type !== 'user' && actor.type !== 'member') { throw new VisibleError( @@ -82,11 +96,39 @@ export namespace MachineApi { ); } - // A team has to be resolved rather than defaulted to null, because - // `machine.teamId` is notNull. The order is: what the caller asked - // for, then the team they are acting inside, then their personal - // team — which `ensurePersonal` makes if this is an older user who - // has none. ref(d-0048) + // Naming an organisation registers fleet hardware: owned outright, + // with no team and no person behind it, so that it survives the + // account of whoever happened to run the command. + if (organisationId) { + if (!(await Organisation.isMember(Actor.userID, organisationId))) { + // Membership is the verified address, so this refuses the + // same way for an organisation that does not exist and one + // the caller simply is not in — there is nothing to learn + // from the difference. + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'You do not belong to that organisation' + ); + } + + const fleet = await Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: null, + teamId: null, + organisationId, + label + }); + return c.json({ + data: { machineId: fleet.id, slug: fleet.slug, secret: fleet.secret } + }); + } + + // Otherwise a team has to be resolved rather than left null, since + // hardware with neither owner is hardware nothing can bill. The + // order is: what the caller asked for, then the team they are + // acting inside, then their personal team — which `ensurePersonal` + // makes if this is an older user who has none. ref(d-0048) const owningTeam = teamId ?? (actor.type === 'member' diff --git a/apps/api/app/routes/organisation.ts b/apps/api/app/routes/organisation.ts new file mode 100644 index 00000000..93023eb3 --- /dev/null +++ b/apps/api/app/routes/organisation.ts @@ -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) }); + } + ); +} diff --git a/docs/deploy.md b/docs/deploy.md index f2556fc5..21356098 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 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 ```sh diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index eab2357e..f24e580c 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -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) diff --git a/packages/core/migrations/0015_organisation_owns_fleet_hardware.sql b/packages/core/migrations/0015_organisation_owns_fleet_hardware.sql new file mode 100644 index 00000000..a91407e0 --- /dev/null +++ b/packages/core/migrations/0015_organisation_owns_fleet_hardware.sql @@ -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)); \ No newline at end of file diff --git a/packages/core/migrations/meta/0015_snapshot.json b/packages/core/migrations/meta/0015_snapshot.json new file mode 100644 index 00000000..7a78a910 --- /dev/null +++ b/packages/core/migrations/meta/0015_snapshot.json @@ -0,0 +1,2989 @@ +{ + "id": "c6c87171-c5af-4461-a7cb-58ee99277f3a", + "prevId": "f625bf1c-518c-4ae0-acf9-1e4256450993", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": ["machine_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": ["host_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": ["steam_app_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_slug_unique": { + "name": "machine_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_endpoint_id_unique": { + "name": "machine_endpoint_id_unique", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_organisation_idx": { + "name": "machine_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "machine_organisation_id_organisation_id_fk": { + "name": "machine_organisation_id_organisation_id_fk", + "tableFrom": "machine", + "tableTo": "organisation", + "columnsFrom": ["organisation_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "machine_one_owner": { + "name": "machine_one_owner", + "value": "(\"machine\".\"team_id\" is null) != (\"machine\".\"organisation_id\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.organisation": { + "name": "organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "organisation_slug_unique": { + "name": "organisation_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organisation_domain_unique": { + "name": "organisation_domain_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": ["box_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": ["linked_account_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steam_enrolment": { + "name": "steam_enrolment", + "schema": "", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "steam_id": { + "name": "steam_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "steam_enrolment_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ok_at": { + "name": "last_ok_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steam_enrolment_user_idx": { + "name": "steam_enrolment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steam_enrolment_machine_id_machine_id_fk": { + "name": "steam_enrolment_machine_id_machine_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "machine", + "columnsFrom": ["machine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steam_enrolment_user_id_user_id_fk": { + "name": "steam_enrolment_user_id_user_id_fk", + "tableFrom": "steam_enrolment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "steam_enrolment_machine_id_user_id_pk": { + "name": "steam_enrolment_machine_id_user_id_pk", + "columns": ["machine_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "organisation_id": { + "name": "organisation_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_organisation_idx": { + "name": "team_organisation_idx", + "columns": [ + { + "expression": "organisation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_organisation_id_organisation_id_fk": { + "name": "team_organisation_id_organisation_id_fk", + "tableFrom": "team", + "tableTo": "organisation", + "columnsFrom": ["organisation_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": ["game_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": ["pending", "approved", "denied"] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": ["signing", "encryption"] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": ["created", "running", "stopped"] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": ["xs", "sm", "md", "lg", "xl"] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": ["pending", "downloading", "complete", "error", "deleted"] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": ["pending", "verifying", "downloading", "ready", "failed"] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": ["requested", "starting", "live", "ended", "failed"] + }, + "public.steam_enrolment_state": { + "name": "steam_enrolment_state", + "schema": "public", + "values": ["enrolled", "stale", "revoked"] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": ["owner", "admin", "member"] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": ["steam", "ssh", "discord"] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": ["email"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index eaf63885..b1e0bd3b 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/core/src/actor.ts b/packages/core/src/actor.ts index 22c28504..4e731139 100644 --- a/packages/core/src/actor.ts +++ b/packages/core/src/actor.ts @@ -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() }) }); diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 9e1c236e..ea2e8d92 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -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', diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index 1fcce66b..a52b1086 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -6,6 +6,7 @@ export namespace Identifier { export const prefixes = { user: 'usr', linkedAccount: 'lac', + organisation: 'org', team: 'tem', teamMember: 'mem', verification: 'ver', diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 7506d7f2..4d3bced7 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -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; @@ -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, diff --git a/packages/core/src/machine/machine.sql.ts b/packages/core/src/machine/machine.sql.ts index b38380fa..95fa59f2 100644 --- a/packages/core/src/machine/machine.sql.ts +++ b/packages/core/src/machine/machine.sql.ts @@ -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)`) ] ); diff --git a/packages/core/src/machine/machine.test.ts b/packages/core/src/machine/machine.test.ts index 97cfcf5f..b7a45501 100644 --- a/packages/core/src/machine/machine.test.ts +++ b/packages/core/src/machine/machine.test.ts @@ -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' }) diff --git a/packages/core/src/organisation/index.ts b/packages/core/src/organisation/index.ts new file mode 100644 index 00000000..6cd188d8 --- /dev/null +++ b/packages/core/src/organisation/index.ts @@ -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; + + /** + * 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 { + 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 + }; + } +} diff --git a/packages/core/src/organisation/organisation.sql.ts b/packages/core/src/organisation/organisation.sql.ts new file mode 100644 index 00000000..f3f862be --- /dev/null +++ b/packages/core/src/organisation/organisation.sql.ts @@ -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) + ] +); diff --git a/packages/core/src/organisation/organisation.test.ts b/packages/core/src/organisation/organisation.test.ts new file mode 100644 index 00000000..8dfc3743 --- /dev/null +++ b/packages/core/src/organisation/organisation.test.ts @@ -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' }); + }); +}); diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 33e43687..be5542a1 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -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, diff --git a/packages/core/src/team/team.sql.ts b/packages/core/src/team/team.sql.ts index e848c9ef..16beb3c9 100644 --- a/packages/core/src/team/team.sql.ts +++ b/packages/core/src/team/team.sql.ts @@ -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)] +);