Skip to Content
TechnicalPackagesServices Package

Services Package

@sustentus/services holds the platform’s server-side business logic — database access, AI, email, notifications, and shared utilities. It is consumed by apps/web (and other apps) through scoped entrypoints, never a single root import.

Overview

Package@sustentus/services
Pathpackages/services/
BuildTSUP (ESM + type declarations)
DatabaseMongoDB + Mongoose
AIVercel AI SDK v6 (Anthropic via the AI Gateway)

Entrypoints (import the right one)

import { … } from "@sustentus/services/server"; // DB connections, server-only logic import { … } from "@sustentus/services/shared"; // isomorphic utilities + types import { … } from "@sustentus/services/db"; // models + db services import { … } from "@sustentus/services/ai"; // the AI connector: defineAgent, model constants, tenant tools, prompt workspace import { … } from "@sustentus/services/sanitize"; // sanitizeAgentHtml / sanitizeRichTextHtml — HTML → the DOM, its own entry import { … } from "@sustentus/services/email"; // Resend + React Email sending import { … } from "@sustentus/services/blob"; // Vercel Blob upload/download

In apps/web and apps/agent, reach for /server and /shared first (ESLint bans the root @sustentus/services and /db outright); /ai, /sanitize, /email and /blob are the narrower entries for the jobs they name.

/sanitize is deliberately not folded into /shared. It pulls isomorphic-dompurify, which brings jsdom on the server, and /shared is imported by every app’s edge middleware (proxy.ts) — tsup bundles each entry as one file, so a jsdom import in /shared would land in the edge runtime.

The entry exports two sanitizers, and the split is by audience rather than by caller convenience. sanitizeAgentHtml allows the TipTap-safe set agent prompts are constrained to (p strong em ul ol li h2 h3 a br) and is for surfaces that render a model response unedited. sanitizeRichTextHtml allows what MinimalTiptapEditor can emit — every heading level, <u>, <code>, blockquote, code block, horizontal rule, the editor’s own class names, and inline data:image/* images — and is for the read view of a field a person authors in RichTextEditor. Remote image sources are dropped there rather than allowed: on a field an agent also writes, a <img src="https://attacker/…"> is an exfiltration beacon that fires on render. One of the two is the only sanctioned way HTML from an AI agent or the rich-text editor reaches dangerouslySetInnerHTML; widening the agent allowlist to suit an editor field widens every agent surface with it, which is the trade the split exists to avoid.

Source structure

packages/services/src/ ├── db/ # Mongoose models, db services, plugins, connection cache ├── ai/ # the connector: core/ (defineAgent, model constants, tenant tools), workspace/ │ # (runtime prompts), canned/, tools/, config — plus apps/web's legacy │ # brd/ and onboarding/ agents ├── sanitize/ # sanitizeAgentHtml + sanitizeRichTextHtml — own entry so jsdom stays out of /shared ├── email/ # Resend client + React Email templates ├── notifications/ # Per-resource notifyX (Mongo + Ably) · console/ tenant-event email ├── permissions/ # Feature registry, role templates + overrides, effective-permissions resolver ├── auth/ # Request identity — who is calling and which tenant they may read ├── blob/ # Vercel Blob helpers ├── server/ · shared/ · types/ · utils/ # entrypoint barrels └── index.ts

Request identity

Two apps have to answer the same three questions on every request — who is calling, which tenant they are working in, and whether they may read it — so the answer lives here once rather than twice. apps/web and apps/agent both resolve it from this package; neither owns a second copy.

  • resolvePlatformContext(selectedTenantId?) from /server returns a discriminated union over the platform’s two populations, or null for anyone else. A kind: "tenant" context is a user with a Clerk organisation and a role in the platform’s enum. A kind: "console" context is an org-less Sustentus staff member or invited partner — recognised by the same publicMetadata marker the tenant-management console authorises on — and carries the ConsoleTenantScope that bounds them plus the tenant they have currently selected, which may be none. It never throws at the caller: no session, an unrecognised role, an unreachable database and a tenant that will not load all resolve to null or to “no selection”, so a surface renders an honest empty state rather than a stack trace.
  • A console caller’s tenant is a choice, not a claim. The id arrives in an httpOnly cookie the app never trusts on its own: it is re-authorised against the caller’s scope on every request through tenantService.findForConsole, so a forged id, a revoked partner assignment and a deleted tenant are indistinguishable — each is simply no selection. That single authorisation home is what keeps the cookie from becoming a second, weaker gate.
  • Reach, not power. Staff and partners get more tenants to work in, never different rules inside one: both context kinds bind to exactly one tenant through the unchanged createTenantContext, and both get the identical toolset. isTenantReachable gates both branches, so a suspended workspace is stood down in the agent as it is in the platform.
  • getTenant() / getTenantOrNull() from /server are the tenant read behind the org branch, including the upsert-from-Clerk path and the sanitised failure logging. apps/web/lib/tenant.ts re-exports them and defines neither.
  • platform-auth.ts in /shared holds the claim-reading half of the role model — mapClerkRoleToSystemRole, deriveGrantedRoles, resolveEffectiveRole and the claim/cookie names. It is pure and isomorphic (no Clerk SDK, no next/headers, no database), which is what lets either app’s edge middleware call it. The cookie-reading and View As layers stay in the app that owns them.

AI

./ai contains the runtime AI built on the Vercel AI SDK:

  • Agent core (ai/core/) — the reusable foundation any AI feature builds on: defineAgent to declare a tool-loop agent, generateStructured for typed (Zod schema in, validated object out) generation, and a tenant-scoped tool registry (createTenantContext, defineTenantTool, buildTenantTools) so a tool wraps a domain service bound to one tenant and cannot reach across tenants. Exported from /ai (no database access).

  • Prompt workspace (ai/workspace/ + db/services/prompt-workspace/) — versioned system prompts assembled at runtime, structured as ICM layers, so prompt text is markdown someone edits rather than a constant a deploy ships. The pure half is exported from /ai: the code-owned rails preamble (tenant isolation, injection defences, tool-use law) composed into every assembled prompt and editable by nobody, the register catalogue deciding which files a register reads, the repo-shipped seed bodies, composition, and deterministic publish validation (required sections, rails overrides, the ICM token band). Its store is two platform-wide collections — promptfiles, one row per file holding the single mutable pointer at its live version, and promptversions, write-once bodies so history is never rewritten. Neither carries a tenantId: one workspace serves every tenant. promptWorkspaceService (from /server) resolves the published body per file, falls back to the seed where a file has never been published or the store cannot be read, and returns the exact version set behind the prompt. Publishing writes a version and moves the pointer; rolling back moves the pointer and writes nothing. Assembly runs per request, so a publish takes effect on the next conversation turn with no deploy. A prompt edit can change what an agent says and never what it may reach — tools stay code-owned and tenant-bound. The agentic assistant is the first consumer.

  • BRD agent (ai/brd/) — a tool-loop agent that qualifies a customer’s need into a business-requirements document through a guided conversation. Used by apps/web.

  • Tenant tools (ai/tools/) — domain services exposed as tenant-scoped agent tools (e.g. the read-only catalogue tool over products/services). Because they touch the database, they are exported from /server, not /ai.

  • Onboarding concierge (ai/onboarding/) — the tenant setup flow, in two parts. Source analysis turns a tenant’s own source (a website URL or a free-text business description) into confidence-scored candidate items per section against a shared Zod extraction contract — it persists nothing; website fetch is timeout- and size-bounded and degrades to a machine-readable reason rather than throwing. The concierge agent (buildOnboardingConciergeAgent) is a per-tenant tool-loop agent that interviews the first user through the six blueprint sections in order, offering each section’s candidates in conversation and drafting only what the operator confirms. The products section is a per-product gap interview: each product is captured as a typed, confidence-scored profile (overview, target customers, use cases, key features, pricing, integrations, differentiators, docs, support, FAQ — beyond the committed name + description), and the agent grills every product one at a time, asking only about the fields source analysis left empty or low-confidence. The conversation is deliberately scoped to just two sections — the company profile and, above all, the deepest possible knowledge of every product the vendor sells. Its tenant-scoped tools read and draft the profile, read and update per-product knowledge gaps, invoke source analysis, and advance the blueprint to in_review when setup is complete. Consumed by the apps/web /onboarding concierge wizard — its chat pane streams through the /api/onboarding/chat route while its read-only blueprint panel mirrors the conversation; the operator confirms and launches the blueprint as a whole via server actions over the onboarding-blueprint service. At launch the commit writes each product’s rich profile into the product’s knowledge sub-document, derives a default service and skill per product, and seeds the SLA and tenant settings from platform defaults — so the vendor never has to discuss them. Because it writes to the database it is exported from /server. The captured knowledge is read back with getProductKnowledge(tenantId, productId), which reads the product’s knowledge directly — the seam the BRD agent will consume — also from /server.

  • Canned replay (ai/canned/) — deterministic replay of the onboarding and BRD conversations for live walkthroughs, layered at the provider boundary: replay-model.ts is a LanguageModelV3 that streams a recorded transcript instead of calling a provider, so the agents, routes, tools and approval affordances above it are untouched. Transcripts are versioned JSON fixtures (transcripts/{onboarding,brd}.json) schema-checked on first use, and agent-model.ts is the one call a route makes — flow in, model plus replayed tool results out. Client-safe and DB-free, so it is exported from /ai. Reachable only on a tenant flagged isDemo — see the demo environment.

  • Expert-fit scoring (db/services/matching/) — generateObject scoring of expert↔lead fit.

Models

Every agent routes through Vercel’s AI Gateway (AI SDK v6) — models are addressed by gateway string, never a raw provider SDK. The current models are defined once, in packages/services/src/ai/core/agent.ts:

ConstantModelUsed for
DEFAULT_AGENT_MODELanthropic/claude-haiku-4.5Default for every platform agent
DEEP_EXTRACTION_MODELanthropic/claude-sonnet-5One-shot structured extraction over large schemas

This table is the docs’ single statement of the models — other pages link here instead of naming models, so a model bump is one code constant plus this table. Canned mode makes no provider call at all.

Notifications

A per-resource architecture. External callers use the notifyX functions exported from @sustentus/services/server (e.g. notifyBidSubmitted, notifyInvoicePaid, notifyServiceLeadCsatRequested). These persist a notification to MongoDB and publish it to Ably for real-time delivery. The notifications/core/** primitives are internal — never imported from outside the package. Full contract: packages/services/AGENTS.md.

Sends fail silently by design, and every failure is logged. A send never throws to its caller: createNotification returns null and the Ably publish and Resend send are fire-and-forget. So each failure path also writes a row to the notification-delivery-log collection — channel (in-app / realtime / email), reason, recipient and a truncated error — which admins read at /admin/notifications, and which the admin dashboard flags over a trailing 24 hours. Deliberate suppression (a preference switched off, a type in EMAIL_DISABLED_TYPES) is not a failure and is not recorded. The collection is bounded by a 30-day TTL index, so it cannot grow without limit.

The console’s email is a separate path under notifications/console/, and the two do not share a code path or a log. This one is per-resource, tenant-scoped, and delivers in-app + Ably + email to platform user rows; the console’s is a fan-out over tenant lifecycle events to three audiences — a tenant’s administrators, the assigned partner, and Sustentus staff — none of whom is necessarily a platform user, and one of whose events carries no tenant at all. It owns the matrix, the copy, the templates and its own failures-only console-email-delivery-log (also 30-day TTL); recipient resolution stays in apps/console, because it is entirely Clerk reads. See the console.

Permissions

./permissions holds the platform’s authorisation engine. The DB-backed resolver and models are exported from @sustentus/services/server; the pure, DB-free half is also exported from @sustentus/services/shared — the Permission type, the FEATURES / ALL_FEATURES registry, the PermissionScope type, the platform-default role templates, and the licence-class mapping. That split lets middleware-runtime code (the apps/web route policies) derive a route’s allowed roles from a required permission, and lets read-only surfaces render the permission vocabulary, without either pulling the resolver — and Mongoose behind it — into the bundle.

Licence classes are reporting, never a gate. LICENCE_CLASS_BY_ROLE maps each role to exactly one of Platform, Sales or Delivery — every role, the customer included, so every user appears in the licence report exactly once. Nothing in the platform reads it to allow or deny an action; it exists so capability can be grouped by class on the admin scope surface, and so the per-user licence report on that surface counts from the same mapping rather than a second one.

Two enforcement layers now read the model. Server actions enforce it via resolveActionContext, which resolves the caller’s effective set and rejects (Forbidden) when it lacks the action’s requiredPermission, threading the resolved own/full scope into the action context. Route access enforces it too: each route policy declares a requiredPermission and the proxy derives its allowed roles from the platform-default role templates — a coarse, static gate with no per-request DB read — and the AI route handlers gate through a shared requirePermission() helper backed by the resolver. The remaining admin/config surfaces and the workspace UI capability matrix stay on legacy role checks until later phases; a handful of full-tenant directory/log routes keep an explicit staff-only allow-list until page-level scoping lands with the UI capability cutover.

  • Feature-set registry (permissions/registry.ts) — a typed code constant and the single permission vocabulary, `${Feature}.${Action}` (e.g. invoice.approve). An unknown feature.action string is a compile error; a runtime isPermission guard checks DB-sourced values.
  • ModelsRoleTemplate (per-tenant role → default permission set, with own/full scope qualifiers and an append-only, bounded audit trail) and UserPermissionOverride (per-user grants + revocations, bounded, with an audit trail). Both tenant-scoped via the tenantPlugin.
  • ResolverresolveEffectivePermissions(tenantId, clerkUserId) returns effective permissions = (template ∪ grants) − revocations, with admin short-circuited to allow-all. The role comes from the local user mirror (no Clerk round-trip); the result is memoised per request and cached with a short TTL.
  • Cutover complete — every server action declares requiredPermission (resolver-enforced) or a documented authnOnly; the registry’s few unmodelled persona surfaces gate through an explicit allow-list (resolvePersonaActionContext in apps/web/lib/actions). The interim shadow-mode logger and its PERMISSIONS_SHADOW flag are gone — the resolver is the only decision path.
  • Admin “View As”ViewAsAudit is the append-only record of admin emulation: enter/exit events and every write made while emulating, each stamped with both the real admin and the emulated identity. apps/web swaps the principal the resolver and data-scoping filters see through a single admin-only, own-tenant guard (read-only — emulation never provisions a doc), so an emulated user’s effective set resolves through this same resolveEffectivePermissions with no separate code path. No Clerk session swap is involved — the emulation is server-side principal state, not an actor token.

Per-tenant role templates are seeded from platform defaults by a ts-migrate-mongoose migration, and a tenant admin edits them in-app from Settings → Role templates (roleTemplateService) — each change diffs the grid, writes the template’s audit trail, and (because enforcement is live) takes effect on the affected users’ next request. The admin role’s template renders as a read-only superuser set and cannot be edited or reset, so an admin can never be locked out of the permission system.

Live UI convergence. Enforcement is next-request-fresh, but an open session would otherwise keep rendering the old permission-gated UI (levers, nav) until the user navigates. On any per-user override, role-template, or primary-role change, invalidatePermissionsForUser / invalidatePermissionsForRole publish a permissions_changed event on the affected user’s per-user Ably channel and mirror the freshly resolved effective-permissions version hash into their Clerk publicMetadata (the hash only — a few bytes, well inside the custom-claim budget). apps/web subscribes and refreshes the server-rendered UI, so an open session converges on the new set without a manual reload. Both are best-effort — a failed nudge never fails the admin’s save — and server enforcement is untouched, so it stays a client-freshness optimisation layered on the existing next-request checks.

Metric dictionary

metricdefinitions is a platform-wide collection — definitions are the business’s, set centrally and identical for every vendor — so unlike almost every other model it carries no tenantPlugin. Ten records (M1M10) are seeded by a ts-migrate-mongoose migration, and every later change lands as a further migration rather than an edit to the one that shipped: corrected wording, a threshold a family depends on, or the removal of a definition whose figure has left the product. The collection is read-only: there is no write path in the product today, and a definition the dictionary no longer holds is one no surface can render.

A record holds what a figure means, never the figure itself — the KPI’s name, one-line summary, the question it answers and why it matters; its group and order on the vendor grid; its shape (cohort, activity or stock); and the claim vocabulary: population, period, basisDate, measure, exclusions, opensTo, and the settings it depends on. Each linksTo entry resolves to another record’s metricId or is explicitly marked not yet defined, so a dangling KPI name cannot render.

  • ReadsmetricDictionaryService from /server: list(), grid() (the three groups in order) and registry() (the groups plus the definitions with no grid tile). The vendor dictionary page, the vendor dashboard’s popovers and the admin registry all read the same records, which is what stops two surfaces stating a different definition.
  • Claim vocabulary and period resolvershared/metric-claim.ts and shared/period.ts, both exported from /shared so the model and the client surfaces share one definition of them. The surfaces render a Claim, which cannot exist without the RecordSet behind it; resolvePeriod({ preset, timeZone, now }) returns the window for last 90 days rolling, the current quarter or the last 12 months, with boundaries at local midnight in the vendor’s business timezone.
  • Tenant scopingvendorClaimFilter is the single place a vendor’s figures get scoped to that vendor. It throws rather than returning an unfiltered query, so a metric family that forgets it fails loudly instead of leaking. It exists because tenantPlugin does not hook aggregate, and the metric families are aggregation-shaped.
  • Metric families — the modules that turn the definitions into figures. The first is the vendor funnel and activation family: shared/vendor-funnel.ts holds the pure cohort logic — the lead workflow’s own forward path, the furthest stage each lead ever reached, the loss breakdown, and the reconciliation carried as data on the record set rather than as prose in a component — and vendorFunnelService from /server does the read, scoped through vendorClaimFilter, with stage labels resolved from workflows.json so the funnel carries no second vocabulary. The second is the delivery family: shared/vendor-delivery.ts classifies an engagement against the three conditions that make it Delivered — every milestone complete, every one acknowledged by the customer, and every bill settled — and derives Revenue Delivered and the on-time share from one list of records, so the value and the count cannot disagree. vendorDeliveryService does its read through the same scoping guard. The third is the satisfaction family: shared/vendor-satisfaction.ts holds the response cohort, the three-response floor below which no average is published, and the revenue weighting with its coverage share built into the claim rather than left to a surface to remember. vendorSatisfactionService reads through the same guard and windows responses on completedAt, a field csats now carries: the completion write sets it with $min, so the first answer stands and no later write can move a response into another period. The fourth is the money family: shared/vendor-money.ts and the money types in shared/metric-claim.ts hold the rule that every money figure names which of Delivered, Committed, Billed or Collected it is — MoneyClaim has no unlabelled variant to construct, and totalMoney fixes the state and basis from its first amount via NoInfer, so a mixed set is a type error rather than a silently wrong number. Active service revenue is the order book: a stock as at a moment rather than a period total, carrying the composition its value decomposes into and the delivery-stage partition its rows sum to. vendorMoneyService reads through the same guard, takes one reporting currency and excludes engagements in any other, stating the count it left out beside the figure rather than converting them. The fifth is the health family: shared/vendor-health.ts scores a customer out of 100 from four weighted components — satisfaction 40, engagement recency 25, delivery reliability 20, commercial trend 15 — where recency and trend are discrete buckets rather than interpolated curves, so a score can be reproduced by reading one value off the drill-down and finding its row. Each component is rounded once and the score is the sum of those rounded contributions, so the four figures on a row add up to the score beside them. A component with no record behind it is not a component worth zero: the customer carries no total and no band, lists every reason that applies, keeps the components it does have, and is excluded from the at-risk count rather than counted as safe. vendorHealthService reads through the same guard and reuses the delivery family’s own classifyEngagement rather than re-deriving Delivered, so the two surfaces cannot disagree about which work is done; for a period still in progress it narrows the comparison window to the same elapsed span, so an open quarter is read against a like-for-like slice of the previous one instead of against a complete period. No score is persisted — the arithmetic module holds no database import, so there is no stored figure for any role to adjust. The sixth is the retention family: shared/vendor-retention.ts folds the previous period’s delivered engagements into one row per customer — their median delivery time, their region, and whether they came back — and cuts that single list two ways. Both breakdowns counting off one list is what makes them reconcile to the same population and the same returner count by construction, rather than by two aggregations agreeing on the day they were written. Delivery time is calendar days in the vendor’s business timezone, both ends reduced to local midnight, so the count matches the basis printed beside it rather than the number of elapsed 24-hour spans, and the median is taken unrounded, so 14.5 days falls in the 15-to-30 bucket. Every speed bucket is emitted whether or not anyone is in it, and the unknown-region bucket is always shown and always last: it holds both unclassifiable cases — no country stamped on the engagement, and a country stamped that no region groups — and is never merged into a named region. vendorRetentionService reads through the same guard and takes Delivered from the delivery family’s own engagementInputs() rather than re-deriving it, deduplicating to one row per lead so a second accepted proposal cannot double-weight a job in its customer’s median. The returner side reads the first work_in_progress transition per lead from status history, over the accepted-proposal set only, and the tenant’s region rows are read without an isActive filter, so deactivating a populated region cannot move its customers into the unknown bucket and restate a completed period. The seventh is the risk family: shared/vendor-risk.ts takes the money family’s own active service revenue as its population rather than running a second query over engagements, so what counts as in flight cannot differ between the two figures, and joins it to the health family’s scored customers by customerId — never by display name, which two customers can share. Each engagement is flagged against three deliberately overlapping conditions — the customer is at renewal risk on the health family’s own atRisk verdict, the engagement has been in its stage longer than STALLED_IN_STAGE_DAYS, or the customer’s mean response is at or below LOW_SATISFACTION_DISPLAY_SCORE once divided by CSAT_SCALE_FACTOR into the displayed scale rather than compared against the stored 1–10 value — and counted exactly once however many it carries, so the total is not a sum of four overlapping categories. The record set carries a column per flag so the drill-down can open on one of them without implying the three partition anything, and the claim is a labelled slice of the order book that names the stock it came out of, never a second pot to add to it. Both thresholds have one home, the constants in the module, and M10’s displayed settings are generated from them by a migration, so the number on the definition is the number in the computation rather than a second copy that can drift from it.

Development

pnpm services:dev # TSUP watch mode pnpm services:build # build to dist/

Apps pick up changes automatically via the workspace link once the package is built.

Learn more

Last updated on