Dentolize · Plugin Platform Walkthrough
On this pageBusiness viewTechnical view

How plugins work

Business view

Think of a Dentolize plugin the way you'd think of a Shopify app or a Slack integration: it's somebody else's product, built and hosted by them, that gets permission to talk to one clinic's Dentolize account. Dentolize never runs the plugin's code, and the plugin never gets a copy of Dentolize's database — it only sees what it's explicitly allowed to see, for as long as it stays installed.

The lifecycle has five stages:

  1. Listing. Xolize (today — there's no self-serve developer signup yet) creates a plugin listing: what it does, who built it, and exactly which data scopes and event notifications it's asking for.
  2. Review & publish. Xolize checks the listing — privacy policy present, scopes justified, settings form valid, Arabic copy included — then publishes it into the Plugin Hub.
  3. Install. A clinic owner finds the plugin, reads precisely what it will be able to see (grouped by sensitivity, in plain language), accepts a data-processing agreement, and installs it. This mints credentials.
  4. Runtime. The plugin's backend calls Dentolize's API with its token to pull data, and Dentolize pushes it webhook notifications when relevant things happen in the clinic.
  5. Change and exit. The clinic can rotate credentials, manage webhook endpoints, watch a delivery log, or uninstall entirely — which immediately kills API access and webhook delivery.

Two design choices matter for anyone explaining this to a clinic or a partner:

  • Consent is granular by data type, not by feature. A clinic doesn't approve "the reminders plugin" as a black box — it approves a specific list of scopes (e.g. "read appointment times", "read patients with masked identity") and a specific list of events. If the plugin later asks for more, the clinic has to re-consent.
  • Nothing runs inside Dentolize. There's no plugin sandbox executing partner code on Xolize infrastructure. This drastically limits what can go wrong on Dentolize's side, at the cost of every plugin needing its own hosted backend.

Technical view

The data model

Six new Prisma models carry the whole platform (packages/prisma/schema.prisma:7210-7379):

ModelPurpose
PluginThe registry entry / listing: name, description (EN+AR), scopes/events it requests, settings schema, status (DRAFT / IN_REVIEW / PUBLISHED / SUSPENDED / DEPRECATED), version. (schema.prisma:7238-7267)
PluginInstallationOne row per (plugin, company) install: the consent snapshotgrantedScopes, grantedEvents, dpaAcceptedAt/By, pluginVersionAtConsent — plus status (ACTIVE/DISABLED/UNINSTALLED). (schema.prisma:7269-7294)
PluginTokenAn API credential: tokenHash (SHA-256 of the secret — the plaintext is never stored), tokenPrefix for display, scopes, mode (LIVE/TEST), and an optional branchIds[] to narrow the token to specific branches. (schema.prisma:7296-7317)
WebhookEndpointA clinic-configured delivery URL: secretCiphertext (AES-256-GCM), which events[] it's subscribed to, active, consecutiveFailures. (schema.prisma:7319-7339)
WebhookEventAn emitted domain event (thin payload, ids + minimal state). (schema.prisma:7341-7355)
WebhookDeliveryOne delivery attempt of one event to one endpoint — status, attempts, response snippet. (schema.prisma:7357-7379)

The consent snapshot on PluginInstallation is deliberately not a live pointer to the plugin's current requested scopes — it's a point-in-time copy. If a plugin's listing later requests more scopes, existing installations keep their old, narrower grant until the clinic re-consents (enforced by comparing pluginVersionAtConsent — see docs/plugin-platform/compliance.md).

Install-time authorization logic

installPlugin (packages/server/src/resolvers/mutations/actions/plugin/installPlugin.js:21-59) is the core state transition:

  1. Rejects if acceptDpa isn't true.
  2. Loads the plugin and rejects if it isn't PUBLISHED.
  3. Validates the caller's requested grantedScopes against validateGrantedScopes() (packages/server/src/plugins/registry/validation.js:89): every scope in the plugin's requestedScopes is required (must all be granted); anything beyond that must come from the plugin's optionalScopes list (added in the 20260801134437_plugin_optional_scopes_branch_tokens migration).
  4. Requires grantedEvents to exactly match requestedEvents — event consent is still strictly all-or-nothing, with no optional-events concept.
  5. Mints the first PluginToken via mintToken(), in TEST mode automatically when PLUGIN_TEST_MODE_ENABLED is set (sandbox), else LIVE.

A gap worth knowing about: the data model and this resolver already support per-scope optional consent (a clinic could in principle grant a subset of a plugin's optional scopes), but neither UI exposes it yet. AdminPlugin.js (the Xolize admin listing editor) has no field to set optionalScopes on a listing, and the clinic install modal always sends grantedScopes: plugin.requestedScopes verbatim with no per-scope toggle (InstallPluginModal.tsx, handleInstall). So in practice, as shipped, consent is still fully all-or-nothing — the optionalScopes/branch-scoped-token plumbing is present at the API layer for future UI work, not yet reachable by a clinic user or a listing author.

Runtime request path

Every plugin API request flows through a fixed middleware chain (packages/server/src/plugins/api/router.js:46-48):

requestLog → pre-auth IP rate limit → token auth → per-token weighted rate limit
  → synthetic tenant context → route handler → 404/error envelope

authenticatePluginToken (packages/server/src/plugins/auth/authenticatePluginToken.js:60) hashes the bearer token, looks it up (with a Redis cache keyed by pluginTokenCacheKey), and rejects revoked/expired tokens or a dtz_test_ token hitting production. attachSyntheticRequest then builds the same kind of synthetic session context the rest of the platform's GraphQL resolvers use, narrowed to the token's companyId and — if set — branchIds, so every downstream Prisma query is tenant-isolated by construction rather than by convention.

Rate limits

packages/server/src/plugins/auth/rateLimit.js:11-16: pre-auth 30 req/min per IP; post-auth 300 weighted points/min for live tokens, 60/min for test tokens, plus a 20-request/second burst cap. Route costs (ROUTE_COSTS, line 94): list = 3, detail = 1, write = 5.