From ee2de45be259200a80d5c78f9078593edc5e5025 Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 7 Oct 2026 15:13:25 +1100 Subject: [PATCH 1/2] feat(organisations): multi-tenancy resources and ownership fields Adds organisations, partners and grants modules, currentReach for the caller's reach, claimZonesForOrganisation, an organisation_id field on domains, zones, systems, modules, triggers, edges and brokers, an organisation_id query option on their index calls, and an organisation owner option when creating domains, zones and modules. --- src/api.ts | 39 ++++++++ src/broker/broker.ts | 3 + src/domains/domain.ts | 3 + src/domains/functions.ts | 18 ++-- src/edge/edge.ts | 3 + src/grants/functions.ts | 45 ++++++++++ src/grants/grant.ts | 27 ++++++ src/grants/interfaces.ts | 22 +++++ src/modules/functions.ts | 8 +- src/modules/interfaces.ts | 2 + src/modules/module.ts | 3 + src/organisations/functions.ts | 115 ++++++++++++++++++++++++ src/organisations/interfaces.ts | 44 +++++++++ src/organisations/organisation.ts | 25 ++++++ src/partners/functions.ts | 67 ++++++++++++++ src/partners/partner.ts | 22 +++++ src/systems/interfaces.ts | 2 + src/systems/system.ts | 3 + src/triggers/interfaces.ts | 5 +- src/triggers/trigger.ts | 3 + src/users/interfaces.ts | 2 + src/zones/functions.ts | 8 +- src/zones/interfaces.ts | 2 + src/zones/zone.ts | 3 + test/grants/functions.spec.ts | 28 ++++++ test/grants/grant.spec.ts | 21 +++++ test/organisations/functions.spec.ts | 101 +++++++++++++++++++++ test/organisations/organisation.spec.ts | 36 ++++++++ test/partners/functions.spec.ts | 26 ++++++ test/partners/partner.spec.ts | 13 +++ 30 files changed, 689 insertions(+), 10 deletions(-) create mode 100644 src/grants/functions.ts create mode 100644 src/grants/grant.ts create mode 100644 src/grants/interfaces.ts create mode 100644 src/organisations/functions.ts create mode 100644 src/organisations/interfaces.ts create mode 100644 src/organisations/organisation.ts create mode 100644 src/partners/functions.ts create mode 100644 src/partners/partner.ts create mode 100644 test/grants/functions.spec.ts create mode 100644 test/grants/grant.spec.ts create mode 100644 test/organisations/functions.spec.ts create mode 100644 test/organisations/organisation.spec.ts create mode 100644 test/partners/functions.spec.ts create mode 100644 test/partners/partner.spec.ts diff --git a/src/api.ts b/src/api.ts index 7e5ca5ac2..332f8848f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -111,6 +111,45 @@ export { updateDomain, } from './domains/functions'; +export { PlaceGrant } from './grants/grant'; +export { + addGrant, + queryGrants, + removeGrant, + showGrant, +} from './grants/functions'; +export type { + PlaceGrantCreatePayload, + PlaceGrantQueryOptions, +} from './grants/interfaces'; + +export { PlaceOrganisation } from './organisations/organisation'; +export { + addOrganisation, + claimZonesForOrganisation, + currentReach, + queryOrganisations, + removeOrganisation, + showOrganisation, + updateOrganisation, +} from './organisations/functions'; +export type { + PlaceOrganisationClaimResult, + PlaceOrganisationOwnerOptions, + PlaceOrganisationQueryOptions, + PlaceOrganisationWriteOptions, + PlaceReach, +} from './organisations/interfaces'; + +export { PlacePartner } from './partners/partner'; +export { + addPartner, + queryPartners, + removePartner, + showPartner, + updatePartner, +} from './partners/functions'; + export { PlaceDriver } from './drivers/driver'; export { PlaceDriverRole } from './drivers/enums'; export { diff --git a/src/broker/broker.ts b/src/broker/broker.ts index 74c05b8e2..22700c9c5 100644 --- a/src/broker/broker.ts +++ b/src/broker/broker.ts @@ -7,6 +7,8 @@ export enum AuthType { } export class PlaceMQTTBroker extends PlaceResource { + /** ID of the organisation that owns this row; empty when unowned */ + public readonly organisation_id: string; /** Unique identifier for the Broker */ public readonly id: string; /** Name of the Broker */ @@ -34,6 +36,7 @@ export class PlaceMQTTBroker extends PlaceResource { constructor(data: Partial = {}) { super(); + this.organisation_id = data.organisation_id || ''; this.id = data.id || ''; this.name = data.name || ''; this.auth_type = data.auth_type || AuthType.UserPassword; diff --git a/src/domains/domain.ts b/src/domains/domain.ts index 7dc63d652..cbeeb4f1f 100644 --- a/src/domains/domain.ts +++ b/src/domains/domain.ts @@ -2,6 +2,8 @@ import { PlaceResource } from '../resources/resource'; import { HashMap } from '../utilities/types'; export class PlaceDomain extends PlaceResource { + /** ID of the organisation that owns this row; empty when unowned */ + public readonly organisation_id: string; /** Domain name */ public readonly domain: string; /** Login URL for the domain */ @@ -19,6 +21,7 @@ export class PlaceDomain extends PlaceResource { constructor(raw_data: Partial = {}) { super(raw_data); + this.organisation_id = raw_data.organisation_id || ''; this.description = raw_data.description || ''; this.domain = raw_data.domain || ''; this.login_url = raw_data.login_url || ''; diff --git a/src/domains/functions.ts b/src/domains/functions.ts index f01b77a34..3bd6d94bd 100644 --- a/src/domains/functions.ts +++ b/src/domains/functions.ts @@ -1,6 +1,7 @@ import { apiEndpoint } from '../auth/functions'; import { get } from '../http/functions'; import { create, query, remove, show, update } from '../resources/functions'; +import { PlaceOrganisationOwnerOptions } from '../organisations/interfaces'; import { PlaceResourceQueryOptions } from '../resources/interface'; import { PlaceDomain } from './domain'; @@ -18,7 +19,9 @@ function process(item: Partial) { * Query the available domains * @param query_params Query parameters to add the to request URL */ -export function queryDomains(query_params: PlaceResourceQueryOptions = {}) { +export function queryDomains( + query_params: PlaceResourceQueryOptions & PlaceOrganisationOwnerOptions = {}, +) { return query({ query_params, fn: process, path: PATH }); } @@ -36,16 +39,18 @@ export function showDomain(id: string) { * @param form_data New values for the domain * @param query_params Query parameters to add the to request URL * @param method HTTP verb to use on request. Defaults to `patch` + * @param options Organisation to move the domain to (cluster admins only) */ export function updateDomain( id: string, form_data: Partial, method: 'put' | 'patch' = 'patch', + options: PlaceOrganisationOwnerOptions = {}, ) { return update({ id, form_data, - query_params: {}, + query_params: options, method, fn: process, path: PATH, @@ -55,10 +60,13 @@ export function updateDomain( /** * Add a new domain to the database * @param form_data Domain data - * @param query_params Query parameters to add the to request URL + * @param options Organisation that owns the new domain (cluster admins only; defaults to the caller's) */ -export function addDomain(form_data: Partial) { - return create({ form_data, query_params: {}, fn: process, path: PATH }); +export function addDomain( + form_data: Partial, + options: PlaceOrganisationOwnerOptions = {}, +) { + return create({ form_data, query_params: options, fn: process, path: PATH }); } /** diff --git a/src/edge/edge.ts b/src/edge/edge.ts index 86d8b158f..a4cc2e058 100644 --- a/src/edge/edge.ts +++ b/src/edge/edge.ts @@ -2,6 +2,8 @@ import { PlaceResource } from '../resources/resource'; import { HashMap } from '../utilities/types'; export class PlaceEdge extends PlaceResource { + /** ID of the organisation that owns this row; empty when unowned */ + public readonly organisation_id: string; public readonly description: string; public readonly secret: string; public readonly x_api_key: string; @@ -10,6 +12,7 @@ export class PlaceEdge extends PlaceResource { constructor(raw_data: Partial = {}) { super(raw_data); + this.organisation_id = raw_data.organisation_id || ''; this.description = raw_data.description || ''; this.secret = raw_data.secret || ''; this.x_api_key = raw_data.x_api_key || ''; diff --git a/src/grants/functions.ts b/src/grants/functions.ts new file mode 100644 index 000000000..2271563b9 --- /dev/null +++ b/src/grants/functions.ts @@ -0,0 +1,45 @@ +import { create, query, remove, show } from '../resources/functions'; +import { PlaceGrant } from './grant'; +import { PlaceGrantCreatePayload, PlaceGrantQueryOptions } from './interfaces'; + +/** + * @private + */ +const PATH = 'grants'; + +/** Convert raw server data to a grant object */ +function process(item: Partial) { + return new PlaceGrant(item); +} + +/** + * Query grants on a scope or held by a user + * @param query_params Query parameters to add the to request URL + */ +export function queryGrants(query_params: PlaceGrantQueryOptions = {}) { + return query({ query_params, fn: process, path: PATH }); +} + +/** + * Get the data for a grant + * @param id ID of the grant to retrieve + */ +export function showGrant(id: string) { + return show({ id, query_params: {}, fn: process, path: PATH }); +} + +/** + * Issue a grant + * @param form_data Grant data + */ +export function addGrant(form_data: PlaceGrantCreatePayload) { + return create({ form_data, query_params: {}, fn: process, path: PATH }); +} + +/** + * Revoke a grant + * @param id ID of the grant + */ +export function removeGrant(id: string) { + return remove({ id, query_params: {}, path: PATH }); +} diff --git a/src/grants/grant.ts b/src/grants/grant.ts new file mode 100644 index 000000000..e66dda635 --- /dev/null +++ b/src/grants/grant.ts @@ -0,0 +1,27 @@ +import { PlaceResource } from '../resources/resource'; + +/** Explicit, revocable, optionally time-boxed reach for a user into a partner, organisation or domain */ +export class PlaceGrant extends PlaceResource { + /** ID of the user holding the grant */ + public readonly user_id: string; + /** `partner`, `organisation` or `authority` */ + public readonly scope_type: 'partner' | 'organisation' | 'authority'; + /** ID of the partner, organisation or domain */ + public readonly scope_id: string; + /** Permission bitmask (same flags as group memberships) */ + public readonly permissions: number; + /** When the grant stops applying; empty for a standing grant */ + public readonly expires_at: string; + /** ID of the user who issued the grant */ + public readonly granted_by: string; + + constructor(raw_data: Partial = {}) { + super(raw_data); + this.user_id = raw_data.user_id || ''; + this.scope_type = raw_data.scope_type || 'organisation'; + this.scope_id = raw_data.scope_id || ''; + this.permissions = raw_data.permissions || 0; + this.expires_at = raw_data.expires_at || ''; + this.granted_by = raw_data.granted_by || ''; + } +} diff --git a/src/grants/interfaces.ts b/src/grants/interfaces.ts new file mode 100644 index 000000000..23a0803c0 --- /dev/null +++ b/src/grants/interfaces.ts @@ -0,0 +1,22 @@ +import { PlaceResourceQueryOptions } from '../resources/interface'; + +/** Mapping of available query parameters for the grants index endpoint */ +export interface PlaceGrantQueryOptions extends PlaceResourceQueryOptions { + /** `partner`, `organisation` or `authority` */ + scope_type?: 'partner' | 'organisation' | 'authority'; + /** ID of the partner, organisation or domain */ + scope_id?: string; + /** Grants held by this user */ + user_id?: string; +} + +/** Body for creating a grant */ +export interface PlaceGrantCreatePayload { + user_id: string; + scope_type: 'partner' | 'organisation' | 'authority'; + scope_id: string; + /** Permission bitmask; defaults to Read */ + permissions?: number; + /** ISO 8601 time the grant stops applying; omit for a standing grant */ + expires_at?: string; +} diff --git a/src/modules/functions.ts b/src/modules/functions.ts index e5f93de73..b9cdaa511 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -1,3 +1,4 @@ +import { PlaceOrganisationOwnerOptions } from '../organisations/interfaces'; import { create, query, @@ -72,8 +73,11 @@ export function updateModule( * @param form_data Module data * @param query_params Query parameters to add the to request URL */ -export function addModule(form_data: Partial) { - return create({ form_data, query_params: {}, fn: process, path: PATH }); +export function addModule( + form_data: Partial, + options: PlaceOrganisationOwnerOptions = {}, +) { + return create({ form_data, query_params: options, fn: process, path: PATH }); } /** diff --git a/src/modules/interfaces.ts b/src/modules/interfaces.ts index d1062c025..4d4ac18cf 100644 --- a/src/modules/interfaces.ts +++ b/src/modules/interfaces.ts @@ -2,6 +2,8 @@ import { PlaceResourceQueryOptions } from '../resources/interface'; /** Mapping of available query parameters for the modules index endpoint */ export interface PlaceModuleQueryOptions extends PlaceResourceQueryOptions { + /** Only modules owned by this organisation (must be within reach) */ + organisation_id?: string; /** Only return modules updated before this time (unix epoch) */ as_of?: number; /** Only return modules running in this system (query params are ignored if this is provided) */ diff --git a/src/modules/module.ts b/src/modules/module.ts index e4fd6673a..3b569227b 100644 --- a/src/modules/module.ts +++ b/src/modules/module.ts @@ -20,6 +20,8 @@ export interface PlaceModuleComplete extends Partial { export type EndDebugFn = () => void; export class PlaceModule extends PlaceResource { + /** ID of the organisation that owns this row; empty when unowned */ + public readonly organisation_id: string; /** Whether the associated hardware is connected */ public readonly connected: boolean | undefined; /** Whether the module driver is running */ @@ -76,6 +78,7 @@ export class PlaceModule extends PlaceResource { constructor(raw_data: PlaceModuleComplete = {}) { super(raw_data); + this.organisation_id = raw_data.organisation_id || ''; this.driver_id = raw_data.driver_id || raw_data.dependency_id || ''; this.control_system_id = raw_data.control_system_id || ''; this.edge_id = raw_data.edge_id || ''; diff --git a/src/organisations/functions.ts b/src/organisations/functions.ts new file mode 100644 index 000000000..76f79b052 --- /dev/null +++ b/src/organisations/functions.ts @@ -0,0 +1,115 @@ +import { apiEndpoint } from '../auth/functions'; +import { get, post } from '../http/functions'; +import { PlacePartner } from '../partners/partner'; +import { create, query, remove, show, update } from '../resources/functions'; +import { HashMap } from '../utilities/types'; +import { + PlaceOrganisationClaimResult, + PlaceOrganisationQueryOptions, + PlaceOrganisationWriteOptions, + PlaceReach, +} from './interfaces'; +import { PlaceOrganisation } from './organisation'; + +/** + * @private + */ +const PATH = 'organisations'; + +/** Convert raw server data to an organisation object */ +function process(item: Partial) { + return new PlaceOrganisation(item); +} + +/** + * Query the organisations within reach + * @param query_params Query parameters to add the to request URL + */ +export function queryOrganisations( + query_params: PlaceOrganisationQueryOptions = {}, +) { + return query({ query_params, fn: process, path: PATH }); +} + +/** + * Get the data for an organisation + * @param id ID of the organisation to retrieve + */ +export function showOrganisation(id: string) { + return show({ id, query_params: {}, fn: process, path: PATH }); +} + +/** + * Update an organisation (cluster admins only) + * @param id ID of the organisation + * @param form_data New values for the organisation + * @param options Payer and partner staff flags + * @param method HTTP verb to use on request. Defaults to `patch` + */ +export function updateOrganisation( + id: string, + form_data: Partial, + options: PlaceOrganisationWriteOptions = {}, + method: 'put' | 'patch' = 'patch', +) { + return update({ + id, + form_data, + query_params: options, + method, + fn: process, + path: PATH, + }); +} + +/** + * Add a new organisation (cluster admins only) + * @param form_data Organisation data + * @param options Payer and partner staff flags + */ +export function addOrganisation( + form_data: Partial, + options: PlaceOrganisationWriteOptions = {}, +) { + return create({ form_data, query_params: options, fn: process, path: PATH }); +} + +/** + * Remove an organisation (cluster admins only). Fails while domains or estate remain under it. + * @param id ID of the organisation + */ +export function removeOrganisation(id: string) { + return remove({ id, query_params: {}, path: PATH }); +} + +/** + * What the signed-in user can reach: the level, their own organisation and + * partner, and the organisations in reach + */ +export function currentReach(): Promise { + const url = `${apiEndpoint()}/${PATH}/current`; + return get(url).then((resp: HashMap) => ({ + reach: resp.reach || 'organisation', + enforcing: !!resp.enforcing, + organisation: resp.organisation ? process(resp.organisation) : null, + partner: resp.partner ? new PlacePartner(resp.partner) : null, + organisations: resp.organisations + ? (resp.organisations as HashMap[]).map(process) + : null, + })); +} + +/** + * Assign unowned zone trees, with their systems and modules, to an organisation (cluster admins only) + * @param id ID of the organisation + * @param zone_ids Root zone IDs to claim + */ +export function claimZonesForOrganisation( + id: string, + zone_ids: string[], +): Promise { + const url = `${apiEndpoint()}/${PATH}/${encodeURIComponent(id)}/claim`; + return post(url, { zone_ids }).then( + (resp: HashMap) => resp as PlaceOrganisationClaimResult, + ); +} diff --git a/src/organisations/interfaces.ts b/src/organisations/interfaces.ts new file mode 100644 index 000000000..ec0485402 --- /dev/null +++ b/src/organisations/interfaces.ts @@ -0,0 +1,44 @@ +import { PlacePartner } from '../partners/partner'; +import { PlaceResourceQueryOptions } from '../resources/interface'; +import { PlaceOrganisation } from './organisation'; + +/** Mapping of available query parameters for the organisations index endpoint */ +export interface PlaceOrganisationQueryOptions extends PlaceResourceQueryOptions { + /** Only organisations under this partner */ + partner_id?: string; +} + +/** Values that are not mass-assignable and travel as query parameters on create and update */ +export interface PlaceOrganisationWriteOptions { + /** Who is invoiced: the partner or the organisation */ + payer?: 'partner' | 'organisation'; + /** True for the partner's own staff organisation */ + partner_staff?: boolean; +} + +/** Which organisation a newly created domain, zone or module belongs to (cluster admins only) */ +export interface PlaceOrganisationOwnerOptions { + organisation_id?: string; +} + +/** The caller's reach, as resolved by the API for the current request */ +export interface PlaceReach { + /** `cluster` sees every organisation, `partner` every organisation under one partner, `organisation` one */ + reach: 'cluster' | 'partner' | 'organisation'; + /** Whether the API is refusing cross-organisation access or only logging it */ + enforcing: boolean; + /** The caller's own organisation, if their domain has one */ + organisation: PlaceOrganisation | null; + /** The caller's own partner, if their organisation has one */ + partner: PlacePartner | null; + /** The organisations in reach; null for cluster reach */ + organisations: PlaceOrganisation[] | null; +} + +/** Rows adopted by a claim */ +export interface PlaceOrganisationClaimResult { + zones: number; + systems: number; + modules: number; + triggers: number; +} diff --git a/src/organisations/organisation.ts b/src/organisations/organisation.ts new file mode 100644 index 000000000..675a1a3f9 --- /dev/null +++ b/src/organisations/organisation.ts @@ -0,0 +1,25 @@ +import { PlaceResource } from '../resources/resource'; +import { HashMap } from '../utilities/types'; + +/** A customer organisation: the owner of domains and estate on a shared instance */ +export class PlaceOrganisation extends PlaceResource { + /** Description of the organisation */ + public readonly description: string; + /** ID of the partner that manages this organisation; empty when self managed */ + public readonly partner_id: string; + /** Who is invoiced: the partner or the organisation */ + public readonly payer: 'partner' | 'organisation'; + /** Whether this is the partner's own staff organisation, whose admins reach every organisation under the partner */ + public readonly partner_staff: boolean; + /** Local configuration for the organisation */ + public readonly config: HashMap; + + constructor(raw_data: Partial = {}) { + super(raw_data); + this.description = raw_data.description || ''; + this.partner_id = raw_data.partner_id || ''; + this.payer = raw_data.payer || 'organisation'; + this.partner_staff = !!raw_data.partner_staff; + this.config = raw_data.config || {}; + } +} diff --git a/src/partners/functions.ts b/src/partners/functions.ts new file mode 100644 index 000000000..8dc852df7 --- /dev/null +++ b/src/partners/functions.ts @@ -0,0 +1,67 @@ +import { create, query, remove, show, update } from '../resources/functions'; +import { PlaceResourceQueryOptions } from '../resources/interface'; +import { PlacePartner } from './partner'; + +/** + * @private + */ +const PATH = 'partners'; + +/** Convert raw server data to a partner object */ +function process(item: Partial) { + return new PlacePartner(item); +} + +/** + * Query the partners within reach. Cluster admins see every partner, everyone + * else sees their own. + * @param query_params Query parameters to add the to request URL + */ +export function queryPartners(query_params: PlaceResourceQueryOptions = {}) { + return query({ query_params, fn: process, path: PATH }); +} + +/** + * Get the data for a partner + * @param id ID of the partner to retrieve + */ +export function showPartner(id: string) { + return show({ id, query_params: {}, fn: process, path: PATH }); +} + +/** + * Update a partner (cluster admins only) + * @param id ID of the partner + * @param form_data New values for the partner + * @param method HTTP verb to use on request. Defaults to `patch` + */ +export function updatePartner( + id: string, + form_data: Partial, + method: 'put' | 'patch' = 'patch', +) { + return update({ + id, + form_data, + query_params: {}, + method, + fn: process, + path: PATH, + }); +} + +/** + * Add a new partner (cluster admins only) + * @param form_data Partner data + */ +export function addPartner(form_data: Partial) { + return create({ form_data, query_params: {}, fn: process, path: PATH }); +} + +/** + * Remove a partner (cluster admins only). Fails while organisations remain under it. + * @param id ID of the partner + */ +export function removePartner(id: string) { + return remove({ id, query_params: {}, path: PATH }); +} diff --git a/src/partners/partner.ts b/src/partners/partner.ts new file mode 100644 index 000000000..fe0e3f1f0 --- /dev/null +++ b/src/partners/partner.ts @@ -0,0 +1,22 @@ +import { PlaceResource } from '../resources/resource'; +import { HashMap } from '../utilities/types'; + +/** An integrator that brings in and manages organisations, or the management partner that is PlaceOS itself */ +export class PlacePartner extends PlaceResource { + /** Description of the partner */ + public readonly description: string; + /** Whether this is the platform operator's own partner; its staff reach every organisation */ + public readonly management: boolean; + /** ID of the parent partner, if any */ + public readonly parent_id: string; + /** Local configuration for the partner */ + public readonly config: HashMap; + + constructor(raw_data: Partial = {}) { + super(raw_data); + this.description = raw_data.description || ''; + this.management = !!raw_data.management; + this.parent_id = raw_data.parent_id || ''; + this.config = raw_data.config || {}; + } +} diff --git a/src/systems/interfaces.ts b/src/systems/interfaces.ts index e4b6cf232..5d9b8f09b 100644 --- a/src/systems/interfaces.ts +++ b/src/systems/interfaces.ts @@ -14,6 +14,8 @@ export interface PlaceModuleFunction { /** Allowable query parameters for systems index endpoint */ export interface PlaceSystemsQueryOptions extends PlaceResourceQueryOptions { + /** Only systems owned by this organisation (must be within reach) */ + organisation_id?: string; /** Return only bookable or non-bookable rooms (returns both when not specified) */ bookable?: boolean; /** Return only rooms with capacity equal or greater than provided */ diff --git a/src/systems/system.ts b/src/systems/system.ts index bce2cffd4..86f784b89 100644 --- a/src/systems/system.ts +++ b/src/systems/system.ts @@ -12,6 +12,8 @@ export interface PlaceSystemComplete extends Partial { } export class PlaceSystem extends PlaceResource { + /** ID of the organisation that owns this row; empty when unowned */ + public readonly organisation_id: string; /** Tuple of user settings of differring encryption levels for the system */ public readonly settings: [ PlaceSettings | null, @@ -82,6 +84,7 @@ export class PlaceSystem extends PlaceResource { constructor(raw_data: PlaceSystemComplete = {}) { super(raw_data); + this.organisation_id = raw_data.organisation_id || ''; this.display_name = raw_data.display_name || ''; this.description = raw_data.description || ''; this.email = raw_data.email || ''; diff --git a/src/triggers/interfaces.ts b/src/triggers/interfaces.ts index 1f9689e35..786dfbffb 100644 --- a/src/triggers/interfaces.ts +++ b/src/triggers/interfaces.ts @@ -2,7 +2,10 @@ import { PlaceResourceQueryOptions } from '../resources/interface'; import { HashMap } from '../utilities/types'; /** Mapping of available query parameters for the triggers index endpoint */ -export interface PlaceTriggerQueryOptions extends PlaceResourceQueryOptions {} +export interface PlaceTriggerQueryOptions extends PlaceResourceQueryOptions { + /** Only triggers bound to systems owned by this organisation; templates are always included */ + organisation_id?: string; +} /** Mapping of available query parameters for the triggers show endpoint */ export interface PlaceTriggerShowOptions { diff --git a/src/triggers/trigger.ts b/src/triggers/trigger.ts index 86af74b4f..2d320d8b6 100644 --- a/src/triggers/trigger.ts +++ b/src/triggers/trigger.ts @@ -12,6 +12,8 @@ export interface PlaceTriggerComplete extends Partial { } export class PlaceTrigger extends PlaceResource { + /** ID of the organisation that owns this row; empty when unowned */ + public readonly organisation_id: string; /** Name of the system assocaited with the trigger */ public readonly system_name: string; /** Number of times the trigger has been activated/triggered */ @@ -82,6 +84,7 @@ export class PlaceTrigger extends PlaceResource { constructor(raw_data: PlaceTriggerComplete = {}) { super(raw_data); + this.organisation_id = raw_data.organisation_id || ''; this.description = raw_data.description || ''; this._actions = raw_data.actions || { functions: [], mailers: [] }; this._conditions = raw_data.conditions || { diff --git a/src/users/interfaces.ts b/src/users/interfaces.ts index 0a2783caa..d3dcd8e71 100644 --- a/src/users/interfaces.ts +++ b/src/users/interfaces.ts @@ -8,6 +8,8 @@ export interface PlaceUserQueryOptions extends PlaceResourceQueryOptions { include_metadata?: boolean; /** Admin users can view other domains (ignored for other users) */ authority_id?: string; + /** Only users on domains owned by this organisation (must be within reach) */ + organisation_id?: string; } /** Mapping of available query parameters for the users show endpoint */ diff --git a/src/zones/functions.ts b/src/zones/functions.ts index cefa70585..39827a2cb 100644 --- a/src/zones/functions.ts +++ b/src/zones/functions.ts @@ -1,3 +1,4 @@ +import { PlaceOrganisationOwnerOptions } from '../organisations/interfaces'; import { create, query, @@ -83,8 +84,11 @@ export function updateZone( * @param form_data Application data * @param query_params Query parameters to add the to request URL */ -export function addZone(form_data: Partial) { - return create({ form_data, query_params: {}, fn: process, path: PATH }); +export function addZone( + form_data: Partial, + options: PlaceOrganisationOwnerOptions = {}, +) { + return create({ form_data, query_params: options, fn: process, path: PATH }); } /** diff --git a/src/zones/interfaces.ts b/src/zones/interfaces.ts index 1812a16a8..6b142e79e 100644 --- a/src/zones/interfaces.ts +++ b/src/zones/interfaces.ts @@ -2,6 +2,8 @@ import { PlaceResourceQueryOptions } from '../resources/interface'; /** Mapping of available query parameters for the zones index endpoint */ export interface PlaceZoneQueryOptions extends PlaceResourceQueryOptions { + /** Only zones owned by this organisation (must be within reach) */ + organisation_id?: string; /** ID of the parent zone to filter the results (supports comma-separated list) */ parent_id?: string; /** List of space separated tags to filter the results */ diff --git a/src/zones/zone.ts b/src/zones/zone.ts index 535c43e7d..a889203d9 100644 --- a/src/zones/zone.ts +++ b/src/zones/zone.ts @@ -11,6 +11,8 @@ export interface PlaceZoneComplete extends Partial { } export class PlaceZone extends PlaceResource { + /** ID of the organisation that owns this row; empty when unowned */ + public readonly organisation_id: string; /** Tuple of user settings of differring encryption levels for the zone */ public readonly settings: [ PlaceSettings | null, @@ -56,6 +58,7 @@ export class PlaceZone extends PlaceResource { constructor(raw_data: PlaceZoneComplete = {}) { super(raw_data); + this.organisation_id = raw_data.organisation_id || ''; this.description = raw_data.description || ''; this.tags = raw_data.tags || []; this.triggers = raw_data.triggers || []; diff --git a/test/grants/functions.spec.ts b/test/grants/functions.spec.ts new file mode 100644 index 000000000..209536d73 --- /dev/null +++ b/test/grants/functions.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, test, vi } from 'vitest'; +import * as SERVICE from '../../src/grants/functions'; +import { PlaceGrant } from '../../src/grants/grant'; +import * as Resources from '../../src/resources/functions'; + +describe('Grants API', () => { + test('should allow querying grants by scope or user', async () => { + const spy = vi.spyOn(Resources, 'query'); + spy.mockImplementation((_) => + Promise.resolve({ data: [_.fn!({})] } as any), + ); + const list = await SERVICE.queryGrants({ scope_type: 'organisation', scope_id: 'org-1' }); + expect(list.data[0]).toBeInstanceOf(PlaceGrant); + expect(spy.mock.calls[0][0].query_params).toEqual({ scope_type: 'organisation', scope_id: 'org-1' }); + }); + + test('should allow issuing, showing and revoking grants', async () => { + const createSpy = vi.spyOn(Resources, 'create'); + createSpy.mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + vi.spyOn(Resources, 'show').mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + vi.spyOn(Resources, 'remove').mockImplementation(() => Promise.resolve()); + const payload = { user_id: 'user-1', scope_type: 'organisation' as const, scope_id: 'org-1', permissions: 1 }; + expect(await SERVICE.addGrant(payload)).toBeInstanceOf(PlaceGrant); + expect(createSpy.mock.calls[0][0].form_data).toEqual(payload); + expect(await SERVICE.showGrant('1')).toBeInstanceOf(PlaceGrant); + expect(await SERVICE.removeGrant('1')).toBeFalsy(); + }); +}); diff --git a/test/grants/grant.spec.ts b/test/grants/grant.spec.ts new file mode 100644 index 000000000..80c30c9d8 --- /dev/null +++ b/test/grants/grant.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; +import { PlaceGrant } from '../../src/grants/grant'; + +describe('PlaceGrant', () => { + test('should expose fields with defaults', () => { + const grant = new PlaceGrant({ + id: 'grant-1', + user_id: 'user-1', + scope_type: 'partner', + scope_id: 'partner-1', + permissions: 3, + expires_at: '2026-12-01T00:00:00Z', + granted_by: 'user-2', + }); + expect(grant).toBeInstanceOf(PlaceGrant); + expect(grant.scope_type).toBe('partner'); + expect(grant.permissions).toBe(3); + expect(new PlaceGrant().scope_type).toBe('organisation'); + expect(new PlaceGrant().permissions).toBe(0); + }); +}); diff --git a/test/organisations/functions.spec.ts b/test/organisations/functions.spec.ts new file mode 100644 index 000000000..ada143a2b --- /dev/null +++ b/test/organisations/functions.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, test, vi } from 'vitest'; +import * as Auth from '../../src/auth/functions'; +import * as Http from '../../src/http/functions'; +import * as SERVICE from '../../src/organisations/functions'; +import { PlaceOrganisation } from '../../src/organisations/organisation'; +import { PlacePartner } from '../../src/partners/partner'; +import * as Resources from '../../src/resources/functions'; + +describe('Organisations API', () => { + test('should allow querying organisations', async () => { + const spy = vi.spyOn(Resources, 'query'); + spy.mockImplementation((_) => + Promise.resolve({ data: [_.fn!({})] } as any), + ); + const list = await SERVICE.queryOrganisations({ partner_id: 'p' }); + expect(list.data[0]).toBeInstanceOf(PlaceOrganisation); + expect(spy.mock.calls[0][0].query_params).toEqual({ partner_id: 'p' }); + }); + + test('should allow showing organisation details', async () => { + const spy = vi.spyOn(Resources, 'show'); + spy.mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + const item = await SERVICE.showOrganisation('1'); + expect(item).toBeInstanceOf(PlaceOrganisation); + }); + + test('should pass payer and partner staff as query parameters on create', async () => { + const spy = vi.spyOn(Resources, 'create'); + spy.mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + const item = await SERVICE.addOrganisation( + { name: 'NTT staff' }, + { payer: 'partner', partner_staff: true }, + ); + expect(item).toBeInstanceOf(PlaceOrganisation); + expect(spy.mock.calls[0][0].query_params).toEqual({ + payer: 'partner', + partner_staff: true, + }); + }); + + test('should allow updating organisation details', async () => { + const spy = vi.spyOn(Resources, 'update'); + spy.mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + const item = await SERVICE.updateOrganisation('1', {}, { payer: 'organisation' }); + expect(item).toBeInstanceOf(PlaceOrganisation); + expect(spy.mock.calls[0][0].query_params).toEqual({ payer: 'organisation' }); + }); + + test('should allow removing organisations', async () => { + const spy = vi.spyOn(Resources, 'remove'); + spy.mockImplementation(() => Promise.resolve()); + const item = await SERVICE.removeOrganisation('1'); + expect(item).toBeFalsy(); + }); + + test('should resolve the current reach', async () => { + vi.spyOn(Auth, 'apiEndpoint').mockReturnValue('/api/engine/v2'); + const httpSpy = vi.spyOn(Http, 'get'); + httpSpy.mockImplementation( + () => + Promise.resolve({ + reach: 'partner', + enforcing: true, + organisation: { id: 'org-ntt', name: 'NTT' }, + partner: { id: 'partner-ntt', name: 'NTT' }, + organisations: [{ id: 'org-ntt' }, { id: 'org-acadian' }], + }) as any, + ); + const reach = await SERVICE.currentReach(); + expect(httpSpy).toHaveBeenCalledWith('/api/engine/v2/organisations/current'); + expect(reach.reach).toBe('partner'); + expect(reach.enforcing).toBe(true); + expect(reach.organisation).toBeInstanceOf(PlaceOrganisation); + expect(reach.partner).toBeInstanceOf(PlacePartner); + expect(reach.organisations?.map((o) => o.id)).toEqual(['org-ntt', 'org-acadian']); + }); + + test('should report cluster reach with no organisation list', async () => { + vi.spyOn(Auth, 'apiEndpoint').mockReturnValue('/api/engine/v2'); + vi.spyOn(Http, 'get').mockImplementation( + () => Promise.resolve({ reach: 'cluster', enforcing: false }) as any, + ); + const reach = await SERVICE.currentReach(); + expect(reach.reach).toBe('cluster'); + expect(reach.organisation).toBeNull(); + expect(reach.organisations).toBeNull(); + }); + + test('should claim zone trees for an organisation', async () => { + vi.spyOn(Auth, 'apiEndpoint').mockReturnValue('/api/engine/v2'); + const postSpy = vi.spyOn(Http, 'post'); + postSpy.mockImplementation( + () => Promise.resolve({ zones: 3, systems: 2, modules: 4, triggers: 0 }) as any, + ); + const result = await SERVICE.claimZonesForOrganisation('org-1', ['zone-a']); + expect(postSpy).toHaveBeenCalledWith('/api/engine/v2/organisations/org-1/claim', { + zone_ids: ['zone-a'], + }); + expect(result.zones).toBe(3); + }); +}); diff --git a/test/organisations/organisation.spec.ts b/test/organisations/organisation.spec.ts new file mode 100644 index 000000000..22d6f051c --- /dev/null +++ b/test/organisations/organisation.spec.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { PlaceOrganisation } from '../../src/organisations/organisation'; + +describe('PlaceOrganisation', () => { + let organisation: PlaceOrganisation; + + beforeEach(() => { + organisation = new PlaceOrganisation({ + id: 'org-test', + name: 'Acadian', + description: 'NTT client', + partner_id: 'partner-ntt', + payer: 'partner', + partner_staff: false, + config: { region: 'us' }, + }); + }); + + test('should create instance', () => { + expect(organisation).toBeInstanceOf(PlaceOrganisation); + }); + + test('should expose ownership fields', () => { + expect(organisation.partner_id).toBe('partner-ntt'); + expect(organisation.payer).toBe('partner'); + expect(organisation.partner_staff).toBe(false); + expect(organisation.config).toEqual({ region: 'us' }); + }); + + test('should default to self managed', () => { + const direct = new PlaceOrganisation({ name: 'UCLA' }); + expect(direct.partner_id).toBe(''); + expect(direct.payer).toBe('organisation'); + expect(direct.partner_staff).toBe(false); + }); +}); diff --git a/test/partners/functions.spec.ts b/test/partners/functions.spec.ts new file mode 100644 index 000000000..1336698a5 --- /dev/null +++ b/test/partners/functions.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test, vi } from 'vitest'; +import * as SERVICE from '../../src/partners/functions'; +import { PlacePartner } from '../../src/partners/partner'; +import * as Resources from '../../src/resources/functions'; + +describe('Partners API', () => { + test('should allow querying partners', async () => { + const spy = vi.spyOn(Resources, 'query'); + spy.mockImplementation((_) => + Promise.resolve({ data: [_.fn!({})] } as any), + ); + const list = await SERVICE.queryPartners(); + expect(list.data[0]).toBeInstanceOf(PlacePartner); + }); + + test('should allow showing, creating, updating and removing partners', async () => { + vi.spyOn(Resources, 'show').mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + vi.spyOn(Resources, 'create').mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + vi.spyOn(Resources, 'update').mockImplementation((_) => Promise.resolve(_.fn!({}) as any)); + vi.spyOn(Resources, 'remove').mockImplementation(() => Promise.resolve()); + expect(await SERVICE.showPartner('1')).toBeInstanceOf(PlacePartner); + expect(await SERVICE.addPartner({ name: 'NTT' })).toBeInstanceOf(PlacePartner); + expect(await SERVICE.updatePartner('1', {})).toBeInstanceOf(PlacePartner); + expect(await SERVICE.removePartner('1')).toBeFalsy(); + }); +}); diff --git a/test/partners/partner.spec.ts b/test/partners/partner.spec.ts new file mode 100644 index 000000000..458191757 --- /dev/null +++ b/test/partners/partner.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'vitest'; +import { PlacePartner } from '../../src/partners/partner'; + +describe('PlacePartner', () => { + test('should expose fields with defaults', () => { + const partner = new PlacePartner({ id: 'partner-1', name: 'NTT', management: true }); + expect(partner).toBeInstanceOf(PlacePartner); + expect(partner.management).toBe(true); + expect(partner.parent_id).toBe(''); + expect(partner.config).toEqual({}); + expect(new PlacePartner().management).toBe(false); + }); +}); From 7f4e777e2264cd980eb50f959c32b0940d6e6dee Mon Sep 17 00:00:00 2001 From: Cameron Reeves Date: Wed, 7 Oct 2026 15:16:58 +1100 Subject: [PATCH 2/2] fix(websocket): narrow the send payload type for newer TypeScript lib typings --- src/utilities/websocket.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utilities/websocket.ts b/src/utilities/websocket.ts index 2ce1f9bd9..6234e24c4 100644 --- a/src/utilities/websocket.ts +++ b/src/utilities/websocket.ts @@ -93,7 +93,9 @@ export class BrowserWebSocket public override next(value: T): void { if (this._socket.readyState === WebSocket.OPEN) { - this._socket.send(this._serialize(value)); + this._socket.send( + this._serialize(value) as Parameters[0], + ); } else { this._queue.push(value); }