@structured-id/ui-core 4.1.0 → 4.3.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,5 +1,10 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { beforeAll, describe, it, expect } from "vitest";
2
2
  import { detectLoginInputType, normalizePrincipal } from "./normalizePrincipal";
3
+ import { initEmail } from "./email";
4
+ import { TEST_PSL } from "./email.testing";
5
+
6
+ // As an application does at start: the email parser loaded, LOCAL namespace.
7
+ beforeAll(() => initEmail({ psl: TEST_PSL }));
3
8
 
4
9
  describe("detectLoginInputType", () => {
5
10
  it("detects email", () => {
@@ -17,10 +22,18 @@ describe("detectLoginInputType", () => {
17
22
  expect(detectLoginInputType("alice")).toBe("username");
18
23
  expect(detectLoginInputType("bob_smith")).toBe("username");
19
24
  expect(detectLoginInputType("dev42")).toBe("username");
20
- // Uppercase accepted in detection (will be lowercased on normalize)
25
+ // Uppercase accepted; the server derives the key.
21
26
  expect(detectLoginInputType("Admin123")).toBe("username");
22
27
  });
23
28
 
29
+ it("detects a username in any script, counted in code points", () => {
30
+ expect(detectLoginInputType("Иван")).toBe("username");
31
+ expect(detectLoginInputType("Иван_Петров", 6)).toBe("username");
32
+ expect(detectLoginInputType("Щукин", 6)).toBe("unknown"); // 5 < 6
33
+ expect(detectLoginInputType("ЩЩ")).toBe("unknown"); // 2 < 3
34
+ expect(detectLoginInputType("Иван Петров")).toBe("unknown"); // a space
35
+ });
36
+
24
37
  it("detects username with minLength=6 (registration)", () => {
25
38
  expect(detectLoginInputType("alice", 6)).toBe("unknown"); // too short
26
39
  expect(detectLoginInputType("alicew", 6)).toBe("username"); // exactly 6
@@ -126,13 +139,42 @@ describe("normalizePrincipal", () => {
126
139
  expect(result.display).toBe("+380501234567");
127
140
  });
128
141
 
129
- it("normalizes username: value=lowercase, display=original casing", () => {
142
+ // Regression: the client lowercased a username before sending it, so the
143
+ // server never saw the spelling the user typed and could not keep it as
144
+ // the display form. The server derives the key, as it does for email.
145
+ it("sends a username as typed, case kept", () => {
130
146
  const result = normalizePrincipal("Alice_Bob");
131
147
  expect(result.type).toBe("username");
132
- expect(result.value).toBe("alice_bob");
148
+ expect(result.value).toBe("Alice_Bob");
133
149
  expect(result.display).toBe("Alice_Bob");
134
150
  });
135
151
 
152
+ // Whitespace is never part of a name and is not repaired: a username with
153
+ // surrounding spaces is refused, not trimmed into another name.
154
+ it("refuses a username with surrounding whitespace", () => {
155
+ for (const input of [" Alice_Bob ", "Alice_Bob ", "\tAlice_Bob"]) {
156
+ expect(() => normalizePrincipal(input)).toThrow("Cannot determine");
157
+ }
158
+ });
159
+
160
+ // Regression: a letter outside a-z made the name no type at all, so the
161
+ // field refused it before the server could apply its repertoire.
162
+ it("sends a username in any script as typed", () => {
163
+ for (const name of ["Иван_Петров", "Ωmega42", "Jürgen"]) {
164
+ const result = normalizePrincipal(name);
165
+ expect(result.type).toBe("username");
166
+ expect(result.value).toBe(name);
167
+ }
168
+ });
169
+
170
+ it("sends a username unnormalized and counts it after NFC", () => {
171
+ // "Jürgen" with ü as u + combining diaeresis: 7 code points, 6 in NFC.
172
+ const decomposed = "Jürgen";
173
+ const result = normalizePrincipal(decomposed, 6);
174
+ expect(result.value).toBe(decomposed);
175
+ expect(() => normalizePrincipal("Щукин", 6)).toThrow("Cannot determine");
176
+ });
177
+
136
178
  it("rejects username with leading/trailing underscore (detected as unknown)", () => {
137
179
  // Leading/trailing _ blocked at detection level → "Cannot determine"
138
180
  expect(() => normalizePrincipal("_alice_bob")).toThrow("Cannot determine");
@@ -1,7 +1,7 @@
1
1
  import parsePhoneNumberFromString, {
2
2
  isValidPhoneNumber,
3
3
  } from "libphonenumber-js";
4
- import { isValidEmail } from "./validators";
4
+ import { checkEmail } from "./email";
5
5
 
6
6
  export type LoginInputType = "email" | "phone" | "username" | "unknown";
7
7
 
@@ -14,17 +14,18 @@ export interface NormalizedPrincipal {
14
14
  }
15
15
 
16
16
  /**
17
- * Global username rules:
18
- * Display: ^[a-zA-Z0-9_]{3,32}$ — user's original input, mixed case preserved
19
- * Normalized: ^[a-z0-9_]{3,32}$ — lowercase, for storage + uniqueness + login
20
- * - cannot start or end with underscore
21
- * - no consecutive underscores (__)
22
- *
23
- * For detection (prematch) we use a relaxed version:
24
- * - accepts minLength (3 for login, 6 for registration)
25
- * - case-insensitive (will be lowercased on normalization)
17
+ * A global username as the user types it: letters of any script, combining
18
+ * marks, digits and `_`, at most 32 code points in NFC, no `_` at either
19
+ * end. It is sent exactly as typed (nothing trimmed or normalized); the
20
+ * server applies its repertoire and minimum and derives the lookup key.
26
21
  */
27
22
  const USERNAME_MAX_LENGTH = 32;
23
+ const USERNAME_SHAPE = /^[\p{L}\p{M}\p{N}_]+$/u;
24
+
25
+ /** The length of `name` in code points of its NFC form. */
26
+ function codePoints(name: string): number {
27
+ return [...name.normalize("NFC")].length;
28
+ }
28
29
 
29
30
  /**
30
31
  * Detect principal type from user input.
@@ -63,11 +64,16 @@ export function detectLoginInputType(
63
64
  return "unknown";
64
65
  }
65
66
 
66
- // Global username: no #, no @, no +
67
- const re = new RegExp(
68
- `^[a-zA-Z0-9_]{${minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
69
- );
70
- if (re.test(trimmed) && !trimmed.startsWith("_") && !trimmed.endsWith("_")) {
67
+ // Global username: no #, no @, no +. Judged on the input itself, not the
68
+ // trimmed form: whitespace is never part of a name and is never repaired.
69
+ const length = codePoints(input);
70
+ if (
71
+ USERNAME_SHAPE.test(input) &&
72
+ length >= minUsernameLength &&
73
+ length <= USERNAME_MAX_LENGTH &&
74
+ !input.startsWith("_") &&
75
+ !input.endsWith("_")
76
+ ) {
71
77
  return "username";
72
78
  }
73
79
 
@@ -79,7 +85,7 @@ export function detectLoginInputType(
79
85
  *
80
86
  * - Email: as typed (trimmed); the server applies the email policy
81
87
  * - Phone: E.164 format via libphonenumber-js
82
- * - Username: lowercase, validate per arch rules
88
+ * - Username: exactly as typed; a federated one trimmed and lowercased
83
89
  *
84
90
  * @param minUsernameLength - minimum chars for username (3 for login, 6 for register)
85
91
  * Throws if value doesn't pass validation for detected type.
@@ -89,13 +95,18 @@ export function normalizePrincipal(
89
95
  minUsernameLength = 3,
90
96
  ): NormalizedPrincipal {
91
97
  const trimmed = input.trim();
92
- const type = detectLoginInputType(trimmed, minUsernameLength);
98
+ const type = detectLoginInputType(input, minUsernameLength);
93
99
 
94
100
  switch (type) {
95
101
  case "email": {
96
102
  // Sent as typed: the server validates it, derives the lookup key and
97
- // keeps this spelling as the address to mail.
98
- if (!isValidEmail(trimmed)) {
103
+ // keeps this spelling as the address to mail. Checked here under the
104
+ // application's namespace (initEmail), as the server would.
105
+ const check = checkEmail(trimmed);
106
+ if (check === "pending") {
107
+ throw new Error("Email check not loaded: call initEmail() first");
108
+ }
109
+ if (check === "invalid") {
99
110
  throw new Error("Invalid email address");
100
111
  }
101
112
  return { value: trimmed, display: trimmed, type: "email" };
@@ -135,20 +146,13 @@ export function normalizePrincipal(
135
146
  return { value: normalized, display: trimmed, type: "username" };
136
147
  }
137
148
 
138
- // Global username
139
- const normalized = trimmed.toLowerCase();
140
- const re = new RegExp(
141
- `^[a-z0-9_]{${minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
142
- );
143
- if (
144
- !re.test(normalized) ||
145
- normalized.startsWith("_") ||
146
- normalized.endsWith("_") ||
147
- normalized.includes("__")
148
- ) {
149
+ // Global username: detection checked its shape and length on the
150
+ // input itself; sent exactly as typed, the server normalizes it and
151
+ // derives the key.
152
+ if (input.includes("__")) {
149
153
  throw new Error("Invalid username");
150
154
  }
151
- return { value: normalized, display: trimmed, type: "username" };
155
+ return { value: input, display: input, type: "username" };
152
156
  }
153
157
 
154
158
  default:
@@ -1,6 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
- isValidEmail,
4
3
  isValidPhone,
5
4
  isValidUuid,
6
5
  isValidUsername,
@@ -9,28 +8,6 @@ import {
9
8
  isRequired,
10
9
  } from "./validators";
11
10
 
12
- describe("isValidEmail", () => {
13
- it("accepts valid emails", () => {
14
- expect(isValidEmail("user@example.com")).toBe(true);
15
- expect(isValidEmail("a.b+c@d.co")).toBe(true);
16
- });
17
- it("rejects invalid emails", () => {
18
- expect(isValidEmail("")).toBe(false);
19
- expect(isValidEmail("noat")).toBe(false);
20
- expect(isValidEmail("@no-local.com")).toBe(false);
21
- expect(isValidEmail("spaces in@email.com")).toBe(false);
22
- expect(isValidEmail("user@")).toBe(false);
23
- expect(isValidEmail("user@exa mple.com")).toBe(false);
24
- });
25
- // Regression: a dot was required in the domain and quoted local parts were
26
- // refused, so the client turned away addresses the server's policy admits.
27
- it("leaves admission to the server's email policy", () => {
28
- expect(isValidEmail("admin@printer")).toBe(true);
29
- expect(isValidEmail('"a b"@example.com')).toBe(true);
30
- expect(isValidEmail('"a@b"@example.com')).toBe(true);
31
- });
32
- });
33
-
34
11
  describe("isValidPhone", () => {
35
12
  it("accepts E.164 format", () => {
36
13
  expect(isValidPhone("+380501234567")).toBe(true);
@@ -1,20 +1,3 @@
1
- /**
2
- * Whether `value` has the shape of one mailbox: a local part and a domain
3
- * around the last `@`, whitespace only inside a quoted local part. Advisory:
4
- * the server's email policy decides admission (internal single-label domains
5
- * and quoted local parts may be admitted), so this refuses only what can
6
- * never be an address.
7
- */
8
- export function isValidEmail(value: string): boolean {
9
- const at = value.lastIndexOf("@");
10
- if (at <= 0 || at === value.length - 1) return false;
11
- const local = value.slice(0, at);
12
- const domain = value.slice(at + 1);
13
- const quoted =
14
- local.length > 1 && local.startsWith('"') && local.endsWith('"');
15
- return !/\s/.test(domain) && (quoted || !/\s/.test(local));
16
- }
17
-
18
1
  /** Phone number validation (E.164 format). */
19
2
  export function isValidPhone(value: string): boolean {
20
3
  return /^\+[1-9]\d{6,14}$/.test(value);