PSA/ee/docs/plans/2026-03-26-extension-sdk-client-service-read-capabilities-design.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

7.5 KiB

Extension SDK Read-Only Client And Service Capabilities Design

  • Date: 2026-03-26
  • Status: Approved in chat

Summary

Add first-class read-only extension host capabilities for tenant-scoped client and service catalog data so extension handlers can query those records without making HTTP API calls.

The preferred solution is to extend the extension runner and SDK with typed host imports, following the same pattern already used for user, scheduler, and invoicing.

Problem

Extensions can currently reach Alga data through HTTP-oriented paths, but that is the wrong abstraction for common internal lookups like client lists or service catalog reads. It forces handlers to depend on API routes, credentials, and transport concerns instead of using a stable host capability contract.

This is especially awkward for scheduled jobs and webhook-style execution where there may be no authenticated user session but the extension still needs tenant-scoped reference data.

Goals

  • Let extension handlers read clients and service catalog entries without using HTTP APIs.
  • Keep the contract typed and versionable at the WIT and SDK layers.
  • Support both user-backed execution and non-user execution.
  • Reuse existing domain query logic instead of duplicating controller behavior.
  • Enforce capability grants and tenant isolation centrally in the runner.

Non-goals

  • Creating, updating, or deleting clients or services.
  • Exposing arbitrary database queries to extensions.
  • Mirroring the full internal IClient and IService shapes in the public extension contract.
  • Replacing existing REST endpoints for product UI usage.

Implement two new runner-native host capabilities:

  • cap:client.read
  • cap:service.read

Expose them through typed WIT imports:

  • alga:extension/clients
  • alga:extension/services

Extension code will call host bindings such as:

  • host.clients.list(...)
  • host.clients.get(...)
  • host.services.list(...)
  • host.services.get(...)

This keeps handler code transport-free and makes the runner the boundary where capability checks, permission behavior, and data mapping are enforced.

Alternative Approaches Considered

1. Generic data query capability

Expose a single host.data.query(resource, filters) surface.

Why not:

  • weaker typing
  • harder documentation and evolution
  • easy to grow into an implicit internal API clone

2. SDK helper over existing HTTP routes

Hide HTTP APIs behind SDK helpers.

Why not:

  • still API-based
  • still couples extensions to route semantics
  • does not satisfy the stated goal cleanly

Proposed API Boundary

The v1 contract should return stable summary types, not the entire internal record shapes.

Client Summary

type ClientSummary = {
  clientId: string;
  clientName: string;
  clientType?: 'company' | 'individual' | null;
  isInactive: boolean;
  defaultCurrencyCode?: string | null;
  accountManagerId?: string | null;
  accountManagerName?: string | null;
  billingEmail?: string | null;
  tags?: string[];
}

Service Summary

type ServiceSummary = {
  serviceId: string;
  serviceName: string;
  itemKind?: 'service' | 'product';
  billingMethod: 'fixed' | 'hourly' | 'usage';
  serviceTypeId: string;
  serviceTypeName?: string;
  defaultRate: number;
  unitOfMeasure: string;
  isActive?: boolean;
  sku?: string | null;
}

Operations

  • clients.list(input)
  • clients.get(clientId)
  • services.list(input)
  • services.get(serviceId)

List Filters

Clients:

  • search
  • includeInactive
  • page
  • pageSize

Services:

  • search
  • itemKind
  • isActive
  • billingMethod
  • page
  • pageSize

Response Shape

List operations should return:

{
  items: T[];
  totalCount: number;
  page: number;
  pageSize: number;
}

get operations should return option<T> / nullable results for not-found instead of throwing.

Authorization Model

Capability grants and application permissions are separate checks.

Capability Checks

  • Missing install capability returns not-allowed.
  • cap:client.read gates all client reads.
  • cap:service.read gates all service reads.

User-Backed Requests

When the runner execution includes a real user:

  • client reads also require normal client:read
  • service reads also require normal service:read

This prevents interactive extensions from bypassing RBAC.

Non-User Requests

When the execution has no user context, such as scheduled runs or webhooks:

  • allow reads when the install has the capability
  • execute as tenant-scoped extension service access

This supports automation use cases without requiring a synthetic user session.

Tenant Isolation

Tenant identity always comes from runner execution context. The extension cannot supply or override tenant identifiers in capability inputs.

Error Model

Suggested error set:

  • not-allowed
  • invalid-input
  • internal

not-available is likely unnecessary for these read capabilities.

Implementation Shape

SDK / WIT

Extend the extension runtime WIT and TypeScript host bindings with:

  • clients interface
  • services interface
  • result types and error enums
  • mock host bindings for tests

Runner

Add capability providers that:

  • read tenant, install, and optional user context from the execute payload
  • verify install capability grants
  • apply user permission checks when a user is present
  • call shared server-side read services
  • map internal records into summary response types

Shared Read Services

Do not call existing withAuth server actions directly from the runner provider.

Instead, extract shared query logic from:

  • packages/clients/src/actions/clientActions.ts
  • packages/billing/src/actions/serviceActions.ts

into pure read services that accept:

  • tenantId
  • filters / pagination
  • optional actor context for permission-aware behavior

This keeps the runner capability path reusable and testable without HTTP or Next.js server action wrappers.

Testing Strategy

Runtime Contract Tests

Validate:

  • WIT bindings compile and map correctly
  • sdk/extension-runtime host typings and mocks include the new capabilities
  • wrapper code can import and call the new host functions

Provider Tests

Cover:

  • user present and permission granted
  • user present and permission denied
  • no user present with capability granted
  • invalid filter and pagination inputs
  • not-found behavior for get
  • tenant isolation

End-To-End Sample Extension

Add a sample extension that:

  • lists clients
  • lists services
  • gets a single client by id
  • gets a single service by id

Validate it through:

  • direct runner execute
  • iframe path through callHandlerJson

Risks

  • Returning full internal record shapes would create long-term contract drift pressure.
  • Reusing existing server actions directly would over-couple the runner to HTTP and auth wrappers.
  • Non-user execution requires careful capability gating so scheduled or webhook runs remain powerful but bounded.

Open Questions

  • Whether tags should be included in ClientSummary v1 or deferred if they materially expand query cost.
  • Whether services should expose currency-specific prices in v1 or keep only defaultRate.
  • Whether future write capabilities should remain separate (cap:client.write, cap:service.write) rather than extending these read surfaces.

Decision

Proceed with typed, read-only runner-native capabilities for clients and services, using capability-based authorization with conditional user RBAC enforcement and shared read services under the runner provider layer.