@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 +34 -0
- package/README.md +159 -46
- package/package.json +2 -2
- package/src/lib/identity/index.d.ts +1 -0
- package/src/lib/identity/index.js +1 -0
- package/src/lib/identity/index.js.map +1 -1
- package/src/lib/identity/member-not-found.exception.d.ts +18 -0
- package/src/lib/identity/member-not-found.exception.js +26 -0
- package/src/lib/identity/member-not-found.exception.js.map +1 -0
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
|
|
346
|
-
|
|
347
|
-
Verified exports
|
|
348
|
-
|
|
349
|
-
|
|
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;
|
|
363
|
-
readonly code: string;
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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"}
|