@astram/identity 0.20.0 → 0.22.0

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 (22) hide show
  1. package/dist/features/authz/access-change-notifier.d.ts +109 -0
  2. package/dist/features/authz/access-change-notifier.d.ts.map +1 -0
  3. package/dist/features/authz/access-change-notifier.js +421 -0
  4. package/dist/features/authz/grant-admin.service.d.ts +27 -1
  5. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  6. package/dist/features/authz/grant-admin.service.js +52 -2
  7. package/dist/features/identity-feature.module.d.ts.map +1 -1
  8. package/dist/features/identity-feature.module.js +26 -1
  9. package/dist/features/membership/membership.service.d.ts +19 -2
  10. package/dist/features/membership/membership.service.d.ts.map +1 -1
  11. package/dist/features/membership/membership.service.js +57 -5
  12. package/dist/features/membership/posting.service.d.ts +12 -1
  13. package/dist/features/membership/posting.service.d.ts.map +1 -1
  14. package/dist/features/membership/posting.service.js +39 -2
  15. package/dist/features/permission-announcement/permission-announcement.controller.d.ts +22 -0
  16. package/dist/features/permission-announcement/permission-announcement.controller.d.ts.map +1 -0
  17. package/dist/features/permission-announcement/permission-announcement.controller.js +130 -0
  18. package/dist/features/permission-announcement/permission-announcement.service.d.ts +143 -0
  19. package/dist/features/permission-announcement/permission-announcement.service.d.ts.map +1 -0
  20. package/dist/features/permission-announcement/permission-announcement.service.js +389 -0
  21. package/migrations/1710000000109-CreatePermissionAnnouncementDismissal.cjs +46 -0
  22. package/package.json +4 -4
@@ -44,6 +44,7 @@ class GrantAdminService {
44
44
  ceiling;
45
45
  audit;
46
46
  schemas;
47
+ accessNotifier;
47
48
  bundles;
48
49
  /**
49
50
  * @param ceiling The ADR-0029 self-escalation ceiling ("only grant what you hold") plus the
@@ -55,12 +56,19 @@ class GrantAdminService {
55
56
  * connection, fail-closed. The identity module ALWAYS wires the engine `MariaDbAuditSink`.
56
57
  * @param schemas Resolved framework names (absent ⇒ resolved from the namespace, once, here).
57
58
  */
58
- constructor(pool, bumper, bundleStore, ceiling, audit, schemas = (0, schema_js_1.frameworkSchemasFromEnv)()) {
59
+ constructor(pool, bumper, bundleStore, ceiling, audit, schemas = (0, schema_js_1.frameworkSchemasFromEnv)(),
60
+ /**
61
+ * G-027 step 3 (notifying-user SPEC §5B, §5C): personal access-change notices on the grant's
62
+ * own transaction. OPTIONAL (absent ⇒ no notices, no extra reads); the identity module
63
+ * always wires it.
64
+ */
65
+ accessNotifier) {
59
66
  this.pool = pool;
60
67
  this.bumper = bumper;
61
68
  this.ceiling = ceiling;
62
69
  this.audit = audit;
63
70
  this.schemas = schemas;
71
+ this.accessNotifier = accessNotifier;
64
72
  this.bundles = bundleStore ?? new access_bundle_store_js_1.AccessBundleStore(pool, schemas);
65
73
  }
66
74
  /** All grantable exact keys (from the catalog) + the computed wildcard set. */
@@ -167,10 +175,17 @@ class GrantAdminService {
167
175
  await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id), 'revoke');
168
176
  }
169
177
  }
178
+ // G-027 step 3 (SPEC §5B): the holders' effective keys before the write, on this
179
+ // connection, after the lock and every refusal (a refused change never reaches a notice).
180
+ const notice = await this.beginAccessNotice(conn, roleUuid, actor);
170
181
  for (const key of keys)
171
182
  await statement(conn, key);
172
183
  const bumped = await this.bumper.bumpForRole(roleUuid, conn);
173
- await this.auditGrantChange(conn, action, roleUuid, keys, actor, bumped, extra);
184
+ const noticeDetail = await this.finishAccessNotice(notice, actor);
185
+ await this.auditGrantChange(conn, action, roleUuid, keys, actor, bumped, {
186
+ ...extra,
187
+ ...noticeDetail,
188
+ });
174
189
  await conn.commit();
175
190
  // ADR-0035: the committed versions reach Redis now (gate 5 reads them there).
176
191
  await this.bumper.publishCommitted(conn);
@@ -189,6 +204,36 @@ class GrantAdminService {
189
204
  await conn.release();
190
205
  }
191
206
  }
207
+ /**
208
+ * G-027 step 3 (SPEC §5B, §5C): read the role's holders and their effective keys on the grant's
209
+ * own transaction `conn`, BEFORE the `role_permission` write. `null` (no reads at all) when no
210
+ * notifier is wired or it is disabled for this actor (guard `auth: false`, the org bootstrap).
211
+ * Never throws: a failed read is carried as an `AccessReadFailure`.
212
+ */
213
+ async beginAccessNotice(conn, roleUuid, actor) {
214
+ const notifier = this.accessNotifier;
215
+ if (!notifier || !notifier.enabledFor(actor))
216
+ return null;
217
+ const query = (sql, params) => conn.query(sql, params);
218
+ const holders = await notifier.holdersOfRole(query, roleUuid);
219
+ const before = await notifier.snapshot(query, holders);
220
+ return { notifier, query, holders, before };
221
+ }
222
+ /**
223
+ * After the write and the bump: the after-snapshot and the notices, on the same connection (a
224
+ * rollback removes them with the grant). Returns the audit detail addition: `noticesDropped: n`
225
+ * when n > 0, `'unknown'` when a holder or snapshot read failed, else nothing (byte-identical).
226
+ */
227
+ async finishAccessNotice(run, actor) {
228
+ if (run === null)
229
+ return {};
230
+ const after = await run.notifier.snapshot(run.query, run.holders);
231
+ const { dropped } = await run.notifier.notify(run.query, actor, run.before, after);
232
+ const readFailed = !Array.isArray(run.holders) || !run.before.ok || !after.ok;
233
+ if (readFailed)
234
+ return { noticesDropped: 'unknown' };
235
+ return dropped > 0 ? { noticesDropped: dropped } : {};
236
+ }
192
237
  /**
193
238
  * Write the grant-administration audit row(s) on the transaction connection (D6). One row per
194
239
  * distinct tenant among the actor's tenant and every tenant the change bumped, so each affected
@@ -312,6 +357,9 @@ class GrantAdminService {
312
357
  if (this.ceiling && actor.isRoot !== true) {
313
358
  await this.ceiling.assertMayStripRoleFromServiceAccounts(actor, roleUuid, accounts.map((a) => a.sa_tenant_id), 'delete');
314
359
  }
360
+ // G-027 step 3 (SPEC §5C): holders + their keys before the grants go. Holders come from
361
+ // committed postings (the caller's uncommitted role delete is not visible here).
362
+ const notice = await this.beginAccessNotice(conn, roleUuid, actor);
315
363
  const keys = (await conn.query(`SELECT rp_perm_key FROM \`${this.schemas.authz}\`.\`role_permission\` WHERE rp_role_uuid = ? ORDER BY rp_perm_key`, [roleUuid])).map((r) => r.rp_perm_key);
316
364
  const bumped = await this.bumper.bumpForRole(roleUuid, conn);
317
365
  await conn.query(`DELETE FROM \`${this.schemas.authz}\`.\`role_permission\` WHERE rp_role_uuid = ?`, [roleUuid]);
@@ -321,9 +369,11 @@ class GrantAdminService {
321
369
  const previous = a.sa_updated_at_ms === null ? 0 : Number(a.sa_updated_at_ms);
322
370
  await conn.query(`UPDATE \`${this.schemas.app}\`.\`service_account\` SET sa_role_ids = ?, sa_updated_at_ms = ? WHERE sa_id = ?`, [JSON.stringify(remaining), Math.max(nowMs, previous + 1), a.sa_id]);
323
371
  }
372
+ const noticeDetail = await this.finishAccessNotice(notice, actor);
324
373
  await this.auditGrantChange(conn, 'role.permission_revoked', roleUuid, keys, actor, bumped, {
325
374
  roleDeleted: true,
326
375
  serviceAccountsUpdated: accounts.length,
376
+ ...noticeDetail,
327
377
  });
328
378
  await conn.commit();
329
379
  await this.bumper.publishCommitted(conn);
@@ -1 +1 @@
1
- {"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAaL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA0DxB,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AA0C/E,OAAO,EAAiB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAkElF;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAKD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GACzC,IAAI,CAwBN;AAKD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,IAAI,CAQN;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,eAAe,EAAE,EAClC,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,GAChE,gBAAgB,EAAE,CAapB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,cAAc,CAyBhB;AA2BD,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,EACrB,MAAM,GACP,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;QAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;KACnB,GAAG,aAAa;CAgqBvB"}
1
+ {"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAaL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AA4DxB,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AA6C/E,OAAO,EAAiB,KAAK,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAkElF;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAKD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GACzC,IAAI,CAwBN;AAKD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,IAAI,CAQN;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,eAAe,EAAE,EAClC,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,GAChE,gBAAgB,EAAE,CAapB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,cAAc,CAyBhB;AA2BD,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,EACrB,MAAM,GACP,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;QAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;KACnB,GAAG,aAAa;CA6rBvB"}
@@ -54,6 +54,9 @@ const access_bundle_store_js_1 = require("./authz/access-bundle-store.js");
54
54
  const grant_admin_service_js_1 = require("./authz/grant-admin.service.js");
55
55
  const grant_admin_controller_js_1 = require("./authz/grant-admin.controller.js");
56
56
  const grant_ceiling_js_1 = require("./authz/grant-ceiling.js");
57
+ const permission_announcement_service_js_1 = require("./permission-announcement/permission-announcement.service.js");
58
+ const permission_announcement_controller_js_1 = require("./permission-announcement/permission-announcement.controller.js");
59
+ const access_change_notifier_js_1 = require("./authz/access-change-notifier.js");
57
60
  const policy_service_js_1 = require("./policy/policy.service.js");
58
61
  const security_policy_service_js_1 = require("./security-policy/security-policy.service.js");
59
62
  const security_policy_controller_js_1 = require("./security-policy/security-policy.controller.js");
@@ -301,6 +304,10 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
301
304
  // registered for the cross-tenant writer and the policy author too.
302
305
  const recordAuthorization = (0, record_authorization_js_1.buildRecordAuthorizationInputs)(authzPool, extraObjects, schemas);
303
306
  const ceilingRegistry = recordAuthorization.registry;
307
+ // G-027 step 3: personal access-change notices, keys expanded against the SAME registry the
308
+ // request path grants from. Attached to guard's NotificationService by the provider below
309
+ // (absent with `auth: false` ⇒ a no-op notifier).
310
+ const accessChangeNotifier = new access_change_notifier_js_1.AccessChangeNotifier(ceilingRegistry, schemas);
304
311
  const bumper = new permission_version_bumper_js_1.PermissionVersionBumper(undefined, schemas);
305
312
  const postingVersionBump = {
306
313
  bumpTenant: (tenantUuid) => bumper.bumpForTenant(tenantUuid, authzPool),
@@ -331,7 +338,11 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
331
338
  const auditSinkConfig = { schema: schemas.audit };
332
339
  // Grant administration is audited on the grant's own transaction (D6, fail-closed).
333
340
  const grantAdminAuditSink = new engine_1.MariaDbAuditSink(auditSinkConfig);
334
- const grantAdminService = new grant_admin_service_js_1.GrantAdminService(authzPool, bumper, accessBundleStore, grantCeiling, grantAdminAuditSink, schemas);
341
+ const grantAdminService = new grant_admin_service_js_1.GrantAdminService(authzPool, bumper, accessBundleStore, grantCeiling, grantAdminAuditSink, schemas, accessChangeNotifier);
342
+ // G-027 step 4: permission-announcement admin tasks, over the SAME authz pool, ceiling and
343
+ // registry as grant administration; dismissals audited on their own transaction through the
344
+ // grant-admin sink (stateless; same fail-closed in-transaction contract).
345
+ const permissionAnnouncementService = new permission_announcement_service_js_1.PermissionAnnouncementService(authzPool, grantCeiling, ceilingRegistry, grantAdminAuditSink, schemas);
335
346
  // Scope closure (Task 1.2 of the scoped-hierarchy program): the reconciler + node
336
347
  // move/delete maintenance, over the SAME shared authzPool (it reaches BOTH shield_app —
337
348
  // companies — and shield_authz — scope_closure). Reuses the SAME `bumper` instance so a
@@ -661,6 +672,9 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
661
672
  // Mandatory-2FA policies (auth capabilities R2.9) — `security-policies/*` collides with
662
673
  // nothing. Gated on `security_policy:<action>`. Appended last.
663
674
  security_policy_controller_js_1.SecurityPolicyController,
675
+ // Permission announcements (G-027 step 4) — `permission-announcements/*` collides with no
676
+ // `/:uuid` route under `identity`; its `:companyUuid/:key/{dismiss,reopen}` are static-suffixed.
677
+ permission_announcement_controller_js_1.PermissionAnnouncementController,
664
678
  ],
665
679
  providers: [
666
680
  company_service_js_1.CompanyService,
@@ -671,6 +685,16 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
671
685
  org_catalog_service_js_1.OrgCatalogService,
672
686
  record_resolver_js_1.RecordResolverService,
673
687
  { provide: posting_version_bump_js_1.POSTING_VERSION_BUMP, useValue: postingVersionBump },
688
+ // G-027 step 3: the one access-change notifier (also held by GrantAdminService), attached
689
+ // here to guard's NotificationService when guard provides it (`auth: true`), else none.
690
+ {
691
+ provide: access_change_notifier_js_1.ACCESS_CHANGE_NOTIFIER,
692
+ useFactory: (service) => {
693
+ accessChangeNotifier.attach(service ?? null);
694
+ return accessChangeNotifier;
695
+ },
696
+ inject: [{ token: guard_1.SHIELD_NOTIFICATIONS, optional: true }],
697
+ },
674
698
  // Mandatory-2FA policies (R2.6, R2.9): the service, and its permission-version bump over
675
699
  // the ONE shared bumper (bumps on the write's transaction, published after the commit).
676
700
  security_policy_service_js_1.SecurityPolicyService,
@@ -749,6 +773,7 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
749
773
  // Grant-admin stack — all over the ONE shared authzPool.
750
774
  { provide: access_bundle_store_js_1.AccessBundleStore, useValue: accessBundleStore },
751
775
  { provide: grant_admin_service_js_1.GrantAdminService, useValue: grantAdminService },
776
+ { provide: permission_announcement_service_js_1.PermissionAnnouncementService, useValue: permissionAnnouncementService },
752
777
  // The ONE tenant-boundary authority (ADR-0029 ceiling + block A: role assignment, user
753
778
  // dominance, role binding) — injected by RoleService, PostingService and UserService.
754
779
  { provide: grant_ceiling_js_1.GrantCeiling, useValue: grantCeiling },
@@ -20,11 +20,22 @@ import { RecordsService, type RecordId, type RecordView } from '@astram/engine';
20
20
  import type { Principal } from '@astram/core';
21
21
  import { type PostingVersionBump } from './posting-version-bump.js';
22
22
  import { GrantCeiling } from '../authz/grant-ceiling.js';
23
+ import { type AccessChangeNotifier } from '../authz/access-change-notifier.js';
23
24
  export declare class MembershipService {
24
25
  private readonly records;
25
26
  private readonly ceiling;
26
27
  private readonly versionBump?;
27
- constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined);
28
+ private readonly accessNotifier?;
29
+ constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined, accessNotifier?: (AccessChangeNotifier | null) | undefined);
30
+ /**
31
+ * G-027 step 3 (SPEC §5E): the before-snapshot of `pairs` on the engine transaction's own
32
+ * connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted membership
33
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
34
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
35
+ */
36
+ private beginAccessNotice;
37
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
38
+ private finishAccessNotice;
28
39
  /**
29
40
  * Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
30
41
  * acting on their OWN membership, require the actor to dominate its user (security review): a
@@ -48,7 +59,8 @@ export declare class MembershipService {
48
59
  */
49
60
  addMembership(actor: Principal, userUuid: string, companyUuid: string): Promise<RecordId>;
50
61
  /**
51
- * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create (G-009 R4): a brand-new
62
+ * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create, inside one engine
63
+ * transaction with the user's access notice (G-009 R4; G-027 step 3): a brand-new
52
64
  * membership has no other active card to displace, so no create-then-activate pair. A
53
65
  * membership for that pair already existing is an `IdentityConflictError`, from the pre-check
54
66
  * and from the `(cm_user_uuid, cm_company_uuid)` unique index (the pre-check read is SQL-scoped
@@ -88,6 +100,11 @@ export declare class MembershipService {
88
100
  /**
89
101
  * Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
90
102
  * membership needs dominance over the peer (security review); your own does not.
103
+ *
104
+ * Intentionally sends NO access-change notice (notifying-user SPEC §11): the user can no longer
105
+ * open that company's inbox, and the notice store's recipient filter would skip them anyway.
106
+ * `updateMembership` (status only, no key change), user status changes and `deleteUser` are
107
+ * likewise not hooked.
91
108
  */
92
109
  removeMembership(actor: Principal, membershipUuid: string): Promise<void>;
93
110
  }
@@ -1 +1 @@
1
- {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAChF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAYzD,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBAXZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F;;;;;;;OAOG;IACG,mBAAmB,CACvB,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,QAAQ,CAAC;IAwBpB,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA0D1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IA4BhB;;;OAGG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
1
+ {"version":3,"file":"membership.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/membership.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,QAAQ,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEhF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE1F,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,EAEL,KAAK,oBAAoB,EAI1B,MAAM,oCAAoC,CAAC;AAoB5C,qBACa,iBAAiB;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAQxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;gBAhBf,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAQrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,cAAc,CAAC,GAAE,oBAAoB,GAAG,IAAI,aAAA;IAG/D;;;;;OAKG;YACW,iBAAiB;IAY/B,6FAA6F;YAC/E,kBAAkB;IAShC;;;;OAIG;YACW,WAAW;IAQzB;;;;;;OAMG;YACW,WAAW;IAYzB;;;;;;OAMG;IACG,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/F;;;;;;;;OAQG;IACG,mBAAmB,CACvB,KAAK,EAAE,SAAS,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,QAAQ,CAAC;IAgCpB,kEAAkE;IAC5D,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAKvE;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAyE1F;;;;OAIG;IACG,gBAAgB,CACpB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IA4BhB;;;;;;;;OAQG;IACG,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYhF"}
@@ -33,9 +33,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.MembershipService = void 0;
34
34
  const common_1 = require("@nestjs/common");
35
35
  const engine_1 = require("@astram/engine");
36
+ const internal_1 = require("@astram/engine/internal");
36
37
  const posting_version_bump_js_1 = require("./posting-version-bump.js");
37
38
  const identity_errors_js_1 = require("../shared/identity-errors.js");
38
39
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
40
+ const access_change_notifier_js_1 = require("../authz/access-change-notifier.js");
39
41
  /** The only status the active membership may have (case-sensitive, as the readers' predicate). */
40
42
  const ACTIVE_STATUS = 'active';
41
43
  /** A driver duplicate-key error (errno 1062), recognised by errno/code only, never its message. */
@@ -49,13 +51,36 @@ let MembershipService = class MembershipService {
49
51
  records;
50
52
  ceiling;
51
53
  versionBump;
54
+ accessNotifier;
52
55
  constructor(records,
53
56
  // Dominance over the member for destructive ops (security review): REQUIRED, so a missing
54
57
  // ceiling fails DI at boot instead of silently skipping the check.
55
- ceiling, versionBump) {
58
+ ceiling, versionBump, accessNotifier) {
56
59
  this.records = records;
57
60
  this.ceiling = ceiling;
58
61
  this.versionBump = versionBump;
62
+ this.accessNotifier = accessNotifier;
63
+ }
64
+ /**
65
+ * G-027 step 3 (SPEC §5E): the before-snapshot of `pairs` on the engine transaction's own
66
+ * connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted membership
67
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
68
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
69
+ */
70
+ async beginAccessNotice(tx, actor, pairs) {
71
+ const notifier = this.accessNotifier;
72
+ if (!notifier || !notifier.enabledFor(actor))
73
+ return null;
74
+ const query = (0, internal_1.transactionQueryOf)(tx);
75
+ const before = await notifier.snapshot(query, pairs);
76
+ return { notifier, query, pairs, before };
77
+ }
78
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
79
+ async finishAccessNotice(run, actor) {
80
+ if (run === null)
81
+ return;
82
+ const after = await run.notifier.snapshot(run.query, run.pairs);
83
+ await run.notifier.notify(run.query, actor, run.before, after);
59
84
  }
60
85
  /**
61
86
  * Read a membership through the subtree wall (404 when out of reach) and, unless the actor is
@@ -117,7 +142,8 @@ let MembershipService = class MembershipService {
117
142
  });
118
143
  }
119
144
  /**
120
- * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create (G-009 R4): a brand-new
145
+ * Add an ACTIVE membership for `(userUuid, companyUuid)` in one create, inside one engine
146
+ * transaction with the user's access notice (G-009 R4; G-027 step 3): a brand-new
121
147
  * membership has no other active card to displace, so no create-then-activate pair. A
122
148
  * membership for that pair already existing is an `IdentityConflictError`, from the pre-check
123
149
  * and from the `(cm_user_uuid, cm_company_uuid)` unique index (the pre-check read is SQL-scoped
@@ -136,8 +162,16 @@ let MembershipService = class MembershipService {
136
162
  }
137
163
  let id;
138
164
  try {
139
- id = await this.records.create('company_membership', {
140
- fields: { userUuid, companyUuid, isActive: true, status: 'active' },
165
+ // G-027 step 3 (SPEC §5E): the create and the user's access notice in one transaction.
166
+ id = await this.records.transaction(async (tx) => {
167
+ const notice = await this.beginAccessNotice(tx, actor, [
168
+ { userId: userUuid, tenantId: companyUuid },
169
+ ]);
170
+ const created = await tx.create('company_membership', {
171
+ fields: { userUuid, companyUuid, isActive: true, status: 'active' },
172
+ });
173
+ await this.finishAccessNotice(notice, actor);
174
+ return created;
141
175
  });
142
176
  }
143
177
  catch (err) {
@@ -203,6 +237,16 @@ let MembershipService = class MembershipService {
203
237
  if (current.fields?.status !== ACTIVE_STATUS) {
204
238
  throw new identity_errors_js_1.IdentityConflictError('Only a membership with status active can be made the active membership.');
205
239
  }
240
+ // G-027 step 3 (SPEC §5E): the user in every company of their memberships (the target's
241
+ // and each one this flip deactivates), before the first write. One notice per company
242
+ // whose key set changed.
243
+ const companies = new Set();
244
+ for (const m of rows) {
245
+ const c = m.fields?.companyUuid;
246
+ if (typeof c === 'string' && c.length > 0)
247
+ companies.add(c);
248
+ }
249
+ const notice = await this.beginAccessNotice(tx, actor, [...companies].map((tenantId) => ({ userId: userUuid, tenantId })));
206
250
  // 1) TARGET FIRST (defence in depth, see the method comment).
207
251
  await tx.update('company_membership', target.id, { fields: { isActive: true } });
208
252
  // 2) Deactivate every OTHER membership (skip the target we just activated).
@@ -212,6 +256,7 @@ let MembershipService = class MembershipService {
212
256
  await tx.update('company_membership', m.id, { fields: { isActive: false } });
213
257
  deactivated.push(m);
214
258
  }
259
+ await this.finishAccessNotice(notice, actor);
215
260
  });
216
261
  // ADR-0011 STALENESS FIX: activation ADDS the target company's role grants and each
217
262
  // deactivation REMOVES that company's grants — a coarse-grant change in BOTH directions.
@@ -262,6 +307,11 @@ let MembershipService = class MembershipService {
262
307
  /**
263
308
  * Remove a membership card (its postings cascade at the DB via FK ON DELETE CASCADE). A peer's
264
309
  * membership needs dominance over the peer (security review); your own does not.
310
+ *
311
+ * Intentionally sends NO access-change notice (notifying-user SPEC §11): the user can no longer
312
+ * open that company's inbox, and the notice store's recipient filter would skip them anyway.
313
+ * `updateMembership` (status only, no key change), user status changes and `deleteUser` are
314
+ * likewise not hooked.
265
315
  */
266
316
  async removeMembership(actor, membershipUuid) {
267
317
  // ADR-0011 STALENESS FIX: resolve the membership's tenant BEFORE the delete (it's
@@ -281,6 +331,8 @@ exports.MembershipService = MembershipService = __decorate([
281
331
  (0, common_1.Injectable)(),
282
332
  __param(2, (0, common_1.Optional)()),
283
333
  __param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
334
+ __param(3, (0, common_1.Optional)()),
335
+ __param(3, (0, common_1.Inject)(access_change_notifier_js_1.ACCESS_CHANGE_NOTIFIER)),
284
336
  __metadata("design:paramtypes", [engine_1.RecordsService,
285
- grant_ceiling_js_1.GrantCeiling, Object])
337
+ grant_ceiling_js_1.GrantCeiling, Object, Object])
286
338
  ], MembershipService);
@@ -27,6 +27,7 @@ import { RecordsService, type RecordView } from '@astram/engine';
27
27
  import type { Principal } from '@astram/core';
28
28
  import { type PostingVersionBump } from './posting-version-bump.js';
29
29
  import { GrantCeiling } from '../authz/grant-ceiling.js';
30
+ import { type AccessChangeNotifier } from '../authz/access-change-notifier.js';
30
31
  /**
31
32
  * The shape of one desired posting row submitted by the editor. The effective-window
32
33
  * bounds arrive from the client as a bare `YYYY-MM-DD` date string (native `<input
@@ -44,7 +45,8 @@ export declare class PostingService {
44
45
  private readonly records;
45
46
  private readonly ceiling;
46
47
  private readonly versionBump?;
47
- constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined);
48
+ private readonly accessNotifier?;
49
+ constructor(records: RecordsService, ceiling: GrantCeiling, versionBump?: (PostingVersionBump | null) | undefined, accessNotifier?: (AccessChangeNotifier | null) | undefined);
48
50
  /** List a membership's postings (tenant-scoped by the pipe). */
49
51
  listPostings(membershipUuid: string): Promise<readonly RecordView[]>;
50
52
  /**
@@ -64,6 +66,15 @@ export declare class PostingService {
64
66
  addPostings(actor: Principal, membershipUuid: string, toAdd: readonly PostingInput[], opts?: {
65
67
  operationRoleIds?: ReadonlySet<string> | readonly string[];
66
68
  }): Promise<void>;
69
+ /**
70
+ * G-027 step 3 (SPEC §5D): the before-snapshot of (member, company) on the engine transaction's
71
+ * own connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted posting
72
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
73
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
74
+ */
75
+ private beginAccessNotice;
76
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
77
+ private finishAccessNotice;
67
78
  private assertEveryRole;
68
79
  /** The membership through the subtree read wall (404 when out of reach) and its company. */
69
80
  private reachMembership;
@@ -1 +1 @@
1
- {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC1F,OAAO,EAAE,YAAY,EAA6B,MAAM,2BAA2B,CAAC;AAOpF;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AA0ED,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;gBARZ,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA;IAG1D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;OAKG;IACG,eAAe,CACnB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC;IA0EhB;;;;;;OAMG;IACG,WAAW,CACf,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,IAAI,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,CAAA;KAAE,GACpE,OAAO,CAAC,IAAI,CAAC;IA4ChB,OAAO,CAAC,eAAe;IAQvB,4FAA4F;YAC9E,eAAe;IAY7B,iGAAiG;YACnF,YAAY;IAuB1B;;;;OAIG;YACW,cAAc;IAa5B;;;;;;;;;;;;OAYG;YACW,YAAY;YA+BZ,UAAU;CAkBzB"}
1
+ {"version":3,"file":"posting.service.d.ts","sourceRoot":"","sources":["../../../src/features/membership/posting.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC1F,OAAO,EAAE,YAAY,EAA6B,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAEL,KAAK,oBAAoB,EAI1B,MAAM,oCAAoC,CAAC;AAO5C;;;;;GAKG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACvC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACzC;AAkFD,qBACa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAKxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAK7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;gBAbf,OAAO,EAAE,cAAc,EAGvB,OAAO,EAAE,YAAY,EAKrB,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAKvC,cAAc,CAAC,GAAE,oBAAoB,GAAG,IAAI,aAAA;IAG/D,gEAAgE;IAC1D,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,UAAU,EAAE,CAAC;IAK1E;;;;;OAKG;IACG,eAAe,CACnB,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,SAAS,YAAY,EAAE,GAC/B,OAAO,CAAC,IAAI,CAAC;IA6EhB;;;;;;OAMG;IACG,WAAW,CACf,KAAK,EAAE,SAAS,EAChB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,SAAS,YAAY,EAAE,EAC9B,IAAI,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,CAAA;KAAE,GACpE,OAAO,CAAC,IAAI,CAAC;IAiDhB;;;;;OAKG;YACW,iBAAiB;IAiB/B,6FAA6F;YAC/E,kBAAkB;IAShC,OAAO,CAAC,eAAe;IAQvB,4FAA4F;YAC9E,eAAe;IAY7B,iGAAiG;YACnF,YAAY;IAuB1B;;;;OAIG;YACW,cAAc;IAa5B;;;;;;;;;;;;OAYG;YACW,YAAY;YA+BZ,UAAU;CAkBzB"}
@@ -40,8 +40,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.PostingService = void 0;
41
41
  const common_1 = require("@nestjs/common");
42
42
  const engine_1 = require("@astram/engine");
43
+ const internal_1 = require("@astram/engine/internal");
43
44
  const posting_version_bump_js_1 = require("./posting-version-bump.js");
44
45
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
46
+ const access_change_notifier_js_1 = require("../authz/access-change-notifier.js");
45
47
  const identity_errors_js_1 = require("../shared/identity-errors.js");
46
48
  /** The field keys whose values are effective-window epoch-ms bounds (normalized on write). */
47
49
  const WINDOW_KEYS = new Set(['effectiveFrom', 'effectiveUntil']);
@@ -115,13 +117,15 @@ let PostingService = class PostingService {
115
117
  records;
116
118
  ceiling;
117
119
  versionBump;
120
+ accessNotifier;
118
121
  constructor(records,
119
122
  // The role-assignment ceiling + binding rule (security hardening #11). REQUIRED: a missing
120
123
  // ceiling must fail DI at boot, never silently skip the check.
121
- ceiling, versionBump) {
124
+ ceiling, versionBump, accessNotifier) {
122
125
  this.records = records;
123
126
  this.ceiling = ceiling;
124
127
  this.versionBump = versionBump;
128
+ this.accessNotifier = accessNotifier;
125
129
  }
126
130
  /** List a membership's postings (tenant-scoped by the pipe). */
127
131
  async listPostings(membershipUuid) {
@@ -170,12 +174,15 @@ let PostingService = class PostingService {
170
174
  // delete, while we waited is refused before the INSERT (rolled back).
171
175
  await this.lockAndRecheck(tx, actor, tenantUuid, addedRoles, undefined);
172
176
  await this.lockOrgUnits(tx, tenantUuid, added);
177
+ // G-027 step 3 (SPEC §5D): the member's keys in this company before the first write.
178
+ const notice = await this.beginAccessNotice(tx, actor, memberUuid, tenantUuid);
173
179
  for (const d of added) {
174
180
  await tx.create('membership_posting', { fields: { ...stripEmpty(d), membershipUuid } });
175
181
  }
176
182
  for (const r of removed) {
177
183
  await tx.delete('membership_posting', r.id);
178
184
  }
185
+ await this.finishAccessNotice(notice, actor);
179
186
  });
180
187
  await this.bumpTenant(tenantUuid);
181
188
  }
@@ -209,15 +216,43 @@ let PostingService = class PostingService {
209
216
  if (added.length === 0)
210
217
  return; // nothing missing: no write, no bump
211
218
  const addedRoles = await this.assertMayAdd(actor, tenantUuid, added, ceilingOpts);
219
+ const memberUuid = typeof membership.fields?.userUuid === 'string' ? membership.fields.userUuid : '';
212
220
  await this.records.transaction(async (tx) => {
213
221
  await this.lockAndRecheck(tx, actor, tenantUuid, addedRoles, ceilingOpts);
214
222
  await this.lockOrgUnits(tx, tenantUuid, added);
223
+ // G-027 step 3 (SPEC §5D): the member's keys in this company before the first write.
224
+ const notice = await this.beginAccessNotice(tx, actor, memberUuid, tenantUuid);
215
225
  for (const d of added) {
216
226
  await tx.create('membership_posting', { fields: { ...stripEmpty(d), membershipUuid } });
217
227
  }
228
+ await this.finishAccessNotice(notice, actor);
218
229
  });
219
230
  await this.bumpTenant(tenantUuid);
220
231
  }
232
+ /**
233
+ * G-027 step 3 (SPEC §5D): the before-snapshot of (member, company) on the engine transaction's
234
+ * own connection (`transactionQueryOf`), so the after-snapshot sees the uncommitted posting
235
+ * writes and the notice commits or rolls back with them. `null` (no reads) when no notifier is
236
+ * wired or it is disabled for this actor (guard `auth: false`, the org bootstrap). Never throws.
237
+ */
238
+ async beginAccessNotice(tx, actor, memberUuid, tenantUuid) {
239
+ const notifier = this.accessNotifier;
240
+ if (!notifier || !notifier.enabledFor(actor))
241
+ return null;
242
+ const query = (0, internal_1.transactionQueryOf)(tx);
243
+ const pairs = memberUuid !== '' && tenantUuid !== null
244
+ ? [{ userId: memberUuid, tenantId: tenantUuid }]
245
+ : [];
246
+ const before = await notifier.snapshot(query, pairs);
247
+ return { notifier, query, pairs, before };
248
+ }
249
+ /** After the last write: after-snapshot + notify (a drop is logged by the notifier only). */
250
+ async finishAccessNotice(run, actor) {
251
+ if (run === null)
252
+ return;
253
+ const after = await run.notifier.snapshot(run.query, run.pairs);
254
+ await run.notifier.notify(run.query, actor, run.before, after);
255
+ }
221
256
  assertEveryRole(postings) {
222
257
  for (const d of postings) {
223
258
  if (typeof d?.roleUuid !== 'string' || d.roleUuid.length === 0) {
@@ -327,6 +362,8 @@ exports.PostingService = PostingService = __decorate([
327
362
  (0, common_1.Injectable)(),
328
363
  __param(2, (0, common_1.Optional)()),
329
364
  __param(2, (0, common_1.Inject)(posting_version_bump_js_1.POSTING_VERSION_BUMP)),
365
+ __param(3, (0, common_1.Optional)()),
366
+ __param(3, (0, common_1.Inject)(access_change_notifier_js_1.ACCESS_CHANGE_NOTIFIER)),
330
367
  __metadata("design:paramtypes", [engine_1.RecordsService,
331
- grant_ceiling_js_1.GrantCeiling, Object])
368
+ grant_ceiling_js_1.GrantCeiling, Object, Object])
332
369
  ], PostingService);
@@ -0,0 +1,22 @@
1
+ import type { Principal } from '@astram/core';
2
+ import { PermissionAnnouncementService, type PermissionAnnouncementQuery } from './permission-announcement.service.js';
3
+ /** The request shape after the enforcement guard attaches the resolved principal. */
4
+ interface AuthedRequest {
5
+ principal: Principal;
6
+ }
7
+ /** Validate the list query (spec §3, §13: bad `status`/`page`/`limit`/uuid ⇒ 400). */
8
+ export declare function parseAnnouncementQuery(query: Record<string, unknown> | undefined): PermissionAnnouncementQuery;
9
+ /** Validate a `:key` route param (spec §5.B), or 400 (the value is never echoed). */
10
+ export declare function parsePermissionKeyParam(value: unknown): string;
11
+ export declare class PermissionAnnouncementController {
12
+ private readonly announcements;
13
+ constructor(announcements: PermissionAnnouncementService);
14
+ /** The caller's tasks, grouped per company: `{ total, rows, page, limit }`. */
15
+ list(req: AuthedRequest, query: Record<string, unknown>): Promise<import("./permission-announcement.service.js").PermissionAnnouncementPage>;
16
+ /** "Reviewed, not needed" (idempotent, audited). */
17
+ dismiss(req: AuthedRequest, companyUuid: string, key: string): Promise<void>;
18
+ /** Undo a dismissal (idempotent, audited). */
19
+ reopen(req: AuthedRequest, companyUuid: string, key: string): Promise<void>;
20
+ }
21
+ export {};
22
+ //# sourceMappingURL=permission-announcement.controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-announcement.controller.d.ts","sourceRoot":"","sources":["../../../src/features/permission-announcement/permission-announcement.controller.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAIL,6BAA6B,EAE7B,KAAK,2BAA2B,EACjC,MAAM,sCAAsC,CAAC;AAE9C,qFAAqF;AACrF,UAAU,aAAa;IACrB,SAAS,EAAE,SAAS,CAAC;CACtB;AAsBD,sFAAsF;AACtF,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GACzC,2BAA2B,CAoB7B;AAED,qFAAqF;AACrF,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAS9D;AAED,qBAEa,gCAAgC;IAC/B,OAAO,CAAC,QAAQ,CAAC,aAAa;gBAAb,aAAa,EAAE,6BAA6B;IAEzE,+EAA+E;IAGzE,IAAI,CAAQ,GAAG,EAAE,aAAa,EAAW,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI7E,oDAAoD;IAI9C,OAAO,CACJ,GAAG,EAAE,aAAa,EACO,WAAW,EAAE,MAAM,EACrC,GAAG,EAAE,MAAM,GACxB,OAAO,CAAC,IAAI,CAAC;IAIhB,8CAA8C;IAIxC,MAAM,CACH,GAAG,EAAE,aAAa,EACO,WAAW,EAAE,MAAM,EACrC,GAAG,EAAE,MAAM,GACxB,OAAO,CAAC,IAAI,CAAC;CAGjB"}