@structured-id/ui-core 4.0.0 → 4.2.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.
@@ -17,10 +17,18 @@ describe("detectLoginInputType", () => {
17
17
  expect(detectLoginInputType("alice")).toBe("username");
18
18
  expect(detectLoginInputType("bob_smith")).toBe("username");
19
19
  expect(detectLoginInputType("dev42")).toBe("username");
20
- // Uppercase accepted in detection (will be lowercased on normalize)
20
+ // Uppercase accepted; the server derives the key.
21
21
  expect(detectLoginInputType("Admin123")).toBe("username");
22
22
  });
23
23
 
24
+ it("detects a username in any script, counted in code points", () => {
25
+ expect(detectLoginInputType("Иван")).toBe("username");
26
+ expect(detectLoginInputType("Иван_Петров", 6)).toBe("username");
27
+ expect(detectLoginInputType("Щукин", 6)).toBe("unknown"); // 5 < 6
28
+ expect(detectLoginInputType("ЩЩ")).toBe("unknown"); // 2 < 3
29
+ expect(detectLoginInputType("Иван Петров")).toBe("unknown"); // a space
30
+ });
31
+
24
32
  it("detects username with minLength=6 (registration)", () => {
25
33
  expect(detectLoginInputType("alice", 6)).toBe("unknown"); // too short
26
34
  expect(detectLoginInputType("alicew", 6)).toBe("username"); // exactly 6
@@ -126,13 +134,42 @@ describe("normalizePrincipal", () => {
126
134
  expect(result.display).toBe("+380501234567");
127
135
  });
128
136
 
129
- it("normalizes username: value=lowercase, display=original casing", () => {
137
+ // Regression: the client lowercased a username before sending it, so the
138
+ // server never saw the spelling the user typed and could not keep it as
139
+ // the display form. The server derives the key, as it does for email.
140
+ it("sends a username as typed, case kept", () => {
130
141
  const result = normalizePrincipal("Alice_Bob");
131
142
  expect(result.type).toBe("username");
132
- expect(result.value).toBe("alice_bob");
143
+ expect(result.value).toBe("Alice_Bob");
133
144
  expect(result.display).toBe("Alice_Bob");
134
145
  });
135
146
 
147
+ // Whitespace is never part of a name and is not repaired: a username with
148
+ // surrounding spaces is refused, not trimmed into another name.
149
+ it("refuses a username with surrounding whitespace", () => {
150
+ for (const input of [" Alice_Bob ", "Alice_Bob ", "\tAlice_Bob"]) {
151
+ expect(() => normalizePrincipal(input)).toThrow("Cannot determine");
152
+ }
153
+ });
154
+
155
+ // Regression: a letter outside a-z made the name no type at all, so the
156
+ // field refused it before the server could apply its repertoire.
157
+ it("sends a username in any script as typed", () => {
158
+ for (const name of ["Иван_Петров", "Ωmega42", "Jürgen"]) {
159
+ const result = normalizePrincipal(name);
160
+ expect(result.type).toBe("username");
161
+ expect(result.value).toBe(name);
162
+ }
163
+ });
164
+
165
+ it("sends a username unnormalized and counts it after NFC", () => {
166
+ // "Jürgen" with ü as u + combining diaeresis: 7 code points, 6 in NFC.
167
+ const decomposed = "Jürgen";
168
+ const result = normalizePrincipal(decomposed, 6);
169
+ expect(result.value).toBe(decomposed);
170
+ expect(() => normalizePrincipal("Щукин", 6)).toThrow("Cannot determine");
171
+ });
172
+
136
173
  it("rejects username with leading/trailing underscore (detected as unknown)", () => {
137
174
  // Leading/trailing _ blocked at detection level → "Cannot determine"
138
175
  expect(() => normalizePrincipal("_alice_bob")).toThrow("Cannot determine");
@@ -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,7 +95,7 @@ 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": {
@@ -135,20 +141,13 @@ export function normalizePrincipal(
135
141
  return { value: normalized, display: trimmed, type: "username" };
136
142
  }
137
143
 
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
- ) {
144
+ // Global username: detection checked its shape and length on the
145
+ // input itself; sent exactly as typed, the server normalizes it and
146
+ // derives the key.
147
+ if (input.includes("__")) {
149
148
  throw new Error("Invalid username");
150
149
  }
151
- return { value: normalized, display: trimmed, type: "username" };
150
+ return { value: input, display: input, type: "username" };
152
151
  }
153
152
 
154
153
  default:
@@ -4,6 +4,7 @@ import { base64encode } from "@protobuf-ts/runtime";
4
4
  import { Any } from "@structured-id/proto/google/protobuf/any";
5
5
  import { Status } from "@structured-id/proto/google/rpc/status";
6
6
  import {
7
+ BadRequest,
7
8
  ErrorInfo,
8
9
  LocalizedMessage,
9
10
  } from "@structured-id/proto/google/rpc/error_details";
@@ -142,6 +143,47 @@ describe("rpcRefusal", () => {
142
143
  );
143
144
  });
144
145
 
146
+ // A refusal that names the fields it concerns lets the form show each
147
+ // text under its field: the server's text for people when it sent one,
148
+ // else its description.
149
+ it("reads the field violations", () => {
150
+ const bad = Any.pack(
151
+ BadRequest.create({
152
+ fieldViolations: [
153
+ {
154
+ field: "principal",
155
+ description: "registration here accepts: email",
156
+ reason: "PRINCIPAL_TYPE_NOT_ACCEPTED",
157
+ },
158
+ {
159
+ field: "claim_token",
160
+ description: "not the installation's token",
161
+ reason: "",
162
+ localizedMessage: LocalizedMessage.create({
163
+ locale: "en",
164
+ message: "This claim token is not valid.",
165
+ }),
166
+ },
167
+ ],
168
+ }),
169
+ BadRequest,
170
+ );
171
+ const refusal = rpcRefusal(refused([info, bad]));
172
+ expect(refusal?.fieldViolations).toEqual([
173
+ {
174
+ field: "principal",
175
+ reason: "PRINCIPAL_TYPE_NOT_ACCEPTED",
176
+ text: "registration here accepts: email",
177
+ },
178
+ {
179
+ field: "claim_token",
180
+ reason: null,
181
+ text: "This claim token is not valid.",
182
+ },
183
+ ]);
184
+ expect(rpcRefusal(refused([info]))?.fieldViolations).toEqual([]);
185
+ });
186
+
145
187
  it("survives an unreadable trailer", () => {
146
188
  const err = new RpcError("m", "INTERNAL", {
147
189
  "grpc-status-details-bin": "!!!",
@@ -8,6 +8,7 @@ import { base64decode } from "@protobuf-ts/runtime";
8
8
  import { Any } from "@structured-id/proto/google/protobuf/any";
9
9
  import { Status } from "@structured-id/proto/google/rpc/status";
10
10
  import {
11
+ BadRequest,
11
12
  ErrorInfo,
12
13
  LocalizedMessage,
13
14
  } from "@structured-id/proto/google/rpc/error_details";
@@ -28,6 +29,18 @@ export interface RpcRefusal {
28
29
  message: string;
29
30
  /** The server's text for people (`LocalizedMessage`), when it sent one. */
30
31
  localized: string | null;
32
+ /** The request fields the refusal names (`BadRequest`), in the server's order. */
33
+ fieldViolations: FieldViolation[];
34
+ }
35
+
36
+ /** One request field a refusal names. */
37
+ export interface FieldViolation {
38
+ /** The field's path in the request, as the proto names it (`principal`). */
39
+ field: string;
40
+ /** The field-level reason, when the server gave one. */
41
+ reason: string | null;
42
+ /** What to show under the field: the server's text for people, else its description. */
43
+ text: string;
31
44
  }
32
45
 
33
46
  /**
@@ -50,6 +63,7 @@ export function rpcRefusal(err: unknown): RpcRefusal | null {
50
63
  metadata: {},
51
64
  message: decodeStatusMessage(err.message),
52
65
  localized: null,
66
+ fieldViolations: [],
53
67
  };
54
68
  for (const detail of statusDetails(err.meta[DETAILS_KEY])) {
55
69
  if (Any.contains(detail, ErrorInfo)) {
@@ -61,6 +75,14 @@ export function rpcRefusal(err: unknown): RpcRefusal | null {
61
75
  info.domain === SID_ERROR_DOMAIN ? sidReason(info.reason) : null;
62
76
  } else if (Any.contains(detail, LocalizedMessage)) {
63
77
  refusal.localized = Any.unpack(detail, LocalizedMessage).message;
78
+ } else if (Any.contains(detail, BadRequest)) {
79
+ for (const v of Any.unpack(detail, BadRequest).fieldViolations) {
80
+ refusal.fieldViolations.push({
81
+ field: v.field,
82
+ reason: v.reason || null,
83
+ text: v.localizedMessage?.message || v.description,
84
+ });
85
+ }
64
86
  }
65
87
  }
66
88
  return refusal;