@logto/core-kit 2.10.0 → 2.12.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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Validates an email blocklist item.
3
+ *
4
+ * An item can be either a full email address (`foo@example.com`) or a domain entry
5
+ * prefixed with `@` (`@example.com`). `*` can be used inside the local part or
6
+ * domain while keeping the same email/domain shapes.
7
+ */
8
+ export declare const isEmailBlocklistItem: (value: string) => boolean;
9
+ /**
10
+ * Checks whether an email address matches an email blocklist item.
11
+ *
12
+ * Matching is case-insensitive. Domain items (`@example.com`) match only the email
13
+ * domain, while full email items match the complete email address.
14
+ */
15
+ export declare const matchesEmailBlocklistItem: (item: string, email: string) => boolean;
@@ -0,0 +1,65 @@
1
+ import { emailOrEmailDomainRegEx } from './regex.js';
2
+ const wildcard = '*';
3
+ const emailSeparator = '@';
4
+ const domainSeparator = '.';
5
+ const whitespaceRegEx = /\s/u;
6
+ const wildcardOnlyDomainRegEx = /^[*.]+$/u;
7
+ const hasWildcard = (value) => value.includes(wildcard);
8
+ const escapeRegExp = (value) => value.replaceAll(/[.+?^${}()|[\]\\]/gu, '\\$&');
9
+ const buildWildcardRegExp = (pattern) => new RegExp(`^${escapeRegExp(pattern).replaceAll(wildcard, '.*')}$`, 'u');
10
+ const isValidWildcardLocalPart = (localPart) => localPart.length > 0 && !localPart.includes(emailSeparator) && !whitespaceRegEx.test(localPart);
11
+ const isValidWildcardDomain = (domain) => domain.length > 0 &&
12
+ domain.includes(domainSeparator) &&
13
+ !domain.includes(emailSeparator) &&
14
+ !domain.includes(`${domainSeparator}${domainSeparator}`) &&
15
+ !domain.startsWith(domainSeparator) &&
16
+ !domain.endsWith(domainSeparator) &&
17
+ !whitespaceRegEx.test(domain) &&
18
+ !wildcardOnlyDomainRegEx.test(domain);
19
+ /**
20
+ * Validates an email blocklist item.
21
+ *
22
+ * An item can be either a full email address (`foo@example.com`) or a domain entry
23
+ * prefixed with `@` (`@example.com`). `*` can be used inside the local part or
24
+ * domain while keeping the same email/domain shapes.
25
+ */
26
+ export const isEmailBlocklistItem = (value) => {
27
+ if (!hasWildcard(value)) {
28
+ return emailOrEmailDomainRegEx.test(value);
29
+ }
30
+ if (whitespaceRegEx.test(value)) {
31
+ return false;
32
+ }
33
+ if (value.startsWith(emailSeparator)) {
34
+ return isValidWildcardDomain(value.slice(1));
35
+ }
36
+ const emailParts = value.split(emailSeparator);
37
+ if (emailParts.length !== 2) {
38
+ return false;
39
+ }
40
+ const [localPart, domain] = emailParts;
41
+ return (localPart !== undefined &&
42
+ domain !== undefined &&
43
+ isValidWildcardLocalPart(localPart) &&
44
+ isValidWildcardDomain(domain));
45
+ };
46
+ /**
47
+ * Checks whether an email address matches an email blocklist item.
48
+ *
49
+ * Matching is case-insensitive. Domain items (`@example.com`) match only the email
50
+ * domain, while full email items match the complete email address.
51
+ */
52
+ export const matchesEmailBlocklistItem = (item, email) => {
53
+ const normalizedItem = item.toLowerCase();
54
+ const normalizedEmail = email.toLowerCase();
55
+ const domain = normalizedEmail.split(emailSeparator)[1];
56
+ if (normalizedItem.startsWith(emailSeparator)) {
57
+ return Boolean(domain &&
58
+ (hasWildcard(normalizedItem.slice(1))
59
+ ? buildWildcardRegExp(normalizedItem.slice(1)).test(domain)
60
+ : domain === normalizedItem.slice(1)));
61
+ }
62
+ return hasWildcard(normalizedItem)
63
+ ? buildWildcardRegExp(normalizedItem).test(normalizedEmail)
64
+ : normalizedEmail === normalizedItem;
65
+ };
package/lib/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export * from './utils/index.js';
2
2
  export * from './custom-ui-csp.js';
3
+ export * from './email-blocklist.js';
3
4
  export * from './regex.js';
4
5
  export * from './openid.js';
5
6
  export * from './models/index.js';
6
7
  export * from './http.js';
7
8
  export * from './password-policy.js';
9
+ export * from './username-policy.js';
package/lib/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export * from './utils/index.js';
2
2
  export * from './custom-ui-csp.js';
3
+ export * from './email-blocklist.js';
3
4
  export * from './regex.js';
4
5
  export * from './openid.js';
5
6
  export * from './models/index.js';
6
7
  export * from './http.js';
7
8
  export * from './password-policy.js';
9
+ export * from './username-policy.js';
package/lib/regex.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export const emailRegEx = /^\S+@\S+\.\S+$/;
2
2
  /** Validates full email address or email domain. */
3
- export const emailOrEmailDomainRegEx = /^\S+@\S+\.\S+|^@\S+\.\S+$/;
3
+ export const emailOrEmailDomainRegEx = /^(?:[^\s@]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+|@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+)$(?![\s\S])/u;
4
4
  export const phoneRegEx = /^\d+$/;
5
5
  export const phoneInputRegEx = /^\+?[\d-( )]+$/;
6
6
  export const usernameRegEx = /^[A-Z_a-z]\w*$/;
@@ -0,0 +1,111 @@
1
+ import { z } from 'zod';
2
+ /** Per-tenant username policy: case sensitivity, length bounds, and allowed character classes. */
3
+ export type UsernamePolicy = {
4
+ caseSensitive: boolean;
5
+ /** Integer in [1, 128], inclusive. */
6
+ minLength: number;
7
+ /** Integer in [1, 128], inclusive. Must be >= minLength. */
8
+ maxLength: number;
9
+ /** At least one of lowercase, uppercase, or underscore must be enabled (see usernamePolicyGuard). */
10
+ allowedChars: {
11
+ lowercase: boolean;
12
+ uppercase: boolean;
13
+ numbers: boolean;
14
+ underscore: boolean;
15
+ };
16
+ };
17
+ export declare const usernamePolicyGuard: z.ZodEffects<z.ZodEffects<z.ZodObject<{
18
+ caseSensitive: z.ZodBoolean;
19
+ minLength: z.ZodNumber;
20
+ maxLength: z.ZodNumber;
21
+ allowedChars: z.ZodObject<{
22
+ lowercase: z.ZodBoolean;
23
+ uppercase: z.ZodBoolean;
24
+ numbers: z.ZodBoolean;
25
+ underscore: z.ZodBoolean;
26
+ }, "strip", z.ZodTypeAny, {
27
+ lowercase: boolean;
28
+ uppercase: boolean;
29
+ numbers: boolean;
30
+ underscore: boolean;
31
+ }, {
32
+ lowercase: boolean;
33
+ uppercase: boolean;
34
+ numbers: boolean;
35
+ underscore: boolean;
36
+ }>;
37
+ }, "strip", z.ZodTypeAny, {
38
+ caseSensitive: boolean;
39
+ minLength: number;
40
+ maxLength: number;
41
+ allowedChars: {
42
+ lowercase: boolean;
43
+ uppercase: boolean;
44
+ numbers: boolean;
45
+ underscore: boolean;
46
+ };
47
+ }, {
48
+ caseSensitive: boolean;
49
+ minLength: number;
50
+ maxLength: number;
51
+ allowedChars: {
52
+ lowercase: boolean;
53
+ uppercase: boolean;
54
+ numbers: boolean;
55
+ underscore: boolean;
56
+ };
57
+ }>, {
58
+ caseSensitive: boolean;
59
+ minLength: number;
60
+ maxLength: number;
61
+ allowedChars: {
62
+ lowercase: boolean;
63
+ uppercase: boolean;
64
+ numbers: boolean;
65
+ underscore: boolean;
66
+ };
67
+ }, {
68
+ caseSensitive: boolean;
69
+ minLength: number;
70
+ maxLength: number;
71
+ allowedChars: {
72
+ lowercase: boolean;
73
+ uppercase: boolean;
74
+ numbers: boolean;
75
+ underscore: boolean;
76
+ };
77
+ }>, {
78
+ caseSensitive: boolean;
79
+ minLength: number;
80
+ maxLength: number;
81
+ allowedChars: {
82
+ lowercase: boolean;
83
+ uppercase: boolean;
84
+ numbers: boolean;
85
+ underscore: boolean;
86
+ };
87
+ }, {
88
+ caseSensitive: boolean;
89
+ minLength: number;
90
+ maxLength: number;
91
+ allowedChars: {
92
+ lowercase: boolean;
93
+ uppercase: boolean;
94
+ numbers: boolean;
95
+ underscore: boolean;
96
+ };
97
+ }>;
98
+ /**
99
+ * Mirrors current username behavior so the policy is a no-op when unset: `usernameRegEx` charset
100
+ * (all classes), length 1-128 (`z.string().min(1)` + `users.username varchar(128)`), case-sensitive
101
+ * (the `CASE_SENSITIVE_USERNAME` env default).
102
+ */
103
+ export declare const defaultUsernamePolicy: UsernamePolicy;
104
+ export type UsernameViolation = 'required' | 'starts_with_number' | 'invalid_charset_hard' | 'too_short' | 'too_long' | 'uppercase_not_allowed' | 'lowercase_not_allowed' | 'numbers_not_allowed' | 'underscore_not_allowed';
105
+ /**
106
+ * The always-on baseline, independent of any per-tenant policy: non-empty, no leading digit, and
107
+ * matching the existing `usernameRegEx` charset. Applies to admin writes too.
108
+ */
109
+ export declare const validateUsernameHardFloor: (username: string) => UsernameViolation | undefined;
110
+ /** Returns the first violation against the hard floor then the per-tenant policy, or undefined. */
111
+ export declare const validateUsernameAgainstPolicy: (username: string, policy: UsernamePolicy) => UsernameViolation | undefined;
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod';
2
+ import { usernameRegEx } from './regex.js';
3
+ export const usernamePolicyGuard = z
4
+ .object({
5
+ caseSensitive: z.boolean(),
6
+ minLength: z.number().int().min(1).max(128),
7
+ maxLength: z.number().int().min(1).max(128),
8
+ allowedChars: z.object({
9
+ lowercase: z.boolean(),
10
+ uppercase: z.boolean(),
11
+ numbers: z.boolean(),
12
+ underscore: z.boolean(),
13
+ }),
14
+ })
15
+ .refine((policy) => policy.minLength <= policy.maxLength, {
16
+ message: 'Minimum length cannot exceed maximum length.',
17
+ path: ['maxLength'],
18
+ })
19
+ // Messages are full sentences on purpose: koaGuard returns them verbatim in the 400 response,
20
+ // so a Management API caller posting an invalid policy reads exactly why.
21
+ .refine((policy) => policy.allowedChars.lowercase ||
22
+ policy.allowedChars.uppercase ||
23
+ policy.allowedChars.underscore, {
24
+ message: 'At least one of lowercase, uppercase, or underscore must be enabled. Usernames cannot start with a number, so numbers alone are not allowed.',
25
+ path: ['allowedChars'],
26
+ });
27
+ /**
28
+ * Mirrors current username behavior so the policy is a no-op when unset: `usernameRegEx` charset
29
+ * (all classes), length 1-128 (`z.string().min(1)` + `users.username varchar(128)`), case-sensitive
30
+ * (the `CASE_SENSITIVE_USERNAME` env default).
31
+ */
32
+ export const defaultUsernamePolicy = Object.freeze({
33
+ caseSensitive: true,
34
+ minLength: 1,
35
+ maxLength: 128,
36
+ allowedChars: Object.freeze({
37
+ lowercase: true,
38
+ uppercase: true,
39
+ numbers: true,
40
+ underscore: true,
41
+ }),
42
+ });
43
+ /**
44
+ * The always-on baseline, independent of any per-tenant policy: non-empty, no leading digit, and
45
+ * matching the existing `usernameRegEx` charset. Applies to admin writes too.
46
+ */
47
+ export const validateUsernameHardFloor = (username) => {
48
+ if (username.length === 0) {
49
+ return 'required';
50
+ }
51
+ if (/^\d/.test(username)) {
52
+ return 'starts_with_number';
53
+ }
54
+ if (!usernameRegEx.test(username)) {
55
+ return 'invalid_charset_hard';
56
+ }
57
+ };
58
+ const checkAllowedChars = (username, allowedChars) => {
59
+ if (!allowedChars.uppercase && /[A-Z]/.test(username)) {
60
+ return 'uppercase_not_allowed';
61
+ }
62
+ if (!allowedChars.lowercase && /[a-z]/.test(username)) {
63
+ return 'lowercase_not_allowed';
64
+ }
65
+ if (!allowedChars.numbers && /\d/.test(username)) {
66
+ return 'numbers_not_allowed';
67
+ }
68
+ if (!allowedChars.underscore && username.includes('_')) {
69
+ return 'underscore_not_allowed';
70
+ }
71
+ };
72
+ /** Returns the first violation against the hard floor then the per-tenant policy, or undefined. */
73
+ export const validateUsernameAgainstPolicy = (username, policy) => {
74
+ const hardFloor = validateUsernameHardFloor(username);
75
+ if (hardFloor) {
76
+ return hardFloor;
77
+ }
78
+ if (username.length < policy.minLength) {
79
+ return 'too_short';
80
+ }
81
+ if (username.length > policy.maxLength) {
82
+ return 'too_long';
83
+ }
84
+ return checkAllowedChars(username, policy.allowedChars);
85
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/core-kit",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "author": "Silverhand Inc. <contact@silverhand.io>",
5
5
  "homepage": "https://github.com/logto-io/toolkit#readme",
6
6
  "repository": {
@@ -36,7 +36,7 @@
36
36
  "@silverhand/essentials": "^2.9.1",
37
37
  "color": "^4.2.3",
38
38
  "@logto/language-kit": "^1.3.0",
39
- "@logto/shared": "^3.4.0"
39
+ "@logto/shared": "^3.4.2"
40
40
  },
41
41
  "optionalDependencies": {
42
42
  "zod": "3.24.3"
@@ -49,14 +49,14 @@
49
49
  "@types/color": "^4.0.0",
50
50
  "@types/node": "^22.14.0",
51
51
  "@types/react": "^18.3.3",
52
- "@vitest/coverage-v8": "^3.1.1",
52
+ "@vitest/coverage-v8": "^4.1.8",
53
53
  "eslint": "^8.56.0",
54
54
  "lint-staged": "^15.0.0",
55
- "postcss": "^8.4.31",
55
+ "postcss": "^8.5.18",
56
56
  "prettier": "^3.5.3",
57
57
  "stylelint": "^15.0.0",
58
58
  "typescript": "^5.5.3",
59
- "vitest": "^3.1.1"
59
+ "vitest": "^4.1.8"
60
60
  },
61
61
  "eslintConfig": {
62
62
  "extends": "@silverhand"