@astram/identity 0.8.1 → 0.9.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.
@@ -1 +1 @@
1
- {"version":3,"file":"identity-error.interceptor.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-error.interceptor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAQL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAA0B,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAqB/D;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAwBzD;AAED,qBACa,wBAAyB,YAAW,eAAe;IAC9D,SAAS,CAAC,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CAAC,OAAO,CAAC;CAK9E"}
1
+ {"version":3,"file":"identity-error.interceptor.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-error.interceptor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAQL,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAA0B,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAsB/D;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAyBzD;AAED,qBACa,wBAAyB,YAAW,eAAe;IAC9D,SAAS,CAAC,QAAQ,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CAAC,OAAO,CAAC;CAK9E"}
@@ -55,6 +55,8 @@ function toIdentityHttpError(err) {
55
55
  return new common_1.ConflictException(err.message);
56
56
  if (err instanceof identity_errors_js_1.IdentityBadRequestError)
57
57
  return new common_1.BadRequestException(err.message);
58
+ if (err instanceof identity_errors_js_1.IdentityUnavailableError)
59
+ return new common_1.ServiceUnavailableException(err.message);
58
60
  if (err instanceof guard_1.ScopeCycleError) {
59
61
  return new common_1.ConflictException('That move would put an organisation inside its own sub-tree.');
60
62
  }
@@ -16,6 +16,14 @@ export declare class IdentityNotFoundError extends Error {
16
16
  export declare class IdentityConflictError extends Error {
17
17
  constructor(message: string);
18
18
  }
19
+ /**
20
+ * A store the action needs could not answer (a deadlock, a lock timeout, an outage) and nothing
21
+ * was changed (→ 503, retryable). The message is fixed: never the driver's text, which can name
22
+ * the values written.
23
+ */
24
+ export declare class IdentityUnavailableError extends Error {
25
+ constructor(message: string);
26
+ }
19
27
  /** The request is malformed (→ 400). */
20
28
  export declare class IdentityBadRequestError extends Error {
21
29
  constructor(message: string);
@@ -1 +1 @@
1
- {"version":3,"file":"identity-errors.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,qEAAqE;AACrE,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,EAAE,MAAM;CAI5B;AAED,6FAA6F;AAC7F,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,uFAAuF;AACvF,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,wCAAwC;AACxC,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B"}
1
+ {"version":3,"file":"identity-errors.d.ts","sourceRoot":"","sources":["../../../src/features/shared/identity-errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,qEAAqE;AACrE,qBAAa,sBAAuB,SAAQ,KAAK;gBACnC,OAAO,EAAE,MAAM;CAI5B;AAED,6FAA6F;AAC7F,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,uFAAuF;AACvF,qBAAa,qBAAsB,SAAQ,KAAK;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;GAIG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;gBACrC,OAAO,EAAE,MAAM;CAI5B;AAED,wCAAwC;AACxC,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B"}
@@ -6,7 +6,7 @@
6
6
  * Messages are user-facing: they name no raw ids (ADR-0012) and carry no store detail.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.IdentityBadRequestError = exports.IdentityConflictError = exports.IdentityNotFoundError = exports.IdentityForbiddenError = void 0;
9
+ exports.IdentityBadRequestError = exports.IdentityUnavailableError = exports.IdentityConflictError = exports.IdentityNotFoundError = exports.IdentityForbiddenError = void 0;
10
10
  /** The caller may not perform this action on this target (→ 403). */
11
11
  class IdentityForbiddenError extends Error {
12
12
  constructor(message) {
@@ -31,6 +31,18 @@ class IdentityConflictError extends Error {
31
31
  }
32
32
  }
33
33
  exports.IdentityConflictError = IdentityConflictError;
34
+ /**
35
+ * A store the action needs could not answer (a deadlock, a lock timeout, an outage) and nothing
36
+ * was changed (→ 503, retryable). The message is fixed: never the driver's text, which can name
37
+ * the values written.
38
+ */
39
+ class IdentityUnavailableError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = 'IdentityUnavailableError';
43
+ }
44
+ }
45
+ exports.IdentityUnavailableError = IdentityUnavailableError;
34
46
  /** The request is malformed (→ 400). */
35
47
  class IdentityBadRequestError extends Error {
36
48
  constructor(message) {
@@ -45,6 +45,8 @@ export declare class UserService {
45
45
  * Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
46
46
  * ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
47
47
  * by the engine's create wall (a company outside the caller's subtree ⇒ 403, nothing persists).
48
+ * A username already taken (KI #87: also a concurrent create of the same one; the unique index
49
+ * decides) ⇒ {@link IdentityConflictError} (409), nothing persists.
48
50
  */
49
51
  createUser(actor: Principal, input: {
50
52
  username: string;
@@ -64,6 +66,8 @@ export declare class UserService {
64
66
  * (engine shape, fail closed). Reset links are burned BEFORE (a failure aborts the change).
65
67
  */
66
68
  private changeEmail;
69
+ /** The helper call with the audit row on its transaction (see {@link changeEmail}). */
70
+ private applyEmailChange;
67
71
  /**
68
72
  * After a status change (security hardening Phase 3): bump every tenant the user is a member of
69
73
  * (the next request reloads its grants: none while disabled, back when re-enabled), and, when the
@@ -1 +1 @@
1
- {"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACjF,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAOL,KAAK,mBAAmB,EACxB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAWzD,4EAA4E;AAC5E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBACa,WAAW;IAMpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IACY,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAMtE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAK1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAI7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;IAzBlC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;IACvD,4FAA4F;IAC5F,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA4B;gBAGpC,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAGrB,WAAW,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA,EACK,WAAW,CAAC,GAAE,WAAW,GAAG,IAAI,aAAA,EAMzE,QAAQ,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAKhC,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAIvC,cAAc,CAAC,GAAE,mBAAmB,GAAG,IAAI,aAAA;IAG9D;;;;OAIG;IACG,UAAU,CACd,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GAC5D,OAAO,CAAC,QAAQ,CAAC;IAcpB,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,mGAAmG;IAC7F,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjF;;;;;;OAMG;YACW,WAAW;IAmCzB;;;;;;;;;OASG;YACW,iBAAiB;IAY/B,uDAAuD;IACjD,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO/D,6FAA6F;YAC/E,kBAAkB;CAkBjC"}
1
+ {"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACjF,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAOL,KAAK,mBAAmB,EAExB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,eAAe,CAAC;AACvB,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1E,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAqCzD,4EAA4E;AAC5E,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBACa,WAAW;IAMpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAGxB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IACY,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAMtE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAK1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAI7B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC;IAzBlC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;IACvD,4FAA4F;IAC5F,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA4B;gBAGpC,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EAGrB,WAAW,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA,EACK,WAAW,CAAC,GAAE,WAAW,GAAG,IAAI,aAAA,EAMzE,QAAQ,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAKhC,WAAW,CAAC,GAAE,kBAAkB,GAAG,IAAI,aAAA,EAIvC,cAAc,CAAC,GAAE,mBAAmB,GAAG,IAAI,aAAA;IAG9D;;;;;;OAMG;IACG,UAAU,CACd,KAAK,EAAE,SAAS,EAChB,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GAC5D,OAAO,CAAC,QAAQ,CAAC;IAkBpB,uEAAuE;IACvE,SAAS,CAAC,MAAM,CAAC,EAAE,UAAU;IAI7B,0BAA0B;IAC1B,OAAO,CAAC,IAAI,EAAE,MAAM;IAIpB,mGAAmG;IAC7F,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjF;;;;;;OAMG;YACW,WAAW;IAyBzB,uFAAuF;IACvF,OAAO,CAAC,gBAAgB;IA8BxB;;;;;;;;;OASG;YACW,iBAAiB;IAY/B,uDAAuD;IACjD,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO/D,6FAA6F;YAC/E,kBAAkB;CAkBjC"}
@@ -22,6 +22,31 @@ const list_query_js_1 = require("../shared/list-query.js");
22
22
  const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
23
23
  const identity_errors_js_1 = require("../shared/identity-errors.js");
24
24
  const email_address_changer_token_js_1 = require("./email-address-changer.token.js");
25
+ /**
26
+ * KI #87: `users` has two unique keys besides the PK, `IDX_users_username` (migration 001) and
27
+ * `uq_users_email` (021, `utf8mb4_unicode_ci`: folds case, accents, width, trailing spaces). The
28
+ * engine passes the driver's duplicate-key error through unchanged; this service, which knows
29
+ * those are the only keys a user write can collide on, answers it as a 409 with a fixed text.
30
+ * Recognised by errno/code only: the driver's message names the value and is never echoed.
31
+ */
32
+ function isDuplicateKey(err) {
33
+ if (typeof err !== 'object' || err === null)
34
+ return false;
35
+ const e = err;
36
+ return e.errno === 1062 || e.code === 'ER_DUP_ENTRY';
37
+ }
38
+ const USERNAME_OR_EMAIL_TAKEN = 'That username or email is already in use.';
39
+ /** Run a user write; a duplicate key becomes {@link IdentityConflictError}, all else rethrows. */
40
+ async function mappingDuplicates(write) {
41
+ try {
42
+ return await write();
43
+ }
44
+ catch (err) {
45
+ if (isDuplicateKey(err))
46
+ throw new identity_errors_js_1.IdentityConflictError(USERNAME_OR_EMAIL_TAKEN);
47
+ throw err;
48
+ }
49
+ }
25
50
  const USER_SEARCH_FIELDS = ['username', 'email'];
26
51
  const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
27
52
  /**
@@ -67,19 +92,21 @@ let UserService = UserService_1 = class UserService {
67
92
  * Create a user. With `companyUuid`, the user is created AND attached (one active membership) in
68
93
  * ONE transaction — the onboarding path for a non-root admin, whose membership create is checked
69
94
  * by the engine's create wall (a company outside the caller's subtree ⇒ 403, nothing persists).
95
+ * A username already taken (KI #87: also a concurrent create of the same one; the unique index
96
+ * decides) ⇒ {@link IdentityConflictError} (409), nothing persists.
70
97
  */
71
98
  async createUser(actor, input) {
72
99
  if (!input.companyUuid) {
73
- return this.records.create('user', { fields: { username: input.username } });
100
+ return mappingDuplicates(() => this.records.create('user', { fields: { username: input.username } }));
74
101
  }
75
102
  const companyUuid = input.companyUuid;
76
- return this.records.transaction(async (tx) => {
103
+ return mappingDuplicates(() => this.records.transaction(async (tx) => {
77
104
  const id = await tx.create('user', { fields: { username: input.username } });
78
105
  await tx.create('company_membership', {
79
106
  fields: { userUuid: id, companyUuid, isActive: true, status: 'active' },
80
107
  });
81
108
  return id;
82
- });
109
+ }));
83
110
  }
84
111
  /** List users (tenant-scoped by the pipe), with paging/search/sort. */
85
112
  listUsers(params) {
@@ -121,7 +148,7 @@ let UserService = UserService_1 = class UserService {
121
148
  if (emailChanged)
122
149
  await this.changeEmail(actor, uuid, newEmail);
123
150
  if (Object.keys(fields).length > 0) {
124
- await this.records.update('user', uuid, { fields });
151
+ await mappingDuplicates(() => this.records.update('user', uuid, { fields }));
125
152
  }
126
153
  if (statusChanged || wantsInactive)
127
154
  await this.applyStatusChange(uuid, String(patch.status));
@@ -143,7 +170,26 @@ let UserService = UserService_1 = class UserService {
143
170
  }
144
171
  if (this.resetTokens)
145
172
  await this.resetTokens.invalidateAllForPrincipal(uuid);
146
- const outcome = await changer.change({
173
+ let outcome;
174
+ try {
175
+ outcome = await this.applyEmailChange(changer, actor, uuid, newEmail);
176
+ }
177
+ catch {
178
+ // The helper's contract: a throw is a store error (a 1213 deadlock, the named-lock timeout,
179
+ // the audit write), rolled back. Retryable → 503; the driver text (it can name the address)
180
+ // is neither returned nor logged.
181
+ this.logger.warn('admin email change could not be applied (store error); nothing changed');
182
+ throw new identity_errors_js_1.IdentityUnavailableError('The email address could not be changed right now. Try again.');
183
+ }
184
+ if (outcome.status === 'unavailable') {
185
+ throw new identity_errors_js_1.IdentityConflictError('That email address is already in use.');
186
+ }
187
+ if (outcome.status !== 'changed')
188
+ throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
189
+ }
190
+ /** The helper call with the audit row on its transaction (see {@link changeEmail}). */
191
+ applyEmailChange(changer, actor, uuid, newEmail) {
192
+ return changer.change({
147
193
  principalId: uuid,
148
194
  newEmail,
149
195
  withinTransaction: async (query, result) => {
@@ -165,11 +211,6 @@ let UserService = UserService_1 = class UserService {
165
211
  await this.emailAudit.record(event, (sql, params) => query(sql, params));
166
212
  },
167
213
  });
168
- if (outcome.status === 'unavailable') {
169
- throw new identity_errors_js_1.IdentityConflictError('That email address is already in use.');
170
- }
171
- if (outcome.status !== 'changed')
172
- throw new identity_errors_js_1.IdentityNotFoundError('User not found.');
173
214
  }
174
215
  /**
175
216
  * After a status change (security hardening Phase 3): bump every tenant the user is a member of
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Framework identity migration — `UNIQUE INDEX uq_users_email (u_email)` on `shield_app.users`
3
+ * (KI #87, follow-up to security hardening Phase 12 / KI #14).
4
+ *
5
+ * WHY: `u_email` (VARCHAR(255) NULL, created by `...001`) had no index and no uniqueness, so two
6
+ * users could hold the same address (a concurrent create, a direct SQL write) and every collision
7
+ * read scanned the table. The email is a credential attribute (forgot-password mails it), so
8
+ * uniqueness is now a database guarantee: a duplicate write fails with ER_DUP_ENTRY (1062).
9
+ *
10
+ * WHAT COUNTS AS EQUAL is the column collation, `utf8mb4_unicode_ci` (PAD SPACE), probed live on
11
+ * MariaDB 11.4: case (`A` = `a`), trailing spaces (`a@x.com ` = `a@x.com`), full-width forms
12
+ * (`a` = `a`), accents (`é` = `e`), `ß` = `ss`, ligatures and ignorable characters (a zero-width
13
+ * space, a trailing no-break space) all fold. NOT folded: LEADING spaces and other trailing
14
+ * whitespace (tab, newline). The app writes `canonicalEmail` (trim → NFKC → lower-case), so those
15
+ * never reach the column through the framework.
16
+ *
17
+ * Steps (inside the runner's ONE transaction for this migration, `@astram/migrate`):
18
+ * 0. Skip entirely when `uq_users_email` already exists as a UNIQUE index (idempotent re-run).
19
+ * 1. Normalise empty / whitespace-only `u_email` to NULL (`''` and `' '` are collation-equal
20
+ * and would collide; NULLs may repeat in a UNIQUE index).
21
+ * 2. PRE-CHECK: `GROUP BY u_email HAVING COUNT(*) > 1` (grouped by the same collation the index
22
+ * uses). Any group ⇒ the migration THROWS, listing each group as a MASKED address
23
+ * (`a***@example.com`) and a count — never a user id, never a full address. The throw rolls
24
+ * the transaction back, so step 1 is undone too and NO row is changed. Duplicates are never
25
+ * auto-resolved: the operator gives each user a distinct address (e.g. the admin user edit)
26
+ * and re-runs.
27
+ * 3. `ADD UNIQUE INDEX IF NOT EXISTS` (DDL: implicit commit of step 1). A duplicate written
28
+ * between step 2 and the build fails the build with 1062; it is reported the same masked way
29
+ * (the driver's own message would echo the full address). On THAT late path step 1 is already
30
+ * committed (blanks stay NULL); the report says so, and a re-run is safe.
31
+ *
32
+ * `down` drops the index only (`DROP INDEX IF EXISTS`). The NULL normalisation is NOT reverted:
33
+ * an empty address and NULL mean the same thing (no email), and the original values are gone.
34
+ *
35
+ * Literal schema/table/column names, parameterized values, no ids. Run by the DDL account
36
+ * (`shield_migrate`: DDL + DML on `shield_app`). `shield_core` / `shield_app_crud` need no new
37
+ * grant. Sorts after `...001` (creates `users`) and `...020` (the latest), before the dev seeds
38
+ * (`...099`/`...100`, distinct addresses).
39
+ */
40
+
41
+ const SCHEMA = 'shield_app';
42
+ const TABLE = 'users';
43
+ const COLUMN = 'u_email';
44
+ const INDEX = 'uq_users_email';
45
+ const MIGRATION = '1710000000021-AddUsersEmailUniqueIndex';
46
+ /** Groups listed in the failure message; the rest are counted. */
47
+ const MAX_LISTED_GROUPS = 50;
48
+ const ER_DUP_ENTRY = 1062;
49
+
50
+ /** Same rule as `@astram/guard` `maskEmail` (kept local: a migration loads no runtime package). */
51
+ function maskEmail(value) {
52
+ const at = value.lastIndexOf('@');
53
+ if (at <= 0 || at === value.length - 1) return '***';
54
+ return `${value.slice(0, 1)}***@${value.slice(at + 1)}`;
55
+ }
56
+
57
+ /** `null` = no index of that name; otherwise whether it is UNIQUE. */
58
+ async function indexState(query) {
59
+ const rows = await query(
60
+ `SELECT NON_UNIQUE AS non_unique
61
+ FROM information_schema.STATISTICS
62
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?
63
+ LIMIT 1`,
64
+ [SCHEMA, TABLE, INDEX],
65
+ );
66
+ if (!Array.isArray(rows) || rows.length === 0) return null;
67
+ return { unique: Number(rows[0].non_unique) === 0 };
68
+ }
69
+
70
+ /** Duplicate groups by the column collation: `[{ masked, count }]`, largest first. */
71
+ async function duplicateGroups(query) {
72
+ const rows = await query(
73
+ `SELECT ${COLUMN} AS sample, COUNT(*) AS n
74
+ FROM \`${SCHEMA}\`.\`${TABLE}\`
75
+ WHERE ${COLUMN} IS NOT NULL
76
+ GROUP BY ${COLUMN}
77
+ HAVING COUNT(*) > 1
78
+ ORDER BY n DESC`,
79
+ );
80
+ // The group's representative is any member; trim it so a padded one masks the same way.
81
+ return (Array.isArray(rows) ? rows : []).map((r) => ({
82
+ masked: maskEmail(String(r.sample).trim()),
83
+ count: Number(r.n),
84
+ }));
85
+ }
86
+
87
+ function duplicateError(groups, emptiesNormalised) {
88
+ const listed = groups.slice(0, MAX_LISTED_GROUPS).map((g) => ` - ${g.masked}: ${g.count} users`);
89
+ if (groups.length > MAX_LISTED_GROUPS) {
90
+ listed.push(` - ... and ${groups.length - MAX_LISTED_GROUPS} more group(s)`);
91
+ }
92
+ const changed = emptiesNormalised
93
+ ? 'No index was created and no duplicate was modified (empty or whitespace-only addresses were set to NULL).'
94
+ : 'No index was created and no row was changed.';
95
+ return new Error(
96
+ `${MIGRATION}: cannot add UNIQUE index ${INDEX} on ${SCHEMA}.${TABLE}.${COLUMN}: ` +
97
+ `${groups.length} email address(es) are held by more than one user ` +
98
+ '(compared as the column collation utf8mb4_unicode_ci does: case-, accent-, width- and ' +
99
+ 'trailing-space-insensitive; addresses masked):\n' +
100
+ `${listed.join('\n')}\n` +
101
+ `${changed} Give each of these users a distinct address or clear it (for example with the ` +
102
+ 'administrator user edit, PUT /identity/users/:uuid), then re-run astram-migrate. ' +
103
+ 'See "Upgrading from 0.9.x" in the create-astram-app README.',
104
+ );
105
+ }
106
+
107
+ async function up({ query }) {
108
+ const state = await indexState(query);
109
+ if (state !== null) {
110
+ if (state.unique) return; // already applied (idempotent re-run)
111
+ throw new Error(
112
+ `${MIGRATION}: ${SCHEMA}.${TABLE} has a NON-unique index named ${INDEX}; ` +
113
+ 'drop or rename it, then re-run astram-migrate.',
114
+ );
115
+ }
116
+
117
+ await query(
118
+ `UPDATE \`${SCHEMA}\`.\`${TABLE}\`
119
+ SET ${COLUMN} = NULL
120
+ WHERE ${COLUMN} IS NOT NULL AND ${COLUMN} REGEXP '^[[:space:]]*$'`,
121
+ );
122
+
123
+ const groups = await duplicateGroups(query);
124
+ if (groups.length > 0) throw duplicateError(groups, false);
125
+
126
+ try {
127
+ await query(
128
+ `ALTER TABLE \`${SCHEMA}\`.\`${TABLE}\` ADD UNIQUE INDEX IF NOT EXISTS \`${INDEX}\` (${COLUMN})`,
129
+ );
130
+ } catch (err) {
131
+ if (err === null || typeof err !== 'object' || err.errno !== ER_DUP_ENTRY) throw err;
132
+ // A duplicate was written after the pre-check. Never surface the driver message (full address).
133
+ const late = await duplicateGroups(query);
134
+ if (late.length > 0) throw duplicateError(late, true);
135
+ throw new Error(
136
+ `${MIGRATION}: a duplicate ${COLUMN} was written while the index was built; re-run astram-migrate.`,
137
+ );
138
+ }
139
+ }
140
+
141
+ async function down({ query }) {
142
+ await query(`ALTER TABLE \`${SCHEMA}\`.\`${TABLE}\` DROP INDEX IF EXISTS \`${INDEX}\``);
143
+ }
144
+
145
+ module.exports = { up, down };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/identity",
3
- "version": "0.8.1",
3
+ "version": "0.9.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/engine": "0.5.2",
40
39
  "@astram/core": "1.4.0",
41
- "@astram/guard": "0.11.1"
40
+ "@astram/engine": "0.5.2",
41
+ "@astram/guard": "0.11.2"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@nestjs/common": "^11",