@oxyhq/core 16.0.0 → 17.0.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +28 -6
- package/dist/cjs/i18n/locales/en-US.json +5 -3
- package/dist/cjs/i18n/locales/es-ES.json +5 -3
- package/dist/cjs/i18n/locales/locales/en-US.json +5 -3
- package/dist/cjs/i18n/locales/locales/es-ES.json +5 -3
- package/dist/cjs/index.js +6 -3
- package/dist/cjs/mixins/OxyServices.auth.js +57 -19
- package/dist/cjs/server/index.js +7 -1
- package/dist/cjs/server/userInvalidation.js +172 -0
- package/dist/cjs/utils/displayNamePolicyRanges.generated.js +28 -3
- package/dist/cjs/utils/validationUtils.js +112 -21
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +28 -6
- package/dist/esm/i18n/locales/en-US.json +5 -3
- package/dist/esm/i18n/locales/es-ES.json +5 -3
- package/dist/esm/i18n/locales/locales/en-US.json +5 -3
- package/dist/esm/i18n/locales/locales/es-ES.json +5 -3
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.auth.js +57 -19
- package/dist/esm/server/index.js +3 -0
- package/dist/esm/server/userInvalidation.js +167 -0
- package/dist/esm/utils/displayNamePolicyRanges.generated.js +27 -2
- package/dist/esm/utils/validationUtils.js +112 -21
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +13 -4
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +19 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/userInvalidation.d.ts +133 -0
- package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +27 -2
- package/dist/types/utils/validationUtils.d.ts +106 -20
- package/package.json +1 -1
- package/src/HttpService.ts +32 -7
- package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
- package/src/i18n/locales/en-US.json +5 -3
- package/src/i18n/locales/es-ES.json +5 -3
- package/src/index.ts +4 -1
- package/src/mixins/OxyServices.auth.ts +76 -20
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +72 -11
- package/src/server/__tests__/userInvalidation.test.ts +208 -0
- package/src/server/index.ts +13 -0
- package/src/server/userInvalidation.ts +221 -0
- package/src/utils/__tests__/coldBoot.test.ts +9 -4
- package/src/utils/__tests__/validationUtils.test.ts +292 -1
- package/src/utils/displayNamePolicyRanges.generated.ts +31 -2
- package/src/utils/validationUtils.ts +117 -20
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
"firstNamePlaceholder": "Introduce tu nombre",
|
|
204
204
|
"lastName": "Apellido",
|
|
205
205
|
"lastNamePlaceholder": "Introduce tu apellido (opcional)",
|
|
206
|
-
"invalidChars": "Usa solo letras y
|
|
206
|
+
"invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
|
|
207
207
|
},
|
|
208
208
|
"username": {
|
|
209
209
|
"title": "Usuario",
|
|
@@ -1752,7 +1752,8 @@
|
|
|
1752
1752
|
"title": "Crear cuenta",
|
|
1753
1753
|
"subtitle": "Crea una cuenta que tú controlas. Tendrá su propio perfil, miembros y aplicaciones.",
|
|
1754
1754
|
"displayName": {
|
|
1755
|
-
"label": "Nombre visible"
|
|
1755
|
+
"label": "Nombre visible",
|
|
1756
|
+
"invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
|
|
1756
1757
|
},
|
|
1757
1758
|
"bio": {
|
|
1758
1759
|
"label": "Biografía (opcional)"
|
|
@@ -1829,7 +1830,8 @@
|
|
|
1829
1830
|
"subtitle": "Gestiona el perfil, los miembros y el acceso de esta cuenta.",
|
|
1830
1831
|
"save": "Guardar cambios",
|
|
1831
1832
|
"displayName": {
|
|
1832
|
-
"label": "Nombre visible"
|
|
1833
|
+
"label": "Nombre visible",
|
|
1834
|
+
"invalidChars": "Usa solo letras, espacios, apóstrofos y separadores de nombre"
|
|
1833
1835
|
},
|
|
1834
1836
|
"bio": {
|
|
1835
1837
|
"label": "Biografía (opcional)"
|
package/src/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ export type {
|
|
|
49
49
|
CommonsDeliveryPlatform,
|
|
50
50
|
CommonsDeliveryRoute,
|
|
51
51
|
} from './utils/commonsDelivery';
|
|
52
|
-
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
52
|
+
export type { ServiceTokenResponse, OAuthUserInfoResponse } from './mixins/OxyServices.auth';
|
|
53
53
|
// "Sign in with Oxy" — handoff (Workstream C)
|
|
54
54
|
export type {
|
|
55
55
|
CommonsSignInHandle,
|
|
@@ -479,6 +479,8 @@ export {
|
|
|
479
479
|
EMAIL_REGEX,
|
|
480
480
|
USERNAME_REGEX,
|
|
481
481
|
PASSWORD_REGEX,
|
|
482
|
+
MAX_DISPLAY_NAME_LENGTH,
|
|
483
|
+
DISPLAY_NAME_INVALID_MESSAGE,
|
|
482
484
|
isValidEmail,
|
|
483
485
|
isValidUsername,
|
|
484
486
|
isValidPassword,
|
|
@@ -486,6 +488,7 @@ export {
|
|
|
486
488
|
DISPLAY_NAME_ALLOWED_SCRIPTS,
|
|
487
489
|
DISPLAY_NAME_DISALLOWED_SOURCE,
|
|
488
490
|
DISPLAY_NAME_ORPHANED_MARK_SOURCE,
|
|
491
|
+
DISPLAY_NAME_UNFLANKED_SEPARATOR_SOURCE,
|
|
489
492
|
isRequiredString,
|
|
490
493
|
isRequiredNumber,
|
|
491
494
|
isRequiredBoolean,
|
|
@@ -32,6 +32,13 @@ import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/use
|
|
|
32
32
|
*/
|
|
33
33
|
const COMMONS_SIGN_IN_EXPIRY_MS = 5 * 60 * 1000;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Fallback access-token lifetime used only if the token endpoint ever omits the
|
|
37
|
+
* RFC 6749 `expires_in` member. Matches the server's current 15-minute access
|
|
38
|
+
* token; the server's value always wins when present.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
|
|
41
|
+
|
|
35
42
|
export interface ChallengeResponse {
|
|
36
43
|
challenge: string;
|
|
37
44
|
expiresAt: string;
|
|
@@ -59,6 +66,14 @@ export interface PublicKeyCheckResponse {
|
|
|
59
66
|
message: string;
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
/** OpenID Connect userinfo claims returned by `GET /auth/oauth/userinfo`. */
|
|
70
|
+
export interface OAuthUserInfoResponse {
|
|
71
|
+
sub: string;
|
|
72
|
+
preferred_username?: string;
|
|
73
|
+
name?: string;
|
|
74
|
+
picture?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
// ===========================================================================
|
|
63
78
|
// "Sign in with Oxy" — cross-device QR / app-to-app handoff (Workstream C)
|
|
64
79
|
// ===========================================================================
|
|
@@ -1677,6 +1692,13 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1677
1692
|
* after sign-in at auth.oxy.so) for a device-first session.
|
|
1678
1693
|
* Public first-party clients use PKCE (`codeVerifier`); the access token is
|
|
1679
1694
|
* planted immediately on success.
|
|
1695
|
+
*
|
|
1696
|
+
* Speaks the standard RFC 6749 §4.1.3 token request — a form-urlencoded
|
|
1697
|
+
* body with snake_case parameters and `grant_type=authorization_code` — and
|
|
1698
|
+
* reads the flat §5.1 response. The camelCase JSON request and `{ data }`
|
|
1699
|
+
* response this method used before were an Oxy invention no OAuth library
|
|
1700
|
+
* could interoperate with; the endpoint no longer accepts them. The method's
|
|
1701
|
+
* OWN signature is unchanged, so callers are unaffected.
|
|
1680
1702
|
*/
|
|
1681
1703
|
async exchangeOAuthCode(params: {
|
|
1682
1704
|
code: string;
|
|
@@ -1685,38 +1707,39 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1685
1707
|
codeVerifier: string;
|
|
1686
1708
|
}): Promise<LoginSessionResult> {
|
|
1687
1709
|
try {
|
|
1688
|
-
const
|
|
1710
|
+
const form = new URLSearchParams({
|
|
1711
|
+
grant_type: 'authorization_code',
|
|
1689
1712
|
code: params.code,
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
}
|
|
1694
|
-
const
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1713
|
+
redirect_uri: params.redirectUri,
|
|
1714
|
+
client_id: params.clientId,
|
|
1715
|
+
code_verifier: params.codeVerifier,
|
|
1716
|
+
});
|
|
1717
|
+
const res = await this.makeRequest<unknown>(
|
|
1718
|
+
'POST',
|
|
1719
|
+
'/auth/oauth/token',
|
|
1720
|
+
form,
|
|
1721
|
+
{ cache: false, skipAuth: true },
|
|
1722
|
+
);
|
|
1723
|
+
if (!res || typeof res !== 'object') {
|
|
1698
1724
|
throw new Error('auth/oauth/token returned an unexpected response shape');
|
|
1699
1725
|
}
|
|
1700
|
-
|
|
1701
|
-
const
|
|
1702
|
-
const
|
|
1703
|
-
const
|
|
1704
|
-
const
|
|
1726
|
+
// RFC 6749 §5.1: every member sits at the TOP LEVEL of the document.
|
|
1727
|
+
const record = res as Record<string, unknown>;
|
|
1728
|
+
const accessToken = typeof record.access_token === 'string' ? record.access_token : undefined;
|
|
1729
|
+
const sessionId = typeof record.session_id === 'string' ? record.session_id : undefined;
|
|
1730
|
+
const deviceId = typeof record.deviceId === 'string' ? record.deviceId : undefined;
|
|
1731
|
+
const deviceSecret = typeof record.deviceSecret === 'string' ? record.deviceSecret : undefined;
|
|
1705
1732
|
const userRaw = record.user;
|
|
1706
1733
|
if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
|
|
1707
1734
|
throw new Error('auth/oauth/token returned an incomplete session payload');
|
|
1708
1735
|
}
|
|
1709
1736
|
const userObj = userRaw as Record<string, unknown>;
|
|
1710
|
-
const userId = userObj.id
|
|
1737
|
+
const userId = typeof userObj.id === 'string' ? userObj.id : undefined;
|
|
1711
1738
|
if (!userId) {
|
|
1712
1739
|
throw new Error('auth/oauth/token returned a session without user.id');
|
|
1713
1740
|
}
|
|
1714
1741
|
const expiresInSec =
|
|
1715
|
-
typeof record.expires_in === 'number'
|
|
1716
|
-
? record.expires_in
|
|
1717
|
-
: typeof record.expiresIn === 'number'
|
|
1718
|
-
? record.expiresIn
|
|
1719
|
-
: 15 * 60;
|
|
1742
|
+
typeof record.expires_in === 'number' ? record.expires_in : DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
|
|
1720
1743
|
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
|
|
1721
1744
|
if (accessToken) {
|
|
1722
1745
|
this.setTokens(accessToken);
|
|
@@ -1737,5 +1760,38 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1737
1760
|
throw this.handleError(error);
|
|
1738
1761
|
}
|
|
1739
1762
|
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Fetch OpenID Connect userinfo for the current bearer (`GET /auth/oauth/userinfo`).
|
|
1766
|
+
* The response is a flat JSON document — no `{ data }` wrapper.
|
|
1767
|
+
*/
|
|
1768
|
+
async getOAuthUserInfo(): Promise<OAuthUserInfoResponse> {
|
|
1769
|
+
try {
|
|
1770
|
+
const res = await this.makeRequest<unknown>(
|
|
1771
|
+
'GET',
|
|
1772
|
+
'/auth/oauth/userinfo',
|
|
1773
|
+
undefined,
|
|
1774
|
+
{ cache: false },
|
|
1775
|
+
);
|
|
1776
|
+
if (!res || typeof res !== 'object') {
|
|
1777
|
+
throw new Error('auth/oauth/userinfo returned an unexpected response shape');
|
|
1778
|
+
}
|
|
1779
|
+
const record = res as Record<string, unknown>;
|
|
1780
|
+
const sub = typeof record.sub === 'string' ? record.sub : undefined;
|
|
1781
|
+
if (!sub) {
|
|
1782
|
+
throw new Error('auth/oauth/userinfo returned a response without sub');
|
|
1783
|
+
}
|
|
1784
|
+
return {
|
|
1785
|
+
sub,
|
|
1786
|
+
...(typeof record.preferred_username === 'string'
|
|
1787
|
+
? { preferred_username: record.preferred_username }
|
|
1788
|
+
: {}),
|
|
1789
|
+
...(typeof record.name === 'string' ? { name: record.name } : {}),
|
|
1790
|
+
...(typeof record.picture === 'string' ? { picture: record.picture } : {}),
|
|
1791
|
+
};
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
throw this.handleError(error);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1740
1796
|
};
|
|
1741
1797
|
}
|
|
@@ -124,12 +124,14 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
124
124
|
);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
it('exchangeOAuthCode skips auth preflight', async () => {
|
|
127
|
+
it('exchangeOAuthCode skips auth preflight and sends an RFC 6749 form request', async () => {
|
|
128
128
|
makeRequest.mockResolvedValueOnce({
|
|
129
|
-
|
|
129
|
+
access_token: 'tok',
|
|
130
|
+
token_type: 'Bearer',
|
|
131
|
+
expires_in: 900,
|
|
132
|
+
session_id: 's1',
|
|
130
133
|
deviceId: 'd1',
|
|
131
134
|
deviceSecret: 'ds_secret',
|
|
132
|
-
accessToken: 'tok',
|
|
133
135
|
user: { id: 'u1' },
|
|
134
136
|
});
|
|
135
137
|
await oxy.exchangeOAuthCode({
|
|
@@ -141,21 +143,28 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
141
143
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
142
144
|
'POST',
|
|
143
145
|
'/auth/oauth/token',
|
|
144
|
-
|
|
145
|
-
code: 'code-1',
|
|
146
|
-
clientId: 'oxy_dk_test',
|
|
147
|
-
redirectUri: 'https://app.example/callback',
|
|
148
|
-
codeVerifier: 'verifier',
|
|
149
|
-
},
|
|
146
|
+
expect.any(URLSearchParams),
|
|
150
147
|
expect.objectContaining({ skipAuth: true }),
|
|
151
148
|
);
|
|
149
|
+
// The body is the standard §4.1.3 request: snake_case names, an explicit
|
|
150
|
+
// grant type, and nothing camelCase that only Oxy's old endpoint understood.
|
|
151
|
+
const body = makeRequest.mock.calls[0][2] as URLSearchParams;
|
|
152
|
+
expect(Object.fromEntries(body.entries())).toEqual({
|
|
153
|
+
grant_type: 'authorization_code',
|
|
154
|
+
code: 'code-1',
|
|
155
|
+
redirect_uri: 'https://app.example/callback',
|
|
156
|
+
client_id: 'oxy_dk_test',
|
|
157
|
+
code_verifier: 'verifier',
|
|
158
|
+
});
|
|
152
159
|
});
|
|
153
160
|
|
|
154
161
|
it('exchangeOAuthCode rejects a response without deviceSecret', async () => {
|
|
155
162
|
makeRequest.mockResolvedValueOnce({
|
|
156
|
-
|
|
163
|
+
access_token: 'tok',
|
|
164
|
+
token_type: 'Bearer',
|
|
165
|
+
expires_in: 900,
|
|
166
|
+
session_id: 's1',
|
|
157
167
|
deviceId: 'd1',
|
|
158
|
-
accessToken: 'tok',
|
|
159
168
|
user: { id: 'u1' },
|
|
160
169
|
});
|
|
161
170
|
await expect(
|
|
@@ -167,4 +176,56 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
167
176
|
}),
|
|
168
177
|
).rejects.toThrow(/incomplete session payload/i);
|
|
169
178
|
});
|
|
179
|
+
|
|
180
|
+
it('exchangeOAuthCode reads the FLAT RFC 6749 §5.1 response', async () => {
|
|
181
|
+
makeRequest.mockResolvedValueOnce({
|
|
182
|
+
access_token: 'tok',
|
|
183
|
+
token_type: 'Bearer',
|
|
184
|
+
expires_in: 900,
|
|
185
|
+
scope: 'profile:read',
|
|
186
|
+
session_id: 's1',
|
|
187
|
+
deviceId: 'd1',
|
|
188
|
+
deviceSecret: 'ds_secret',
|
|
189
|
+
user: { id: 'u1', username: 'alice', avatar: 'file-1' },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const result = await oxy.exchangeOAuthCode({
|
|
193
|
+
code: 'code-1',
|
|
194
|
+
clientId: 'oxy_dk_test',
|
|
195
|
+
redirectUri: 'https://app.example/callback',
|
|
196
|
+
codeVerifier: 'verifier',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result).toMatchObject({
|
|
200
|
+
sessionId: 's1',
|
|
201
|
+
deviceId: 'd1',
|
|
202
|
+
deviceSecret: 'ds_secret',
|
|
203
|
+
accessToken: 'tok',
|
|
204
|
+
user: { id: 'u1', username: 'alice', avatar: 'file-1' },
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('getOAuthUserInfo reads the flat OIDC userinfo response', async () => {
|
|
209
|
+
makeRequest.mockResolvedValueOnce({
|
|
210
|
+
sub: '507f1f77bcf86cd799439011',
|
|
211
|
+
preferred_username: 'alice',
|
|
212
|
+
name: 'Alice Example',
|
|
213
|
+
picture: 'https://api.oxy.so/assets/file-1/stream',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const result = await oxy.getOAuthUserInfo();
|
|
217
|
+
|
|
218
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
219
|
+
'GET',
|
|
220
|
+
'/auth/oauth/userinfo',
|
|
221
|
+
undefined,
|
|
222
|
+
expect.objectContaining({ cache: false }),
|
|
223
|
+
);
|
|
224
|
+
expect(result).toEqual({
|
|
225
|
+
sub: '507f1f77bcf86cd799439011',
|
|
226
|
+
preferred_username: 'alice',
|
|
227
|
+
name: 'Alice Example',
|
|
228
|
+
picture: 'https://api.oxy.so/assets/file-1/stream',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
170
231
|
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { OXY_USER_INVALIDATION_CHANNEL } from '@oxyhq/contracts';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createOxyUserInvalidationHandler,
|
|
5
|
+
evictOxyIdentityCache,
|
|
6
|
+
publishOxyUserInvalidation,
|
|
7
|
+
type OxyIdentityCacheEvictor,
|
|
8
|
+
} from '../userInvalidation';
|
|
9
|
+
|
|
10
|
+
function makePublisher() {
|
|
11
|
+
const calls: Array<{ channel: string; message: string }> = [];
|
|
12
|
+
return {
|
|
13
|
+
calls,
|
|
14
|
+
publish(channel: string, message: string) {
|
|
15
|
+
calls.push({ channel, message });
|
|
16
|
+
return Promise.resolve(1);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeEvictor() {
|
|
22
|
+
const entries: string[] = [];
|
|
23
|
+
const prefixes: string[] = [];
|
|
24
|
+
const evictor: OxyIdentityCacheEvictor = {
|
|
25
|
+
clearCacheEntry: (key) => {
|
|
26
|
+
entries.push(key);
|
|
27
|
+
},
|
|
28
|
+
clearCacheByPrefix: (prefix) => {
|
|
29
|
+
prefixes.push(prefix);
|
|
30
|
+
return 0;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return { evictor, entries, prefixes };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('@oxyhq/core/server publishOxyUserInvalidation', () => {
|
|
37
|
+
it('publishes a profile change on the contract channel', () => {
|
|
38
|
+
const publisher = makePublisher();
|
|
39
|
+
const published = publishOxyUserInvalidation(publisher, 'user-1', 'profile');
|
|
40
|
+
|
|
41
|
+
expect(published).toBe(true);
|
|
42
|
+
expect(publisher.calls).toHaveLength(1);
|
|
43
|
+
expect(publisher.calls[0].channel).toBe(OXY_USER_INVALIDATION_CHANNEL);
|
|
44
|
+
|
|
45
|
+
const payload = JSON.parse(publisher.calls[0].message);
|
|
46
|
+
expect(payload.userId).toBe('user-1');
|
|
47
|
+
expect(payload.reason).toBe('profile');
|
|
48
|
+
expect(typeof payload.at).toBe('number');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('puts NOTHING on the wire for graph churn', () => {
|
|
52
|
+
// The whole point of the suppression: not a message subscribers discard,
|
|
53
|
+
// but no message at all. Bulk follow moves up to 200 edges per call.
|
|
54
|
+
const publisher = makePublisher();
|
|
55
|
+
const published = publishOxyUserInvalidation(publisher, 'user-1', 'graph');
|
|
56
|
+
|
|
57
|
+
expect(published).toBe(false);
|
|
58
|
+
expect(publisher.calls).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('publishes nothing for an empty user id', () => {
|
|
62
|
+
const publisher = makePublisher();
|
|
63
|
+
expect(publishOxyUserInvalidation(publisher, '', 'profile')).toBe(false);
|
|
64
|
+
expect(publisher.calls).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('never throws when the client throws synchronously', () => {
|
|
68
|
+
const boom = new Error('redis down');
|
|
69
|
+
const publisher = {
|
|
70
|
+
publish() {
|
|
71
|
+
throw boom;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const onError = jest.fn();
|
|
75
|
+
|
|
76
|
+
expect(() => publishOxyUserInvalidation(publisher, 'user-1', 'profile', onError)).not.toThrow();
|
|
77
|
+
expect(publishOxyUserInvalidation(publisher, 'user-1', 'profile', onError)).toBe(false);
|
|
78
|
+
expect(onError).toHaveBeenCalledWith(boom);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('never surfaces a rejected publish as an unhandled rejection', async () => {
|
|
82
|
+
const boom = new Error('publish rejected');
|
|
83
|
+
const publisher = {
|
|
84
|
+
publish: () => Promise.reject(boom),
|
|
85
|
+
};
|
|
86
|
+
const onError = jest.fn();
|
|
87
|
+
|
|
88
|
+
// Returns true — the message was handed to the client; the failure is async.
|
|
89
|
+
expect(publishOxyUserInvalidation(publisher, 'user-1', 'profile', onError)).toBe(true);
|
|
90
|
+
await Promise.resolve();
|
|
91
|
+
expect(onError).toHaveBeenCalledWith(boom);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('tolerates a client whose publish returns a non-promise', () => {
|
|
95
|
+
const calls: string[] = [];
|
|
96
|
+
const publisher = {
|
|
97
|
+
publish: (_channel: string, message: string) => {
|
|
98
|
+
calls.push(message);
|
|
99
|
+
return 1;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
expect(publishOxyUserInvalidation(publisher, 'user-1', 'profile')).toBe(true);
|
|
103
|
+
expect(calls).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('@oxyhq/core/server createOxyUserInvalidationHandler', () => {
|
|
108
|
+
const validMessage = JSON.stringify({ userId: 'user-1', reason: 'profile', at: 1 });
|
|
109
|
+
|
|
110
|
+
it('sweeps the SDK identity cache for the invalidated user', () => {
|
|
111
|
+
const { evictor, entries, prefixes } = makeEvictor();
|
|
112
|
+
createOxyUserInvalidationHandler({ oxy: evictor })(validMessage);
|
|
113
|
+
|
|
114
|
+
expect(entries).toEqual(['GET:/users/user-1']);
|
|
115
|
+
expect(prefixes).toEqual(['GET:/profiles/username/', 'GET:/profiles/resolve']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('hands the parsed event to app-specific eviction', () => {
|
|
119
|
+
const onInvalidate = jest.fn();
|
|
120
|
+
createOxyUserInvalidationHandler({ onInvalidate })(validMessage);
|
|
121
|
+
|
|
122
|
+
expect(onInvalidate).toHaveBeenCalledWith({ userId: 'user-1', reason: 'profile', at: 1 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('drops an unparseable message without throwing', () => {
|
|
126
|
+
const onInvalidate = jest.fn();
|
|
127
|
+
const onError = jest.fn();
|
|
128
|
+
const handle = createOxyUserInvalidationHandler({ onInvalidate, onError });
|
|
129
|
+
|
|
130
|
+
expect(() => handle('not json at all')).not.toThrow();
|
|
131
|
+
expect(onInvalidate).not.toHaveBeenCalled();
|
|
132
|
+
expect(onError).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('drops a message that fails the contract schema', () => {
|
|
136
|
+
const onInvalidate = jest.fn();
|
|
137
|
+
const onError = jest.fn();
|
|
138
|
+
const handle = createOxyUserInvalidationHandler({ onInvalidate, onError });
|
|
139
|
+
|
|
140
|
+
handle(JSON.stringify({ userId: 'user-1', reason: 'graph', at: 1 }));
|
|
141
|
+
handle(JSON.stringify({ reason: 'profile', at: 1 }));
|
|
142
|
+
|
|
143
|
+
expect(onInvalidate).not.toHaveBeenCalled();
|
|
144
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('never throws when app eviction throws', () => {
|
|
148
|
+
// The handler runs inside the Redis client's message dispatch. An escape
|
|
149
|
+
// there kills the subscription — silently and permanently — which is far
|
|
150
|
+
// worse than losing the one message that caused it.
|
|
151
|
+
const boom = new Error('app eviction failed');
|
|
152
|
+
const onError = jest.fn();
|
|
153
|
+
const handle = createOxyUserInvalidationHandler({
|
|
154
|
+
onInvalidate: () => {
|
|
155
|
+
throw boom;
|
|
156
|
+
},
|
|
157
|
+
onError,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(() => handle(validMessage)).not.toThrow();
|
|
161
|
+
expect(onError).toHaveBeenCalledWith(boom, validMessage);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('never throws when app eviction rejects', async () => {
|
|
165
|
+
const boom = new Error('async eviction failed');
|
|
166
|
+
const onError = jest.fn();
|
|
167
|
+
const handle = createOxyUserInvalidationHandler({
|
|
168
|
+
onInvalidate: () => Promise.reject(boom),
|
|
169
|
+
onError,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(() => handle(validMessage)).not.toThrow();
|
|
173
|
+
await Promise.resolve();
|
|
174
|
+
expect(onError).toHaveBeenCalledWith(boom, validMessage);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('still runs app eviction when the SDK sweep throws', () => {
|
|
178
|
+
const onInvalidate = jest.fn();
|
|
179
|
+
const onError = jest.fn();
|
|
180
|
+
const evictor: OxyIdentityCacheEvictor = {
|
|
181
|
+
clearCacheEntry: () => {
|
|
182
|
+
throw new Error('sweep failed');
|
|
183
|
+
},
|
|
184
|
+
clearCacheByPrefix: () => 0,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
createOxyUserInvalidationHandler({ oxy: evictor, onInvalidate, onError })(validMessage);
|
|
188
|
+
|
|
189
|
+
expect(onInvalidate).toHaveBeenCalled();
|
|
190
|
+
expect(onError).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('is a no-op with no options configured', () => {
|
|
194
|
+
expect(() => createOxyUserInvalidationHandler()(validMessage)).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('@oxyhq/core/server evictOxyIdentityCache', () => {
|
|
199
|
+
it('clears the exact by-id entry and the handle-keyed prefixes', () => {
|
|
200
|
+
// The by-id key is exact. The handle-keyed ones cannot be derived from an
|
|
201
|
+
// id without the lookup being invalidated, so they are swept by prefix.
|
|
202
|
+
const { evictor, entries, prefixes } = makeEvictor();
|
|
203
|
+
evictOxyIdentityCache(evictor, 'abc123');
|
|
204
|
+
|
|
205
|
+
expect(entries).toEqual(['GET:/users/abc123']);
|
|
206
|
+
expect(prefixes).toEqual(['GET:/profiles/username/', 'GET:/profiles/resolve']);
|
|
207
|
+
});
|
|
208
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -82,6 +82,19 @@ export type {
|
|
|
82
82
|
// Constant-time secret comparison.
|
|
83
83
|
export { verifySecret } from './verifySecret';
|
|
84
84
|
|
|
85
|
+
// Cross-service user-invalidation signal: oxy-api publishes when identity
|
|
86
|
+
// changes, every consuming backend sweeps its caches instead of waiting out a TTL.
|
|
87
|
+
export {
|
|
88
|
+
createOxyUserInvalidationHandler,
|
|
89
|
+
evictOxyIdentityCache,
|
|
90
|
+
publishOxyUserInvalidation,
|
|
91
|
+
} from './userInvalidation';
|
|
92
|
+
export type {
|
|
93
|
+
OxyIdentityCacheEvictor,
|
|
94
|
+
OxyInvalidationPublisher,
|
|
95
|
+
OxyUserInvalidationHandlerOptions,
|
|
96
|
+
} from './userInvalidation';
|
|
97
|
+
|
|
85
98
|
// Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
|
|
86
99
|
// SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
|
|
87
100
|
// Pure host handling (no browser deps), so it is safe on the server subpath and
|