@logto/schemas 1.38.0 → 1.39.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 (39) hide show
  1. package/alterations/1.39.0-1774752400-add-delete-account-url.ts +20 -0
  2. package/alterations/1.39.0-1774770686-add-account-center-custom-css.ts +20 -0
  3. package/alterations/1.39.0-1776502301-add-sign-up-profile-fields.ts +20 -0
  4. package/alterations-js/1.39.0-1774752400-add-delete-account-url.js +16 -0
  5. package/alterations-js/1.39.0-1774770686-add-account-center-custom-css.js +16 -0
  6. package/alterations-js/1.39.0-1776502301-add-sign-up-profile-fields.js +16 -0
  7. package/lib/db-entries/account-center.d.ts +9 -1
  8. package/lib/db-entries/account-center.js +8 -0
  9. package/lib/db-entries/sign-in-experience.d.ts +6 -2
  10. package/lib/db-entries/sign-in-experience.js +5 -1
  11. package/lib/foundations/jsonb-types/account-centers.d.ts +1 -0
  12. package/lib/foundations/jsonb-types/account-centers.js +8 -0
  13. package/lib/foundations/jsonb-types/sign-in-experience.d.ts +26 -0
  14. package/lib/foundations/jsonb-types/sign-in-experience.js +4 -0
  15. package/lib/types/alteration.d.ts +5 -0
  16. package/lib/types/application.d.ts +2 -2
  17. package/lib/types/custom-profile-fields.d.ts +7 -13
  18. package/lib/types/custom-profile-fields.js +6 -13
  19. package/lib/types/logto-config/index.d.ts +55 -2
  20. package/lib/types/logto-config/index.js +22 -4
  21. package/lib/types/logto-config/index.test.d.ts +1 -0
  22. package/lib/types/logto-config/index.test.js +29 -0
  23. package/lib/types/logto-config/jwt-customizer.d.ts +9 -0
  24. package/lib/types/logto-config/jwt-customizer.js +1 -0
  25. package/lib/types/logto-config/jwt-customizer.test.js +14 -2
  26. package/lib/types/onboarding.d.ts +93 -1
  27. package/lib/types/onboarding.js +22 -1
  28. package/lib/types/sign-in-experience.d.ts +9 -2
  29. package/lib/types/user-logto-config.d.ts +11 -0
  30. package/lib/types/user-logto-config.js +6 -0
  31. package/lib/utils/index.d.ts +1 -0
  32. package/lib/utils/index.js +1 -0
  33. package/lib/utils/oidc-private-key.d.ts +88 -0
  34. package/lib/utils/oidc-private-key.js +163 -0
  35. package/lib/utils/oidc-private-key.test.d.ts +1 -0
  36. package/lib/utils/oidc-private-key.test.js +128 -0
  37. package/package.json +6 -6
  38. package/tables/account_centers.sql +4 -0
  39. package/tables/sign_in_experiences.sql +2 -0
@@ -0,0 +1,163 @@
1
+ import { OidcSigningKeyStatus } from '../types/index.js';
2
+ const oidcPrivateKeyStatusOrder = {
3
+ [OidcSigningKeyStatus.Next]: 0,
4
+ [OidcSigningKeyStatus.Current]: 1,
5
+ [OidcSigningKeyStatus.Previous]: 2,
6
+ };
7
+ const oidcProviderPrivateKeyOrder = {
8
+ [OidcSigningKeyStatus.Current]: 0,
9
+ [OidcSigningKeyStatus.Next]: 1,
10
+ [OidcSigningKeyStatus.Previous]: 2,
11
+ };
12
+ const sortOidcPrivateKeys = (privateKeys, order) => privateKeys.toSorted((leftKey, rightKey) => order[leftKey.status] - order[rightKey.status]);
13
+ /**
14
+ * Normalize OIDC private signing keys into an explicit status-based model.
15
+ *
16
+ * Legacy keys without `status` are interpreted by index order:
17
+ * the first key becomes `Current` and the second key becomes `Previous`.
18
+ * The helper also validates that the key set contains exactly one `Current`
19
+ * and at most one `Next` and `Previous`.
20
+ */
21
+ export const normalizeOidcPrivateKeys = (privateKeys) => {
22
+ const normalizedPrivateKeys = privateKeys.map((privateKey, index) => ({
23
+ ...privateKey,
24
+ status: privateKey.status ??
25
+ (index === 0 ? OidcSigningKeyStatus.Current : OidcSigningKeyStatus.Previous),
26
+ }));
27
+ const currentKeys = normalizedPrivateKeys.filter(({ status }) => status === OidcSigningKeyStatus.Current);
28
+ const nextKeys = normalizedPrivateKeys.filter(({ status }) => status === OidcSigningKeyStatus.Next);
29
+ const previousKeys = normalizedPrivateKeys.filter(({ status }) => status === OidcSigningKeyStatus.Previous);
30
+ if (currentKeys.length !== 1 || nextKeys.length > 1 || previousKeys.length > 1) {
31
+ throw new Error('Malformed OIDC private key status configuration: expected exactly one Current key and at most one Next and Previous key.');
32
+ }
33
+ return normalizedPrivateKeys;
34
+ };
35
+ /**
36
+ * Return private keys in canonical business order: `Next`, then `Current`, then `Previous`.
37
+ *
38
+ * This order is useful when the caller wants a stable persisted view of key lifecycle state.
39
+ */
40
+ export const getCanonicalOidcPrivateKeys = (privateKeys) => sortOidcPrivateKeys(normalizeOidcPrivateKeys(privateKeys), oidcPrivateKeyStatusOrder);
41
+ /**
42
+ * Return the currently active signing key from a private-key set.
43
+ *
44
+ * This helper reads explicit key status rather than relying on array index.
45
+ */
46
+ export const getCurrentOidcPrivateKey = (privateKeys) => {
47
+ const currentKey = normalizeOidcPrivateKeys(privateKeys).find(({ status }) => status === OidcSigningKeyStatus.Current);
48
+ if (!currentKey) {
49
+ throw new Error('Malformed OIDC private key status configuration: missing Current key.');
50
+ }
51
+ return currentKey;
52
+ };
53
+ /**
54
+ * Return private keys in the order expected by `oidc-provider` for signing and JWKS exposure.
55
+ *
56
+ * The active `Current` key comes first, followed by `Next`, then `Previous`.
57
+ */
58
+ export const getOidcProviderPrivateKeys = (privateKeys) => sortOidcPrivateKeys(normalizeOidcPrivateKeys(privateKeys), oidcProviderPrivateKeyOrder);
59
+ /**
60
+ * Normalize seeded private keys into the explicit status model used by core and CLI.
61
+ *
62
+ * Seeding only supports the legacy one-key or two-key layout, so the helper rejects
63
+ * larger key arrays instead of trying to infer a more complex state machine.
64
+ */
65
+ export const getSeededOidcPrivateKeys = (privateKeys) => {
66
+ if (privateKeys.length > 2) {
67
+ throw new TypeError('CLI seed supports at most 2 OIDC private keys');
68
+ }
69
+ return normalizeOidcPrivateKeys(privateKeys);
70
+ };
71
+ /**
72
+ * Build the persisted private-key state for immediate rotation.
73
+ *
74
+ * The new key becomes `Current`, the previous `Current` key becomes `Previous`,
75
+ * and any older `Previous` key is dropped.
76
+ */
77
+ export const getImmediatelyRotatedOidcPrivateKeys = (privateKeys, newPrivateKey) => {
78
+ const normalizedPrivateKeys = normalizeOidcPrivateKeys(privateKeys);
79
+ if (normalizedPrivateKeys.some(({ status }) => status === OidcSigningKeyStatus.Next)) {
80
+ throw new TypeError('Immediate OIDC private key rotation is not allowed when a Next key exists');
81
+ }
82
+ const currentKey = getCurrentOidcPrivateKey(normalizedPrivateKeys);
83
+ return [
84
+ { ...newPrivateKey, status: OidcSigningKeyStatus.Current },
85
+ { ...currentKey, status: OidcSigningKeyStatus.Previous },
86
+ ];
87
+ };
88
+ /**
89
+ * Build the persisted private-key state for staged rotation.
90
+ *
91
+ * The new key becomes `Next`, the existing `Current` key stays `Current`,
92
+ * and the existing `Previous` key is preserved when present.
93
+ */
94
+ export const getStagedRotatedOidcPrivateKeys = (privateKeys, newPrivateKey) => {
95
+ const normalizedPrivateKeys = normalizeOidcPrivateKeys(privateKeys);
96
+ const currentKey = getCurrentOidcPrivateKey(normalizedPrivateKeys);
97
+ const previousKey = normalizedPrivateKeys.find(({ status }) => status === OidcSigningKeyStatus.Previous);
98
+ return [
99
+ { ...newPrivateKey, status: OidcSigningKeyStatus.Next },
100
+ { ...currentKey, status: OidcSigningKeyStatus.Current },
101
+ ...(previousKey ? [{ ...previousKey, status: OidcSigningKeyStatus.Previous }] : []),
102
+ ];
103
+ };
104
+ /**
105
+ * Promote a staged `Next` key into `Current` and demote the previous `Current` key into `Previous`.
106
+ *
107
+ * If no staged rotation is pending, the helper returns the original key array when it already
108
+ * uses explicit statuses, or the normalized array when the input is still in legacy form.
109
+ */
110
+ export const rotateOidcPrivateKeyStatuses = (privateKeys) => {
111
+ const normalizedPrivateKeys = normalizeOidcPrivateKeys(privateKeys);
112
+ const nextKey = normalizedPrivateKeys.find(({ status }) => status === OidcSigningKeyStatus.Next);
113
+ if (!nextKey) {
114
+ return privateKeys.every(({ status }) => status) ? privateKeys : normalizedPrivateKeys;
115
+ }
116
+ const currentKey = getCurrentOidcPrivateKey(normalizedPrivateKeys);
117
+ return [
118
+ { ...nextKey, status: OidcSigningKeyStatus.Current },
119
+ { ...currentKey, status: OidcSigningKeyStatus.Previous },
120
+ ];
121
+ };
122
+ /**
123
+ * Remove a single private key from the canonical key set after delete validation has already passed.
124
+ *
125
+ * The helper keeps the remaining keys in canonical status order and does not attempt to infer
126
+ * new lifecycle transitions beyond dropping the deleted key.
127
+ */
128
+ export const getOidcPrivateKeysAfterDeletion = (privateKeys, deletedKeyId) => getCanonicalOidcPrivateKeys(privateKeys).filter(({ id }) => id !== deletedKeyId);
129
+ /**
130
+ * Trim one or more `Previous` private keys from the end of the normalized key set.
131
+ *
132
+ * Only `Previous` keys are trim-able; attempting to trim past the available `Previous`
133
+ * keys indicates an invalid operation.
134
+ */
135
+ export const getTrimmedOidcPrivateKeys = (privateKeys, length) => {
136
+ const normalizedPrivateKeys = normalizeOidcPrivateKeys(privateKeys);
137
+ const previousKeys = normalizedPrivateKeys.filter(({ status }) => status === OidcSigningKeyStatus.Previous);
138
+ if (length > previousKeys.length) {
139
+ throw new TypeError('Only Previous OIDC private keys can be trimmed');
140
+ }
141
+ return getCanonicalOidcPrivateKeys(normalizedPrivateKeys).slice(0, -length);
142
+ };
143
+ /**
144
+ * Build rotation state for immediate tenant cache invalidation.
145
+ *
146
+ * This records when a tenant instance should be considered stale so the next reload
147
+ * can pick up newly written signing key data.
148
+ */
149
+ export const getRotationStateForCacheInvalidation = (currentRotationState, now = Date.now()) => ({
150
+ ...currentRotationState,
151
+ tenantCacheExpiresAt: now,
152
+ });
153
+ /**
154
+ * Build rotation state for staged private-key rotation.
155
+ *
156
+ * In addition to immediate tenant invalidation, this records the future activation time
157
+ * when the staged `Next` key should be promoted to `Current`.
158
+ */
159
+ export const getRotationStateForStagedRotation = (currentRotationState, rotationGracePeriod, now = Date.now()) => ({
160
+ ...currentRotationState,
161
+ tenantCacheExpiresAt: now,
162
+ signingKeyRotationAt: now + rotationGracePeriod * 1000,
163
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { OidcSigningKeyStatus } from '../types/index.js';
3
+ import { getCanonicalOidcPrivateKeys, getCurrentOidcPrivateKey, getImmediatelyRotatedOidcPrivateKeys, getOidcPrivateKeysAfterDeletion, getOidcProviderPrivateKeys, getRotationStateForCacheInvalidation, getRotationStateForStagedRotation, getSeededOidcPrivateKeys, getStagedRotatedOidcPrivateKeys, getTrimmedOidcPrivateKeys, normalizeOidcPrivateKeys, rotateOidcPrivateKeyStatuses, } from './oidc-private-key.js';
4
+ const createPrivateKey = (id, createdAt, status) => ({
5
+ id,
6
+ value: `private-key-${id}`,
7
+ createdAt,
8
+ status,
9
+ });
10
+ describe('OIDC private key helpers', () => {
11
+ it('normalizes legacy private keys without status into Current and Previous', () => {
12
+ expect(normalizeOidcPrivateKeys([createPrivateKey('current', 1), createPrivateKey('previous', 2)])).toEqual([
13
+ createPrivateKey('current', 1, OidcSigningKeyStatus.Current),
14
+ createPrivateKey('previous', 2, OidcSigningKeyStatus.Previous),
15
+ ]);
16
+ });
17
+ it('throws for malformed status configurations', () => {
18
+ expect(() => normalizeOidcPrivateKeys([
19
+ createPrivateKey('current-a', 1, OidcSigningKeyStatus.Current),
20
+ createPrivateKey('current-b', 2, OidcSigningKeyStatus.Current),
21
+ ])).toThrow('Malformed OIDC private key status configuration');
22
+ });
23
+ it('orders private keys in canonical business order', () => {
24
+ expect(getCanonicalOidcPrivateKeys([
25
+ createPrivateKey('previous', 3, OidcSigningKeyStatus.Previous),
26
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
27
+ createPrivateKey('next', 1, OidcSigningKeyStatus.Next),
28
+ ])).toEqual([
29
+ createPrivateKey('next', 1, OidcSigningKeyStatus.Next),
30
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
31
+ createPrivateKey('previous', 3, OidcSigningKeyStatus.Previous),
32
+ ]);
33
+ });
34
+ it('orders private keys for oidc-provider consumption', () => {
35
+ expect(getOidcProviderPrivateKeys([
36
+ createPrivateKey('previous', 3, OidcSigningKeyStatus.Previous),
37
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
38
+ createPrivateKey('next', 1, OidcSigningKeyStatus.Next),
39
+ ])).toEqual([
40
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
41
+ createPrivateKey('next', 1, OidcSigningKeyStatus.Next),
42
+ createPrivateKey('previous', 3, OidcSigningKeyStatus.Previous),
43
+ ]);
44
+ });
45
+ it('finds the current signing key by status', () => {
46
+ expect(getCurrentOidcPrivateKey([
47
+ createPrivateKey('previous', 3, OidcSigningKeyStatus.Previous),
48
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
49
+ createPrivateKey('next', 1, OidcSigningKeyStatus.Next),
50
+ ])).toEqual(createPrivateKey('current', 2, OidcSigningKeyStatus.Current));
51
+ });
52
+ it('assigns statuses to seeded keys', () => {
53
+ expect(getSeededOidcPrivateKeys([createPrivateKey('current', 2), createPrivateKey('previous', 1)])).toEqual([
54
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
55
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
56
+ ]);
57
+ });
58
+ it('rejects more than two seeded private keys', () => {
59
+ expect(() => getSeededOidcPrivateKeys([
60
+ createPrivateKey('current', 3),
61
+ createPrivateKey('previous-a', 2),
62
+ createPrivateKey('previous-b', 1),
63
+ ])).toThrow('CLI seed supports at most 2 OIDC private keys');
64
+ });
65
+ it('immediately rotates the new key into Current', () => {
66
+ expect(getImmediatelyRotatedOidcPrivateKeys([
67
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
68
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
69
+ ], createPrivateKey('new', 3))).toEqual([
70
+ createPrivateKey('new', 3, OidcSigningKeyStatus.Current),
71
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Previous),
72
+ ]);
73
+ });
74
+ it('stages the new key as Next while preserving Current and Previous', () => {
75
+ expect(getStagedRotatedOidcPrivateKeys([
76
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
77
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
78
+ ], createPrivateKey('new', 3))).toEqual([
79
+ createPrivateKey('new', 3, OidcSigningKeyStatus.Next),
80
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
81
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
82
+ ]);
83
+ });
84
+ it('promotes Next to Current during activation', () => {
85
+ expect(rotateOidcPrivateKeyStatuses([
86
+ createPrivateKey('next', 3, OidcSigningKeyStatus.Next),
87
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
88
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
89
+ ])).toEqual([
90
+ createPrivateKey('next', 3, OidcSigningKeyStatus.Current),
91
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Previous),
92
+ ]);
93
+ });
94
+ it('preserves status order when deleting Previous', () => {
95
+ expect(getOidcPrivateKeysAfterDeletion([
96
+ createPrivateKey('next', 3, OidcSigningKeyStatus.Next),
97
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
98
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
99
+ ], 'previous')).toEqual([
100
+ createPrivateKey('next', 3, OidcSigningKeyStatus.Next),
101
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
102
+ ]);
103
+ });
104
+ it('only trims Previous keys', () => {
105
+ expect(getTrimmedOidcPrivateKeys([
106
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
107
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
108
+ ], 1)).toEqual([createPrivateKey('current', 2, OidcSigningKeyStatus.Current)]);
109
+ });
110
+ it('trims the Previous key even when the input order is not canonical', () => {
111
+ expect(getTrimmedOidcPrivateKeys([
112
+ createPrivateKey('previous', 1, OidcSigningKeyStatus.Previous),
113
+ createPrivateKey('current', 2, OidcSigningKeyStatus.Current),
114
+ ], 1)).toEqual([createPrivateKey('current', 2, OidcSigningKeyStatus.Current)]);
115
+ });
116
+ it('creates invalidation-only state', () => {
117
+ expect(getRotationStateForCacheInvalidation({ signingKeyRotationAt: 456 }, 123)).toEqual({
118
+ tenantCacheExpiresAt: 123,
119
+ signingKeyRotationAt: 456,
120
+ });
121
+ });
122
+ it('creates staged rotation state with tenant invalidation and activation timestamps', () => {
123
+ expect(getRotationStateForStagedRotation({ tenantCacheExpiresAt: 1 }, 60, 123)).toEqual({
124
+ tenantCacheExpiresAt: 123,
125
+ signingKeyRotationAt: 60_123,
126
+ });
127
+ });
128
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logto/schemas",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "author": "Silverhand Inc. <contact@silverhand.io>",
5
5
  "license": "MPL-2.0",
6
6
  "type": "module",
@@ -65,12 +65,12 @@
65
65
  "dependencies": {
66
66
  "@withtyped/server": "^0.14.0",
67
67
  "nanoid": "^5.0.9",
68
- "@logto/language-kit": "^1.3.0",
69
- "@logto/core-kit": "^2.8.0",
70
- "@logto/phrases": "^1.27.0",
71
- "@logto/shared": "^3.3.1",
68
+ "@logto/core-kit": "^2.9.0",
72
69
  "@logto/connector-kit": "^5.0.0",
73
- "@logto/phrases-experience": "^1.13.0"
70
+ "@logto/phrases": "^1.28.0",
71
+ "@logto/language-kit": "^1.3.0",
72
+ "@logto/phrases-experience": "^1.13.1",
73
+ "@logto/shared": "^3.4.0"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "zod": "3.24.3"
@@ -7,5 +7,9 @@ create table account_centers (
7
7
  /** Control each fields */
8
8
  fields jsonb /* @use AccountCenterFieldControl */ not null default '{}'::jsonb,
9
9
  webauthn_related_origins jsonb /* @use WebauthnRelatedOrigins */ not null default '[]'::jsonb,
10
+ /** URL for custom account deletion endpoint */
11
+ delete_account_url varchar(2048),
12
+ /** User-defined custom CSS for the account center */
13
+ custom_css text,
10
14
  primary key (tenant_id, id)
11
15
  );
@@ -33,5 +33,7 @@ create table sign_in_experiences (
33
33
  email_blocklist_policy jsonb /* @use EmailBlocklistPolicy */ not null default '{}'::jsonb,
34
34
  forgot_password_methods jsonb /* @use ForgotPasswordMethods */ default '[]'::jsonb,
35
35
  passkey_sign_in jsonb /* @use PasskeySignIn */ not null default '{}'::jsonb,
36
+ /** Nullable by design: null keeps legacy full-catalog behavior and [] collects no custom profile fields. */
37
+ sign_up_profile_fields jsonb /* @use SignUpProfileFields */,
36
38
  primary key (tenant_id, id)
37
39
  );