@astram/identity 0.13.0 → 0.15.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 (97) hide show
  1. package/dist/features/authz/grant-admin.service.d.ts +1 -1
  2. package/dist/features/authz/grant-admin.service.d.ts.map +1 -1
  3. package/dist/features/authz/grant-admin.service.js +8 -0
  4. package/dist/features/authz/grant-ceiling.d.ts +11 -3
  5. package/dist/features/authz/grant-ceiling.d.ts.map +1 -1
  6. package/dist/features/authz/grant-ceiling.js +20 -3
  7. package/dist/features/authz/record-authorization.d.ts +1 -1
  8. package/dist/features/authz/record-authorization.d.ts.map +1 -1
  9. package/dist/features/authz/record-authorization.js +3 -2
  10. package/dist/features/company/company-hierarchy.service.d.ts +6 -0
  11. package/dist/features/company/company-hierarchy.service.d.ts.map +1 -1
  12. package/dist/features/company/company-hierarchy.service.js +19 -0
  13. package/dist/features/identity-feature.module.d.ts +7 -1
  14. package/dist/features/identity-feature.module.d.ts.map +1 -1
  15. package/dist/features/identity-feature.module.js +46 -1
  16. package/dist/features/membership/membership.service.d.ts +9 -0
  17. package/dist/features/membership/membership.service.d.ts.map +1 -1
  18. package/dist/features/membership/membership.service.js +40 -0
  19. package/dist/features/membership/posting.service.d.ts +18 -0
  20. package/dist/features/membership/posting.service.d.ts.map +1 -1
  21. package/dist/features/membership/posting.service.js +85 -27
  22. package/dist/features/org-bootstrap/org-bootstrap-error.d.ts +20 -0
  23. package/dist/features/org-bootstrap/org-bootstrap-error.d.ts.map +1 -0
  24. package/dist/features/org-bootstrap/org-bootstrap-error.js +24 -0
  25. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts +58 -0
  26. package/dist/features/org-bootstrap/org-bootstrap-file.d.ts.map +1 -0
  27. package/dist/features/org-bootstrap/org-bootstrap-file.js +323 -0
  28. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts +79 -0
  29. package/dist/features/org-bootstrap/org-bootstrap-plan.d.ts.map +1 -0
  30. package/dist/features/org-bootstrap/org-bootstrap-plan.js +288 -0
  31. package/dist/features/org-bootstrap/org-bootstrap.manifest.d.ts +19 -0
  32. package/dist/features/org-bootstrap/org-bootstrap.manifest.d.ts.map +1 -0
  33. package/dist/features/org-bootstrap/org-bootstrap.manifest.js +30 -0
  34. package/dist/features/org-bootstrap/org-bootstrap.tokens.d.ts +8 -0
  35. package/dist/features/org-bootstrap/org-bootstrap.tokens.d.ts.map +1 -0
  36. package/dist/features/org-bootstrap/org-bootstrap.tokens.js +10 -0
  37. package/dist/features/org-bootstrap/org-names.d.ts +12 -0
  38. package/dist/features/org-bootstrap/org-names.d.ts.map +1 -0
  39. package/dist/features/org-bootstrap/org-names.js +26 -0
  40. package/dist/features/org-bootstrap/org-state.reader.d.ts +15 -0
  41. package/dist/features/org-bootstrap/org-state.reader.d.ts.map +1 -0
  42. package/dist/features/org-bootstrap/org-state.reader.js +225 -0
  43. package/dist/features/org-bootstrap/root-password-source.d.ts +9 -0
  44. package/dist/features/org-bootstrap/root-password-source.d.ts.map +1 -0
  45. package/dist/features/org-bootstrap/root-password-source.js +57 -0
  46. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +61 -0
  47. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -0
  48. package/dist/features/org-bootstrap/run-org-bootstrap.js +774 -0
  49. package/dist/features/org-bootstrap/set-password-links.d.ts +42 -0
  50. package/dist/features/org-bootstrap/set-password-links.d.ts.map +1 -0
  51. package/dist/features/org-bootstrap/set-password-links.js +76 -0
  52. package/dist/features/org-structure/org-catalog.service.d.ts +13 -0
  53. package/dist/features/org-structure/org-catalog.service.d.ts.map +1 -1
  54. package/dist/features/org-structure/org-catalog.service.js +40 -0
  55. package/dist/features/role/role-owner-transition.d.ts +17 -0
  56. package/dist/features/role/role-owner-transition.d.ts.map +1 -0
  57. package/dist/features/role/role-owner-transition.js +23 -0
  58. package/dist/features/role/role.controller.d.ts +1 -1
  59. package/dist/features/role/role.controller.d.ts.map +1 -1
  60. package/dist/features/role/role.controller.js +6 -4
  61. package/dist/features/role/role.service.d.ts +8 -2
  62. package/dist/features/role/role.service.d.ts.map +1 -1
  63. package/dist/features/role/role.service.js +13 -3
  64. package/dist/features/user/credential-provisioner.token.d.ts +8 -0
  65. package/dist/features/user/credential-provisioner.token.d.ts.map +1 -0
  66. package/dist/features/user/credential-provisioner.token.js +10 -0
  67. package/dist/features/user/invite-message.d.ts +8 -0
  68. package/dist/features/user/invite-message.d.ts.map +1 -0
  69. package/dist/features/user/invite-message.js +23 -0
  70. package/dist/features/user/invite.config.d.ts +15 -0
  71. package/dist/features/user/invite.config.d.ts.map +1 -0
  72. package/dist/features/user/invite.config.js +55 -0
  73. package/dist/features/user/invite.service.d.ts +62 -0
  74. package/dist/features/user/invite.service.d.ts.map +1 -0
  75. package/dist/features/user/invite.service.js +254 -0
  76. package/dist/features/user/invite.tokens.d.ts +11 -0
  77. package/dist/features/user/invite.tokens.d.ts.map +1 -0
  78. package/dist/features/user/invite.tokens.js +13 -0
  79. package/dist/features/user/user.controller.d.ts +13 -6
  80. package/dist/features/user/user.controller.d.ts.map +1 -1
  81. package/dist/features/user/user.controller.js +30 -4
  82. package/dist/features/user/user.service.d.ts +51 -10
  83. package/dist/features/user/user.service.d.ts.map +1 -1
  84. package/dist/features/user/user.service.js +96 -27
  85. package/dist/index.d.ts +13 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +9 -1
  88. package/dist/internal.d.ts +2 -0
  89. package/dist/internal.d.ts.map +1 -1
  90. package/dist/internal.js +3 -1
  91. package/dist/role.object.d.ts +1 -0
  92. package/dist/role.object.d.ts.map +1 -1
  93. package/dist/role.object.js +8 -0
  94. package/dist/user.object.d.ts.map +1 -1
  95. package/dist/user.object.js +3 -0
  96. package/migrations/1710000000101-AddRoleOwnerCompany.cjs +98 -0
  97. package/package.json +4 -4
@@ -16,12 +16,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.UserService = void 0;
17
17
  const common_1 = require("@nestjs/common");
18
18
  const engine_1 = require("@astram/engine");
19
+ const internal_1 = require("@astram/engine/internal");
19
20
  const guard_1 = require("@astram/guard");
20
21
  const posting_version_bump_js_1 = require("../membership/posting-version-bump.js");
21
22
  const list_query_js_1 = require("../shared/list-query.js");
22
23
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
23
24
  const identity_errors_js_1 = require("../shared/identity-errors.js");
24
25
  const email_address_changer_token_js_1 = require("./email-address-changer.token.js");
26
+ const credential_provisioner_token_js_1 = require("./credential-provisioner.token.js");
25
27
  const framework_schemas_token_js_1 = require("../framework-schemas.token.js");
26
28
  /**
27
29
  * KI #87: `users` has two unique keys besides the PK, `IDX_users_username` (migration 001) and
@@ -43,11 +45,19 @@ async function mappingDuplicates(write) {
43
45
  return await write();
44
46
  }
45
47
  catch (err) {
46
- if (isDuplicateKey(err))
48
+ // R1-4: an orphan credential already holds this login hint (deleteUser leaves it): same fixed
49
+ // 409 as a duplicate email, never the hint.
50
+ if (isDuplicateKey(err) || err instanceof guard_1.CredentialExistsError) {
47
51
  throw new identity_errors_js_1.IdentityConflictError(USERNAME_OR_EMAIL_TAKEN);
52
+ }
48
53
  throw err;
49
54
  }
50
55
  }
56
+ const SIGN_IN_STATUS = {
57
+ locked: 'pending',
58
+ usable: 'active',
59
+ none: 'none',
60
+ };
51
61
  const USER_SEARCH_FIELDS = ['username', 'email'];
52
62
  const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
53
63
  /**
@@ -77,6 +87,8 @@ let UserService = UserService_1 = class UserService {
77
87
  sessions;
78
88
  versionBump;
79
89
  emailAddresses;
90
+ provisioner;
91
+ dispatcher;
80
92
  logger = new common_1.Logger(UserService_1.name);
81
93
  /**
82
94
  * The engine's audit sink, used on the email helper's transaction (no pool of its own), on the
@@ -84,7 +96,7 @@ let UserService = UserService_1 = class UserService {
84
96
  * service) ⇒ the sink resolves `SHIELD_DB_NAMESPACE` itself.
85
97
  */
86
98
  emailAudit;
87
- constructor(records, ceiling, resetTokens, emailSender, sessions, versionBump, emailAddresses, schemas) {
99
+ constructor(records, ceiling, resetTokens, emailSender, sessions, versionBump, emailAddresses, schemas, provisioner, dispatcher) {
88
100
  this.records = records;
89
101
  this.ceiling = ceiling;
90
102
  this.resetTokens = resetTokens;
@@ -92,8 +104,17 @@ let UserService = UserService_1 = class UserService {
92
104
  this.sessions = sessions;
93
105
  this.versionBump = versionBump;
94
106
  this.emailAddresses = emailAddresses;
107
+ this.provisioner = provisioner;
108
+ this.dispatcher = dispatcher;
95
109
  this.emailAudit = new engine_1.MariaDbAuditSink(schemas ? { schema: schemas.audit } : {});
96
110
  }
111
+ /**
112
+ * Create a user; returns its id. Delegates to {@link createUserWithCredential} (same behaviour;
113
+ * the detailed credential outcome is dropped to keep this public signature stable).
114
+ */
115
+ async createUser(actor, input) {
116
+ return (await this.createUserWithCredential(actor, input)).id;
117
+ }
97
118
  /**
98
119
  * Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
99
120
  * ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
@@ -103,9 +124,13 @@ let UserService = UserService_1 = class UserService {
103
124
  *
104
125
  * KI #94: an `email` is stored canonical (the ONE address rule, as the email-change flow); a
105
126
  * malformed one ⇒ {@link IdentityBadRequestError} (400) before any write; a blank one is no email.
106
- * A new user has no credential yet, so no login hint moves.
127
+ *
128
+ * G-009 T7 (OD-6): with an email AND a company, a LOCKED credential (no password verifies; set
129
+ * later by reset) is created in that same transaction through the provisioner on the engine
130
+ * transaction's own connection; a provisioner failure rolls everything back. Otherwise no
131
+ * credential is created and the result says why (no email takes precedence).
107
132
  */
108
- async createUser(actor, input) {
133
+ async createUserWithCredential(actor, input) {
109
134
  const fields = { username: input.username };
110
135
  const email = typeof input.email === 'string' ? (0, guard_1.canonicalEmail)(input.email) : '';
111
136
  if (email !== '') {
@@ -113,25 +138,56 @@ let UserService = UserService_1 = class UserService {
113
138
  throw new identity_errors_js_1.IdentityBadRequestError('Enter a valid email address.');
114
139
  fields.email = email;
115
140
  }
116
- if (!input.companyUuid) {
117
- return mappingDuplicates(() => this.records.create('user', { fields }));
118
- }
119
141
  const companyUuid = input.companyUuid;
120
- return mappingDuplicates(() => this.records.transaction(async (tx) => {
121
- const id = await tx.create('user', { fields });
142
+ if (!companyUuid) {
143
+ const id = await mappingDuplicates(() => this.records.create('user', { fields }));
144
+ return { id, credential: 'none', reason: email === '' ? 'no_email' : 'no_company' };
145
+ }
146
+ const provisioner = this.provisioner;
147
+ const provision = email !== '' && provisioner != null;
148
+ const id = await mappingDuplicates(() => this.records.transaction(async (tx) => {
149
+ const created = await tx.create('user', { fields });
122
150
  await tx.create('company_membership', {
123
- fields: { userUuid: id, companyUuid, isActive: true, status: 'active' },
151
+ fields: { userUuid: created, companyUuid, isActive: true, status: 'active' },
124
152
  });
125
- return id;
153
+ if (provision) {
154
+ await provisioner.provisionLocked({ principalId: created, hint: email, tenantId: companyUuid }, (0, internal_1.transactionQueryOf)(tx));
155
+ }
156
+ return created;
126
157
  }));
158
+ if (provision)
159
+ return { id, credential: 'locked' };
160
+ return {
161
+ id,
162
+ credential: 'none',
163
+ reason: email === '' ? 'no_email' : 'no_provisioner',
164
+ };
165
+ }
166
+ /**
167
+ * List users (tenant-scoped by the pipe), with paging/search/sort. Each row gains a top-level
168
+ * `signInStatus` (T10), from ONE batched credential read for the page.
169
+ */
170
+ async listUsers(params) {
171
+ const page = await this.records.list('user', (0, list_query_js_1.buildListQuery)(params, USER_SEARCH_FIELDS, USER_SORT_FIELDS));
172
+ return { ...page, rows: await this.withSignInStatus(page.rows) };
127
173
  }
128
- /** List users (tenant-scoped by the pipe), with paging/search/sort. */
129
- listUsers(params) {
130
- return this.records.list('user', (0, list_query_js_1.buildListQuery)(params, USER_SEARCH_FIELDS, USER_SORT_FIELDS));
174
+ /** Read a single user, with its top-level `signInStatus` (T10). */
175
+ async getUser(uuid) {
176
+ const record = await this.records.get('user', uuid);
177
+ if (record === null)
178
+ return null;
179
+ return (await this.withSignInStatus([record]))[0];
131
180
  }
132
- /** Read a single user. */
133
- getUser(uuid) {
134
- return this.records.get('user', uuid);
181
+ /** One `statesOf` call for the given rows; no provisioner (hand-built service) ⇒ rows unchanged. */
182
+ async withSignInStatus(rows) {
183
+ const provisioner = this.provisioner;
184
+ if (rows.length === 0 || provisioner == null)
185
+ return [...rows];
186
+ const states = await provisioner.statesOf(rows.map((r) => String(r.id)));
187
+ return rows.map((r) => ({
188
+ ...r,
189
+ signInStatus: SIGN_IN_STATUS[states.get(String(r.id)) ?? 'none'],
190
+ }));
135
191
  }
136
192
  /** Update a user (whitelisted patch; username/email/status changes need dominance — class doc). */
137
193
  async updateUser(actor, uuid, patch) {
@@ -170,7 +226,7 @@ let UserService = UserService_1 = class UserService {
170
226
  if (statusChanged || wantsInactive)
171
227
  await this.applyStatusChange(uuid, String(patch.status));
172
228
  if (emailChanged && oldEmail)
173
- await this.notifyEmailChanged(oldEmail);
229
+ await this.notifyEmailChanged(actor, oldEmail);
174
230
  }
175
231
  /**
176
232
  * Phase 12 (KI #14): write a dominated user's new (canonical) email through guard's
@@ -261,19 +317,28 @@ let UserService = UserService_1 = class UserService {
261
317
  await this.records.delete('user', uuid);
262
318
  }
263
319
  /** Tell the OLD address its account email changed (best-effort; never names the new one). */
264
- async notifyEmailChanged(oldEmail) {
265
- if (!this.emailSender)
320
+ async notifyEmailChanged(actor, oldEmail) {
321
+ if (!this.dispatcher && !this.emailSender)
266
322
  return;
267
323
  const text = 'The email address on your account was changed by an administrator. ' +
268
324
  'Password-reset links sent before this change no longer work. ' +
269
325
  'If you did not expect this, contact your administrator.';
326
+ const message = {
327
+ to: oldEmail,
328
+ subject: 'Your account email address was changed',
329
+ text,
330
+ html: `<p>${text}</p>`,
331
+ };
270
332
  try {
271
- await this.emailSender.send({
272
- to: oldEmail,
273
- subject: 'Your account email address was changed',
274
- text,
275
- html: `<p>${text}</p>`,
276
- });
333
+ if (this.dispatcher) {
334
+ // G-010: bounded and audited (`message.sent` / `message.failed`); never throws.
335
+ await this.dispatcher.sendEmail('security_notice', message, {
336
+ actorId: actor.id,
337
+ tenantId: actor.tenantId,
338
+ });
339
+ return;
340
+ }
341
+ await this.emailSender.send(message);
277
342
  }
278
343
  catch {
279
344
  // Best-effort: the change is committed; a delivery failure must not fail it.
@@ -296,6 +361,10 @@ exports.UserService = UserService = UserService_1 = __decorate([
296
361
  __param(6, (0, common_1.Inject)(email_address_changer_token_js_1.IDENTITY_EMAIL_ADDRESS_CHANGER)),
297
362
  __param(7, (0, common_1.Optional)()),
298
363
  __param(7, (0, common_1.Inject)(framework_schemas_token_js_1.IDENTITY_FRAMEWORK_SCHEMAS)),
364
+ __param(8, (0, common_1.Optional)()),
365
+ __param(8, (0, common_1.Inject)(credential_provisioner_token_js_1.IDENTITY_CREDENTIAL_PROVISIONER)),
366
+ __param(9, (0, common_1.Optional)()),
367
+ __param(9, (0, common_1.Inject)(guard_1.SHIELD_MESSAGE_DISPATCHER)),
299
368
  __metadata("design:paramtypes", [engine_1.RecordsService,
300
- grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object, Object, Object])
369
+ grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object, Object, Object, Object, Object])
301
370
  ], UserService);
package/dist/index.d.ts CHANGED
@@ -40,6 +40,19 @@ export { buildIdentityAuthKeys, type IdentityAuthKeys } from './auth-facts-keys.
40
40
  * `identityObjects`, and mounts the CRUD controllers — no separate engine setup needed.
41
41
  */
42
42
  export { IdentityFeatureModule } from './features/identity-feature.module.js';
43
+ /**
44
+ * G-010: the administrator invite (`POST /identity/users/:uuid/invite`, option
45
+ * `forRoot({ invite })`) and the computed `signInStatus` on user reads (types only).
46
+ */
47
+ export type { InviteOptions } from './features/user/invite.config.js';
48
+ export type { InviteResult } from './features/user/invite.service.js';
49
+ export type { SignInStatus, UserRecord } from './features/user/user.service.js';
50
+ /**
51
+ * The org bootstrap (G-009): `runOrgBootstrap(app, { file, apply })` applies a declarative org file
52
+ * (roots, company tree, roles, users, postings) through the framework's own services, as the declared
53
+ * operation `shield:org-bootstrap`. Run at deploy time against the app's own `AppModule` context.
54
+ */
55
+ export { runOrgBootstrap, OrgBootstrapError, type OrgBootstrapReport, type RunOrgBootstrapOptions, } from './features/org-bootstrap/run-org-bootstrap.js';
43
56
  /**
44
57
  * Stored files (file-storage SPEC §5 I-K): `store` / `open` / `remove` on the engine pipe, provided
45
58
  * and exported by `IdentityFeatureModule`. Apps call it from their own `@Classified` routes.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;GAIG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AACxF;0DAC0D;AAC1D,OAAO,EAAE,qBAAqB,EAAE,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEpF;;;;;GAKG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;GAGG;AACH,OAAO,EACL,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,UAAU,GAChB,MAAM,mCAAmC,CAAC;AAU3C;;;GAGG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AAEjF;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4CAA4C,CAAC;AAEpF;;;;;;;GAOG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,6CAA6C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;GAIG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AACxF;0DAC0D;AAC1D,OAAO,EAAE,qBAAqB,EAAE,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEpF;;;;;GAKG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;GAGG;AACH,YAAY,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAC;AACtE,YAAY,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAEhF;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,GAC5B,MAAM,+CAA+C,CAAC;AAEvD;;;GAGG;AACH,OAAO,EACL,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,UAAU,GAChB,MAAM,mCAAmC,CAAC;AAU3C;;;GAGG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AAEjF;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4CAA4C,CAAC;AAEpF;;;;;;;GAOG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,6CAA6C,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DbMembershipChecker = exports.DbScopeSubtreeResolver = exports.AlsSessionFactsReader = exports.FilesService = exports.IdentityFeatureModule = exports.buildIdentityAuthKeys = exports.identityControlPlaneKeys = exports.identityAuthActionKeys = exports.identityObjects = exports.identityMigrationsDir = exports.StoredFile = exports.ServiceAccount = exports.CompanyBranch = exports.CompanyDepartment = exports.MembershipPosting = exports.CompanyMembership = exports.Branch = exports.Department = exports.Role = exports.User = exports.Company = void 0;
3
+ exports.DbMembershipChecker = exports.DbScopeSubtreeResolver = exports.AlsSessionFactsReader = exports.FilesService = exports.OrgBootstrapError = exports.runOrgBootstrap = exports.IdentityFeatureModule = exports.buildIdentityAuthKeys = exports.identityControlPlaneKeys = exports.identityAuthActionKeys = exports.identityObjects = exports.identityMigrationsDir = exports.StoredFile = exports.ServiceAccount = exports.CompanyBranch = exports.CompanyDepartment = exports.MembershipPosting = exports.CompanyMembership = exports.Branch = exports.Department = exports.Role = exports.User = exports.Company = void 0;
4
4
  /**
5
5
  * @astram/identity — the framework-owned identity + tenancy model.
6
6
  * The 9 identity object classes (relocated from the consumer app in Sub-plan B) +
@@ -60,6 +60,14 @@ Object.defineProperty(exports, "buildIdentityAuthKeys", { enumerable: true, get:
60
60
  */
61
61
  var identity_feature_module_js_1 = require("./features/identity-feature.module.js");
62
62
  Object.defineProperty(exports, "IdentityFeatureModule", { enumerable: true, get: function () { return identity_feature_module_js_1.IdentityFeatureModule; } });
63
+ /**
64
+ * The org bootstrap (G-009): `runOrgBootstrap(app, { file, apply })` applies a declarative org file
65
+ * (roots, company tree, roles, users, postings) through the framework's own services, as the declared
66
+ * operation `shield:org-bootstrap`. Run at deploy time against the app's own `AppModule` context.
67
+ */
68
+ var run_org_bootstrap_js_1 = require("./features/org-bootstrap/run-org-bootstrap.js");
69
+ Object.defineProperty(exports, "runOrgBootstrap", { enumerable: true, get: function () { return run_org_bootstrap_js_1.runOrgBootstrap; } });
70
+ Object.defineProperty(exports, "OrgBootstrapError", { enumerable: true, get: function () { return run_org_bootstrap_js_1.OrgBootstrapError; } });
63
71
  /**
64
72
  * Stored files (file-storage SPEC §5 I-K): `store` / `open` / `remove` on the engine pipe, provided
65
73
  * and exported by `IdentityFeatureModule`. Apps call it from their own `@Classified` routes.
@@ -8,4 +8,6 @@
8
8
  * subpath everywhere else. Feature code never holds a pool: it uses the RecordEngine.
9
9
  */
10
10
  export { createIdentityAuthzPool } from './features/authz/record-authorization.js';
11
+ export { transitionRoleOwner } from './features/role/role-owner-transition.js';
12
+ export type { RoleOwnerAudit, RoleOwnerQuery } from './features/role/role-owner-transition.js';
11
13
  //# sourceMappingURL=internal.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,0CAA0C,CAAC"}
1
+ {"version":3,"file":"internal.d.ts","sourceRoot":"","sources":["../src/internal.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,0CAA0C,CAAC;AACnF,OAAO,EAAE,mBAAmB,EAAE,MAAM,0CAA0C,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0CAA0C,CAAC"}
package/dist/internal.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createIdentityAuthzPool = void 0;
3
+ exports.transitionRoleOwner = exports.createIdentityAuthzPool = void 0;
4
4
  /**
5
5
  * @astram/identity/internal — composition-root surface (Phase 8 #5).
6
6
  *
@@ -12,3 +12,5 @@ exports.createIdentityAuthzPool = void 0;
12
12
  */
13
13
  var record_authorization_js_1 = require("./features/authz/record-authorization.js");
14
14
  Object.defineProperty(exports, "createIdentityAuthzPool", { enumerable: true, get: function () { return record_authorization_js_1.createIdentityAuthzPool; } });
15
+ var role_owner_transition_js_1 = require("./features/role/role-owner-transition.js");
16
+ Object.defineProperty(exports, "transitionRoleOwner", { enumerable: true, get: function () { return role_owner_transition_js_1.transitionRoleOwner; } });
@@ -2,6 +2,7 @@
2
2
  export declare class Role {
3
3
  uuid: string;
4
4
  name: string;
5
+ ownerCompanyUuid?: string | null;
5
6
  createdAt: string;
6
7
  }
7
8
  //# sourceMappingURL=role.object.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"role.object.d.ts","sourceRoot":"","sources":["../src/role.object.ts"],"names":[],"mappings":"AAGA,8BAA8B;AAC9B,qBAoBa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,IAAI,EAAG,MAAM,CAAC;IAGd,SAAS,EAAG,MAAM,CAAC;CACpB"}
1
+ {"version":3,"file":"role.object.d.ts","sourceRoot":"","sources":["../src/role.object.ts"],"names":[],"mappings":"AAGA,8BAA8B;AAC9B,qBAoBa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,IAAI,EAAG,MAAM,CAAC;IAMd,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAGjC,SAAS,EAAG,MAAM,CAAC;CACpB"}
@@ -16,6 +16,10 @@ const schema_1 = require("./schema");
16
16
  let Role = class Role {
17
17
  uuid;
18
18
  name;
19
+ // The company a role was created for (G-009 T14, SPEC R13); NULL = shared (root-created). Create-only
20
+ // and server-set (RoleService stamps it from the actor); NOT `trusted`, so the engine never
21
+ // derives it and root's UI-created roles stay unowned. Changed later only by `transitionRoleOwner`.
22
+ ownerCompanyUuid;
19
23
  createdAt;
20
24
  };
21
25
  exports.Role = Role;
@@ -27,6 +31,10 @@ __decorate([
27
31
  (0, engine_1.Field)({ column: 'r_name', type: 'string', required: true }),
28
32
  __metadata("design:type", String)
29
33
  ], Role.prototype, "name", void 0);
34
+ __decorate([
35
+ (0, engine_1.Field)({ column: 'r_owner_company_uuid', type: 'uuid', immutable: true }),
36
+ __metadata("design:type", Object)
37
+ ], Role.prototype, "ownerCompanyUuid", void 0);
30
38
  __decorate([
31
39
  (0, engine_1.Field)({ column: 'r_created_at', type: 'timestamp' }),
32
40
  __metadata("design:type", String)
@@ -1 +1 @@
1
- {"version":3,"file":"user.object.d.ts","sourceRoot":"","sources":["../src/user.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBAuBa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,QAAQ,EAAG,MAAM,CAAC;IAGlB,KAAK,EAAG,MAAM,CAAC;IAGf,MAAM,EAAG,MAAM,CAAC;IAGhB,SAAS,EAAG,MAAM,CAAC;CACpB"}
1
+ {"version":3,"file":"user.object.d.ts","sourceRoot":"","sources":["../src/user.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBA0Ba,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,QAAQ,EAAG,MAAM,CAAC;IAGlB,KAAK,EAAG,MAAM,CAAC;IAGf,MAAM,EAAG,MAAM,CAAC;IAGhB,SAAS,EAAG,MAAM,CAAC;CACpB"}
@@ -54,6 +54,9 @@ exports.User = User = __decorate([
54
54
  // capped root, the identity administrator, may list/read AND create/update/delete users). Uniform
55
55
  // across all actions (read reclassified control 2026-07-16), so plane is per-type.
56
56
  access: { plane: 'control', system: true },
57
+ // G-010 R11 (ADR-0038): `invite` (`identity:user:invite`, POST /identity/users/:uuid/invite) next
58
+ // to CRUD. Roles holding `identity:*` or `identity:user:*` gain it on upgrade; exact keys do not.
59
+ actions: ['invite'],
57
60
  // Read-scope wall (ADR-0027, Task 4.1): a user has no company column (they belong to many
58
61
  // companies via `company_membership`) — a user is in-scope iff they have a membership in
59
62
  // the caller's reachable subtree. A multi-tenant user correctly appears for each subtree
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Framework identity migration — `roles.r_owner_company_uuid` (G-009 T14, AC-19; SPEC R13).
3
+ *
4
+ * WHY: `roles` was a global catalog with no owner, so a company delete could not say which roles
5
+ * were its own. The owner is the company (tenant node) a role was created for; NULL = shared
6
+ * (root-created, or unowned). It is create-only at the engine (immutable); the only later change
7
+ * is the audited `transitionRoleOwner` seam (company -> NULL on a company delete, NULL -> company
8
+ * for an owner claim).
9
+ *
10
+ * Steps (idempotent; a half-applied state is completed, nothing is dropped or overwritten):
11
+ * 1. ADD the nullable column when absent.
12
+ * 2. ADD `idx_roles_owner_company` when absent (the FK needs it; the delete's owned-roles read).
13
+ * 3. ADD `FK_roles_owner_company` -> `companies (c_uuid)` ON DELETE SET NULL when absent. The
14
+ * app clears owners itself on a company delete (audited); SET NULL is only the backstop.
15
+ * 4. BACK-FILL unowned roles bound ONLY inside ONE top-level tenant's subtree (postings ->
16
+ * memberships -> companies -> `scope_closure` ancestors with no parent) to that tenant. A role
17
+ * bound across several tenants, or bound nowhere, stays NULL. Only `IS NULL` rows are
18
+ * touched, so a re-run never overwrites an owner. No ids are hardcoded.
19
+ *
20
+ * `down` drops the FK, then the index, then the column, each with IF EXISTS (a second run, or a
21
+ * database that never had the column, does nothing).
22
+ */
23
+
24
+ const { frameworkSchemasForMigration } = require('@astram/core');
25
+
26
+ const COLUMN = 'r_owner_company_uuid';
27
+ const INDEX = 'idx_roles_owner_company';
28
+ const FK = 'FK_roles_owner_company';
29
+
30
+ const present = async (query, sql, params) => {
31
+ const rows = await query(sql, params);
32
+ return Array.isArray(rows) && rows.length > 0;
33
+ };
34
+
35
+ async function up(ctx) {
36
+ const { query } = ctx;
37
+ const schemas = frameworkSchemasForMigration(ctx, process.env);
38
+ const APP = schemas.app;
39
+ const AUTHZ = schemas.authz;
40
+
41
+ const hasColumn = await present(
42
+ query,
43
+ `SELECT 1 AS n FROM information_schema.COLUMNS
44
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'roles' AND COLUMN_NAME = ? LIMIT 1`,
45
+ [APP, COLUMN],
46
+ );
47
+ if (!hasColumn) {
48
+ await query(`ALTER TABLE \`${APP}\`.\`roles\` ADD COLUMN ${COLUMN} VARCHAR(36) NULL`);
49
+ }
50
+
51
+ const hasIndex = await present(
52
+ query,
53
+ `SELECT 1 AS n FROM information_schema.STATISTICS
54
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'roles' AND INDEX_NAME = ? LIMIT 1`,
55
+ [APP, INDEX],
56
+ );
57
+ if (!hasIndex) {
58
+ await query(`CREATE INDEX ${INDEX} ON \`${APP}\`.\`roles\` (${COLUMN})`);
59
+ }
60
+
61
+ const hasFk = await present(
62
+ query,
63
+ `SELECT 1 AS n FROM information_schema.REFERENTIAL_CONSTRAINTS
64
+ WHERE CONSTRAINT_SCHEMA = ? AND TABLE_NAME = 'roles' AND CONSTRAINT_NAME = ? LIMIT 1`,
65
+ [APP, FK],
66
+ );
67
+ if (!hasFk) {
68
+ await query(`ALTER TABLE \`${APP}\`.\`roles\`
69
+ ADD CONSTRAINT ${FK} FOREIGN KEY (${COLUMN})
70
+ REFERENCES \`${APP}\`.\`companies\` (c_uuid) ON DELETE SET NULL`);
71
+ }
72
+
73
+ // Back-fill: exactly one top-level tenant over every posting of the role, else leave NULL.
74
+ const tenantsOfRole = `SELECT COUNT(DISTINCT sc.ancestor_uuid)
75
+ FROM \`${APP}\`.\`membership_posting\` mp
76
+ JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
77
+ JOIN \`${AUTHZ}\`.\`scope_closure\` sc ON sc.descendant_uuid = cm.cm_company_uuid
78
+ JOIN \`${APP}\`.\`companies\` top ON top.c_uuid = sc.ancestor_uuid AND top.c_parent_uuid IS NULL
79
+ WHERE mp.mp_role_uuid = r.r_uuid`;
80
+ await query(`UPDATE \`${APP}\`.\`roles\` r
81
+ SET r.${COLUMN} = (SELECT MIN(sc.ancestor_uuid)
82
+ FROM \`${APP}\`.\`membership_posting\` mp
83
+ JOIN \`${APP}\`.\`company_membership\` cm ON cm.cm_uuid = mp.mp_membership_uuid
84
+ JOIN \`${AUTHZ}\`.\`scope_closure\` sc ON sc.descendant_uuid = cm.cm_company_uuid
85
+ JOIN \`${APP}\`.\`companies\` top ON top.c_uuid = sc.ancestor_uuid AND top.c_parent_uuid IS NULL
86
+ WHERE mp.mp_role_uuid = r.r_uuid)
87
+ WHERE r.${COLUMN} IS NULL AND (${tenantsOfRole}) = 1`);
88
+ }
89
+
90
+ async function down(ctx) {
91
+ const { query } = ctx;
92
+ const APP = frameworkSchemasForMigration(ctx, process.env).app;
93
+ await query(`ALTER TABLE \`${APP}\`.\`roles\` DROP FOREIGN KEY IF EXISTS ${FK}`);
94
+ await query(`DROP INDEX IF EXISTS ${INDEX} ON \`${APP}\`.\`roles\``);
95
+ await query(`ALTER TABLE \`${APP}\`.\`roles\` DROP COLUMN IF EXISTS ${COLUMN}`);
96
+ }
97
+
98
+ module.exports = { up, down };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/identity",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the framework-owned identity + tenancy model. Hosts the identity @ObjectType classes (user, company, membership, posting), framework-authored identity migrations, and the identity resolver. Top of the dependency graph: depends on @astram/engine (for @ObjectType/@Field decorators) + @astram/guard / @astram/core (for the auth ports).",
6
6
  "type": "commonjs",
@@ -36,9 +36,9 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "mariadb": "^3.5.4",
39
- "@astram/core": "1.8.0",
40
- "@astram/guard": "0.16.0",
41
- "@astram/engine": "0.9.0"
39
+ "@astram/core": "1.9.0",
40
+ "@astram/guard": "0.18.0",
41
+ "@astram/engine": "0.10.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@nestjs/common": "^11",