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

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +159 -12
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -9,6 +9,66 @@ 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.8 — 2026-05-28
13
+
14
+ ### Documentation
15
+
16
+ - **Casbin example BPMAuthContext shape corrected.** The 0.1.6/0.1.7
17
+ example returned `{ memberId, email, name, roles, permissions,
18
+ metadata }` — the actual `BPMAuthContext` interface is
19
+ `{ memberId, roles, permissions, metadata }` (no `email`, no `name`).
20
+ Example now stashes display data inside `metadata`, with an explicit
21
+ callout that name/email reach resolvers through `BPMMemberResolver`
22
+ instead.
23
+ - **`createPosition` / `updatePosition` / `createMembership` /
24
+ `updateMembership` / `createManagerResolution` exact signatures
25
+ added** to the org Worked Example section. The 0.1.7 README only
26
+ listed function names; downstream developers were guessing input
27
+ shapes (e.g. mandatory `level` and `metadataJson` on positions).
28
+ - **`metadataJson` write-only clarification.** The "host-FK stash"
29
+ rule was misleading: `metadataJson` is accepted by every mutation
30
+ but the records returned by `readOrganizationDashboard` do NOT
31
+ surface a `metadata` field. Updated to "always reconcile by `code`;
32
+ treat `metadataJson` as audit/debugging breadcrumb, not a live FK
33
+ pointer."
34
+
35
+ ### Why a patch
36
+
37
+ Documentation only. No source change.
38
+
39
+ ## 0.1.7 — 2026-05-28
40
+
41
+ ### Documentation
42
+
43
+ - **README "Bring-your-own-host-auth" recipe** added under Auth
44
+ Context. Documents the `ApiMember` JSON shape, the expected status
45
+ codes for `/auth/login`, `/auth/me`, `/auth/logout`, and two
46
+ integration patterns: (A) extend the host API with `/auth/*`
47
+ controllers that emit `ApiMember` (recommended for member-base
48
+ hosts), or (B) run a thin wrapper host on a separate subdomain.
49
+ - **README "Sharing a Postgres cluster with the host"** added under
50
+ Database Setup. Compares same-schema / separate-schema / separate-DB
51
+ trade-offs, recommends separate-schema, clarifies Vault path
52
+ isolation between host and BPM.
53
+ - **`commitOrgUnitTreeDraft` signature corrected** in the Worked
54
+ Example listing — previously `{ baseUpdatedAt, draft }` (incorrect),
55
+ now `{ moves: { id, parentId, baseUpdatedAt }[] }` matching the
56
+ actual export.
57
+ - **`OrgUnitType` literal case corrected** in the README's example
58
+ comment — previously `'COMPANY' | 'DIVISION' | ...`, now `'company'
59
+ | 'division' | ...`. (See `@rytass/bpm-core-shared@0.1.7` for the
60
+ actual TS type change.)
61
+ - **"Positions are intentionally not deletable" note** added to the
62
+ Organization mirror pattern. Explains why no `deletePosition`
63
+ mutation exists and how to retire a position safely.
64
+ - **README "Package Status" current-version line refreshed** to
65
+ `0.1.7`.
66
+
67
+ ### Why a patch
68
+
69
+ Documentation-only. No public API change beyond what
70
+ `@rytass/bpm-core-shared@0.1.7` ships.
71
+
12
72
  ## 0.1.6 — 2026-05-28
13
73
 
14
74
  ### Added
package/README.md CHANGED
@@ -14,7 +14,7 @@ directory integration, storage adapters, and deployment.
14
14
 
15
15
  ## Package Status
16
16
 
17
- Current version: `0.0.1`
17
+ Current version: `0.1.7`
18
18
 
19
19
  The package is intended for NestJS backend hosts. It does not include the
20
20
  Next.js backoffice UI and does not provide a production auth system by itself.
@@ -198,6 +198,104 @@ class HostResolver {
198
198
  }
199
199
  ```
200
200
 
201
+ ### Bring-your-own-host-auth (for hosts that already have auth)
202
+
203
+ The BPM React `<AuthProvider>` calls `POST /auth/login`, `GET /auth/me`,
204
+ and `POST /auth/logout` on the BPM API host and expects responses that
205
+ match the `ApiMember` shape (exported from `@rytass/bpm-core-client`):
206
+
207
+ ```ts
208
+ interface ApiMember {
209
+ readonly memberId: string;
210
+ readonly email: string;
211
+ readonly name: string;
212
+ readonly roles: readonly string[];
213
+ readonly permissions: readonly string[];
214
+ readonly expiresAt: string; // ISO 8601
215
+ }
216
+ ```
217
+
218
+ Status-code contract:
219
+
220
+ | Endpoint | Success | Failure |
221
+ | --------------------- | ----------- | ----------------------------------- |
222
+ | `POST /auth/login` | `200` + `ApiMember` JSON | `401` (bad credentials) / `400` (validation) |
223
+ | `GET /auth/me` | `200` + `ApiMember` JSON | `401` (no session — React client treats as anonymous, not error) |
224
+ | `POST /auth/logout` | `204` (or `200` empty) | `401` ignored |
225
+
226
+ Session lifetime is owned by the host. The React `<AuthProvider>`
227
+ relies on `credentials: 'include'` plus an HTTP-only cookie issued by
228
+ the host on successful login — BPM does **not** issue cookies itself.
229
+
230
+ Hosts that already have their own JWT / cookie auth have two integration
231
+ patterns:
232
+
233
+ #### Pattern A — Extend the host API surface
234
+
235
+ Add `/auth/login`, `/auth/me`, `/auth/logout` controllers to your
236
+ existing NestJS host. Internally those endpoints call the host's
237
+ existing auth service (e.g. `MemberBaseService.login()`) and emit the
238
+ `ApiMember` shape. This is the simplest integration when BPM and the
239
+ host run on the same origin. Example for a member-base host:
240
+
241
+ ```ts
242
+ @Controller('auth')
243
+ export class HostAuthController {
244
+ constructor(private readonly memberBase: MemberBaseService) {}
245
+
246
+ @Post('login')
247
+ async login(@Body() input: LoginInput, @Res({ passthrough: true }) res: Response): Promise<ApiMember> {
248
+ const { member, accessToken, refreshToken } = await this.memberBase.login(input);
249
+ // Set the host's existing HTTP-only cookies; the React client will
250
+ // forward them on subsequent /auth/me and /graphql calls.
251
+ res.cookie('access_token', accessToken, { httpOnly: true, sameSite: 'lax' });
252
+ res.cookie('refresh_token', refreshToken, { httpOnly: true, sameSite: 'lax' });
253
+ return projectMemberToApiMember(member);
254
+ }
255
+
256
+ @Get('me')
257
+ async me(@Req() req: Request): Promise<ApiMember> {
258
+ const member = await this.memberBase.resolveCurrentMember(req); // throws 401 if no cookie
259
+ return projectMemberToApiMember(member);
260
+ }
261
+
262
+ @Post('logout')
263
+ @HttpCode(204)
264
+ logout(@Res({ passthrough: true }) res: Response): void {
265
+ res.clearCookie('access_token');
266
+ res.clearCookie('refresh_token');
267
+ }
268
+ }
269
+ ```
270
+
271
+ The `projectMemberToApiMember` helper is host-specific — map your own
272
+ member shape to BPM's expectations, project Casbin roles into the BPM
273
+ role-literal strings (see "Mapping from a host RBAC system" below),
274
+ and compute `expiresAt` from the token TTL.
275
+
276
+ #### Pattern B — Run BPM on a separate auth host
277
+
278
+ When BPM lives on its own subdomain (e.g. `bpm.example.com`) separate
279
+ from the main app at `app.example.com`, you can run a thin wrapper
280
+ host that **only** owns the `/auth/*` endpoints and a session cookie
281
+ scoped to `*.example.com`. The BPM React client points at this
282
+ subdomain via `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_API_AUTH_URL`
283
+ (or `configureBPMClient` for server-side). The wrapper host
284
+ internally calls back to the main app to verify the user.
285
+
286
+ This pattern is heavier (cross-origin cookies, CORS) but lets the main
287
+ app stay completely decoupled from BPM's auth surface. Use it only
288
+ when a same-origin deployment is impossible.
289
+
290
+ #### Cross-host auth precedence
291
+
292
+ If the consumer wants the React client to skip the BPM auth entirely
293
+ and rely on the host's existing session check at the Next.js layer,
294
+ the simplest path is to **not mount `<AuthProvider>` from
295
+ `@rytass/bpm-core-react`** and instead implement a host-side
296
+ `useAuth()` shim that returns the host's session. This is rare — most
297
+ consumers find Pattern A simpler.
298
+
201
299
  ## Role and Permission Contract
202
300
 
203
301
  BPM guards inspect `BPMAuthContext.roles` and `BPMAuthContext.permissions` with
@@ -255,16 +353,23 @@ export class BPMAuthContextFactory {
255
353
 
256
354
  return {
257
355
  memberId: member.id,
258
- email: member.email,
259
- name: member.name,
260
356
  roles: bpmRoles,
261
357
  permissions: [], // Host can also use `bpm:*` style permissions if preferred.
262
- metadata: {},
358
+ // The BPMAuthContext shape does NOT include name/email — those are
359
+ // surfaced through BPMMemberResolver instead. If you want them
360
+ // available to downstream resolvers without an extra resolver call,
361
+ // stash them in `metadata` (BPM passes it through unchanged).
362
+ metadata: { name: member.name, email: member.email },
263
363
  };
264
364
  }
265
365
  }
266
366
  ```
267
367
 
368
+ > **`BPMAuthContext` shape**: `{ memberId, roles, permissions, metadata }`
369
+ > — exactly four fields. The host's `name` / `email` are not part of the
370
+ > contract; resolvers call `BPMMemberResolver.resolve(memberId)` when
371
+ > they need display data.
372
+
268
373
  > The host owns the mapping table. Keep it in one place (a constant
269
374
  > module or a database table) so adding a new BPM tier in a future
270
375
  > BPMCore release becomes a one-line addition.
@@ -384,14 +489,26 @@ and observable:
384
489
  uniqueness; use your host's stable identifier here (e.g. an LDAP DN
385
490
  slug or a internal ERP code). On a re-sync, look up existing entities
386
491
  by `code` before deciding INSERT vs UPDATE.
387
- 2. **`metadata` is the host-FK stash.** Both `OrgUnit` and `Membership`
388
- carry an opaque `metadata` JSON object that BPM never introspects.
389
- Store your host's primary key here (e.g. `{ shuttleOrgUnitId: 12345 }`)
390
- so you can chase the back-pointer when reconciling.
391
- 3. **Soft delete via `deleteOrgUnit` / `deleteMembership`.** Deletes set
492
+ 2. **`metadataJson` is write-only.** The mutations
493
+ (`createOrgUnit`/`updateOrgUnit`/`createPosition`/`updatePosition`)
494
+ accept a `metadataJson` string that BPM stores verbatim. **However**,
495
+ `OrgUnitRecord` and `PositionRecord` returned by
496
+ `readOrganizationDashboard` do NOT include the metadata field
497
+ it's intentionally hidden so the JSON blob doesn't bloat every
498
+ pagination payload. **For reconciliation, always key on `code`**
499
+ (which IS returned). Treat `metadataJson` as a debugging/audit
500
+ stash, not a live FK pointer.
501
+ 3. **Soft delete via `deleteOrgUnit` / `deleteMembership` / `deleteManagerResolution`.** Deletes set
392
502
  `deletedAt` rather than removing rows. BPM's query layer hides
393
503
  soft-deleted rows automatically; reading `orgUnitCount()` will report
394
504
  the live count.
505
+ 4. **Positions are intentionally not deletable.** There is no
506
+ `deletePosition` mutation — positions are part of the historical
507
+ record (every signed approval task references the assignee's position
508
+ at sign time, so deleting a position would lose audit data). Retire a
509
+ position by removing all active memberships referencing it; the
510
+ position itself stays. Use `updatePosition({ id, name: 'retired-' + oldName, ... })`
511
+ if you want a UI hint that a position is no longer in use.
395
512
 
396
513
  ### Worked example: idempotent sync
397
514
 
@@ -405,8 +522,12 @@ input shapes — no `{id, input: {...}}` wrapping):
405
522
  - `createOrgUnit({ code, name, type, parentId, metadataJson })`
406
523
  - `updateOrgUnit({ id, code, name, type, parentId, metadataJson })`
407
524
  - `deleteOrgUnit(id)` (soft-delete)
408
- - `commitOrgUnitTreeDraft({ baseUpdatedAt, draft })` (transactional batch moves)
409
- - `createPosition` / `updatePosition` (key on `code`)
525
+ - `commitOrgUnitTreeDraft({ moves: { id, parentId, baseUpdatedAt }[] })` (transactional batch moves — each entry's `baseUpdatedAt` is the row's last-known `updatedAt`, used by BPM for optimistic-locking conflict detection)
526
+ - `createPosition({ code, name, level, metadataJson })` `level` (1+) controls hierarchy weight (higher = more senior, used by manager-resolution priority sort); `metadataJson` is required (pass `'{}'` if unused)
527
+ - `updatePosition({ id, code, name, level, metadataJson })` — every field except `id` is `T | null`; pass `null` to leave a field unchanged
528
+ - `createMembership({ memberId, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — `positionId` and `effectiveTo` accept `null`; `effectiveFrom` is an ISO timestamp string. Uniqueness key is `(memberId, orgUnitId, positionId)`
529
+ - `updateMembership({ id, orgUnitId, positionId, isPrimary, effectiveFrom, effectiveTo })` — every field except `id` is `T | null`
530
+ - `createManagerResolution({ scopeType, scopeId, managerMemberId, priority, effectiveFrom, effectiveTo })` — `scopeType` is `'MEMBER' | 'ORG_UNIT' | 'POSITION'`; `priority` orders the resolver chain (lower number = checked first)
410
531
  - `createMembership` / `updateMembership` (unique by `(memberId, orgUnitId, positionId)`)
411
532
  - `createManagerResolution` / `updateManagerResolution`
412
533
 
@@ -423,7 +544,7 @@ interface HostOrgUnit {
423
544
  readonly hostId: string; // e.g. ERP primary key — your host-FK
424
545
  readonly code: string; // stable across sync runs
425
546
  readonly name: string;
426
- readonly type: OrgUnitType; // 'COMPANY' | 'DIVISION' | 'DEPARTMENT' | 'TEAM'
547
+ readonly type: OrgUnitType; // 'company' | 'division' | 'department' | 'team'
427
548
  readonly parentCode: string | null;
428
549
  }
429
550
 
@@ -653,6 +774,32 @@ Required environment variables for direct Vault loading:
653
774
  | `VAULT_PASSWORD` | Vault userpass password. |
654
775
  | `VAULT_PATH` | Vault KV path, defaults to develop. |
655
776
 
777
+ ### Sharing a Postgres cluster with the host
778
+
779
+ Hosts that already run their own Postgres (and their own Vault path,
780
+ e.g. `shuttle/develop`) have three options for where BPM's 22 tables
781
+ live. Pick the one that matches your operational model:
782
+
783
+ | Option | When to use | Trade-off |
784
+ |---|---|---|
785
+ | **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. |
786
+ | **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`). |
787
+ | **Separate database** | Hard multi-tenant or compliance boundary | Two pools; cross-schema joins impossible (BPM doesn't need them); migration runners stay completely independent. |
788
+
789
+ For option B (separate schema), the host's own TypeORM `forRoot()` and
790
+ BPM's `buildTypeOrmModuleOptions` create **two distinct connections**
791
+ to the same Postgres cluster with different `schema:` settings — that
792
+ is the cleanest separation Nest's TypeORM module supports.
793
+
794
+ Vault paths can be split independently: keep `shuttle/develop` for the
795
+ host and create `bpm_core/develop` for BPM secrets. BPM's helper reads
796
+ its own path; the host's auth module reads its own. They do not
797
+ interfere.
798
+
799
+ > **Schema migrations** are per-schema. Run BPM's migrations against
800
+ > the schema named in `DB_SCHEMA`; the host's migrations stay on its
801
+ > own schema. There is no cross-schema migration coordination.
802
+
656
803
  ## Migrations
657
804
 
658
805
  This package ships TypeORM migrations under `src/lib/migrations`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rytass/bpm-core-nestjs-module",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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.6",
144
+ "@rytass/bpm-core-shared": "^0.1.8",
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",