Organization Service

Tenant & admin directory. The table-of-organizations for both YourEra DTC and GuideGLP B2B clients. Owns per-org branding, portal config, notification config, custom domains, admin users, and physician × organization memberships.

Shipped in Wave A1 · 2026-04-20 · commit 900ef59.

Foundational service for the Canvas-first refactor. All other sidecars depend on it for tenant resolution, role enforcement, and physician-org routing. 510 unit tests passing. Deployed behind api.hisera.com/v1/organizations/* via the unified API gateway.

Overview

The organization-service is the backbone of multi-tenancy. Every Canvas-first service that needs to know "which tenant is this?", "is this admin allowed to see this org?", or "which physician covers this patient?" calls organization-service first and Canvas second.

Canonical spec: /docs/schema/organization-domain.md in the platform monorepo. The source of truth for the table shapes, role matrix, and event taxonomy below.

Responsibilities

Non-responsibilities

Status

FieldValue
WaveA1 — Foundation
Shipped2026-04-20
Commit900ef59
Tests510 unit
Spec/docs/schema/organization-domain.md
Route prefixapi.hisera.com/v1/organizations/* and sibling admin paths
DependenciesCanvas FHIR (via @yourera/canvas-client) for Organization mirroring; Clerk for admin identity

Database Schema

Ten tables owned by the service. All FKs are intra-service; nothing cross-service.

TablePurpose
organizationsCore row. Slug, display name, kind (dtc / b2b), canvas_organization_id, status (active / suspended / archived), timestamps.
organization_brandingTheme tokens: primary color, logo URL, favicon, email header image, preferred display name per surface.
organization_portal_configPortal feature flags: messaging enabled, appointment self-scheduling, refill self-service, terms URL.
organization_notification_configVerified sender identity: from-address, from-name, Twilio sender number, SES configuration set, default template set.
organization_custom_domainsHostname rows with a state machine: pending_dnspending_tlsactivedeactivated. Carries verification token, last-checked timestamp.
admin_usersClerk-linked admins. One row per human. is_superadmin boolean for platform-level access.
admin_org_membershipsAdmin × Org join. Roles: org_admin, org_user, support. Drives tenant-scoped RBAC across the whole platform.
physician_org_membershipsPhysician × Org join. Lets one physician serve multiple orgs. Cross-referenced with physician-registry for state licensure.
outboxStandard transactional outbox. Every state change emits here and gets shipped to EventBridge by the outbox worker.
audit_eventsAdmin-action log: who did what to whom, when, via which IP/UA. Retained per org policy.

Auth & Role Matrix

All authenticated endpoints require a JWT issued by organization-service. The JWT is HS256, short-lived (15 min), and carries { sub, kind: 'admin', isSuperadmin, memberships: [{ orgId, role }] }. Admins obtain it via either a Clerk session exchange or an email-OTP verification (for admins without a Clerk identity yet).

Middleware chain. Every protected route runs requireAdmin first (JWT + freshness) and then, for org-scoped routes, requireOrgAccess which verifies the claim carries a membership for the target organization_id. Superadmins bypass requireOrgAccess.
RoleScopeCan do
superadminPlatform-wideEverything, including creating orgs and granting memberships. Triggered by admin_users.is_superadmin = true.
org_adminSingle orgEdit branding, portal config, notification config, custom domains, and manage org_user + support roles within their org. Cannot grant other org_admins.
org_userSingle orgRead + operate (e.g. open saga console, view patients). No config writes.
supportSingle orgRead-only everywhere in their org. Used for customer-service staff and auditors.

HTTP Endpoints

Organizations

POST /organizations

Create a new tenant. Superadmin only. Creates the Canvas Organization via canvas-client in the same transaction and emits organization.created.

GET /organizations

List. Superadmin sees all; other roles see only orgs they have membership in.

GET /organizations/:id

Read single. Requires requireOrgAccess.

PATCH /organizations/:id

Update core fields (display name, slug). Emits organization.updated.

POST /organizations/:id/suspend

Soft-disable. Patient portal resolution returns 503 for suspended orgs. Emits organization.suspended.

POST /organizations/:id/archive

Terminal state. Archived orgs are invisible to all non-superadmin APIs. Emits organization.archived.

Per-org config

PUT /organizations/:id/branding

Replace the branding row. Emits organization.branding_updated consumed by patient-portal + notification-service for asset rehydration.

PUT /organizations/:id/portal-config

Replace portal feature flags. Patient portal re-fetches on next load; no forced session invalidation.

PUT /organizations/:id/notification-config

Replace sender identity. Notification-service is the authoritative consumer; it verifies the sender is still valid in SES/Twilio on the next send.

Custom domains

POST /organizations/:id/custom-domains

Register a hostname. Returns the DNS verification record the org must publish.

POST /organizations/:id/custom-domains/:domain/verify

Polls DNS for the verification record. On success, transitions state pending_dnspending_tls and kicks off certificate issuance.

POST /organizations/:id/custom-domains/:domain/activate

Once TLS is live, flip to active. Emits organization.custom_domain_activated consumed by the portal edge router.

DELETE /organizations/:id/custom-domains/:domain

Tear down. Transitions to deactivated (not deleted — we keep the audit row).

Admin users + memberships

POST /admin-users

Create an admin shell row (invited state). An invite email is sent; on Clerk sign-up the row activates.

GET /admin-users

List admins, filtered by visibility of the caller's memberships.

POST /admin-users/:id/memberships

Grant a role on an org. Body: { organizationId, role }. Superadmins may grant org_admin; org_admins may grant only org_user or support.

DELETE /admin-users/:id/memberships/:orgId

Revoke. Emits admin.membership_revoked; admin-portal invalidates any cached session derivatives.

Physician memberships

POST /physicians/:id/memberships

Grant a physician the right to serve an org. Intake routing and Canvas RxQueue filtering use this relation.

DELETE /physicians/:id/memberships/:orgId

Revoke. In-flight sagas continue; new sagas will route elsewhere.

Custom Domain Lifecycle

White-label hostnames (portal and intake) flow through an explicit state machine:

StateMeaningExit
pending_dnsHostname registered; verification record not yet detected/verify succeeds → pending_tls
pending_tlsDNS good; certificate issuance in flightCert ready → operator calls /activate
activeLive — edge routes traffic to patient-portal or intake with the org resolvedDELETE → deactivated
deactivatedHistorical row; no traffic servedTerminal
Edge routing contract. Only active custom domains are resolvable via /public/resolve. The portal edge treats non-200 responses as 404 and serves a generic landing page — do not leak that a hostname is registered but not yet active.

Public Host Resolver

GET /public/resolve?hostname=patients.sleepcorner.com

Unauthenticated. The only endpoint that is public. Called by the patient-portal and intake-gateway edges on every request to resolve hostname → organization. Returns minimal, non-sensitive data: org id, slug, kind, branding snapshot, portal config snapshot. Aggressively cached at the edge with a short TTL (60s) and invalidated on organization.branding_updated and custom-domain activation/deactivation events.

{
  "organization": {
    "id": "01HXY...",
    "slug": "sleepcorner",
    "kind": "b2b",
    "displayName": "Sleep Corner",
    "status": "active"
  },
  "branding": {
    "primaryColor": "#0b3a5b",
    "logoUrl": "https://cdn.../sleepcorner/logo.svg"
  },
  "portalConfig": {
    "messagingEnabled": true,
    "selfSchedulingEnabled": false
  }
}

Emitted Events

Every state change writes to outbox and is shipped to EventBridge by the outbox worker. All events carry organizationId so downstream subscribers can filter.

EventWhenPrimary consumers
organization.createdNew tenantnotification-service (provision sender slot), webhook-receiver (none), analytics
organization.updatedCore field changeadmin-portal caches
organization.suspendedTemporary disablepatient-portal edge, intake-gateway
organization.archivedTerminal closeAll services (deny further activity)
organization.branding_updatedTheme changepatient-portal, notification-service (template header)
organization.custom_domain_registeredNew domain rowDNS verifier worker
organization.custom_domain_activatedTLS liveportal edge router, intake edge
organization.custom_domain_deactivatedTear-downportal edge router
admin.user_invitedShell row creatednotification-service (invite email)
admin.user_activatedClerk linkedadmin-portal
admin.user_suspendedLockoutadmin-portal, session revocation
admin.membership_grantedGrant roleadmin-portal
admin.membership_revokedRevoke roleadmin-portal (kill sessions)
physician.membership_grantedPhysician joins orgintake-gateway (routing), orchestrator
physician.membership_revokedPhysician leaves orgintake-gateway, orchestrator

Outbox + Worker

Every write that would emit an event does so inside the same Postgres transaction via the outbox table. A background worker ticks every few seconds, pulls unshipped rows, posts them to EventBridge, and marks them shipped. Exactly-once is guaranteed by a unique outbox.id used as EventBridge idempotency_token; consumers are still expected to be idempotent.

POST /admin/workers/outbox/tick

Superadmin-only operator control. Runs one pass of the outbox worker synchronously. Used in incident response when the background tick is behind or paused.

Consumers

Who calls organization-service and why:

Testing

510 unit tests live in services/organization-service/src/**/*.test.ts. Coverage split: role matrix exhaustively tested (every role × endpoint combination), custom-domain state machine property-tested, outbox emission verified for every mutating endpoint. Integration tests (real Postgres) are skipped in CI and run locally via pnpm test:integration --filter organization-service.

TODOs & Future Waves