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.
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
- Tenant directory. One row per Organization (YourEra DTC, Biologics, LCMC, Sleep Corner, etc.). Mirrored into Canvas FHIR as
Organizationresources; this service owns the non-FHIR attributes Canvas doesn't model. - Branding, portal config, notification config. Per-org theme tokens, portal feature flags, and verified sender identity for email/SMS. Materialized for the patient portal and notification-service via event subscription.
- Custom domains. DNS verification lifecycle for white-label intake and portal hostnames (e.g.
patients.sleepcorner.com). - Admin users + memberships. Superadmin, org_admin, org_user, support. Admin users are created via Clerk then linked into org memberships.
- Physician memberships. Which physicians serve which orgs — used by the intake flow, the orchestrator, and the Canvas RxQueue plugin.
Non-responsibilities
- No clinical data. Patient records, MedicationRequests, Observations, Consents — all in Canvas.
- No Stripe. Org-level billing belongs in commerce-service / payment-service.
- No notification delivery. Only config. Sender identity moves; notification-service actually sends.
- No physician licensure. Owned by physician-registry; organization-service only holds the org↔physician relation.
Status
| Field | Value |
|---|---|
| Wave | A1 — Foundation |
| Shipped | 2026-04-20 |
| Commit | 900ef59 |
| Tests | 510 unit |
| Spec | /docs/schema/organization-domain.md |
| Route prefix | api.hisera.com/v1/organizations/* and sibling admin paths |
| Dependencies | Canvas 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.
| Table | Purpose |
|---|---|
organizations | Core row. Slug, display name, kind (dtc / b2b), canvas_organization_id, status (active / suspended / archived), timestamps. |
organization_branding | Theme tokens: primary color, logo URL, favicon, email header image, preferred display name per surface. |
organization_portal_config | Portal feature flags: messaging enabled, appointment self-scheduling, refill self-service, terms URL. |
organization_notification_config | Verified sender identity: from-address, from-name, Twilio sender number, SES configuration set, default template set. |
organization_custom_domains | Hostname rows with a state machine: pending_dns → pending_tls → active → deactivated. Carries verification token, last-checked timestamp. |
admin_users | Clerk-linked admins. One row per human. is_superadmin boolean for platform-level access. |
admin_org_memberships | Admin × Org join. Roles: org_admin, org_user, support. Drives tenant-scoped RBAC across the whole platform. |
physician_org_memberships | Physician × Org join. Lets one physician serve multiple orgs. Cross-referenced with physician-registry for state licensure. |
outbox | Standard transactional outbox. Every state change emits here and gets shipped to EventBridge by the outbox worker. |
audit_events | Admin-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).
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.
| Role | Scope | Can do |
|---|---|---|
superadmin | Platform-wide | Everything, including creating orgs and granting memberships. Triggered by admin_users.is_superadmin = true. |
org_admin | Single org | Edit branding, portal config, notification config, custom domains, and manage org_user + support roles within their org. Cannot grant other org_admins. |
org_user | Single org | Read + operate (e.g. open saga console, view patients). No config writes. |
support | Single org | Read-only everywhere in their org. Used for customer-service staff and auditors. |
HTTP Endpoints
Organizations
Create a new tenant. Superadmin only. Creates the Canvas Organization via canvas-client in the same transaction and emits organization.created.
List. Superadmin sees all; other roles see only orgs they have membership in.
Read single. Requires requireOrgAccess.
Update core fields (display name, slug). Emits organization.updated.
Soft-disable. Patient portal resolution returns 503 for suspended orgs. Emits organization.suspended.
Terminal state. Archived orgs are invisible to all non-superadmin APIs. Emits organization.archived.
Per-org config
Replace the branding row. Emits organization.branding_updated consumed by patient-portal + notification-service for asset rehydration.
Replace portal feature flags. Patient portal re-fetches on next load; no forced session invalidation.
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
Register a hostname. Returns the DNS verification record the org must publish.
Polls DNS for the verification record. On success, transitions state pending_dns → pending_tls and kicks off certificate issuance.
Once TLS is live, flip to active. Emits organization.custom_domain_activated consumed by the portal edge router.
Tear down. Transitions to deactivated (not deleted — we keep the audit row).
Admin users + memberships
Create an admin shell row (invited state). An invite email is sent; on Clerk sign-up the row activates.
List admins, filtered by visibility of the caller's memberships.
Grant a role on an org. Body: { organizationId, role }. Superadmins may grant org_admin; org_admins may grant only org_user or support.
Revoke. Emits admin.membership_revoked; admin-portal invalidates any cached session derivatives.
Physician memberships
Grant a physician the right to serve an org. Intake routing and Canvas RxQueue filtering use this relation.
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:
| State | Meaning | Exit |
|---|---|---|
pending_dns | Hostname registered; verification record not yet detected | /verify succeeds → pending_tls |
pending_tls | DNS good; certificate issuance in flight | Cert ready → operator calls /activate |
active | Live — edge routes traffic to patient-portal or intake with the org resolved | DELETE → deactivated |
deactivated | Historical row; no traffic served | Terminal |
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
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.
| Event | When | Primary consumers |
|---|---|---|
organization.created | New tenant | notification-service (provision sender slot), webhook-receiver (none), analytics |
organization.updated | Core field change | admin-portal caches |
organization.suspended | Temporary disable | patient-portal edge, intake-gateway |
organization.archived | Terminal close | All services (deny further activity) |
organization.branding_updated | Theme change | patient-portal, notification-service (template header) |
organization.custom_domain_registered | New domain row | DNS verifier worker |
organization.custom_domain_activated | TLS live | portal edge router, intake edge |
organization.custom_domain_deactivated | Tear-down | portal edge router |
admin.user_invited | Shell row created | notification-service (invite email) |
admin.user_activated | Clerk linked | admin-portal |
admin.user_suspended | Lockout | admin-portal, session revocation |
admin.membership_granted | Grant role | admin-portal |
admin.membership_revoked | Revoke role | admin-portal (kill sessions) |
physician.membership_granted | Physician joins org | intake-gateway (routing), orchestrator |
physician.membership_revoked | Physician leaves org | intake-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.
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:
- Intake Gateway. Calls
/public/resolveat session start to attach organization_id to theintake_sessionsrow. - Patient Portal. Calls
/public/resolveat edge to render branding and flip portal feature flags. Calls admin endpoints never. - Admin Portal. Primary client. Uses the full admin API for org CRUD, membership management, and config editing.
- Notification Service. Subscribes to
organization.*events to keep sender identity and branding in sync. - Patient Service. Reads org metadata for the email-lookup privacy check and to verify callers have membership on
body.organizationId. - Physician Registry. Reads
physician_org_membershipswhen computing routing eligibility. - Webhook Receiver. Resolves Canvas resource → managingOrganization → internal org id on incoming Canvas webhooks.
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
- B2B contract table. Today, contract terms (pricing, prepaid bundles, margins) live in the commerce-service spec. They may migrate to organization-service or stay separate — pending the commerce-service design doc.
- Membership expiry. Currently memberships are permanent until revoked. Time-bound grants (e.g. locum physicians) are deferred.
- Audit export.
audit_eventsis queryable via admin API but there's no bulk export for SOC2 review cycles. Planned. - Per-domain cert rotation alerts. Cert expiry monitoring exists in infra, but a first-class alert at the org level (so org_admins can see domain health) is pending.
- Soft-delete for admin users. Currently only suspend. A terminal
archivedstate for admin_users parallel to organizations is a Wave B follow-up.