@rytass/bpm-core-nestjs-module 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
  Releases are managed by [`nx release`](https://nx.dev/recipes/nx-release) with
10
10
  Conventional Commits — see `nx.json` for the release config.
11
11
 
12
+ ## 0.1.6 — 2026-05-28
13
+
14
+ ### Added
15
+
16
+ - **`MemberNotFoundException`** is now exported from the root barrel
17
+ (and `/identity` subpath). The `BPMMemberResolver.resolve` JSDoc has
18
+ referenced this class for two releases without actually shipping it;
19
+ hosts can now `throw new MemberNotFoundException(memberId)` directly.
20
+
21
+ ### Documentation
22
+
23
+ - **README "Mapping from a host RBAC system (e.g. Casbin)"**: worked
24
+ example showing how to project a host's grouping policy
25
+ (`enforcer.getRolesForUser(memberId)`) into BPM's exact-string role
26
+ literals inside `authContextFactory`.
27
+ - **README "Cross-origin authentication"** under Attachment Storage:
28
+ documents that signed URLs carry auth inside the URL (not via cookie),
29
+ what the TTL controls, and the production-grade defenses on top.
30
+ - **README "Disabling the `/auth/test-members` endpoint in production"**:
31
+ clarifies that the endpoint is wrapper-host-owned (BPM does not
32
+ register it) and that the React client degrades gracefully when the
33
+ endpoint is absent.
34
+ - **README Worked Example rewritten** to call the actual published
35
+ `@rytass/bpm-core-client/organization` exports
36
+ (`readOrganizationDashboard`, flat-input `createOrgUnit` /
37
+ `updateOrgUnit`) — the 0.1.5 example called functions that didn't
38
+ exist. Adds an "Atomicity caveat" note clarifying that only
39
+ `commitOrgUnitTreeDraft` is transactional; sequential
40
+ `createMembership` calls are independent.
41
+
42
+ ### Why a patch
43
+
44
+ `MemberNotFoundException` is purely additive. Other changes are docs.
45
+
12
46
  ## 0.1.5 — 2026-05-28
13
47
 
14
48
  ### Documentation
package/README.md CHANGED
@@ -215,6 +215,60 @@ clear admin / designer guards; otherwise BPM resolvers reject with
215
215
  `BPMAuthenticatedGuard`. The host does not need to add `@BPMAuthenticated()`
216
216
  on top of them.
217
217
 
218
+ ### Mapping from a host RBAC system (e.g. Casbin)
219
+
220
+ BPM does not call the host's RBAC engine — it reads exact-string `roles`
221
+ and `permissions` arrays off `BPMAuthContext`. Hosts using Casbin /
222
+ Permify / OPA must **project their grouping policy** into the BPM
223
+ literals inside their `authContextFactory`. A worked Casbin example:
224
+
225
+ ```ts
226
+ import { Inject, Injectable, type ExecutionContext } from '@nestjs/common';
227
+ import { GqlExecutionContext } from '@nestjs/graphql';
228
+ import { CASBIN_ENFORCER, type CasbinEnforcer } from '@your-host/auth';
229
+ import type { BPMAuthContext } from '@rytass/bpm-core-nestjs-module';
230
+
231
+ const HOST_TO_BPM_ROLES: Readonly<Record<string, string>> = {
232
+ approval_admin: 'BPM_ADMIN',
233
+ approval_designer: 'BPM_DESIGNER',
234
+ };
235
+
236
+ @Injectable()
237
+ export class BPMAuthContextFactory {
238
+ constructor(@Inject(CASBIN_ENFORCER) private readonly enforcer: CasbinEnforcer) {}
239
+
240
+ async build(ctx: ExecutionContext): Promise<BPMAuthContext | null> {
241
+ const req = GqlExecutionContext.create(ctx).getContext<{
242
+ req?: { member?: { id: string; email: string; name: string } };
243
+ }>().req;
244
+ const member = req?.member;
245
+ if (!member) return null;
246
+
247
+ // 1. Look up the host's Casbin role assignments for this member.
248
+ const hostRoles = await this.enforcer.getRolesForUser(member.id);
249
+
250
+ // 2. Translate into BPM's exact-string literals. Unknown host roles
251
+ // do nothing — BPM only checks for the documented strings.
252
+ const bpmRoles = hostRoles
253
+ .map((r) => HOST_TO_BPM_ROLES[r])
254
+ .filter((r): r is string => Boolean(r));
255
+
256
+ return {
257
+ memberId: member.id,
258
+ email: member.email,
259
+ name: member.name,
260
+ roles: bpmRoles,
261
+ permissions: [], // Host can also use `bpm:*` style permissions if preferred.
262
+ metadata: {},
263
+ };
264
+ }
265
+ }
266
+ ```
267
+
268
+ > The host owns the mapping table. Keep it in one place (a constant
269
+ > module or a database table) so adding a new BPM tier in a future
270
+ > BPMCore release becomes a one-line addition.
271
+
218
272
  ## Member Resolver
219
273
 
220
274
  The package resolves display names, email addresses, and approver candidates
@@ -342,66 +396,75 @@ and observable:
342
396
  ### Worked example: idempotent sync
343
397
 
344
398
  The example below uses only published exports from
345
- `@rytass/bpm-core-client/organization`. It pushes a host org tree into
346
- BPM, handling the create-or-update path by checking `code` matches.
347
- Verified exports: `orgUnits`, `createOrgUnit`, `updateOrgUnit`,
348
- `deleteOrgUnit`, `createPosition`, `createMembership`, `updateMembership`,
349
- `createManagerResolution`, `commitOrgUnitTreeDraft`.
399
+ `@rytass/bpm-core-client/organization`. It loads the entire BPM-side
400
+ state once via `readOrganizationDashboard()`, builds a `code → record`
401
+ index, then upserts each host node. Verified exports (all with flat
402
+ input shapes — no `{id, input: {...}}` wrapping):
403
+
404
+ - `readOrganizationDashboard({ orgUnitPageSize, orgUnitSearchText, ... })`
405
+ - `createOrgUnit({ code, name, type, parentId, metadataJson })`
406
+ - `updateOrgUnit({ id, code, name, type, parentId, metadataJson })`
407
+ - `deleteOrgUnit(id)` (soft-delete)
408
+ - `commitOrgUnitTreeDraft({ baseUpdatedAt, draft })` (transactional batch moves)
409
+ - `createPosition` / `updatePosition` (key on `code`)
410
+ - `createMembership` / `updateMembership` (unique by `(memberId, orgUnitId, positionId)`)
411
+ - `createManagerResolution` / `updateManagerResolution`
350
412
 
351
413
  ```ts
352
414
  import {
415
+ readOrganizationDashboard,
353
416
  createOrgUnit,
354
417
  updateOrgUnit,
355
- createPosition,
356
- createMembership,
357
- orgUnits,
358
418
  type OrgUnitRecord,
359
419
  } from '@rytass/bpm-core-client/organization';
420
+ import type { OrgUnitType } from '@rytass/bpm-core-shared';
360
421
 
361
422
  interface HostOrgUnit {
362
- readonly hostId: string; // e.g. ERP primary key — your host-FK
363
- readonly code: string; // stable across sync runs
423
+ readonly hostId: string; // e.g. ERP primary key — your host-FK
424
+ readonly code: string; // stable across sync runs
364
425
  readonly name: string;
365
- readonly type: 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM';
426
+ readonly type: OrgUnitType; // 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM'
366
427
  readonly parentCode: string | null;
367
428
  }
368
429
 
369
- async function syncOrgUnit(host: HostOrgUnit): Promise<OrgUnitRecord> {
370
- // 1. Look up by host's stable code; BPM enforces uniqueness.
371
- const existing = (
372
- await orgUnits({ page: 1, pageSize: 1, searchText: host.code })
373
- ).find((u) => u.code === host.code);
374
-
375
- const parentId = host.parentCode
376
- ? (await orgUnits({ searchText: host.parentCode })).find(
377
- (u) => u.code === host.parentCode,
378
- )?.id ?? null
379
- : null;
380
-
381
- // 2. Stash the host-FK back into metadata so future reconciliations
382
- // can chase the pointer either direction.
383
- const metadataJson = JSON.stringify({ hostId: host.hostId });
384
-
385
- if (existing) {
386
- return updateOrgUnit({
387
- id: existing.id,
388
- input: {
389
- code: host.code,
390
- name: host.name,
391
- type: host.type,
392
- parentId,
393
- metadataJson,
394
- },
395
- });
430
+ async function syncOrgTree(
431
+ hostNodes: readonly HostOrgUnit[],
432
+ ): Promise<void> {
433
+ // 1. Single round trip: load every existing BPM org unit, index by code.
434
+ const dashboard = await readOrganizationDashboard({ orgUnitPageSize: null });
435
+ const byCode = new Map<string, OrgUnitRecord>(
436
+ dashboard.orgUnits.map((unit) => [unit.code, unit]),
437
+ );
438
+
439
+ // 2. Topo-sort by parent so a parent always exists before its child
440
+ // is upserted (createOrgUnit needs the parentId to be valid).
441
+ const ordered = topoSortByParent(hostNodes);
442
+
443
+ for (const host of ordered) {
444
+ const parentId =
445
+ host.parentCode != null ? (byCode.get(host.parentCode)?.id ?? null) : null;
446
+ const metadataJson = JSON.stringify({ hostId: host.hostId });
447
+ const existing = byCode.get(host.code);
448
+
449
+ const saved = existing
450
+ ? await updateOrgUnit({
451
+ id: existing.id,
452
+ code: host.code,
453
+ name: host.name,
454
+ type: host.type,
455
+ parentId,
456
+ metadataJson,
457
+ })
458
+ : await createOrgUnit({
459
+ code: host.code,
460
+ name: host.name,
461
+ type: host.type,
462
+ parentId,
463
+ metadataJson,
464
+ });
465
+
466
+ byCode.set(saved.code, saved);
396
467
  }
397
-
398
- return createOrgUnit({
399
- code: host.code,
400
- name: host.name,
401
- type: host.type,
402
- parentId,
403
- metadataJson,
404
- });
405
468
  }
406
469
  ```
407
470
 
@@ -411,6 +474,19 @@ The same pattern works for `Position` (key on `code`), `Membership`
411
474
  tree-shape changes (multi-node moves under a single parent),
412
475
  `commitOrgUnitTreeDraft` accepts the full draft in one transaction.
413
476
 
477
+ > **Atomicity caveat.** Only `commitOrgUnitTreeDraft` is transactional.
478
+ > Sequential `createMembership` / `createPosition` calls are independent
479
+ > mutations — if the 401st call in a batch of 500 fails, the prior 400
480
+ > stay committed. Wrap your sync loop in a "from-cursor" resume strategy
481
+ > if you need at-least-once semantics across crashes.
482
+
483
+ > **Programmatic base URL.** Server-side scripts that aren't running
484
+ > under Next.js (cron workers, one-off seeds) cannot rely on
485
+ > `NEXT_PUBLIC_API_URL` resolution — call `configureBPMClient({ baseUrl,
486
+ > fetch, headers })` from `@rytass/bpm-core-client` once at startup to
487
+ > point the transport at the right host and inject a session cookie or
488
+ > bearer token.
489
+
414
490
  ### What you do NOT mirror
415
491
 
416
492
  Member identity itself stays in your host's user table — BPM reaches it
@@ -660,6 +736,43 @@ bootstrap, `attachmentRoutePrefix` must be supplied at module wiring time:
660
736
  - A process can only mount BPM under a single prefix. Multi-tenant hosts that
661
737
  want different prefixes per tenant should run separate processes.
662
738
 
739
+ ### Cross-origin authentication
740
+
741
+ Signed attachment URLs carry the auth **inside the URL** itself —
742
+ specifically a query-string signature derived from
743
+ `attachmentSignedUrlSecret` and `attachmentSignedUrlTtlSeconds`. The
744
+ browser does **not** send the session cookie to fetch the attachment;
745
+ BPM's controller verifies the signature instead.
746
+
747
+ This means:
748
+
749
+ - The attachment URL is safe to embed in `<img>` / `<a href>` /
750
+ `<iframe>` tags across origins; CORS is not in the auth path.
751
+ - Anyone who possesses the URL (until TTL expiry) can fetch the file.
752
+ Choose `attachmentSignedUrlTtlSeconds` short enough that a leaked URL
753
+ expires before exploitation (we suggest **600s** in production).
754
+ - Hosts that need true cross-tenant isolation should keep
755
+ `attachmentPublicBaseUrl` pointing at a CDN edge that enforces its own
756
+ authz on top — BPM's signature is freshness, not authorization.
757
+
758
+ ### Disabling the `/auth/test-members` endpoint in production
759
+
760
+ Hosts wired with the demo seed (typical for `pnpm demo:reset` /
761
+ `pnpm staging:reset`) expose `GET /auth/test-members` so the login form
762
+ shows a clickable list of fixture accounts. **Do not ship this in
763
+ production.** Two ways to keep it out:
764
+
765
+ 1. The endpoint is **owned by the wrapper host**, not by BPM —
766
+ `@rytass/bpm-core-nestjs-module` does not register it. Simply omit
767
+ the wrapper module / controller that serves
768
+ `/auth/test-members`. The reference implementation in BPMCore's
769
+ `apps/api` only exposes it when `NODE_ENV !== 'production'`; copy
770
+ that pattern in your own auth controller.
771
+ 2. The browser-side `listApiTestMembers()` client function tolerates a
772
+ `404` / `401` and returns an empty array, so removing the endpoint
773
+ degrades gracefully — the login form simply hides the demo-account
774
+ picker.
775
+
663
776
  ## Notifications and SLA
664
777
 
665
778
  BPM creates in-app notifications by default. Email and webhook delivery are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rytass/bpm-core-nestjs-module",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Embeddable NestJS BPM approval workflow module: workflow engine, approval templates, forms, organization/member contracts, attachments, signatures, notifications, delegation, and TypeORM migrations.",
5
5
  "keywords": [
6
6
  "bpm",
@@ -141,7 +141,7 @@
141
141
  "@nestjs/core": "^11.0.0",
142
142
  "@nestjs/graphql": "^13.4.0",
143
143
  "@nestjs/typeorm": "^11.0.1",
144
- "@rytass/bpm-core-shared": "^0.1.5",
144
+ "@rytass/bpm-core-shared": "^0.1.6",
145
145
  "@rytass/secret-adapter-vault-nestjs": "^0.4.5 || ^0.5.0",
146
146
  "express": "^4.0.0 || ^5.0.0",
147
147
  "graphql": "^16.0.0",
@@ -1,3 +1,4 @@
1
1
  export * from './identity-options';
2
2
  export * from './member-base.adapter';
3
+ export * from './member-not-found.exception';
3
4
  export * from './member-resolver.interface';
@@ -3,5 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const tslib_1 = require("tslib");
4
4
  tslib_1.__exportStar(require("./identity-options"), exports);
5
5
  tslib_1.__exportStar(require("./member-base.adapter"), exports);
6
+ tslib_1.__exportStar(require("./member-not-found.exception"), exports);
6
7
  tslib_1.__exportStar(require("./member-resolver.interface"), exports);
7
8
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../../libs/bpm-core/src/lib/identity/index.ts"],"names":[],"mappings":";;;AAAA,6DAAmC;AACnC,gEAAsC;AACtC,sEAA4C"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../../libs/bpm-core/src/lib/identity/index.ts"],"names":[],"mappings":";;;AAAA,6DAAmC;AACnC,gEAAsC;AACtC,uEAA6C;AAC7C,sEAA4C"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Thrown by `BPMMemberResolver.resolve(memberId)` when the host's user
3
+ * directory has no entry for the requested id. BPM surfaces the error
4
+ * as a GraphQL `errors[]` entry on whichever query triggered the
5
+ * lookup; the caller receives a structured response rather than a
6
+ * synthetic placeholder member.
7
+ *
8
+ * Note: `BPMMemberResolver.resolveMany(ids)` has the **opposite**
9
+ * contract — unknown ids are silently omitted from the returned `Map`.
10
+ * Use this exception only inside the single-id `resolve` path.
11
+ *
12
+ * Hosts may throw any `Error` subclass; this class is provided as a
13
+ * recommended default so error messages stay consistent in BPM's logs.
14
+ */
15
+ export declare class MemberNotFoundException extends Error {
16
+ readonly memberId: string;
17
+ constructor(memberId: string, message?: string);
18
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemberNotFoundException = void 0;
4
+ /**
5
+ * Thrown by `BPMMemberResolver.resolve(memberId)` when the host's user
6
+ * directory has no entry for the requested id. BPM surfaces the error
7
+ * as a GraphQL `errors[]` entry on whichever query triggered the
8
+ * lookup; the caller receives a structured response rather than a
9
+ * synthetic placeholder member.
10
+ *
11
+ * Note: `BPMMemberResolver.resolveMany(ids)` has the **opposite**
12
+ * contract — unknown ids are silently omitted from the returned `Map`.
13
+ * Use this exception only inside the single-id `resolve` path.
14
+ *
15
+ * Hosts may throw any `Error` subclass; this class is provided as a
16
+ * recommended default so error messages stay consistent in BPM's logs.
17
+ */
18
+ class MemberNotFoundException extends Error {
19
+ constructor(memberId, message) {
20
+ super(message ?? `BPM member not found: ${memberId}`);
21
+ this.name = 'MemberNotFoundException';
22
+ this.memberId = memberId;
23
+ }
24
+ }
25
+ exports.MemberNotFoundException = MemberNotFoundException;
26
+ //# sourceMappingURL=member-not-found.exception.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"member-not-found.exception.js","sourceRoot":"","sources":["../../../../../../libs/bpm-core/src/lib/identity/member-not-found.exception.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;GAaG;AACH,MAAa,uBAAwB,SAAQ,KAAK;IAGhD,YAAY,QAAgB,EAAE,OAAgB;QAC5C,KAAK,CAAC,OAAO,IAAI,yBAAyB,QAAQ,EAAE,CAAC,CAAC;QACtD,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;QACtC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF;AARD,0DAQC"}