@rytass/bpm-core-nestjs-module
v0.1.10
Published
Embeddable NestJS BPM approval workflow module: workflow engine, approval templates, forms, organization/member contracts, attachments, signatures, notifications, delegation, and TypeORM migrations.
Maintainers
Readme
@rytass/bpm-core-nestjs-module
Embeddable NestJS module for BPM approval workflows.
This package provides the backend BPM domain layer: GraphQL resolvers, TypeORM entities, migrations, workflow execution, approval tasks, form definitions, approval templates, organization/member lookup contracts, delegation, notifications, SLA handling, attachments, and decision signatures.
It is designed to be embedded into a host NestJS application. The host application owns runtime infrastructure such as GraphQL setup, TypeORM connection setup, auth/session handling, Vault or secret loading, member directory integration, storage adapters, and deployment.
Package Status
Current version: 0.1.10
The package is intended for NestJS backend hosts. It does not include the Next.js backoffice UI and does not provide a production auth system by itself.
Frontend / cross-framework consumers should use
@rytass/bpm-core-client for the GraphQL
transport, REST auth client, and pre-baked typed operations against this
backend module.
Install
pnpm add @rytass/bpm-core-nestjs-module @rytass/bpm-core-shared
pnpm add @nestjs/common @nestjs/core @nestjs/graphql @nestjs/typeorm graphql typeorm reflect-metadata
pnpm add pgTypeScript moduleResolution: prefer node16, nodenext, or bundler in
your tsconfig.json so the package's exports field is honored. Classic
moduleResolution: "node" also works through the typesVersions fallback
shipped with this package, but is on TypeScript's long-term deprecation
path — new hosts should opt in to modern resolution.
If your host uses Apollo GraphQL:
pnpm add @nestjs/apollo @apollo/serverIf your host uses Vault-backed database settings:
pnpm add @rytass/secret-adapter-vault-nestjsRuntime Responsibilities
@rytass/bpm-core-nestjs-module owns BPM domain behavior.
Your host application must provide:
- A NestJS application runtime.
- A configured
GraphQLModule. - A configured TypeORM
DataSource/TypeOrmModule. - Auth/session/JWT logic.
- A
BPMAuthContextbridge from the request execution context. - A
BPM_MEMBER_RESOLVERprovider for member metadata lookup. - Attachment storage configuration when local storage is not acceptable.
- A dedicated worker or host dispatcher when email/webhook/SLA work should not run inside API replicas.
- Production secrets for attachment URL signing, signatures, SMTP, webhook, and Vault.
The package intentionally stores member IDs instead of owning user accounts. Member profiles, roles, permissions, and email addresses are resolved by the host.
Quick Start
The host must wire the same authenticated member into both GraphQL context and
BPMRootModule. BPM expects host root-level routing — do not call Nest's
setGlobalPrefix(). BPM controllers (notably AttachmentController) hardcode
relative paths and use attachmentRoutePrefix to mount themselves; if you
want a prefix in production URLs, set attachmentRoutePrefix or push the
prefix into a reverse proxy.
A minimal host module looks like this:
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { BPMRootModule, BPM_MEMBER_RESOLVER, buildTypeOrmModuleOptions } from '@rytass/bpm-core-nestjs-module';
import { VaultModule, VaultService } from '@rytass/secret-adapter-vault-nestjs';
import type { Request } from 'express';
import { buildBPMAuthContextFromExecutionContext } from './bpm-auth-context';
import { HostAuthModule } from './host-auth.module';
import { HostBPMMemberResolver } from './host-bpm-member.resolver';
import { HostSessionService } from './host-session.service';
@Module({
imports: [
HostAuthModule,
VaultModule.forRoot({
path: process.env.VAULT_PATH ?? 'bpm_core/develop',
}),
TypeOrmModule.forRootAsync({
imports: [VaultModule],
inject: [VaultService],
useFactory: buildTypeOrmModuleOptions,
}),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [HostAuthModule],
inject: [HostSessionService],
useFactory: (sessionService: HostSessionService) => ({
autoSchemaFile: true,
context: async ({ req }: { readonly req?: Request }) => ({
bpmAuthContext: await sessionService.readBPMAuthContextFromRequest(req),
req,
}),
driver: ApolloDriver,
path: '/graphql',
sortSchema: true,
}),
}),
BPMRootModule.forRoot({
imports: [HostAuthModule],
authContextFactory: buildBPMAuthContextFromExecutionContext,
memberResolverProvider: {
provide: BPM_MEMBER_RESOLVER,
useExisting: HostBPMMemberResolver,
},
}),
],
})
export class AppModule {}Auth Context
BPM guards and mutations need the current BPM member. The host may provide the
context in the GraphQL context object and expose it through
authContextFactory.
import type { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import type { BPMAuthContext } from '@rytass/bpm-core-nestjs-module';
interface HostGraphQLContext {
readonly bpmAuthContext?: BPMAuthContext | null;
readonly req?: {
readonly bpmAuthContext?: BPMAuthContext | null;
};
}
export function buildBPMAuthContextFromExecutionContext(context?: ExecutionContext): BPMAuthContext | null {
if (!context) {
return null;
}
const graphqlContext = GqlExecutionContext.create(context).getContext<HostGraphQLContext | undefined>();
return graphqlContext?.bpmAuthContext ?? graphqlContext?.req?.bpmAuthContext ?? null;
}BPMAuthContext shape:
export interface BPMAuthContext {
readonly memberId: string;
readonly metadata: Readonly<Record<string, unknown>>;
readonly permissions: readonly string[];
readonly roles: readonly string[];
}BPM also exports param decorators so host resolvers don't need to call
authContextFactory directly:
import {
BPMAuthenticated,
BPMCurrentAuthContext,
BPMCurrentMemberId,
type BPMAuthContext,
} from '@rytass/bpm-core-nestjs-module';
@Resolver()
class HostResolver {
@Query(() => HostSummary)
@BPMAuthenticated()
hostSummary(
@BPMCurrentAuthContext() auth: BPMAuthContext,
@BPMCurrentMemberId() memberId: string,
): HostSummary {
return buildHostSummary(auth, memberId);
}
}Bring-your-own-host-auth (for hosts that already have auth)
The BPM React <AuthProvider> calls POST /auth/login, GET /auth/me,
and POST /auth/logout on the BPM API host and expects responses that
match the ApiMember shape (exported from @rytass/bpm-core-client):
interface ApiMember {
readonly memberId: string;
readonly email: string;
readonly name: string;
readonly roles: readonly string[];
readonly permissions: readonly string[];
readonly expiresAt: string; // ISO 8601
}Status-code contract:
| Endpoint | Success | Failure |
| --------------------- | ----------- | ----------------------------------- |
| POST /auth/login | 200 + ApiMember JSON | 401 (bad credentials) / 400 (validation) |
| GET /auth/me | 200 + ApiMember JSON | 401 (no session — React client treats as anonymous, not error) |
| POST /auth/logout | 204 (or 200 empty) | 401 ignored |
Session lifetime is owned by the host. The React <AuthProvider>
relies on credentials: 'include' plus an HTTP-only cookie issued by
the host on successful login — BPM does not issue cookies itself.
Hosts that already have their own JWT / cookie auth have two integration patterns:
Pattern A — Extend the host API surface
Add /auth/login, /auth/me, /auth/logout controllers to your
existing NestJS host. Internally those endpoints call the host's
existing auth service (e.g. MemberBaseService.login()) and emit the
ApiMember shape. This is the simplest integration when BPM and the
host run on the same origin. Example for a member-base host:
@Controller('auth')
export class HostAuthController {
constructor(private readonly memberBase: MemberBaseService) {}
@Post('login')
async login(@Body() input: LoginInput, @Res({ passthrough: true }) res: Response): Promise<ApiMember> {
const { member, accessToken, refreshToken } = await this.memberBase.login(input);
// Set the host's existing HTTP-only cookies; the React client will
// forward them on subsequent /auth/me and /graphql calls.
res.cookie('access_token', accessToken, { httpOnly: true, sameSite: 'lax' });
res.cookie('refresh_token', refreshToken, { httpOnly: true, sameSite: 'lax' });
return projectMemberToApiMember(member);
}
@Get('me')
async me(@Req() req: Request): Promise<ApiMember> {
const member = await this.memberBase.resolveCurrentMember(req); // throws 401 if no cookie
return projectMemberToApiMember(member);
}
@Post('logout')
@HttpCode(204)
logout(@Res({ passthrough: true }) res: Response): void {
res.clearCookie('access_token');
res.clearCookie('refresh_token');
}
}The projectMemberToApiMember helper is host-specific — map your own
member shape to BPM's expectations, project Casbin roles into the BPM
role-literal strings (see "Mapping from a host RBAC system" below),
and compute expiresAt from the token TTL.
function projectMemberToApiMember(member: HostMember): ApiMember {
return {
memberId: member.id,
email: member.email ?? '', // ApiMember.email is `string`, not `string | null`
name: member.name,
roles: projectHostRolesToBPMRoles(member.roles), // [] if no admin/designer claim
permissions: [], // optional — return [] if not used
expiresAt: new Date(Date.now() + JWT_TTL_MS).toISOString(),
};
}Response status contract that BPM's React client expects:
| Endpoint | Success | Auth fail | Validation fail |
|---|---|---|---|
| POST /auth/login | 200 + ApiMember JSON, set HTTP-only cookie | 401 | 400 |
| GET /auth/me | 200 + ApiMember JSON | 401 (client treats as anonymous, not error) | n/a |
| POST /auth/logout | 204 (or 200 with empty body) | 401 ignored — client clears local state anyway | n/a |
roles and permissions MUST be arrays (use [] for no claim), not
null or undefined — the React client's member.roles.includes(...)
checks would crash otherwise.
Pattern B — Run BPM on a separate auth host
When BPM lives on its own subdomain (e.g. bpm.example.com) separate
from the main app at app.example.com, you can run a thin wrapper
host that only owns the /auth/* endpoints and a session cookie
scoped to *.example.com. The BPM React client points at this
subdomain via NEXT_PUBLIC_API_URL / NEXT_PUBLIC_API_AUTH_URL
(or configureBPMClient for server-side). The wrapper host
internally calls back to the main app to verify the user.
This pattern is heavier (cross-origin cookies, CORS) but lets the main app stay completely decoupled from BPM's auth surface. Use it only when a same-origin deployment is impossible.
Cross-host auth precedence
If the consumer wants the React client to skip the BPM auth entirely
and rely on the host's existing session check at the Next.js layer,
the simplest path is to not mount <AuthProvider> from
@rytass/bpm-core-react and instead implement a host-side
useAuth() shim that returns the host's session. This is rare — most
consumers find Pattern A simpler.
Machine-to-machine authentication
Server-side scripts (org seeds, cron workers, integration tests) call
configureBPMClient to set the GraphQL base URL and optional default
headers, then must establish a session. Three patterns work:
Pattern 1 — Service member + login flow (recommended)
Create a dedicated BPM admin member (e.g. [email protected]),
store its credentials in Vault, and let the script call loginApi
during bootstrap. The login response sets an HTTP-only session cookie
that subsequent requestGraphQl calls automatically include.
import { configureBPMClient, loginApi } from '@rytass/bpm-core-client';
async function bootstrap(): Promise<void> {
configureBPMClient({
baseUrl: process.env.BPM_API_URL ?? 'http://localhost:17603',
fetch: globalThis.fetch,
});
await loginApi({
identifier: process.env.BPM_SYNC_IDENTIFIER!,
password: process.env.BPM_SYNC_PASSWORD!,
});
// Subsequent calls to readOrganizationDashboard / createOrgUnit / etc.
// automatically forward the session cookie.
}Caveats: globalThis.fetch on Node 20+ does not maintain a cookie
jar across calls by default — set up one of:
undici'sAgentwithcookiesenabled, thensetGlobalDispatcher(agent)- the
tough-cookie+node-fetch-cookiespairing - BPM's
configureBPMClient({ fetch: cookieAwareFetch })injection
Without a cookie jar, the login succeeds but no subsequent call carries
the session. Most production deployments already use undici with
cookie support; verify before shipping.
Pattern 2 — Service token header (when supported by host)
If your wrapper host issues a long-lived service token (e.g. a JWT
signed with a known shared secret), pass it through
configureBPMClient's headers:
configureBPMClient({
baseUrl: process.env.BPM_API_URL!,
headers: { Authorization: `Bearer ${process.env.BPM_SERVICE_TOKEN!}` },
});This requires the wrapper host's auth middleware to honor Authorization
headers (member-base hosts can be configured to do so via
authStrategy: 'jwt' in addition to cookieMode: true).
Pattern 3 — Direct cookie injection (testing only)
For integration tests where you've already obtained a session cookie out-of-band (e.g. from a Playwright fixture), pass it raw:
configureBPMClient({
baseUrl: 'http://localhost:17603',
headers: { Cookie: 'access_token=<jwt>; refresh_token=<jwt>' },
});Do not use this in production scripts — cookies expire and the script must handle refresh, which means you actually want Pattern 1.
Coexisting with a host's existing <AuthProvider>
Many host applications (Shuttle, custom admin panels) already mount
their own <AuthProvider> at the root of the Next.js tree. BPM's
<AuthProvider> (mounted indirectly via <BPMNextProviders>) is a
separate context that only tracks BPM session state. The two do
not conflict — they coexist as parallel React contexts:
// app/layout.tsx — host's root layout (untouched)
import { HostAuthProvider } from '@your-host/auth';
export default function RootLayout({ children }) {
return (
<html><body>
<HostAuthProvider>{children}</HostAuthProvider>
</body></html>
);
}
// app/operations/approval/layout.tsx — BPM sub-tree
'use client';
import { BPMNextProviders } from '@rytass/bpm-core-react/next';
export default function ApprovalLayout({ children }) {
return (
<BPMNextProviders loginPath="/login">
{children}
</BPMNextProviders>
);
}Rules of thumb:
- Scope
<BPMNextProviders>to the BPM sub-tree — never put it at the root. Otherwise every host page goes through BPM's auth gate, which redirects unauthenticated users to BPM's/login. - BPM auth and host auth share the cookie jar when same-origin.
If
<BPMNextProviders loginPath="/login">and the host's auth share/login, the same login form can satisfy both — implement the host login endpoint to also satisfy/auth/loginfrom BPM's contract. - Do not import
<AuthProvider>from@rytass/bpm-core-reactdirectly if you've already mounted<BPMNextProviders>— double-wrapping creates redundant/auth/mepolls.
Role and Permission Contract
BPM guards inspect BPMAuthContext.roles and BPMAuthContext.permissions with
exact-string matching. The host must inject one of the following strings to
clear admin / designer guards; otherwise BPM resolvers reject with
ForbiddenException.
| Tier | Accepted roles or permissions | Guards / decorators / helpers |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Admin | roles: ['BPM_ADMIN']; or permissions contains one of bpm:*, bpm:admin, bpm.admin, bpm:admin:* | BPMAdminGuard, @BPMAdminOnly(), isBPMAdmin(authContext) |
| Designer | Includes admin; or roles: ['BPM_DESIGNER']; or permissions contains one of bpm:design, bpm.design, bpm.form.design, bpm.template.design, bpm:form:design, bpm:template:design | BPMDesignerGuard, @BPMDesignerOnly(), isBPMDesigner(authContext) |
| Authenticated | BPMAuthContext.memberId is non-empty | BPMAuthenticatedGuard, @BPMAuthenticated() |
@BPMAdminOnly() and @BPMDesignerOnly() already chain
BPMAuthenticatedGuard. The host does not need to add @BPMAuthenticated()
on top of them.
Mapping from a host RBAC system (e.g. Casbin)
BPM does not call the host's RBAC engine — it reads exact-string roles
and permissions arrays off BPMAuthContext. Hosts using Casbin /
Permify / OPA must project their grouping policy into the BPM
literals inside their authContextFactory. A worked Casbin example:
import { Inject, Injectable, type ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { CASBIN_ENFORCER, type CasbinEnforcer } from '@your-host/auth';
import type { BPMAuthContext } from '@rytass/bpm-core-nestjs-module';
const HOST_TO_BPM_ROLES: Readonly<Record<string, string>> = {
approval_admin: 'BPM_ADMIN',
approval_designer: 'BPM_DESIGNER',
};
@Injectable()
export class BPMAuthContextFactory {
constructor(@Inject(CASBIN_ENFORCER) private readonly enforcer: CasbinEnforcer) {}
async build(ctx: ExecutionContext): Promise<BPMAuthContext | null> {
const req = GqlExecutionContext.create(ctx).getContext<{
req?: { member?: { id: string; email: string; name: string } };
}>().req;
const member = req?.member;
if (!member) return null;
// 1. Look up the host's Casbin role assignments for this member.
const hostRoles = await this.enforcer.getRolesForUser(member.id);
// 2. Translate into BPM's exact-string literals. Unknown host roles
// do nothing — BPM only checks for the documented strings.
const bpmRoles = hostRoles
.map((r) => HOST_TO_BPM_ROLES[r])
.filter((r): r is string => Boolean(r));
return {
memberId: member.id,
roles: bpmRoles,
permissions: [], // Host can also use `bpm:*` style permissions if preferred.
// The BPMAuthContext shape does NOT include name/email — those are
// surfaced through BPMMemberResolver instead. If you want them
// available to downstream resolvers without an extra resolver call,
// stash them in `metadata` (BPM passes it through unchanged).
metadata: { name: member.name, email: member.email },
};
}
}
BPMAuthContextshape:{ memberId, roles, permissions, metadata }— exactly four fields. The host'sname/BPMMemberResolver.resolve(memberId)when they need display data.
The host owns the mapping table. Keep it in one place (a constant module or a database table) so adding a new BPM tier in a future BPMCore release becomes a one-line addition.
Member Resolver
The package resolves display names, email addresses, and approver candidates
through BPM_MEMBER_RESOLVER.
For a member-base-like directory, prefer the package helper. It creates a Nest provider without forcing BPM to depend on the host identity package:
import { Injectable, type InjectionToken, type Provider } from '@nestjs/common';
import { BPM_MEMBER_RESOLVER, type BPMMemberBaseDirectory, createBPMMemberBaseResolverProvider } from '@rytass/bpm-core-nestjs-module';
interface HostMember {
readonly email: string;
readonly id: string;
readonly name: string;
readonly permissions: readonly string[];
readonly roles: readonly string[];
}
@Injectable()
export class HostMemberDirectory implements BPMMemberBaseDirectory<HostMember> {
async resolveMember(memberId: string): Promise<HostMember | null> {
return findHostMember(memberId);
}
async searchMembers(searchText: string): Promise<readonly HostMember[]> {
return searchHostMembers(searchText);
}
}
export const HOST_MEMBER_DIRECTORY: InjectionToken<BPMMemberBaseDirectory<HostMember>> = Symbol('HOST_MEMBER_DIRECTORY');
export const hostMemberProviders: readonly Provider[] = [
{
provide: HOST_MEMBER_DIRECTORY,
useClass: HostMemberDirectory,
},
createBPMMemberBaseResolverProvider<HostMember>({
// ^^^^^^^^^^^^ explicit generic is recommended — without it,
// TS infers `unknown` for the adapter callbacks below and you get
// "Parameter 'member' implicitly has an 'any' type" errors.
adapterOptions: {
readEmail: (member): string => member.email,
readMemberId: (member): string => member.id,
readName: (member): string => member.name,
},
directoryToken: HOST_MEMBER_DIRECTORY,
provide: BPM_MEMBER_RESOLVER,
}),
];Required resolver contract:
export interface BPMMemberResolver {
resolve(memberId: string): Promise<MemberMetadata>;
resolveMany(memberIds: readonly string[]): Promise<ReadonlyMap<string, MemberMetadata>>;
search?(searchText: string): Promise<readonly MemberMetadata[]>;
}Organization Data Ownership
Read this before integrating BPM into a host that already has its own organization model. The org-integration shape is asymmetric with member integration above — knowing which side owns what saves a day of source spelunking.
Ownership claim
BPM is the sole authority for four organization tables:
| Table | Owns |
|---|---|
| org_units | Department / division / company nodes (typed by OrgUnitType) |
| positions | Job titles, with optional org-unit scoping |
| memberships | Member × org-unit × position relations (the join table that puts a member at a position inside a unit) |
| manager_resolutions | "Who is whose manager" rules (per-member, per-org-unit, or per-position scope), consumed by the ORG_MANAGER approver resolver |
There is no host-injectable OrgUnitResolver / PositionResolver /
MembershipResolver pattern, deliberately so. If you went looking for
the symmetric counterpart to BPMMemberResolver, stop now — it doesn't
exist, and the rest of this section explains why and what to do instead.
Why no resolver
The BPM workflow engine reads the org graph on hot paths:
- Approver routing — every running instance evaluates
UserTaskNodecandidates (resolved viaDIRECT,POSITION,ORG_MANAGER, orCANDIDATE_GROUP) against the current org snapshot. AParallelGatewayfanning out to "all managers in the IT division" may touch dozens of membership rows in one step. - Tree-diff commits —
commitOrgUnitTreeDraftwrites batched moves inside a single transaction, with referential integrity againstmembershipsandmanager_resolutions. Round-tripping each lookup through a host adapter would make this prohibitively chatty. - Reporting — admin dashboards aggregate counts across the full graph.
A read-through adapter would force the host to mirror BPM's tree semantics (ltree paths, soft-delete masking, position-vs-org-unit join shape) anyway. We chose to make BPM authoritative so the contract is one direction only.
Mirror pattern
Host applications that already maintain their own org structure should
mirror their data into BPM through the GraphQL mutations exposed by
@rytass/bpm-core-client/organization. Three rules keep this idempotent
and observable:
OrgUnit.codeis your natural key. EverycreateOrgUnit/createPositionaccepts acodefield that you control. BPM enforces uniqueness; use your host's stable identifier here (e.g. an LDAP DN slug or a internal ERP code). On a re-sync, look up existing entities bycodebefore deciding INSERT vs UPDATE.metadataJsonis write-only. The mutations (createOrgUnit/updateOrgUnit/createPosition/updatePosition) accept ametadataJsonstring that BPM stores verbatim. However,OrgUnitRecordandPositionRecordreturned byreadOrganizationDashboarddo NOT include the metadata field — it's intentionally hidden so the JSON blob doesn't bloat every pagination payload. For reconciliation, always key oncode(which IS returned). TreatmetadataJsonas a debugging/audit stash, not a live FK pointer.- Soft delete via
deleteOrgUnit/deleteMembership/deleteManagerResolution. Deletes setdeletedAtrather than removing rows. BPM's query layer hides soft-deleted rows automatically; readingorgUnitCount()will report the live count. - Positions are intentionally not deletable. There is no
deletePositionmutation — positions are part of the historical record (every signed approval task references the assignee's position at sign time, so deleting a position would lose audit data). Retire a position by removing all active memberships referencing it; the position itself stays. UseupdatePosition({ id, name: 'retired-' + oldName, ... })if you want a UI hint that a position is no longer in use.
Worked example: idempotent sync
The example below uses only published exports from
@rytass/bpm-core-client/organization. It loads the entire BPM-side
state once via readOrganizationDashboard(), builds a code → record
index, then upserts each host node. Verified exports (all with flat
input shapes — no {id, input: {...}} wrapping):
readOrganizationDashboard({ orgUnitPageSize, orgUnitSearchText, ... })createOrgUnit({ code, name, type, parentId, metadataJson })updateOrgUnit({ id, code, name, type, parentId, metadataJson })deleteOrgUnit(id)(soft-delete)commitOrgUnitTreeDraft({ moves: { id, parentId, baseUpdatedAt }[] })(transactional batch moves — each entry'sbaseUpdatedAtis the row's last-knownupdatedAt, used by BPM for optimistic-locking conflict detection)createPosition({ code, name, level, metadataJson })—level(1+) controls hierarchy weight (higher = more senior, used by manager-resolution priority sort);metadataJsonis required (pass'{}'if unused)updatePosition({ id, code, name, level, metadataJson })— every field exceptidisT | null; passnullto leave a field unchangedcreateMembership({ memberId, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })—positionIdandeffectiveToacceptnull;effectiveFromis an ISO timestamp string. Uniqueness key is(memberId, orgUnitId, positionId)updateMembership({ id, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })— every field exceptidisT | nullcreateManagerResolution({ scopeType, scopeId, managerMemberId, priority, effectiveFrom, effectiveTo })—scopeTypeis'MEMBER' | 'ORG_UNIT' | 'POSITION';priorityorders the resolver chain (lower number = checked first)createMembership/updateMembership(unique by(memberId, orgUnitId, positionId))createManagerResolution/updateManagerResolution
import {
readOrganizationDashboard,
createOrgUnit,
updateOrgUnit,
type OrgUnitRecord,
} from '@rytass/bpm-core-client/organization';
import type { OrgUnitType } from '@rytass/bpm-core-shared';
interface HostOrgUnit {
readonly hostId: string; // e.g. ERP primary key — your host-FK
readonly code: string; // stable across sync runs
readonly name: string;
readonly type: OrgUnitType; // 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM'
readonly parentCode: string | null;
}
async function syncOrgTree(
hostNodes: readonly HostOrgUnit[],
): Promise<void> {
// 1. Single round trip: load every existing BPM org unit, index by code.
const dashboard = await readOrganizationDashboard({ orgUnitPageSize: null });
const byCode = new Map<string, OrgUnitRecord>(
dashboard.orgUnits.map((unit) => [unit.code, unit]),
);
// 2. Topo-sort by parent so a parent always exists before its child
// is upserted (createOrgUnit needs the parentId to be valid).
const ordered = topoSortByParent(hostNodes);
for (const host of ordered) {
const parentId =
host.parentCode != null ? (byCode.get(host.parentCode)?.id ?? null) : null;
const metadataJson = JSON.stringify({ hostId: host.hostId });
const existing = byCode.get(host.code);
const saved = existing
? await updateOrgUnit({
id: existing.id,
code: host.code,
name: host.name,
type: host.type,
parentId,
metadataJson,
})
: await createOrgUnit({
code: host.code,
name: host.name,
type: host.type,
parentId,
metadataJson,
});
byCode.set(saved.code, saved);
}
}The same pattern works for Position (key on code), Membership
(unique by memberId × orgUnitId × positionId), and
ManagerResolution (unique by scope shape + active flag). For bulk
tree-shape changes (multi-node moves under a single parent),
commitOrgUnitTreeDraft accepts the full draft in one transaction.
Atomicity caveat. Only
commitOrgUnitTreeDraftis transactional. SequentialcreateMembership/createPositioncalls are independent mutations — if the 401st call in a batch of 500 fails, the prior 400 stay committed. Wrap your sync loop in a "from-cursor" resume strategy if you need at-least-once semantics across crashes.
Programmatic base URL. Server-side scripts that aren't running under Next.js (cron workers, one-off seeds) cannot rely on
NEXT_PUBLIC_API_URLresolution — callconfigureBPMClient({ baseUrl, fetch, headers })from@rytass/bpm-core-clientonce at startup to point the transport at the right host and inject a session cookie or bearer token.
What you do NOT mirror
Member identity itself stays in your host's user table — BPM reaches it
through BPMMemberResolver (see the previous section). Only the
org structure (who reports where, what unit they sit in, who manages
them) needs to live inside BPM.
Root Module Configuration
Use BPMRootModule.forRoot() for static configuration or
BPMRootModule.forRootAsync() when values come from Vault, ConfigService, KMS,
or another secret provider.
BPMRootModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
memberResolverProvider: {
provide: BPM_MEMBER_RESOLVER,
useClass: HostBPMMemberResolver,
},
useFactory: (config: ConfigService) => ({
authContextFactory: buildBPMAuthContextFromExecutionContext,
attachmentPublicBaseUrl: config.getOrThrow<string>('BPM_API_PUBLIC_URL'),
attachmentSignedUrlSecret: config.getOrThrow<string>('BPM_ATTACHMENT_SIGNING_SECRET'),
attachmentSignedUrlTtlSeconds: 300,
identityMemberMetadataCacheTtlMs: 300_000,
notificationEmailEnabled: 'auto',
notificationEmailFrom: config.get<string>('BPM_NOTIFICATION_FROM'),
notificationEmailSmtpHost: config.get<string>('BPM_SMTP_HOST'),
notificationEmailSmtpPassword: config.get<string>('BPM_SMTP_PASSWORD'),
notificationEmailSmtpPort: Number(config.get<string>('BPM_SMTP_PORT')),
notificationEmailSmtpSecure: false,
notificationEmailSmtpUsername: config.get<string>('BPM_SMTP_USERNAME'),
notificationWebhookEnabled: 'auto',
notificationWebhookEndpointUrl: config.get<string>('BPM_WEBHOOK_URL'),
notificationWebhookSigningSecret: config.get<string>('BPM_WEBHOOK_SIGNING_SECRET'),
signatureCurrentKeyVersion: 1,
signatureKeyProvider: {
readKey: (keyVersion: number): string => config.getOrThrow<string>(`BPM_SIGNATURE_KEY_V${keyVersion}`),
},
signatureTimestampProvider: {
createTimestampToken: ({ signedAt, signedPayloadHash }): Buffer => Buffer.from(JSON.stringify({ signedAt, signedPayloadHash }), 'utf8'),
},
}),
});Configuration Reference
| Option | Default | Description |
| ------------------------------------------------ | ------------------------------ | -------------------------------------------------------------- |
| authContextFactory | undefined | Reads BPMAuthContext from NestJS ExecutionContext. |
| memberResolverProvider | required | Provider for BPM_MEMBER_RESOLVER. |
| attachmentStorageProvider | local .storage/attachments | Host-provided @rytass/storages adapter. |
| workflowServiceTaskDispatcherProvider | built-in fetch dispatcher | Host provider for executable workflow WEBHOOK service tasks. |
| attachmentRoutePrefix | /attachments | Drives both the BPM signed URL path and the Nest controller mount path for AttachmentController. Must be set at module wiring time (see "Attachment Storage" below). |
| attachmentStorageProviderId | local | Value stored on attachment metadata for the active adapter. |
| attachmentPublicBaseUrl | http://localhost:17603 | Public base URL for signed attachment URLs. |
| attachmentSignedUrlSecret | local development secret | HMAC secret for signed attachment download/preview tokens. |
| attachmentSignedUrlTtlSeconds | 300 | Signed attachment URL lifetime in seconds. |
| identityMemberMetadataCacheTtlMs | 300000 | Member metadata cache TTL. |
| notificationInAppEnabled | true | Enables in-app notification records. |
| notificationEmailEnabled | auto | Enables email when SMTP settings are complete. |
| notificationEmailSmtpHost | null | SMTP host. |
| notificationEmailSmtpPort | null | SMTP port. |
| notificationEmailSmtpSecure | false | true for implicit TLS, false for STARTTLS. |
| notificationEmailSmtpUsername | null | SMTP username. |
| notificationEmailSmtpPassword | null | SMTP password or app password. |
| notificationEmailFrom | null | Email sender address. |
| notificationWebhookEnabled | auto | Enables webhook when URL and signing secret are complete. |
| notificationWebhookEndpointUrl | null | Default webhook endpoint URL. |
| notificationWebhookSigningSecret | null | HMAC secret for webhook payload signatures. |
| notificationDeliverySchedulerEnabled | false | Runs pending email/webhook delivery loop in this process. |
| notificationDeliveryScanIntervalMs | 30000 | Delivery scheduler interval. |
| notificationDeliveryBatchSize | 25 | Maximum pending notifications per delivery scan. |
| notificationDeliveryMaxAttempts | 3 | Attempts before a notification is marked failed. |
| notificationDeliveryRetryBaseDelayMs | 60000 | Base retry delay multiplied by attempt count. |
| notificationSlaSchedulerEnabled | false | Runs automatic SLA scan loop in this process. |
| notificationSlaScanIntervalMs | 60000 | SLA scheduler interval. |
| notificationSlaTimeoutRemindEnabled | true | Enables SLA timeout REMIND. |
| notificationSlaTimeoutAutoApproveEnabled | false | Enables SLA timeout AUTO_APPROVE. |
| notificationSlaTimeoutEscalateEnabled | false | Enables SLA timeout ESCALATE. |
| notificationSlaTimeoutTerminateInstanceEnabled | false | Enables SLA timeout TERMINATE_INSTANCE. |
| notificationTemplateEngine | simple | simple only — handlebars is reserved and currently rendered as simple. |
| notificationDefaultChannels | [IN_APP] | Fallback channels when a workflow node has no channel list. |
| notificationDefaultEmailDigestMode | INSTANT | Default digest mode for missing preferences. |
| notificationDefaultInAppPreferenceEnabled | true | Default in-app preference for missing preferences. |
| notificationDefaultEmailPreferenceEnabled | true | Default email preference for missing preferences. |
| signatureCurrentKeyVersion | 1 | Key version used for new signatures. |
| signatureKeyProvider | local development key provider | Host key provider for signing and verification. |
| signatureTimestampProvider | mock timestamp provider | Host timestamp token provider. |
Production deployments should override all local development secrets.
Database Setup
The package exports helpers for Vault-backed TypeORM setup:
import { TypeOrmModule } from '@nestjs/typeorm';
import { VaultModule, VaultService } from '@rytass/secret-adapter-vault-nestjs';
import { buildTypeOrmModuleOptions } from '@rytass/bpm-core-nestjs-module';
TypeOrmModule.forRootAsync({
imports: [VaultModule],
inject: [VaultService],
useFactory: buildTypeOrmModuleOptions,
});Expected Vault keys:
| Key | Description |
| ----------- | -------------------------- |
| DB_HOST | PostgreSQL host. |
| DB_PORT | PostgreSQL port, optional. |
| DB_NAME | PostgreSQL database name. |
| DB_USER | PostgreSQL username. |
| DB_PASS | PostgreSQL password. |
| DB_SCHEMA | PostgreSQL schema. |
buildTypeOrmModuleOptions adds autoLoadEntities: true so the NestJS
TypeOrmModule picks up BPM entities through each domain module's
TypeOrmModule.forFeature. buildBPMDataSourceOptions() returns
entities: [] because TypeORM CLI tooling reads entities from the host's own
glob; if you plan to run typeorm migration:generate or
typeorm migration:show from a script, attach BPM entities to the data
source yourself.
Use buildBPMDataSourceOptions() when your host already has database secrets
and does not want the Vault adapter:
import { buildBPMDataSourceOptions } from '@rytass/bpm-core-nestjs-module';
const dataSourceOptions = buildBPMDataSourceOptions({
database: 'bpm_core',
host: '127.0.0.1',
password: 'secret',
port: 5432,
schema: 'bpm_core_staging',
username: 'bpm_core_staging',
});The local migration CLI helper can also read Vault directly from environment variables:
VAULT_HOST=https://vault.example.com \
VAULT_ACCOUNT=your-account \
VAULT_PASSWORD=your-password \
VAULT_PATH=bpm_core/develop \
pnpm typeorm migration:runRequired environment variables for direct Vault loading:
| Variable | Description |
| ---------------- | ----------------------------------- |
| VAULT_HOST | Vault HTTP base URL. |
| VAULT_ACCOUNT | Vault userpass account. |
| VAULT_PASSWORD | Vault userpass password. |
| VAULT_PATH | Vault KV path, defaults to develop. |
Sharing a Postgres cluster with the host
Hosts that already run their own Postgres (and their own Vault path,
e.g. shuttle/develop) have three options for where BPM's 22 tables
live. Pick the one that matches your operational model:
| Option | When to use | Trade-off |
|---|---|---|
| Same database, same schema (public) | Tiny prototypes, monorepo dev | Table-name collision risk — BPM owns org_units, positions, etc., common names. Audit your host's tables before choosing. |
| Same database, separate schema (recommended) | Most production hosts | Clean isolation; pg_dump per-schema; one connection pool. Set DB_SCHEMA=bpm_core in BPM's Vault path. Host stays on its own schema (typically public). |
| Separate database | Hard multi-tenant or compliance boundary | Two pools; cross-schema joins impossible (BPM doesn't need them); migration runners stay completely independent. |
For option B (separate schema), the host's own TypeORM forRoot() and
BPM's buildTypeOrmModuleOptions create two distinct connections
to the same Postgres cluster with different schema: settings — that
is the cleanest separation Nest's TypeORM module supports.
Concrete wiring:
// apps/api/src/app/app.module.ts
@Module({
imports: [
// Host's existing TypeORM connection (default name, host's schema).
TypeOrmModule.forRootAsync({
imports: [HostVaultModule],
inject: [HostVaultService],
useFactory: (vault) => buildHostDataSourceOptions(vault), // schema: 'public'
}),
// BPM's own connection (named, separate schema, separate Vault path).
TypeOrmModule.forRootAsync({
name: 'bpm', // <-- distinct connection name
imports: [BPMVaultModule],
inject: [BPMVaultService],
useFactory: buildTypeOrmModuleOptions, // reads DB_SCHEMA=bpm_core from Vault
}),
// Hand BPM the connection name so its repositories bind to the right pool.
BPMRootModule.forRootAsync({
typeormConnectionName: 'bpm',
// ...
}),
],
})If the host runs only one Vault path (e.g. shuttle/develop) and you
want to keep secrets there, expose bpm_core/* keys under the host
path with a prefix and provide a custom factory that reads them — BPM
doesn't require its own Vault path, only that the secrets are
available to buildTypeOrmModuleOptions.
Vault paths can be split independently: keep shuttle/develop for the
host and create bpm_core/develop for BPM secrets. BPM's helper reads
its own path; the host's auth module reads its own. They do not
interfere.
Schema migrations are per-schema. Run BPM's migrations against the schema named in
DB_SCHEMA; the host's migrations stay on its own schema. There is no cross-schema migration coordination.
Migrations
This package ships TypeORM migrations under src/lib/migrations.
For this repository:
pnpm migration:runFor an external host, import the class list instead of relying on a repository source glob:
import { BPM_CORE_MIGRATIONS } from '@rytass/bpm-core-nestjs-module/migrations';buildBPMDataSourceOptions(), buildDataSourceOptionsFromVaultEnv(), and
buildTypeOrmModuleOptions() use this exported migration list. Runtime
TypeOrmModule setup still keeps migrationsRun: false; run migrations
explicitly during deploy.
The first migration attempts to create required PostgreSQL extensions
(uuid-ossp, ltree) if they do not exist. Hosts that cannot grant
CREATE EXTENSION should pre-provision those extensions in the target database
before running BPM migrations. Schema selection comes from the TypeORM schema
option, so multi-schema hosts should run the same migration list once per BPM
schema/user pair.
Do not enable synchronize in production.
Attachment Storage
By default, attachments use local storage under .storage/attachments. For
production, provide an @rytass/storages compatible adapter:
BPMRootModule.forRoot({
attachmentStorageProvider: {
provide: ATTACHMENT_STORAGE,
useFactory: (): AttachmentStorage => createYourStorageAdapter(),
},
authContextFactory: buildBPMAuthContextFromExecutionContext,
memberResolverProvider: {
provide: BPM_MEMBER_RESOLVER,
useClass: HostBPMMemberResolver,
},
});Signed attachment URLs are served by BPM's attachment controller. Configure
attachmentPublicBaseUrl, attachmentRoutePrefix,
attachmentSignedUrlSecret, and attachmentSignedUrlTtlSeconds for production.
Set attachmentStorageProviderId when replacing local storage so attachment
metadata does not incorrectly say local.
attachmentRoutePrefix controls two things at the same time:
- The path BPM bakes into signed URLs
(
${publicBaseUrl}${routePrefix}/:id/download). - The Nest controller mount path.
AttachmentModule.forRoot/forRootAsynccallReflect.defineMetadata(PATH_METADATA, ...)onAttachmentControllerso the controller actually serves on the same path the signed URL points to.
The default is /attachments, matching the BPM controller's relative
declaration. BPM no longer assumes the host calls setGlobalPrefix('api');
do not enable setGlobalPrefix on a BPM host. To expose the attachment
endpoint under a different prefix, set attachmentRoutePrefix (for example
/internal/bpm/attachments) — BPM rewrites both the controller path and the
signed URL accordingly. Alternative URL shaping should happen at a reverse
proxy (Nginx, Cloudflare, k8s ingress), not via Nest globalPrefix.
Because Nest reads controller path metadata synchronously at application
bootstrap, attachmentRoutePrefix must be supplied at module wiring time:
BPMRootModule.forRootreadsoptions.attachmentRoutePrefixdirectly.BPMRootModule.forRootAsyncexposesattachmentRoutePrefixat the top-level (sibling ofuseFactory), not as part of the async factory return.- A process can only mount BPM under a single prefix. Multi-tenant hosts that want different prefixes per tenant should run separate processes.
Cross-origin authentication
Signed attachment URLs carry the auth inside the URL itself —
specifically a query-string signature derived from
attachmentSignedUrlSecret and attachmentSignedUrlTtlSeconds. The
browser does not send the session cookie to fetch the attachment;
BPM's controller verifies the signature instead.
This means:
- The attachment URL is safe to embed in
<img>/<a href>/<iframe>tags across origins; CORS is not in the auth path. - Anyone who possesses the URL (until TTL expiry) can fetch the file.
Choose
attachmentSignedUrlTtlSecondsshort enough that a leaked URL expires before exploitation (we suggest 600s in production). - Hosts that need true cross-tenant isolation should keep
attachmentPublicBaseUrlpointing at a CDN edge that enforces its own authz on top — BPM's signature is freshness, not authorization.
Disabling the /auth/test-members endpoint in production
Hosts wired with the demo seed (typical for pnpm demo:reset /
pnpm staging:reset) expose GET /auth/test-members so the login form
shows a clickable list of fixture accounts. Do not ship this in
production. Two ways to keep it out:
- The endpoint is owned by the wrapper host, not by BPM —
@rytass/bpm-core-nestjs-moduledoes not register it. Simply omit the wrapper module / controller that serves/auth/test-members. The reference implementation in BPMCore'sapps/apionly exposes it whenNODE_ENV !== 'production'; copy that pattern in your own auth controller. - The browser-side
listApiTestMembers()client function tolerates a404/401and returns an empty array, so removing the endpoint degrades gracefully — the login form simply hides the demo-account picker.
Notifications and SLA
BPM creates in-app notifications by default. Email and webhook delivery are disabled unless enough configuration is present or explicitly enabled.
The auto setting on notificationEmailEnabled only activates email delivery
when all of the following are non-empty strings:
notificationEmailSmtpHostnotificationEmailSmtpPortnotificationEmailSmtpUsernamenotificationEmailSmtpPasswordnotificationEmailFrom
If any one of those is missing, email stays off even on auto. Webhook
auto requires both notificationWebhookEndpointUrl and
notificationWebhookSigningSecret.
notificationTemplateEngine: 'handlebars' is currently a reserved value; the
runtime template renderer still uses the built-in simple renderer regardless
of this setting. Leave it as simple until the Handlebars implementation is
wired.
Delivery and SLA schedulers are disabled by default. Enable them only in a dedicated worker process or in a single-replica host that intentionally owns background work:
BPMRootModule.forRoot({
notificationDeliverySchedulerEnabled: true,
notificationSlaSchedulerEnabled: true,
authContextFactory: buildBPMAuthContextFromExecutionContext,
memberResolverProvider: {
provide: BPM_MEMBER_RESOLVER,
useClass: HostBPMMemberResolver,
},
});BPM claims pending delivery rows with FOR UPDATE SKIP LOCKED and records
SLA notifications with an idempotency index, but a dedicated worker is still
the recommended production topology.
Hosts can replace built-in SMTP and signed webhook dispatch by providing
BPM_NOTIFICATION_DISPATCHER from an imported host module. The dispatcher can
publish to an existing mail service, queue, tenant router, or event bus:
import { BPM_NOTIFICATION_DISPATCHER } from '@rytass/bpm-core-nestjs-module/notification';
@Module({
providers: [
{
provide: BPM_NOTIFICATION_DISPATCHER,
useClass: HostNotificationDispatcher,
},
],
exports: [BPM_NOTIFICATION_DISPATCHER],
})
export class HostNotificationModule {}Workflow service tasks are a separate integration point from notification
delivery. WEBHOOK service-task nodes run inside the workflow engine and use
BPM_WORKFLOW_SERVICE_TASK_DISPATCHER. The default dispatcher sends a JSON
POST with fetch; wrapper apps can replace it when outbound workflow actions
must be signed, queued, audited, or routed through an internal integration
service.
BPMRootModule.forRoot / forRootAsync forward the host imports to the
internal WorkflowEngineModule.forRoot, so the dispatcher provider may use
useFactory plus inject to read host-side tokens such as Vault secrets or
shared integration bus instances. Providers do not have to live in a
@Global() module to be injectable from the dispatcher.
import { Injectable } from '@nestjs/common';
import {
BPMRootModule,
BPM_MEMBER_RESOLVER,
BPM_WORKFLOW_SERVICE_TASK_DISPATCHER,
BPMWorkflowServiceTaskDispatcher,
BPMWorkflowWebhookDispatchInput,
BPMWorkflowWebhookDispatchResult,
} from '@rytass/bpm-core-nestjs-module';
interface IntegrationBusResult {
readonly accepted: boolean;
readonly reason?: string;
}
interface IntegrationBus {
enqueue(
topic: string,
payload: Readonly<Record<string, unknown>>,
): Promise<IntegrationBusResult>;
}
@Injectable()
class HostWorkflowServiceTaskDispatcher implements BPMWorkflowServiceTaskDispatcher {
constructor(private readonly integrationBus: IntegrationBus) {}
async dispatchWebhook(
input: BPMWorkflowWebhookDispatchInput,
): Promise<BPMWorkflowWebhookDispatchResult> {
const result = await this.integrationBus.enqueue('bpm.workflow.webhook', {
headers: input.headers ?? {},
payload: input.payload,
url: input.url,
});
return {
ok: result.accepted,
status: result.accepted ? 202 : null,
error: result.accepted ? undefined : result.reason,
};
}
}
BPMRootModule.forRoot({
authContextFactory: buildBPMAuthContextFromExecutionContext,
memberResolverProvider: {
provide: BPM_MEMBER_RESOLVER,
useClass: HostBPMMemberResolver,
},
workflowServiceTaskDispatcherProvider: {
provide: BPM_WORKFLOW_SERVICE_TASK_DISPATCHER,
useClass: HostWorkflowServiceTaskDispatcher,
},
});SLA timeout actions that change workflow state are disabled by default:
AUTO_APPROVEESCALATETERMINATE_INSTANCE
Enable them only after the host application's business policy is explicit.
Signatures
Decision signatures use HMAC-SHA256 and are chained through the previous signature hash. Production hosts should provide a durable key provider and real timestamp provider:
BPMRootModule.forRoot({
signatureCurrentKeyVersion: 2,
signatureKeyProvider: {
readKey: async (keyVersion: number): Promise<string | null> => readKeyFromKmsOrVault(keyVersion),
},
signatureTimestampProvider: {
createTimestampToken: async ({ signedAt, signedPayloadHash }): Promise<Buffer> => requestTimestampTokenFromTsa({ signedAt, signedPayloadHash }),
},
authContextFactory: buildBPMAuthContextFromExecutionContext,
memberResolverProvider: {
provide: BPM_MEMBER_RESOLVER,
useClass: HostBPMMemberResolver,
},
});Keep old signature keys readable after rotation. Verification needs the key version stored on each signature row.
Both readKey and createTimestampToken accept either a synchronous return
or a Promise; the example above uses async because production KMS / TSA
clients are typically network-bound. Returning a synchronous value is fine
for in-memory or local-file providers.
GraphQL Surface
Importing BPMRootModule registers GraphQL resolvers for:
- Organization units, positions, memberships, manager resolution, and summary.
- Member profile lookup and member metadata cache inspection.
- Form definitions and form definition versions.
- Approval templates, template versions, categories, validation, and dry run.
- Workflow instances, tokens, tasks, task candidates, decisions, activity logs, submit/process/approve/return/cancel/resubmit operations.
- Delegation rule CRUD and transfer support.
- Notifications, unread counts, preferences, and read status.
- Attachments, signed download URLs, and signed preview URLs.
- Decision signatures and signature-chain verification.
The schema is generated by NestJS GraphQL code-first decorators. Configure the
host GraphQLModule with autoSchemaFile if you want NestJS to generate the
schema at runtime.
Shared Types
Workflow, form, condition, identity, organization, and status contracts live in
@rytass/bpm-core-shared.
import type { WorkflowDefinition } from '@rytass/bpm-core-shared/workflow';
import type { FormSchema } from '@rytass/bpm-core-shared/form';Use shared contracts when building UI clients or host-side integration tests.
Import Paths
The package root is the canonical import path for common host-facing APIs. Stable feature subpaths are also published for narrower imports such as migrations or notification dispatcher tokens.
import {
AllExceptionsFilter,
BPMAdminGuard,
BPMAdminOnly,
BPMAuthenticated,
BPMAuthenticatedGuard,
BPMCurrentAuthContext,
BPMCurrentMemberId,
BPMDesignerGuard,
BPMDesignerOnly,
BPMRootModule,
buildTypeOrmModuleOptions,
createBPMMemberBaseResolverProvider,
} from '@rytass/bpm-core-nestjs-module';
import { BPM_CORE_MIGRATIONS } from '@rytass/bpm-core-nestjs-module/migrations';
import { BPM_NOTIFICATION_DISPATCHER } from '@rytass/bpm-core-nestjs-module/notification';AllExceptionsFilter is the BPM-aware global exception filter the wrapper
app uses on its NestJS bootstrap; hosts that want consistent GraphQL/HTTP
error responses can call app.useGlobalFilters(new AllExceptionsFilter()).
Use root imports for module wiring, guards, options, and adapter helpers. Feature subpaths are intended for narrow integration tokens or migration lists. Do not treat internal domain services as the primary host API unless their method contracts are explicitly documented.
Do not import from internal compiled paths such as
@rytass/bpm-core-nestjs-module/src/lib/....
Local Development
From this repository, use the wrapper app to run the backend and client:
pnpm install
pnpm demo:reset
pnpm api
pnpm clientpnpm demo:reset runs migrations before resetting and seeding develop data. Use
pnpm migration:run separately only when you need migrations without resetting
the wrapper-app seed scenario.
Default local service URLs:
- API:
http://localhost:17603 - GraphQL:
http://localhost:17603/graphql - Client:
http://localhost:17602
The normal local flow uses Vault-backed develop secrets. docker compose is not
required for local verification.
pnpm demo:reset is a repository wrapper-app command, not a package feature. It
resets the develop schema and seeds a Taiwan manufacturing scenario through
apps/api/tools/reset-demo-data.ts, including DB-backed test members in
api_test_members. Staging uses the same script through pnpm staging:reset
with VAULT_PATH=bpm_core/staging.
Verification
Before publishing:
pnpm nx test bpm-core --runInBand
pnpm nx typecheck bpm-core
pnpm nx build bpm-coreRepository-wide checks:
pnpm typecheck
pnpm lint
pnpm test
pnpm buildPublishing Checklist
- Confirm package metadata in
libs/bpm-core/package.json. - Confirm
@rytass/bpm-core-sharedis published at the required version. - Run
pnpm nx build bpm-core. - Inspect
dist/libs/bpm-core/package.json. - Confirm
dist/libs/bpm-core/README.mdis present. - Confirm
src/index.tsexports every intended public API. - Publish from the built package directory:
cd dist/libs/bpm-core
npm publish --access publicProduction Notes
- Replace all local fallback secrets before production.
- Use a production storage adapter instead of local filesystem storage.
- Keep old signature keys available for verification after key rotation.
- Keep in-process schedulers disabled in API replicas unless a dedicated worker is not available.
- Do not enable TypeORM
synchronizein production. - Ensure GraphQL auth context is available for protected operations.
- Run migrations before serving traffic with a new package version.
License
MIT
